| 1 | /** |
| 2 | * ClaudeSkillDispatch — turns `$skill` mentions in a composer prompt into the |
| 3 | * slash invocation Claude Code actually runs. |
| 4 | * |
| 5 | * The composer inserts `$name` for every provider. Codex parses that natively; |
| 6 | * Claude Code does not, and treats it as prose. Claude Code's only user-side |
| 7 | * invocation is a text block whose first character is `/`: the harness |
| 8 | * expands `/name args` into the SKILL.md body, and every character after the |
| 9 | * name (newlines included) arrives as `ARGUMENTS`. Verified against the CLI in |
| 10 | * stream-json mode, which is what the Agent SDK uses: |
| 11 | * |
| 12 | * - The check runs on the LAST text block of the message. Earlier text |
| 13 | * blocks are preserved verbatim, and image blocks may sit before it. |
| 14 | * - Leading whitespace, or a `/name` that starts a later line of the same |
| 15 | * block, is literal text. |
| 16 | * - Only one skill expands per message; a second `/x` becomes argument text |
| 17 | * (anthropics/claude-code#87113). The model still starts the rest through |
| 18 | * its Skill tool when it reads `/name` in the prompt, so earlier mentions |
| 19 | * are rewritten to `/name` inline. |
| 20 | * |
| 21 | * So one mention anywhere in the prompt becomes a guaranteed invocation, and |
| 22 | * the user's text on either side is kept in order. |
| 23 | * |
| 24 | * @module provider/Drivers/ClaudeSkillDispatch |
| 25 | */ |
| 26 | |
| 27 | /** |
| 28 | * Same token shape the composer and timeline chips recognise |
| 29 | * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a |
| 30 | * dispatched skill are always the same set. |
| 31 | */ |
| 32 | const SKILL_MENTION_PATTERN = |
| 33 | /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; |
| 34 | |
| 35 | export interface ClaudeSkillDispatch { |
| 36 | /** Text before the dispatched mention, or `undefined` when it opens the prompt. */ |
| 37 | readonly leadingText: string | undefined; |
| 38 | /** `/name` plus the trailing text, ready to be the message's last text block. */ |
| 39 | readonly commandText: string; |
| 40 | readonly skillName: string; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Split `prompt` around the last `$skill` mention that names a known skill. |
| 45 | * Returns `undefined` when there is nothing to dispatch, in which case the |
| 46 | * prompt should go out unchanged. Mentions that do not match a discovered |
| 47 | * skill stay literal: a `$HOME` in prose must not become a command. |
| 48 | */ |
| 49 | export function planClaudeSkillDispatch( |
| 50 | prompt: string, |
| 51 | skillNames: ReadonlySet<string>, |
| 52 | ): ClaudeSkillDispatch | undefined { |
| 53 | const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => { |
| 54 | const name = match[2] ?? ""; |
| 55 | if (!skillNames.has(name)) return []; |
| 56 | const start = (match.index ?? 0) + (match[1]?.length ?? 0); |
| 57 | return [{ name, start, end: (match.index ?? 0) + match[0].length }]; |
| 58 | }); |
| 59 | const last = mentions.at(-1); |
| 60 | if (!last) { |
| 61 | return undefined; |
| 62 | } |
| 63 | |
| 64 | const leading = prompt.slice(0, last.start); |
| 65 | const trailing = prompt.slice(last.end); |
| 66 | const leadingWithInlineSlashes = mentions |
| 67 | .slice(0, -1) |
| 68 | .reduceRight( |
| 69 | (text, mention) => |
| 70 | `${text.slice(0, mention.start)}/${mention.name}${text.slice(mention.end)}`, |
| 71 | leading, |
| 72 | ) |
| 73 | .trimEnd(); |
| 74 | |
| 75 | return { |
| 76 | leadingText: leadingWithInlineSlashes.length > 0 ? leadingWithInlineSlashes : undefined, |
| 77 | commandText: `/${last.name}${trailing}`.trimEnd(), |
| 78 | skillName: last.name, |
| 79 | }; |
| 80 | } |