SLOPSHOPPER

agent-colours

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…

newrows
A shopper browsing a rack in a slop shop
README

agent-colours

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:

  • The Agent row. ● dev:architect(Plan the cache): the name sits on its badge, and the rest of the row is as Claude Code draws it.
  • The Skill row. ● Skill(dev:dev): the skill name is drawn in its colour.
  • Every reply. Wherever a reply names an agent, the name gets the same badge; a coloured skill named in a reply is drawn in its colour.

Where the colour comes from

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.

  • The built-in agents (Explore, Plan, general-purpose) have no file, so the mod gives them fixed colours.
  • An agent with no 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.

Skills

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:

  1. each installed plugin's 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)
  2. ~/.claude/commands/*.md, then ~/.claude/skills/<name>/SKILL.md, symbolic links included
  3. the project's .claude/commands/*.md, then .claude/skills/<name>/SKILL.md

The 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.

Install

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

Limits

  • Only the name in a reply paragraph is restyled, but the paragraph is redrawn. A mod cannot colour one word inside a paragraph Claude Code draws, so a paragraph that names an agent is drawn by the mod. It keeps bold, inline code and headings. Links, italics and list bullets in that one paragraph show as plain text. Other paragraphs and code blocks are untouched.
  • Explore and Plan are coloured wherever they appear capitalised. Lowercase "plan" never is.
  • Some places have no hook. The agents panel under the prompt, the /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.
  • The typed /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 in subfolders (commands/a/b.md, invoked as a:b) are not coloured.
  • A skill name right beside bold or code markers is judged on the run it sits in. Each bold, code and plain run of a paragraph is matched on its own, so the characters on the far side of a marker do not count. ` a/commit and x/commit colour /commit, and /commits colours the /commit of what reads as /commits`. Plain text has no such edge.

Developing it

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).

Source 5 files
hooks/register.tsx 292 lines
1import 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}
292
hooks/agents.ts 31 lines
1// 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}
31
hooks/colours.ts 104 lines
1// 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}
104
hooks/mentions.ts 93 lines
1// 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}
93
hooks/skills.ts 42 lines
1// 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