Colours subagent and skill names in Claude Code by their own color: field. An agent's name sits on a coloured badge in its Agent row and in replies; a skill's…

A Claude Code mod that colours subagent and skill names by their own color: field, so you can tell them apart at a glance. An agent's name sits on a coloured badge; a skill's name is drawn in its colour, with no badge:
● dev:architect(Plan the cache): the name sits on its badge, and the rest of the row is as Claude Code draws it.● Skill(dev:dev): the skill name is drawn in its colour.The agent's own color: field, the same frontmatter field Claude Code's /agents list reads:
---
name: code-improver
description: Scans files and suggests improvements.
color: green
---
A value is one of red, blue, green, yellow, purple, orange, pink, cyan, or a hex colour such as "#FFD700". The text on a hex badge is black or white, whichever reads on it.
The mod finds agent files where Claude Code does: each installed plugin's agents/ folder, ~/.claude/agents/, and the project's .claude/agents/. A project agent wins over a user agent of the same name.
Explore, Plan, general-purpose) have no file, so the mod gives them fixed colours.color: field still gets a badge: a colour picked from its name, the same one every time.The files are read once each time the mod loads, so an edited color: shows after a new session or /reload-plugins.
A skill or command opts in with the same color: field in its frontmatter:
---
name: release
description: Cuts a release.
color: green
---
The values are the agent ones, a colour name or a #RRGGBB hex colour, drawn as text colour only. A skill with no color: field, or with a value that is not a colour (teal, #FFF), keeps Claude Code's look: there is no colour picked from its name.
The mod finds skills and commands where Claude Code does, each later place overriding the ones before it:
commands/*.md, skills/<name>/SKILL.md, and the skill paths its .claude-plugin/plugin.json lists, keyed plugin:name (a plugin's skill wins over its command of the same name; the newest install whose folder exists is read)~/.claude/commands/*.md, then ~/.claude/skills/<name>/SKILL.md, symbolic links included.claude/commands/*.md, then .claude/skills/<name>/SKILL.mdThe highest definition of a name decides alone: a project skill with no color: leaves the name uncoloured, even when the user's skill of that name has one.
In a reply, a skill is coloured only where it reads as a skill: plugin:name, or /name for any skill, the slash coloured with it. A bare word (commit) never is, nor a /name inside a path or URL (src/commit, https://x.dev/commit) or running on into a longer word (/commits, /commit.md). A name that is also an agent keeps its agent badge.
Run magus, add the magus-alpha marketplace, and install agent-colours@magus-alpha from the Plugins tab. Or, in Claude Code:
/plugin marketplace add MadAppGang/magus-alpha
/plugin install agent-colours@magus-alpha
Explore and Plan are coloured wherever they appear capitalised. Lowercase "plan" never is./agents list, the spinner line and the "Message from …" label keep Claude Code's own look. So do skills in a collapsed tool group, the /skills list and the slash-command typeahead./skill prompt row is not coloured. A hook can only rewrite that row's text, which carries no colour, or redraw the whole prompt row itself.commands/a/b.md, invoked as a:b) are not coloured. a/commit and x/commit colour /commit, and /commits colours the /commit of what reads as /commits`. Plain text has no such edge.claude plugin validate plugins/agent-colours
claude plugin test plugins/agent-colours
tsc -p plugins/agent-colours works once Claude Code has loaded the mod, which writes .claude-plugin/types/ beside it (git-ignored).
hooks/register.tsx 292 lines1import type { EngineInterface, Register } from 'claude-code'
2import { parseAgent, pluginInstalls } from './agents'
3import type { Row } from './agents'
4import { badgeFor, mentionPattern, textColourFromField } from './colours'
5import { splitReply } from './mentions'
6import { manifestSkillPaths, parseSkill, skillKey } from './skills'
7import type { SkillDef } from './skills'
8
9const AGENT_TOOLS = new Set(['Agent', 'Task'])
10
11type AgentInput = { subagent_type?: unknown; description?: unknown }
12
13// One coloured space each side, so a badge reads as a chip, not a tight highlight.
14const pad = (name: string) => ` ${name} `
15
16const text = (value: unknown): string | undefined => (typeof value === 'string' && value ? value : undefined)
17
18// Where Claude Code keeps the user's own files: `$CLAUDE_CONFIG_DIR`, else `~/.claude`.
19async function userConfigDir($: EngineInterface) {
20 const home = await $.env.get('HOME')
21 return (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : undefined)
22}
23
24// Reads every `color:` field in one agents folder into `into`, keyed `<prefix><name>`,
25// and every agent's name, coloured or not, into `names`.
26// False when the folder does not exist.
27async function readAgentsDir(
28 $: EngineInterface,
29 dir: string,
30 prefix: string,
31 into: Map<string, string>,
32 names: Set<string>,
33) {
34 const entries = await $.fs.list(dir).catch(() => undefined)
35 if (!entries) return false
36 for (const entry of entries) {
37 if (entry.kind !== 'file' || !entry.name.endsWith('.md')) continue
38 const body = await $.fs.read(`${dir}/${entry.name}`).catch(() => undefined)
39 if (typeof body !== 'string') continue
40 const agent = parseAgent(body, entry.name)
41 names.add(`${prefix}${agent.name}`)
42 if (agent.color) into.set(`${prefix}${agent.name}`, agent.color)
43 }
44 return true
45}
46
47type Agents = { colours: Map<string, string>; names: Set<string> }
48
49// Each agent type's `color:` field, keyed as the Agent tool names the type:
50// `plugin:name` for a plugin's agent, the bare name for a user or project agent.
51// No engine API lists agent files, so they are found where Claude Code finds them.
52// `names` holds every agent file's name, so a reply never draws an agent as a skill.
53async function loadAgentColours($: EngineInterface): Promise<Agents> {
54 const colours = new Map<string, string>()
55 const names = new Set<string>()
56 const configDir = await userConfigDir($)
57 if (configDir) {
58 const raw = await $.fs.read(`${configDir}/plugins/installed_plugins.json`).catch(() => undefined)
59 for (const [plugin, rows] of pluginInstalls(raw)) {
60 for (const row of rows) {
61 if (typeof row.installPath !== 'string') continue
62 if (await readAgentsDir($, `${row.installPath}/agents`, `${plugin}:`, colours, names)) break
63 }
64 }
65 await readAgentsDir($, `${configDir}/agents`, '', colours, names)
66 }
67 // The project's own agents win over the user's, as they do in Claude Code.
68 await readAgentsDir($, '.claude/agents', '', colours, names)
69 return { colours, names }
70}
71
72// A skill definition replaces whatever a lower one said about its key: a coloured one
73// sets the key's text colour, any other removes it. Only a valid colour is ever stored.
74type Apply = (prefix: string, def: SkillDef) => void
75
76const applyTo =
77 (into: Map<string, string>): Apply =>
78 (prefix, def) => {
79 const key = `${prefix}${def.name}`
80 const colour = def.color ? textColourFromField(def.color) : undefined
81 if (colour) into.set(key, colour)
82 else into.delete(key)
83 }
84
85const lastSegment = (path: string) => path.slice(path.lastIndexOf('/') + 1)
86
87// One skill folder: its SKILL.md, named by `fallbackName` when it has no `name:`.
88// False when there is no SKILL.md to read.
89async function readSkillDir($: EngineInterface, dir: string, fallbackName: string, prefix: string, apply: Apply) {
90 const body = await $.fs.read(`${dir}/SKILL.md`).catch(() => undefined)
91 if (typeof body !== 'string') return false
92 apply(prefix, parseSkill(body, fallbackName))
93 return true
94}
95
96// A folder of skill folders. Any entry but a plain file is tried, since a symbolic
97// link to a skill folder lists as `other`; a failed read is the filter.
98async function readSkillsContainer($: EngineInterface, dir: string, prefix: string, apply: Apply) {
99 const entries = await $.fs.list(dir).catch(() => undefined)
100 for (const entry of entries ?? []) {
101 if (entry.kind === 'file') continue
102 await readSkillDir($, `${dir}/${entry.name}`, entry.name, prefix, apply)
103 }
104}
105
106// A commands folder: each `.md` file directly in it is one command. Subfolders are not read.
107async function readCommandsDir($: EngineInterface, dir: string, prefix: string, apply: Apply) {
108 const entries = await $.fs.list(dir).catch(() => undefined)
109 for (const entry of entries ?? []) {
110 if (entry.kind === 'dir' || !entry.name.endsWith('.md')) continue
111 const body = await $.fs.read(`${dir}/${entry.name}`).catch(() => undefined)
112 if (typeof body === 'string') apply(prefix, parseSkill(body, entry.name))
113 }
114}
115
116// One plugin's commands and skills, keyed `plugin:name`: commands first, then the
117// auto-scanned `skills/`, then any path its manifest lists.
118async function readPluginSkills($: EngineInterface, plugin: string, root: string) {
119 const found = new Map<string, string>()
120 const apply = applyTo(found)
121 const prefix = `${plugin}:`
122 await readCommandsDir($, `${root}/commands`, prefix, apply)
123 await readSkillsContainer($, `${root}/skills`, prefix, apply)
124 const manifest = await $.fs.read(`${root}/.claude-plugin/plugin.json`).catch(() => undefined)
125 for (const path of manifestSkillPaths(manifest)) {
126 const dir = path === '.' ? root : `${root}/${path}`
127 const fallback = path === '.' ? plugin : lastSegment(path)
128 if (!(await readSkillDir($, dir, fallback, prefix, apply))) await readSkillsContainer($, dir, prefix, apply)
129 }
130 return found
131}
132
133// Each plugin's install rows across every marketplace, newest first, in registry order:
134// one plugin name is read from one install, whichever marketplace it came from.
135const rowsByPlugin = (raw: unknown): [plugin: string, rows: Row[]][] => {
136 const grouped = new Map<string, Row[]>()
137 for (const [plugin, rows] of pluginInstalls(raw)) grouped.set(plugin, [...(grouped.get(plugin) ?? []), ...rows])
138 return [...grouped].map(([plugin, rows]) => [
139 plugin,
140 rows.sort((a, b) => String(b.lastUpdated ?? '').localeCompare(String(a.lastUpdated ?? ''))),
141 ])
142}
143
144// The newest install whose directory still exists speaks for its plugin.
145async function installRoot($: EngineInterface, rows: Row[]) {
146 for (const row of rows) {
147 if (typeof row.installPath !== 'string') continue
148 if (await $.fs.list(row.installPath).then(() => true, () => false)) return row.installPath
149 }
150 return undefined
151}
152
153// Each coloured skill key's text colour, found where Claude Code finds skills and
154// commands. Lowest to highest: plugins, user commands, user skills, project commands,
155// project skills; the highest definition of a key decides alone.
156async function loadSkillColours($: EngineInterface) {
157 const colours = new Map<string, string>()
158 const apply = applyTo(colours)
159 const configDir = await userConfigDir($)
160 if (configDir) {
161 const raw = await $.fs.read(`${configDir}/plugins/installed_plugins.json`).catch(() => undefined)
162 // Plugins are walked in parallel for a quicker first draw, then merged in a fixed
163 // order, so the result never depends on which read finished first.
164 const plugins = await Promise.all(
165 rowsByPlugin(raw).map(async ([plugin, rows]) => {
166 const root = await installRoot($, rows)
167 return root ? readPluginSkills($, plugin, root).catch(() => new Map<string, string>()) : new Map<string, string>()
168 }),
169 )
170 for (const found of plugins) for (const [key, colour] of found) colours.set(key, colour)
171 await readCommandsDir($, `${configDir}/commands`, '', apply)
172 await readSkillsContainer($, `${configDir}/skills`, '', apply)
173 }
174 await readCommandsDir($, '.claude/commands', '', apply)
175 await readSkillsContainer($, '.claude/skills', '', apply)
176 return colours
177}
178
179// Drawing caches only: a hot reload empties them, and the next draw refills them.
180// The agent and skill files are read once per load, so an edited `color:` shows after a reload.
181const seenTypes = new Set<string>()
182let agents: Promise<Agents> | undefined
183let skills: Promise<Map<string, string>> | undefined
184
185function agentColours($: EngineInterface) {
186 agents ??= loadAgentColours($).catch(() => ({ colours: new Map<string, string>(), names: new Set<string>() }))
187 return agents
188}
189
190function skillColours($: EngineInterface) {
191 skills ??= loadSkillColours($).catch(() => new Map<string, string>())
192 return skills
193}
194
195export const register: Register = on => {
196 agents = undefined
197 skills = undefined
198
199 // The call's header line, redrawn as the engine draws it with one change. An Agent
200 // call, `● dev:architect(Plan the cache)`, puts the agent name on its `color:` badge;
201 // a Skill call, `● Skill(dev:dev)`, draws the skill name in its `color:`.
202 // The result block under it is a separate component and stays the engine's.
203 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
204 const dot = e.props.isErrored ? 'error' : e.props.isRunning ? undefined : 'success'
205 if (e.props.tool === 'Skill') {
206 const key = skillKey(e.props.input)
207 if (!key || e.props.isInterrupted) return next(e)
208 // The skill's own colour only: an agent of the same name never lends its badge.
209 const colour = (await skillColours($)).get(key)
210 if (!colour) return next(e)
211 const shown = String((e.props.input as { skill: string }).skill).trim()
212 const { Box, Text } = $.ui.resolve(e)
213 return (
214 <Box flexDirection="row">
215 <Text color={dot} dimColor={dot === undefined}>● </Text>
216 <Text bold>Skill</Text>
217 <Text>(</Text>
218 <Text color={colour}>{shown}</Text>
219 <Text>)</Text>
220 </Box>
221 )
222 }
223 if (!AGENT_TOOLS.has(e.props.tool)) return next(e)
224 const input = (e.props.input ?? {}) as AgentInput
225 const type = text(input.subagent_type)
226 if (type) seenTypes.add(type)
227 // The engine names a general-purpose call plain `Agent`.
228 const name = !type || type === 'general-purpose' ? 'Agent' : type
229 const description = text(input.description)
230 const badge = badgeFor(type, (await agentColours($)).colours)
231 const { Box, Text } = $.ui.resolve(e)
232 return (
233 <Box flexDirection="row">
234 <Text color={dot} dimColor={dot === undefined}>● </Text>
235 <Text bold backgroundColor={badge.bg} color={badge.fg}>{pad(name)}</Text>
236 {description && <Text>({description})</Text>}
237 </Box>
238 )
239 })
240
241 // Every reply, as the email example does: a paragraph that names an agent or a
242 // coloured skill is drawn by the mod, the agent on its badge and the skill in its
243 // colour; the rest stays the engine's markdown.
244 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
245 const [{ colours: known, names }, skillColour] = await Promise.all([agentColours($), skillColours($)])
246 const running = await $.agent.list().catch(() => [])
247 for (const a of running) seenTypes.add(a.type)
248 const pattern = mentionPattern([...known.keys(), ...seenTypes], skillColour.keys(), names)
249 const blocks = splitReply(e.props.text, pattern)
250 if (!blocks) return next(e)
251 const { Box, Text, Markdown } = $.ui.resolve(e)
252 return (
253 <Box flexDirection="column" gap={1}>
254 {blocks.map((block, b) =>
255 block.kind === 'markdown' ? (
256 <Markdown key={`m${b}`} text={block.text} />
257 ) : (
258 <Box key={`s${b}`} flexDirection="column">
259 {block.lines.map((line, l) => (
260 <Text key={`l${l}`}>
261 {line.map((span, i) => {
262 const skill = span.mention?.kind === 'skill' ? skillColour.get(span.mention.name) : undefined
263 if (skill) {
264 // Text colour only: no background, no padding; it replaces a code span's colour.
265 return (
266 <Text key={`t${i}`} bold={span.bold || false} color={skill}>
267 {span.text}
268 </Text>
269 )
270 }
271 const badge = span.mention?.kind === 'agent' ? badgeFor(span.mention.name, known) : undefined
272 return (
273 <Text
274 key={`t${i}`}
275 bold={span.bold || badge !== undefined}
276 color={badge?.fg ?? (span.code ? 'permission' : undefined)}
277 backgroundColor={badge?.bg}
278 >
279 {badge ? pad(span.text) : span.text}
280 </Text>
281 )
282 })}
283 </Text>
284 ))}
285 </Box>
286 ),
287 )}
288 </Box>
289 )
290 })
291}
292hooks/agents.ts 31 lines1// Pure parsing of agent definitions; register.tsx does the reading, since the engine
2// lets only functions declared in the hooks module itself receive `$`.
3
4export type Row = { installPath?: unknown; lastUpdated?: unknown }
5type Registry = { plugins?: Record<string, Row[]> }
6
7const field = (frontmatter: string, key: string): string | undefined => {
8 const m = new RegExp(`^${key}:[ \\t]*(.+?)[ \\t]*$`, 'm').exec(frontmatter)
9 return m?.[1]?.replace(/^(['"])(.*)\1$/, '$2') || undefined
10}
11
12export const parseAgent = (text: string, fileName: string): { name: string; color?: string } => {
13 const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? ''
14 return { name: field(fm, 'name') ?? fileName.replace(/\.md$/, ''), color: field(fm, 'color') }
15}
16
17// Each installed plugin's name with its install rows, newest first: one plugin has a
18// row per install, and the newest whose agents exist speaks for it.
19export const pluginInstalls = (raw: unknown): [plugin: string, rows: Row[]][] => {
20 let registry: Registry = {}
21 try {
22 if (typeof raw === 'string') registry = JSON.parse(raw) as Registry
23 } catch {
24 // An unreadable registry means no plugin agents get a colour from it.
25 }
26 return Object.entries(registry.plugins ?? {}).map(([id, rows]) => [
27 id.split('@')[0] ?? id,
28 [...rows].sort((a, b) => String(b.lastUpdated ?? '').localeCompare(String(a.lastUpdated ?? ''))),
29 ])
30}
31hooks/colours.ts 104 lines1// The badge look of Claude Code's own `color:` agent field: a saturated background
2// under the full agent name, white or black text, whichever reads on it.
3export type Badge = { bg: string; fg: string }
4
5const WHITE = '#FFFFFF'
6const BLACK = '#000000'
7
8// The values Claude Code's `color:` field takes, plus a few older ones our agents used.
9const NAMED: Record<string, Badge> = {
10 red: { bg: '#E0102A', fg: WHITE },
11 blue: { bg: '#0A5AF0', fg: WHITE },
12 green: { bg: '#0A9A2E', fg: WHITE },
13 yellow: { bg: '#E0A00F', fg: BLACK },
14 purple: { bg: '#7A30E0', fg: WHITE },
15 orange: { bg: '#FF6A10', fg: BLACK },
16 pink: { bg: '#FF55AA', fg: BLACK },
17 cyan: { bg: '#80C8FF', fg: BLACK },
18 violet: { bg: '#A050F0', fg: BLACK },
19 magenta: { bg: '#FF55AA', fg: BLACK },
20 grey: { bg: '#5A6070', fg: WHITE },
21 gray: { bg: '#5A6070', fg: WHITE },
22}
23
24// A `#RRGGBB` value gets white or black text by its perceived brightness.
25const fromHex = (hex: string): Badge | undefined => {
26 const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex)
27 if (!m) return undefined
28 const [r, g, b] = [m[1], m[2], m[3]].map(c => parseInt(c ?? '0', 16))
29 const brightness = (0.299 * (r ?? 0) + 0.587 * (g ?? 0) + 0.114 * (b ?? 0)) / 255
30 return { bg: hex, fg: brightness > 0.6 ? BLACK : WHITE }
31}
32
33export const badgeFromField = (color: string): Badge | undefined => NAMED[color.toLowerCase()] ?? fromHex(color)
34
35// The built-in agents have no definition file, so no `color:` field.
36const BUILT_IN: Record<string, Badge> = {
37 Explore: NAMED.cyan as Badge,
38 Plan: NAMED.green as Badge,
39 'general-purpose': NAMED.grey as Badge,
40}
41
42const FALLBACK: Badge[] = Object.values(NAMED)
43
44// FNV-1a: an agent with no `color:` field always lands on the same badge.
45const hash = (text: string): number => {
46 let h = 0x811c9dc5
47 for (let i = 0; i < text.length; i++) {
48 h ^= text.charCodeAt(i)
49 h = Math.imul(h, 0x01000193)
50 }
51 return h >>> 0
52}
53
54// `colours` is each agent type's `color:` field, from loadAgentColours.
55export const badgeFor = (agentType: string | undefined, colours: ReadonlyMap<string, string>): Badge => {
56 const type = agentType || 'general-purpose'
57 const field = colours.get(type)
58 return (
59 (field && badgeFromField(field)) ||
60 BUILT_IN[type] ||
61 FALLBACK[hash(type) % FALLBACK.length] ||
62 (NAMED.grey as Badge)
63 )
64}
65
66const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
67
68// A skill's text colour: a named value's saturated colour (the badge background), or a
69// `#RRGGBB` value as written. Undefined for anything else, which means "not coloured".
70export const textColourFromField = (color: string): string | undefined => badgeFromField(color)?.bg
71
72const longestFirst = (names: Iterable<string>): string[] => [...names].sort((a, b) => b.length - a.length).map(escape)
73
74// A skill mention never starts inside a path or URL, and never runs on into a longer
75// word, path or file name; sentence punctuation after it is fine.
76const SKILL_BEFORE = '(?<![\\w./:~-])'
77const SKILL_AFTER = '(?![\\w:/-]|\\.\\w)'
78
79// Matches every agent name a reply can mention: the built-ins, every agent with a
80// `color:` field, and the types this session spawned. Longest first, so
81// `dev:developer` wins over a bare `developer`; never inside a longer word.
82//
83// `skillNames` are the coloured skill keys; `agentNames` beyond `names`, when given, are
84// further names that count as agents only for excluding skills (an agent file with no
85// `color:`). A name that is an agent stays an agent. Skill matches sit in the `skill`
86// named group: `/name` for any skill, `plugin:name` bare. With no skills left, the
87// pattern is exactly the agents-only one.
88export const mentionPattern = (
89 names: Iterable<string>,
90 skillNames: Iterable<string> = [],
91 agentNames: Iterable<string> = [],
92): RegExp => {
93 const all = new Set([...Object.keys(BUILT_IN), ...names])
94 all.delete('teammate')
95 const agents = `(?<![\\w:-])(?:${longestFirst(all).join('|')})(?![\\w:-])`
96 const notSkills = new Set([...all, ...agentNames])
97 const skills = [...new Set(skillNames)].filter(s => s && !notSkills.has(s))
98 if (!skills.length) return new RegExp(agents, 'g')
99 const qualified = skills.filter(s => s.includes(':'))
100 const alternatives = [`/(?:${longestFirst(skills).join('|')})`]
101 if (qualified.length) alternatives.push(`(?:${longestFirst(qualified).join('|')})`)
102 return new RegExp(`${agents}|(?<skill>${SKILL_BEFORE}(?:${alternatives.join('|')})${SKILL_AFTER})`, 'g')
103}
104hooks/mentions.ts 93 lines1// Splits a reply into the parts the engine keeps drawing as markdown and the
2// paragraphs that name an agent or a coloured skill, which the mod draws itself as
3// styled spans.
4
5// `name` is the agent type or the skill key, never with a leading `/`.
6export type Mention = { kind: 'agent' | 'skill'; name: string }
7export type Span = { text: string; bold?: boolean; code?: boolean; mention?: Mention }
8export type Block = { kind: 'markdown'; text: string } | { kind: 'styled'; lines: Span[][] }
9
10const FENCE = /^\s*(```|~~~)/
11
12// Paragraphs, never cutting through a fenced code block, whose names stay as written.
13const paragraphs = (text: string): { text: string; isCode: boolean }[] => {
14 const out: { text: string; isCode: boolean }[] = []
15 let current: string[] = []
16 let inFence = false
17 let hasFence = false
18 const flush = () => {
19 if (current.length) out.push({ text: current.join('\n'), isCode: hasFence })
20 current = []
21 hasFence = false
22 }
23 for (const line of text.split('\n')) {
24 if (FENCE.test(line)) {
25 if (!inFence) flush()
26 inFence = !inFence
27 hasFence = true
28 current.push(line)
29 if (!inFence) flush()
30 continue
31 }
32 if (!inFence && line.trim() === '') {
33 flush()
34 continue
35 }
36 current.push(line)
37 }
38 flush()
39 return out
40}
41
42const withMentions = (text: string, pattern: RegExp, style: Omit<Span, 'text'>): Span[] => {
43 const spans: Span[] = []
44 let last = 0
45 for (const m of text.matchAll(new RegExp(pattern.source, 'g'))) {
46 const at = m.index ?? 0
47 if (at > last) spans.push({ ...style, text: text.slice(last, at) })
48 // A match in the pattern's `skill` group is a skill; any other is an agent.
49 const mention: Mention =
50 m.groups?.skill !== undefined ? { kind: 'skill', name: m[0].replace(/^\//, '') } : { kind: 'agent', name: m[0] }
51 spans.push({ ...style, text: m[0], mention })
52 last = at + m[0].length
53 }
54 if (last < text.length) spans.push({ ...style, text: text.slice(last) })
55 return spans
56}
57
58// The inline markdown a styled paragraph keeps: **bold**, `code`, and headings as bold.
59const INLINE = /\*\*([^*]+)\*\*|`([^`]+)`/g
60
61const lineSpans = (line: string, pattern: RegExp): Span[] => {
62 const heading = /^#{1,6}\s+(.*)$/.exec(line)
63 if (heading) return withMentions(heading[1] ?? '', pattern, { bold: true })
64 const spans: Span[] = []
65 let last = 0
66 for (const m of line.matchAll(INLINE)) {
67 const at = m.index ?? 0
68 if (at > last) spans.push(...withMentions(line.slice(last, at), pattern, {}))
69 if (m[1] !== undefined) spans.push(...withMentions(m[1], pattern, { bold: true }))
70 else spans.push(...withMentions(m[2] ?? '', pattern, { code: true }))
71 last = at + m[0].length
72 }
73 if (last < line.length) spans.push(...withMentions(line.slice(last), pattern, {}))
74 return spans
75}
76
77export const splitReply = (text: string, pattern: RegExp): Block[] | undefined => {
78 const blocks: Block[] = []
79 let hasMention = false
80 for (const p of paragraphs(text)) {
81 const mentions = !p.isCode && new RegExp(pattern.source).test(p.text)
82 if (mentions) {
83 hasMention = true
84 blocks.push({ kind: 'styled', lines: p.text.split('\n').map(l => lineSpans(l, pattern)) })
85 continue
86 }
87 const prev = blocks[blocks.length - 1]
88 if (prev?.kind === 'markdown') prev.text += `\n\n${p.text}`
89 else blocks.push({ kind: 'markdown', text: p.text })
90 }
91 return hasMention ? blocks : undefined
92}
93hooks/skills.ts 42 lines1// Pure parsing of skill and command definitions; register.tsx does the reading, since
2// the engine lets only functions declared in the hooks module itself receive `$`.
3import { parseAgent } from './agents'
4
5export type SkillDef = { name: string; color?: string }
6
7// A skill's frontmatter follows the agent rules exactly: `name:` and `color:`, quotes
8// stripped, CRLF tolerated. `fallbackName` names it when `name:` is absent: the folder
9// name for a SKILL.md, the file name for a command (a trailing `.md` is dropped).
10export const parseSkill = (text: string, fallbackName: string): SkillDef => parseAgent(text, fallbackName)
11
12// The plugin manifest's `skills` field as clean paths relative to the plugin root.
13// `raw` is the plugin.json text; anything unreadable gives no paths. Absolute paths and
14// any path with a `..` segment are dropped, so a manifest never reaches outside its plugin.
15export const manifestSkillPaths = (raw: unknown): string[] => {
16 let field: unknown
17 try {
18 if (typeof raw === 'string') field = (JSON.parse(raw) as { skills?: unknown } | null)?.skills
19 } catch {
20 return []
21 }
22 const listed = typeof field === 'string' ? [field] : Array.isArray(field) ? field : []
23 const paths: string[] = []
24 for (const entry of listed) {
25 if (typeof entry !== 'string') continue
26 const trimmed = entry.trim()
27 if (trimmed.startsWith('/') || /^[A-Za-z]:[\\/]/.test(trimmed)) continue
28 const clean = trimmed.replace(/^(\.\/)+/, '').replace(/\/+$/, '')
29 if (clean.split('/').includes('..')) continue
30 paths.push(clean === '' ? '.' : clean)
31 }
32 return paths
33}
34
35// The key a Skill tool input names: `skill` trimmed, one leading `/` dropped.
36export const skillKey = (input: unknown): string | undefined => {
37 const skill = (input as { skill?: unknown } | null | undefined)?.skill
38 if (typeof skill !== 'string') return undefined
39 const key = skill.trim().replace(/^\//, '')
40 return key || undefined
41}
42