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