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

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

| Mod | What it does |
|---|---|
chameleon | Gives a session a random colour the first time you /rename it, and every /branch a new one. |
skill-session-mods | Names and colours a session from the SKILL.md of a skill you type as /<skill> args. |
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>.
chameleonSince 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.
/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./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./color would otherwise wait in the prompt queue as a visible row./rename or /branch triggers it. Renames and branches made in other ways are not seen.skill-session-modsWhen 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.
| Key | Meaning |
|---|---|
skill-session-mods-title | Template for the session name. |
skill-session-mods-title-fallback | Template used when the title doesn't apply. |
skill-session-mods-title-apply-if-match | Regex tested against the args. If it doesn't match, the title is skipped. |
skill-session-mods-color | A /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.
{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.|. 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.{} 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./<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._, ., -). Namespaced commands such as /plugin:skill or /apps/web:deploy are ignored.metadata: block of key: value lines, not full YAML.hooks/register.ts 154 lines1import 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