SLOPSHOPPER

skill-session-mods

Names and colours a session from metadata in the SKILL.md of a skill typed as /<skill>.

newtimer
v0.3.2MITupdated 2026-10-02aksh1618/claude-mods/skill-session-mods
A shopper browsing a rack in a slop shop
README

claude-mods

A Claude Code plugin marketplace of mods: plugins whose behaviour is a TypeScript module of event hooks running inside Claude Code.

chameleon colouring a session on /rename and /branch, then skill-session-mods naming and colouring it from a skill

ModWhat it does
chameleonGives a session a random colour the first time you /rename it, and every /branch a new one.
skill-session-modsNames and colours a session from the SKILL.md of a skill you type as /<skill> args.

Install

Needs Claude Code 2.1.287 or later, where mods are on by default.

claude plugin marketplace add aksh1618/claude-mods
claude plugin install chameleon@aksh1618-mods
claude plugin install skill-session-mods@aksh1618-mods

To run one from a clone instead, start Claude Code with claude --plugin-dir <clone>/<mod>.

chameleon

Since Claude Code 2.1.246, /rename no longer colours the prompt bar. This mod brings that back: the first /rename of a session runs a bare /color, which picks a random colour.

  • A rename is skipped when the mod has already coloured the session, or has seen you run /color in it (/color default included). It can't read the session's actual colour, so a colour set while the mod wasn't loaded doesn't count.
  • If /color fails, the session isn't marked, and the next rename tries again.
  • /branch always gets a new colour, since a branch is a new session and doesn't inherit its parent's colour.
  • A rename or branch typed while Claude is working is coloured when that turn ends. A plugin's /color would otherwise wait in the prompt queue as a visible row.
  • Only a typed /rename or /branch triggers it. Renames and branches made in other ways are not seen.

skill-session-mods

When a prompt is typed as /<skill> args, the mod reads that skill's SKILL.md and applies the keys it finds under the frontmatter's metadata:. For example:

---
name: arch-pkg
description: …
metadata:
  skill-session-mods-title: "arch-package-mgmt--{2|basename|lower}--{1}-{date}"
  skill-session-mods-title-fallback: "arch-package-mgmt--{date}"
  skill-session-mods-title-apply-if-match: "^(update|create)\\s+(\\S+)"
  skill-session-mods-color: cyan
---

With that skill, typing /arch-pkg update plannotator names the session arch-package-mgmt--plannotator--update-20261002 and turns it cyan.

Keys

KeyMeaning
skill-session-mods-titleTemplate for the session name.
skill-session-mods-title-fallbackTemplate used when the title doesn't apply.
skill-session-mods-title-apply-if-matchRegex tested against the args. If it doesn't match, the title is skipped.
skill-session-mods-colorA /color value, applied when the turn ends.

Every key is optional, and they apply on every matching command, not just the first: the name changes at once, and the colour when that turn ends. The colour doesn't depend on the title applying. The metadata: block is where the Agent Skills spec puts extra keys, so other tools reading the same skill ignore them.

Templates

  • {1}, {2}, …: the args' whitespace-separated words. With title-apply-if-match, they are the regex's capture groups instead (for the title only; the fallback always uses words).
  • {args}: the args, trimmed. {skill}: the skill's name. {date}: today as YYYYMMDD, local time.
  • Filters chain with |. basename drops a URL's ?query and #fragment and any trailing /, keeps the last path segment, and strips one .git, .deb, .rpm, .AppImage, .tgz, .zip or .tar.<ext> (such as .tar.gz) suffix. lower lowercases.
  • A template with an empty or missing placeholder ({} included), or an unknown filter, doesn't apply, and neither does the title when title-apply-if-match isn't a valid regex (that one is also logged). The fallback is tried next, and failing that the name is left alone.

Limits

  • The skill is found the way Claude Code picks the one /<skill> runs: first ~/.claude/skills/<skill>/SKILL.md, then .claude/skills/<skill>/SKILL.md in the project, outermost directory first, from the repository root (or / outside a repository) down to the working directory. The first file that exists is used. Skills are found by directory name, so a frontmatter name that differs from it isn't resolved; skills from plugins and managed skills aren't looked up.
  • The command must be a plain skill name (letters, digits, _, ., -). Namespaced commands such as /plugin:skill or /apps/web:deploy are ignored.
  • Values must be flat strings on one line, quoted or bare. The mod parses only a metadata: block of key: value lines, not full YAML.
  • Only typed commands are handled. A skill Claude invokes through the Skill tool doesn't pass through prompt submission.
  • Session names are unique while sessions are live. If another live session already has the name, Claude Code appends a suffix to it.

License

MIT

