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 */
32const 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
35export 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 */
49export 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}