authorgravatar for 43336552+amiralibg@users.noreply.github.comAmirali Beigi <43336552+amiralibg@users.noreply.github.com> 2026-09-05 04:43:43+03:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 18:13:43-07:00
log82f64cd8d046ab4ee8588f7bcbc1e66a4a0c80fe
treee44af8be389d0e1acf9c0305c4160ad5b6bd090f
parent896fe82f2f191eb8ac0fd620e19c25dd48253592
signature Signed by PGP key B5690EEEBB952194

fix(skills): support names beginning with digits (#9244)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

11 files changed, 157 insertions(+), 7 deletions(-)

apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+2-1
......@@ -195,7 +195,8 @@ function appendRun(
195195 return runs;
196196}
197197
198const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
198const SKILL_TOKEN_REGEX =
199 /(^|\s)\$(?![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|$)/g;
199200
200201function formatSkillLabel(skill: SelectableMarkdownSkill): string {
201202 const displayName = skill.displayName?.trim();
apps/mobile/src/lib/nativeMarkdownText.test.ts+23
......@@ -226,6 +226,29 @@ describe("nativeMarkdownDocumentRuns", () => {
226226 ]);
227227 });
228228
229 it("decorates known skill references that begin with a digit", () => {
230 const node: MarkdownNode = {
231 type: "document",
232 children: [
233 {
234 type: "paragraph",
235 children: [{ type: "text", content: "Use $2spec for this." }],
236 },
237 ],
238 };
239
240 expect(nativeMarkdownDocumentRuns(node, [{ name: "2spec", displayName: "2Spec" }])).toEqual([
241 { text: "Use ", role: "body" },
242 {
243 text: "$2spec",
244 role: "body",
245 skillName: "2spec",
246 skillLabel: "2Spec",
247 },
248 { text: " for this.", role: "body" },
249 ]);
250 });
251
229252 it("decorates known skill references inside blockquotes", () => {
230253 const node: MarkdownNode = {
231254 type: "blockquote",
apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts+15-1
......@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22
33import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts";
44
5const SKILLS = new Set(["implement", "review", "re-release-version"]);
5const SKILLS = new Set(["2spec", "implement", "review", "re-release-version"]);
66
77describe("planClaudeSkillDispatch", () => {
88 it("leaves a prompt without a known skill untouched", () => {
......@@ -27,6 +27,14 @@ describe("planClaudeSkillDispatch", () => {
2727 });
2828 });
2929
30 it("dispatches a known skill whose name begins with a digit", () => {
31 expect(planClaudeSkillDispatch("use $2spec for this", SKILLS)).toEqual({
32 leadingText: "use",
33 commandText: "/2spec for this",
34 skillName: "2spec",
35 });
36 });
37
3038 it("dispatches the last mention and rewrites earlier ones inline", () => {
3139 expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({
3240 leadingText: "/review the diff, then",
......@@ -38,4 +46,10 @@ describe("planClaudeSkillDispatch", () => {
3846 it("ignores a dollar token glued to other text", () => {
3947 expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined();
4048 });
49
50 it("ignores currency amounts and compact monetary expressions", () => {
51 const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M"]);
52 expect(planClaudeSkillDispatch("pay $20 tomorrow", skillsWithCurrency)).toBeUndefined();
53 expect(planClaudeSkillDispatch("budget is $20k tomorrow", skillsWithCurrency)).toBeUndefined();
54 });
4155});
apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts+2-1
......@@ -29,7 +29,8 @@
2929 * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a
3030 * dispatched skill are always the same set.
3131 */
32const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
32const SKILL_MENTION_PATTERN =
33 /(^|\s)\$(?![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|$)/g;
3334
3435export interface ClaudeSkillDispatch {
3536 /** Text before the dispatched mention, or `undefined` when it opens the prompt. */
apps/server/src/provider/Drivers/CursorSkills.ts+3-2
......@@ -19,8 +19,9 @@ import * as Schema from "effect/Schema";
1919import { parse as parseYamlDocument } from "yaml";
2020
2121const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
22const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
23const HAS_SKILL_MENTION_PATTERN = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=\s|$)/;
22const SKILL_MENTION_PATTERN =
23 /(^|\s)\$(?![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|$)/g;
24const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source);
2425const MAX_SKILL_DEPTH = 10;
2526const MAX_SKILL_BYTES = FileSystem.Size(1_000_000);
2627const MAX_SKILL_SCAN_ENTRIES = 10_000;
apps/server/src/provider/Layers/CursorProvider.test.ts+18
......@@ -455,6 +455,24 @@ describe("Cursor skills", () => {
455455 "please /review this",
456456 );
457457 });
458
459 it("detects and invokes digit-leading Cursor skills without rewriting money", () => {
460 const names = new Set(["2spec", "20k", "100M", "1e6"]);
461 // Repeated presence checks must not carry a global-regex cursor.
462 expect(hasCursorSkillMention("use $2spec here")).toBe(true);
463 expect(hasCursorSkillMention("use $2spec here")).toBe(true);
464 expect(rewriteCursorSkillMentions("use $2spec here", names)).toBe("use /2spec here");
465 expect(rewriteCursorSkillMentions("use $2spec here", new Set())).toBe("use $2spec here");
466 for (const text of [
467 "pay $20 tomorrow",
468 "budget $20k here",
469 "cost $100M total",
470 "limit $1e6 here",
471 ]) {
472 expect(hasCursorSkillMention(text)).toBe(false);
473 expect(rewriteCursorSkillMentions(text, names)).toBe(text);
474 }
475 });
458476});
459477
460478describe("getCursorFallbackModels", () => {
apps/web/src/components/ChatMarkdown.test.tsx+40
......@@ -293,6 +293,46 @@ describe("hasMarkdownFilePrimaryAction", () => {
293293 });
294294});
295295
296describe("ChatMarkdown skill chips", () => {
297 it("updates digit-leading skill labels when discovered skills change", async () => {
298 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
299 let renderer: ReactTestRenderer | undefined;
300 const text = "Use $2spec with a $20k budget.";
301 try {
302 await act(async () => {
303 renderer = create(<ChatMarkdown cwd="/tmp/project" text={text} />);
304 });
305 const mounted = renderer!;
306 const labels = (label: string) =>
307 mounted.root.findAllByType("span").filter((node) => node.children.includes(label));
308 expect(labels("2Spec")).toHaveLength(0);
309
310 await act(async () => {
311 mounted.update(
312 <ChatMarkdown
313 cwd="/tmp/project"
314 text={text}
315 skills={[
316 { name: "2spec", displayName: "2Spec" },
317 { name: "20k", displayName: "MoneySkill" },
318 ]}
319 />,
320 );
321 });
322 expect(labels("2Spec")).toHaveLength(1);
323 expect(labels("MoneySkill")).toHaveLength(0);
324
325 await act(async () => {
326 mounted.update(<ChatMarkdown cwd="/tmp/project" text={text} skills={[]} />);
327 });
328 expect(labels("2Spec")).toHaveLength(0);
329 } finally {
330 await act(async () => renderer?.unmount());
331 vi.unstubAllGlobals();
332 }
333 });
334});
335
296336describe("ChatMarkdown file option chips", () => {
297337 it("keeps the fallback button text selectable", () => {
298338 const html = renderToStaticMarkup(
apps/web/src/components/chat/SkillInlineText.tsx+2-1
......@@ -10,7 +10,8 @@ import {
1010} from "../composerInlineChip";
1111import { cn } from "~/lib/utils";
1212
13const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
13const SKILL_TOKEN_REGEX =
14 /(^|\s)\$(?![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|$)/g;
1415
1516type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">;
1617
apps/web/src/composer-editor-mentions.test.ts+23
......@@ -198,6 +198,29 @@ describe("splitPromptIntoComposerSegments", () => {
198198 ]);
199199 });
200200
201 it("splits digit-leading skill tokens into skill segments", () => {
202 expect(splitPromptIntoComposerSegments("Use $2spec please")).toEqual([
203 { type: "text", text: "Use " },
204 { type: "skill", name: "2spec" },
205 { type: "text", text: " please" },
206 ]);
207 });
208
209 it("keeps digits-only dollar amounts and compact monetary expressions as text", () => {
210 expect(splitPromptIntoComposerSegments("I'll pay $20 tomorrow")).toEqual([
211 { type: "text", text: "I'll pay $20 tomorrow" },
212 ]);
213 expect(splitPromptIntoComposerSegments("Budget is $20k tomorrow")).toEqual([
214 { type: "text", text: "Budget is $20k tomorrow" },
215 ]);
216 expect(splitPromptIntoComposerSegments("Cost is $100M total")).toEqual([
217 { type: "text", text: "Cost is $100M total" },
218 ]);
219 expect(splitPromptIntoComposerSegments("Limit is $1e6 here")).toEqual([
220 { type: "text", text: "Limit is $1e6 here" },
221 ]);
222 });
223
201224 it("does not convert an incomplete trailing skill token", () => {
202225 expect(splitPromptIntoComposerSegments("Use $review-follow-up")).toEqual([
203226 { type: "text", text: "Use $review-follow-up" },
packages/shared/src/composerInlineTokens.test.ts+20
......@@ -31,6 +31,26 @@ describe("collectComposerInlineTokens", () => {
3131 ]);
3232 });
3333
34 it("collects skill names that begin with a digit", () => {
35 expect(collectComposerInlineTokens("Use $2spec next")).toEqual([
36 {
37 type: "skill",
38 value: "2spec",
39 source: "$2spec",
40 start: 4,
41 end: 10,
42 },
43 ]);
44 });
45
46 it("leaves digits-only dollar amounts and compact monetary expressions as text", () => {
47 expect(collectComposerInlineTokens("I'll pay $20 tomorrow")).toEqual([]);
48 expect(collectComposerInlineTokens("Budget is $1_000 total")).toEqual([]);
49 expect(collectComposerInlineTokens("Budget is $20k tomorrow")).toEqual([]);
50 expect(collectComposerInlineTokens("Cost is $100M total")).toEqual([]);
51 expect(collectComposerInlineTokens("Limit is $1e6 here")).toEqual([]);
52 });
53
3454 it("does not convert incomplete trailing tokens", () => {
3555 expect(collectComposerInlineTokens("Use $ui")).toEqual([]);
3656 expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]);
packages/shared/src/composerInlineTokens.ts+9-1
......@@ -18,7 +18,15 @@ export interface CollectComposerInlineTokensOptions {
1818 readonly preserveTrailingFrom?: ReadonlyArray<ComposerInlineToken>;
1919}
2020
21const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g;
21/**
22 * A skill name may start with a digit, but compact monetary amounts and
23 * numeric expressions like "$20", "$20k", "$100M", and "$1e6" must stay prose:
24 * the composer chips any matched `$name` token, known or not. Tokens beginning
25 * with digits must not match numbers with currency/exponent suffixes, and must
26 * contain at least one letter.
27 */
28const SKILL_TOKEN_REGEX =
29 /(^|\s)\$(?![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)/g;
2230const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g;
2331/**
2432 * The label body is bounded rather than `*`. Unbounded, every whitespace in