Source 1 files
hooks/register.ts 154 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3export type Metadata = Record<string, string>
4
5export function parseCommand(prompt: string): { skill: string; args: string } | undefined {
6  const match = /^\s*\/([A-Za-z0-9_.-]+)(?:\s+([\s\S]*))?$/.exec(prompt)
7  return match ? { skill: match[1], args: (match[2] ?? '').trim() } : undefined
8}
9
10function unquote(raw: string): string {
11  const value = raw.trim()
12  const double = /^"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/.exec(value)
13  if (double) return double[1].replace(/\\(.)/g, (_, c: string) => ({ n: '\n', t: '\t' })[c] ?? c)
14  const single = /^'((?:[^']|'')*)'\s*(?:#.*)?$/.exec(value)
15  if (single) return single[1].replace(/''/g, "'")
16  return value.replace(/\s+#.*$/, '')
17}
18
19export function parseMetadata(skillMd: string): Metadata {
20  const front = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(skillMd)
21  const metadata: Metadata = {}
22  if (!front) return metadata
23  let inside = false
24  for (const line of front[1].split(/\r?\n/)) {
25    if (/^metadata:\s*$/.test(line)) { inside = true; continue }
26    if (!inside || line.trim() === '' || /^\s*#/.test(line)) continue
27    if (!/^\s/.test(line)) break
28    const pair = /^\s+([A-Za-z0-9_-]+):\s*(.*)$/.exec(line)
29    if (pair) metadata[pair[1]] = unquote(pair[2])
30  }
31  return metadata
32}
33
34const FILTERS = new Map<string, (value: string) => string>([
35  ['lower', (value) => value.toLowerCase()],
36  ['basename', (value) => {
37    const path = value.replace(/[?#].*$/, '').replace(/\/+$/, '')
38    return path.slice(path.lastIndexOf('/') + 1)
39      .replace(/\.(git|deb|rpm|AppImage|tar\.[a-z0-9]+|tgz|zip)$/i, '')
40  }],
41])
42
43export function render(template: string, values: Record<string, string | undefined>): string | undefined {
44  let complete = true
45  const text = template.replace(/\{([^{}|]*)((?:\|[^{}|]*)*)\}/g, (_, name: string, filters: string) => {
46    const key = name.trim()
47    let value = Object.prototype.hasOwnProperty.call(values, key) ? values[key] : undefined
48    for (const filter of filters.split('|').slice(1)) {
49      const apply = FILTERS.get(filter.trim())
50      if (apply === undefined || value === undefined) { value = undefined; break }
51      value = apply(value)
52    }
53    if (!value) complete = false
54    return value ?? ''
55  })
56  return complete ? text : undefined
57}
58
59function compile(pattern: string): RegExp | undefined {
60  try {
61    return new RegExp(pattern)
62  } catch {
63    return undefined
64  }
65}
66
67export function titleFor(metadata: Metadata, skill: string, args: string, now: Date): string | undefined {
68  const day = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
69  const base: Record<string, string | undefined> = { skill, args, date: day }
70  const words = args === '' ? [] : args.split(/\s+/)
71  const positional = (list: readonly (string | undefined)[]) =>
72    Object.fromEntries(list.map((value, i) => [String(i + 1), value]))
73
74  const template = metadata['skill-session-mods-title']
75  const fallback = metadata['skill-session-mods-title-fallback']
76  const condition = metadata['skill-session-mods-title-apply-if-match']
77  let title: string | undefined
78  if (template !== undefined) {
79    if (condition === undefined) {
80      title = render(template, { ...positional(words), ...base })
81    } else {
82      const match = compile(condition)?.exec(args)
83      if (match) title = render(template, { ...positional(match.slice(1)), ...base })
84    }
85  }
86  if (title === undefined && fallback !== undefined) title = render(fallback, { ...positional(words), ...base })
87  return title
88}
89
90async function projectDirs($: EngineInterface): Promise<string[]> {
91  const dirs: string[] = []
92  let dir = await $.session.cwd()
93  for (;;) {
94    dirs.push(dir)
95    if (await $.fs.exists(`${dir}/.git`)) return dirs
96    const last = dir.startsWith('/') ? /\/[^/]*$/ : /[\\/][^\\/]*$/
97    const parent = dir.replace(last, '') || '/'
98    if (parent === dir) break
99    dir = parent
100  }
101  return dirs
102}
103
104async function readMetadata($: EngineInterface, skill: string): Promise<Metadata | undefined> {
105  const home = await $.env.get('HOME')
106  const personal = home === undefined ? undefined : `${home}/.claude/skills/${skill}/SKILL.md`
107  if (personal !== undefined && await $.fs.exists(personal)) return parseMetadata(await $.fs.read(personal))
108  for (const dir of (await projectDirs($)).reverse()) {
109    const path = `${dir === '/' ? '' : dir}/.claude/skills/${skill}/SKILL.md`
110    if (await $.fs.exists(path)) return parseMetadata(await $.fs.read(path))
111  }
112  return undefined
113}
114
115function colorize($: EngineInterface, color: string): void {
116  $.clock.after(0, () => {
117    $.command.run({ command: 'color', args: color }).catch((err: unknown) => $.ui.log(String(err)))
118  })
119}
120
121export const register: Register = (on) => {
122  let pendingColor: string | undefined
123
124  on('classic.UserPromptSubmit', async ($, e, next) => {
125    const result = await next(e)
126    const command = parseCommand(e.prompt)
127    if (command === undefined) return result
128    try {
129      const metadata = await readMetadata($, command.skill)
130      if (metadata === undefined) return result
131      if (metadata['skill-session-mods-color']) pendingColor = metadata['skill-session-mods-color']
132      const condition = metadata['skill-session-mods-title-apply-if-match']
133      if (condition !== undefined && compile(condition) === undefined) {
134        $.ui.log(`${command.skill}: invalid skill-session-mods-title-apply-if-match: ${condition}`)
135      }
136      const title = titleFor(metadata, command.skill, command.args, new Date(await $.clock.now()))
137      return title ? { ...result, sessionTitle: title } : result
138    } catch (err) {
139      $.ui.log(`${command.skill}: ${String(err)}`)
140      return result
141    }
142  })
143
144  on('turn.complete', async ($, e, next) => {
145    const result = await next(e)
146    if (e.agentId === undefined && pendingColor !== undefined) {
147      const color = pendingColor
148      pendingColor = undefined
149      colorize($, color)
150    }
151    return result
152  })
153}
154