History

fix(claude): skills picked from the composer now run (#9128)

Theo Browne committed 2 months ago

Commit ea71a191 parent 9a7b1e216 files · +1080 −82Browse files

Showing the changes to apps/server (6 of 16 files). Show all

Unified · Split

6 files
  1. apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +41 −0
  2. apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +78 −0
  3. apps/server/src/provider/Drivers/ClaudeSkills.test.ts +430 −51
  4. apps/server/src/provider/Drivers/ClaudeSkills.ts +250 −21
  5. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +132 −0
  6. apps/server/src/provider/Layers/ClaudeAdapter.ts +38 −1
apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts+41−0
@@ -0,0 +1,41 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts";
4
5const SKILLS = new Set(["implement", "review", "re-release-version"]);
6
7describe("planClaudeSkillDispatch", () => {
8 it("leaves a prompt without a known skill untouched", () => {
9 expect(planClaudeSkillDispatch("fix the build", SKILLS)).toBeUndefined();
10 // Not a discovered skill, so it stays prose rather than becoming a command.
11 expect(planClaudeSkillDispatch("echo $HOME then $unknown", SKILLS)).toBeUndefined();
12 });
13
14 it("moves a mid-prompt mention into a trailing slash command", () => {
15 expect(planClaudeSkillDispatch("ok, now $implement all the tickets", SKILLS)).toEqual({
16 leadingText: "ok, now",
17 commandText: "/implement all the tickets",
18 skillName: "implement",
19 });
20 });
21
22 it("keeps a mention that opens the prompt as a single command block", () => {
23 expect(planClaudeSkillDispatch("$review\nfocus on auth", SKILLS)).toEqual({
24 leadingText: undefined,
25 commandText: "/review\nfocus on auth",
26 skillName: "review",
27 });
28 });
29
30 it("dispatches the last mention and rewrites earlier ones inline", () => {
31 expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({
32 leadingText: "/review the diff, then",
33 commandText: "/implement the fixes",
34 skillName: "implement",
35 });
36 });
37
38 it("ignores a dollar token glued to other text", () => {
39 expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined();
40 });
41});
apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts+78−0
@@ -0,0 +1,78 @@
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 = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g;
33
34export interface ClaudeSkillDispatch {
35 /** Text before the dispatched mention, or `undefined` when it opens the prompt. */
36 readonly leadingText: string | undefined;
37 /** `/name` plus the trailing text, ready to be the message's last text block. */
38 readonly commandText: string;
39 readonly skillName: string;
40}
41
42/**
43 * Split `prompt` around the last `$skill` mention that names a known skill.
44 * Returns `undefined` when there is nothing to dispatch, in which case the
45 * prompt should go out unchanged. Mentions that do not match a discovered
46 * skill stay literal: a `$HOME` in prose must not become a command.
47 */
48export function planClaudeSkillDispatch(
49 prompt: string,
50 skillNames: ReadonlySet<string>,
51): ClaudeSkillDispatch | undefined {
52 const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => {
53 const name = match[2] ?? "";
54 if (!skillNames.has(name)) return [];
55 const start = (match.index ?? 0) + (match[1]?.length ?? 0);
56 return [{ name, start, end: start + name.length + 1 }];
57 });
58 const last = mentions.at(-1);
59 if (!last) {
60 return undefined;
61 }
62
63 const leading = prompt.slice(0, last.start);
64 const trailing = prompt.slice(last.end);
65 const leadingWithInlineSlashes = mentions
66 .slice(0, -1)
67 .reduceRight(
68 (text, mention) => `${text.slice(0, mention.start)}/${text.slice(mention.start + 1)}`,
69 leading,
70 )
71 .trimEnd();
72
73 return {
74 leadingText: leadingWithInlineSlashes.length > 0 ? leadingWithInlineSlashes : undefined,
75 commandText: `/${last.name}${trailing}`.trimEnd(),
76 skillName: last.name,
77 };
78}
apps/server/src/provider/Drivers/ClaudeSkills.test.ts+430−51
@@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";5import * as Path from "effect/Path";
6 6
7import { discoverClaudeSkills } from "./ClaudeSkills.ts";7import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts";
8 8
9const writeSkill = Effect.fn(function* (9const writeSkill = Effect.fn(function* (
10 skillsDir: string,10 skillsDir: string,
@@ -66,7 +66,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { · 55 unmodified lines
66 }),66 }),
67 );67 );
68 68
69 it.effect("discovers project skills from the workspace .agents directory", () =>69 it.effect("ignores .agents/skills, which Claude Code does not load", () =>
70 Effect.gen(function* () {70 Effect.gen(function* () {
71 const fs = yield* FileSystem.FileSystem;71 const fs = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;72 const path = yield* Path.Path;
@@ -74,6 +74,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { · 1 unmodified line
74 const configDir = path.join(tempDir, "claude-home");74 const configDir = path.join(tempDir, "claude-home");
75 const workspace = path.join(tempDir, "workspace");75 const workspace = path.join(tempDir, "workspace");
76 76
77 // Verified against the CLI: `/review` here is answered with
78 // `Unknown command`, so offering it would dispatch a dead command.
77 yield* writeSkill(79 yield* writeSkill(
78 path.join(workspace, ".agents", "skills"),80 path.join(workspace, ".agents", "skills"),
79 "review",81 "review",
@@ -82,19 +84,11 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { · 2 unmodified lines
82 84
83 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);85 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
84 86
85 assert.deepEqual(skills, [87 assert.deepEqual(skills, []);
86 {
87 name: "review",
88 path: path.join(workspace, ".agents", "skills", "review", "SKILL.md"),
89 enabled: true,
90 scope: "project",
91 description: "Review the changes.",
92 },
93 ]);
94 }),88 }),
95 );89 );
96 90
97 it.effect("prefers workspace .claude skills on three-way name collisions", () =>91 it.effect("prefers user skills on name collisions even with a stray .agents copy", () =>
98 Effect.gen(function* () {92 Effect.gen(function* () {
99 const fs = yield* FileSystem.FileSystem;93 const fs = yield* FileSystem.FileSystem;
100 const path = yield* Path.Path;94 const path = yield* Path.Path;
@@ -123,49 +117,16 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { · 22 unmodified lines
123 assert.deepEqual(skills, [117 assert.deepEqual(skills, [
124 {118 {
125 name: "deploy",119 name: "deploy",
126 path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"),120 path: path.join(configDir, "skills", "deploy", "SKILL.md"),
127 enabled: true,121 enabled: true,
128 scope: "project",122 scope: "user",
129 description: "Claude deploy.",123 description: "User deploy.",
130 },
131 ]);
132 }),
133 );
134
135 it.effect("prefers workspace .agents skills over user skills on name collisions", () =>
136 Effect.gen(function* () {
137 const fs = yield* FileSystem.FileSystem;
138 const path = yield* Path.Path;
139 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
140 const configDir = path.join(tempDir, "claude-home");
141 const workspace = path.join(tempDir, "workspace");
142
143 yield* writeSkill(
144 path.join(configDir, "skills"),
145 "deploy",
146 ["---", "name: deploy", "description: User deploy.", "---"].join("\n"),
147 );
148 yield* writeSkill(
149 path.join(workspace, ".agents", "skills"),
150 "deploy",
151 ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"),
152 );
153
154 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
155
156 assert.deepEqual(skills, [
157 {
158 name: "deploy",
159 path: path.join(workspace, ".agents", "skills", "deploy", "SKILL.md"),
160 enabled: true,
161 scope: "project",
162 description: "Agents deploy.",
163 },124 },
164 ]);125 ]);
165 }),126 }),
166 );127 );
167 128
168 it.effect("prefers project skills over user skills on name collisions", () =>129 it.effect("prefers user skills over project skills on name collisions", () =>
169 Effect.gen(function* () {130 Effect.gen(function* () {
170 const fs = yield* FileSystem.FileSystem;131 const fs = yield* FileSystem.FileSystem;
171 const path = yield* Path.Path;132 const path = yield* Path.Path;
@@ -187,8 +148,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { · 15 unmodified lines
187 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);148 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
188 149
189 assert.equal(skills.length, 1);150 assert.equal(skills.length, 1);
190 assert.equal(skills[0]?.scope, "project");151 assert.equal(skills[0]?.scope, "user");
191 assert.equal(skills[0]?.description, "Project deploy.");152 assert.equal(skills[0]?.description, "User deploy.");
192 }),153 }),
193 );154 );
194 155
@@ -287,6 +248,424 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { · 92 unmodified lines
287 }),248 }),
288 );249 );
289 250
251 it.effect("marks skills that only the user can invoke", () =>
252 Effect.gen(function* () {
253 const fs = yield* FileSystem.FileSystem;
254 const path = yield* Path.Path;
255 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
256 const configDir = path.join(tempDir, "claude-home");
257 const workspace = path.join(tempDir, "workspace");
258
259 yield* writeSkill(
260 path.join(workspace, ".claude", "skills"),
261 "re-release-version",
262 [
263 "---",
264 "name: re-release-version",
265 "description: Move the current tag forward.",
266 "disable-model-invocation: true",
267 "---",
268 "",
269 "# Body",
270 ].join("\n"),
271 );
272 yield* writeSkill(
273 path.join(workspace, ".claude", "skills"),
274 "release-version",
275 ["---", "name: release-version", "description: Cut a release.", "---", "", "# Body"].join(
276 "\n",
277 ),
278 );
279
280 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
281
282 assert.equal(
283 skills.find((skill) => skill.name === "re-release-version")?.userInvocationOnly,
284 true,
285 );
286 assert.equal(
287 skills.find((skill) => skill.name === "release-version")?.userInvocationOnly,
288 undefined,
289 );
290 }),
291 );
292
293 it.effect("disables skills switched off by skillOverrides", () =>
294 Effect.gen(function* () {
295 const fs = yield* FileSystem.FileSystem;
296 const path = yield* Path.Path;
297 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
298 const configDir = path.join(tempDir, "claude-home");
299 const workspace = path.join(tempDir, "workspace");
300
301 for (const name of ["kept", "off-by-user", "off-by-project"]) {
302 yield* writeSkill(
303 path.join(configDir, "skills"),
304 name,
305 ["---", `name: ${name}`, "---", "", "# Body"].join("\n"),
306 );
307 }
308
309 yield* fs.makeDirectory(configDir, { recursive: true });
310 yield* fs.writeFileString(
311 path.join(configDir, "settings.json"),
312 '{ "skillOverrides": { "off-by-user": "off", "kept": "on" } }',
313 );
314 yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true });
315 yield* fs.writeFileString(
316 path.join(workspace, ".claude", "settings.json"),
317 '{ "skillOverrides": { "off-by-project": "off" } }',
318 );
319
320 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
321
322 assert.deepEqual(
323 skills.map((skill) => [skill.name, skill.enabled]),
324 [
325 ["kept", true],
326 ["off-by-project", false],
327 ["off-by-user", false],
328 ],
329 );
330 }),
331 );
332
333 it.effect("ignores unreadable settings when resolving skillOverrides", () =>
334 Effect.gen(function* () {
335 const fs = yield* FileSystem.FileSystem;
336 const path = yield* Path.Path;
337 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
338 const configDir = path.join(tempDir, "claude-home");
339
340 yield* writeSkill(
341 path.join(configDir, "skills"),
342 "kept",
343 ["---", "name: kept", "---", "", "# Body"].join("\n"),
344 );
345 yield* fs.writeFileString(path.join(configDir, "settings.json"), "{ not json");
346
347 const skills = yield* discoverClaudeSkills({ homePath: configDir });
348
349 assert.deepEqual(
350 skills.map((skill) => [skill.name, skill.enabled]),
351 [["kept", true]],
352 );
353 }),
354 );
355
356 it.effect("treats a user-invocable-only override like disable-model-invocation", () =>
357 Effect.gen(function* () {
358 const fs = yield* FileSystem.FileSystem;
359 const path = yield* Path.Path;
360 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
361 const configDir = path.join(tempDir, "claude-home");
362
363 yield* writeSkill(
364 path.join(configDir, "skills"),
365 "ask-matt",
366 ["---", "name: ask-matt", "---", "", "# Body"].join("\n"),
367 );
368 yield* fs.writeFileString(
369 path.join(configDir, "settings.json"),
370 '{ "skillOverrides": { "ask-matt": "user-invocable-only" } }',
371 );
372
373 const skills = yield* discoverClaudeSkills({ homePath: configDir });
374
375 assert.deepEqual(
376 skills.map((skill) => [skill.name, skill.enabled, skill.userInvocationOnly === true]),
377 [["ask-matt", true, true]],
378 );
379 }),
380 );
381
382 it.effect("drops every override in a file when one value is invalid, as Claude Code does", () =>
383 Effect.gen(function* () {
384 const fs = yield* FileSystem.FileSystem;
385 const path = yield* Path.Path;
386 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
387 const configDir = path.join(tempDir, "claude-home");
388
389 for (const name of ["unknown-mode", "boolean-false", "sibling-off"]) {
390 yield* writeSkill(
391 path.join(configDir, "skills"),
392 name,
393 ["---", `name: ${name}`, "---", "", "# Body"].join("\n"),
394 );
395 }
396 // Verified against the CLI: with an unknown string or a boolean in the
397 // map, the valid "off" sibling is ignored too and every skill runs.
398 yield* fs.writeFileString(
399 path.join(configDir, "settings.json"),
400 '{ "skillOverrides": { "unknown-mode": "some-future-mode", "boolean-false": false, "sibling-off": "off" } }',
401 );
402
403 const skills = yield* discoverClaudeSkills({ homePath: configDir });
404
405 assert.deepEqual(
406 skills.map((skill) => [skill.name, skill.enabled]),
407 [
408 ["boolean-false", true],
409 ["sibling-off", true],
410 ["unknown-mode", true],
411 ],
412 );
413 }),
414 );
415
416 it.effect("reads repository root settings from a nested workspace", () =>
417 Effect.gen(function* () {
418 const fs = yield* FileSystem.FileSystem;
419 const path = yield* Path.Path;
420 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
421 const configDir = path.join(tempDir, "claude-home");
422 const repo = path.join(tempDir, "repo");
423 const workspace = path.join(repo, "packages", "app");
424
425 for (const name of ["root-off", "root-off-cwd-on", "cwd-off-root-on"]) {
426 yield* writeSkill(
427 path.join(configDir, "skills"),
428 name,
429 ["---", `name: ${name}`, "---", "", "# Body"].join("\n"),
430 );
431 }
432 yield* fs.makeDirectory(path.join(repo, ".git"), { recursive: true });
433 yield* fs.makeDirectory(path.join(repo, ".claude"), { recursive: true });
434 yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true });
435 // The CLI ignores the root's plain settings.json from a nested cwd.
436 yield* fs.writeFileString(
437 path.join(repo, ".claude", "settings.json"),
438 '{ "skillOverrides": { "cwd-off-root-on": "off" } }',
439 );
440 // The root local file outranks the workspace local file, as in the CLI.
441 yield* fs.writeFileString(
442 path.join(repo, ".claude", "settings.local.json"),
443 '{ "skillOverrides": { "root-off": "off", "root-off-cwd-on": "off", "cwd-off-root-on": "on" } }',
444 );
445 yield* fs.writeFileString(
446 path.join(workspace, ".claude", "settings.local.json"),
447 '{ "skillOverrides": { "root-off-cwd-on": "on", "cwd-off-root-on": "off" } }',
448 );
449
450 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
451
452 assert.deepEqual(
453 skills.map((skill) => [skill.name, skill.enabled]),
454 [
455 ["cwd-off-root-on", true],
456 ["root-off", false],
457 ["root-off-cwd-on", false],
458 ],
459 );
460 }),
461 );
462
463 it.effect("ignores ancestor settings outside a repository", () =>
464 Effect.gen(function* () {
465 const fs = yield* FileSystem.FileSystem;
466 const path = yield* Path.Path;
467 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
468 const configDir = path.join(tempDir, "claude-home");
469 const parent = path.join(tempDir, "not-a-repo");
470 const workspace = path.join(parent, "workspace");
471
472 yield* writeSkill(
473 path.join(configDir, "skills"),
474 "kept",
475 ["---", "name: kept", "---", "", "# Body"].join("\n"),
476 );
477 yield* fs.makeDirectory(path.join(parent, ".claude"), { recursive: true });
478 yield* fs.makeDirectory(workspace, { recursive: true });
479 yield* fs.writeFileString(
480 path.join(parent, ".claude", "settings.local.json"),
481 '{ "skillOverrides": { "kept": "off" } }',
482 );
483
484 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
485
486 assert.deepEqual(
487 skills.map((skill) => [skill.name, skill.enabled]),
488 [["kept", true]],
489 );
490 }),
491 );
492
493 it.effect("lets the administrator's managed policy outrank every other settings file", () =>
494 Effect.gen(function* () {
495 const path = yield* Path.Path;
496
497 for (const [platform, expected] of [
498 ["darwin", "/Library/Application Support/ClaudeCode/managed-settings.json"],
499 ["linux", "/etc/claude-code/managed-settings.json"],
500 ] as const) {
501 const paths = skillOverrideSettingsPaths(path, "/home/.claude", "/workspace", platform, {});
502 assert.deepEqual(paths, [
503 "/home/.claude/settings.json",
504 "/workspace/.claude/settings.json",
505 "/workspace/.claude/settings.local.json",
506 expected,
507 ]);
508 }
509
510 assert.deepEqual(
511 skillOverrideSettingsPaths(path, "/home/.claude", undefined, "win32", {
512 PROGRAMDATA: "C:/ProgramData",
513 }).at(-1),
514 "C:/ProgramData/ClaudeCode/managed-settings.json",
515 );
516 assert.deepEqual(skillOverrideSettingsPaths(path, "/home/.claude", undefined, "win32", {}), [
517 "/home/.claude/settings.json",
518 ]);
519
520 // Only the repository root's local file joins in, after the
521 // workspace's own local file so it wins.
522 assert.deepEqual(
523 skillOverrideSettingsPaths(
524 path,
525 "/home/.claude",
526 "/repo/packages/app",
527 "linux",
528 {},
529 "/repo",
530 ),
531 [
532 "/home/.claude/settings.json",
533 "/repo/packages/app/.claude/settings.json",
534 "/repo/packages/app/.claude/settings.local.json",
535 "/repo/.claude/settings.local.json",
536 "/etc/claude-code/managed-settings.json",
537 ],
538 );
539 // A workspace that is the root itself is not read twice.
540 assert.deepEqual(
541 skillOverrideSettingsPaths(path, "/home/.claude", "/repo", "linux", {}, "/repo"),
542 [
543 "/home/.claude/settings.json",
544 "/repo/.claude/settings.json",
545 "/repo/.claude/settings.local.json",
546 "/etc/claude-code/managed-settings.json",
547 ],
548 );
549 }),
550 );
551
552 it.effect("records a skill Claude Code keeps out of its own slash commands", () =>
553 Effect.gen(function* () {
554 const fs = yield* FileSystem.FileSystem;
555 const path = yield* Path.Path;
556 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
557 const configDir = path.join(tempDir, "claude-home");
558
559 yield* writeSkill(
560 path.join(configDir, "skills"),
561 "agent-only",
562 ["---", "name: agent-only", "user-invocable: false", "---", "", "# Body"].join("\n"),
563 );
564
565 const skills = yield* discoverClaudeSkills({ homePath: configDir });
566
567 assert.deepEqual(
568 skills.map((skill) => [skill.name, skill.userInvocable]),
569 [["agent-only", false]],
570 );
571 }),
572 );
573
574 it.effect("identifies a skill by its directory, as Claude Code does", () =>
575 Effect.gen(function* () {
576 const fs = yield* FileSystem.FileSystem;
577 const path = yield* Path.Path;
578 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
579 const configDir = path.join(tempDir, "claude-home");
580
581 yield* writeSkill(
582 path.join(configDir, "skills"),
583 "probe-alias",
584 ["---", "name: probe-alias-frontmatter", "---", "", "# Body"].join("\n"),
585 );
586 yield* fs.writeFileString(
587 path.join(configDir, "settings.json"),
588 '{ "skillOverrides": { "probe-alias-frontmatter": "off" } }',
589 );
590
591 const skills = yield* discoverClaudeSkills({ homePath: configDir });
592
593 // The frontmatter name is not the command, so an override naming it is
594 // not the override Claude Code would apply either.
595 assert.deepEqual(
596 skills.map((skill) => [skill.name, skill.enabled]),
597 [["probe-alias", true]],
598 );
599 }),
600 );
601
602 it.effect("switches a skill off by its directory name", () =>
603 Effect.gen(function* () {
604 const fs = yield* FileSystem.FileSystem;
605 const path = yield* Path.Path;
606 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
607 const configDir = path.join(tempDir, "claude-home");
608
609 yield* writeSkill(
610 path.join(configDir, "skills"),
611 "probe-alias",
612 ["---", "name: probe-alias-frontmatter", "---", "", "# Body"].join("\n"),
613 );
614 yield* fs.writeFileString(
615 path.join(configDir, "settings.json"),
616 '{ "skillOverrides": { "probe-alias": "off" } }',
617 );
618
619 const skills = yield* discoverClaudeSkills({ homePath: configDir });
620
621 assert.deepEqual(
622 skills.map((skill) => [skill.name, skill.enabled]),
623 [["probe-alias", false]],
624 );
625 }),
626 );
627
628 it.effect("accepts the YAML 1.1 boolean spellings Claude Code allows", () =>
629 Effect.gen(function* () {
630 const fs = yield* FileSystem.FileSystem;
631 const path = yield* Path.Path;
632 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
633 const configDir = path.join(tempDir, "claude-home");
634 const skillsDir = path.join(configDir, "skills");
635
636 yield* writeSkill(
637 skillsDir,
638 "user-only-yes",
639 ["---", "disable-model-invocation: yes", "---", "", "# Body"].join("\n"),
640 );
641 yield* writeSkill(
642 skillsDir,
643 "agent-only-no",
644 ["---", "user-invocable: no", "---", "", "# Body"].join("\n"),
645 );
646 yield* writeSkill(
647 skillsDir,
648 "plain-off",
649 ["---", "disable-model-invocation: off", "---", "", "# Body"].join("\n"),
650 );
651
652 const skills = yield* discoverClaudeSkills({ homePath: configDir });
653
654 assert.deepEqual(
655 skills.map((skill) => [
656 skill.name,
657 skill.userInvocationOnly === true,
658 skill.userInvocable === false,
659 ]),
660 [
661 ["agent-only-no", false, true],
662 ["plain-off", false, false],
663 ["user-only-yes", true, false],
664 ],
665 );
666 }),
667 );
668
290 it.effect("returns an empty list when no skill roots exist", () =>669 it.effect("returns an empty list when no skill roots exist", () =>
291 Effect.gen(function* () {670 Effect.gen(function* () {
292 const fs = yield* FileSystem.FileSystem;671 const fs = yield* FileSystem.FileSystem;
apps/server/src/provider/Drivers/ClaudeSkills.ts+250−21
@@ -1,10 +1,12 @@
1/**1/**
2 * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker.2 * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker.
3 *3 *
4 * Claude Code loads skills from `<config dir>/skills` (user scope), then4 * Claude Code loads skills from `<config dir>/skills` (user scope) and
5 * `<cwd>/.agents/skills` and `<cwd>/.claude/skills` (project scope), one5 * `<cwd>/.claude/skills` (project scope), one directory per skill with a
6 * directory per skill with a `SKILL.md` carrying YAML frontmatter. Later roots6 * `SKILL.md` carrying YAML frontmatter. The user root wins on name collisions,
7 * win on name collisions, so precedence is user, `.agents`, then `.claude`.7 * matching the CLI. `.agents/skills` is a Codex location: verified against the
8 * CLI, a skill that lives only there is answered with `Unknown command`, so it
9 * is not scanned here.
8 * The Agent SDK init handshake surfaces skills only as slash commands without10 * The Agent SDK init handshake surfaces skills only as slash commands without
9 * their filesystem paths, so the provider snapshot scans the same locations11 * their filesystem paths, so the provider snapshot scans the same locations
10 * directly, mirroring how the Codex app-server reports its skills.12 * directly, mirroring how the Codex app-server reports its skills.
@@ -17,6 +19,9 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; · 6 unmodified lines
17import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";20import * as FileSystem from "effect/FileSystem";
19import * as Path from "effect/Path";21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
23import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
24import { fromLenientJson } from "@t3tools/shared/schemaJson";
20import { parse as parseYamlDocument } from "yaml";25import { parse as parseYamlDocument } from "yaml";
21 26
22import { expandHomePath } from "../../pathExpansion.ts";27import { expandHomePath } from "../../pathExpansion.ts";
@@ -28,7 +33,41 @@ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; · 5 unmodified lines
28type SkillFrontmatter =33type SkillFrontmatter =
29 | { readonly kind: "missing" }34 | { readonly kind: "missing" }
30 | { readonly kind: "malformed" }35 | { readonly kind: "malformed" }
31 | { readonly kind: "parsed"; readonly name?: string; readonly description?: string };36 | {
37 readonly kind: "parsed";
38 readonly description?: string;
39 readonly userInvocationOnly?: boolean;
40 readonly userInvocable?: boolean;
41 };
42
43/**
44 * Claude Code accepts the YAML 1.1 boolean spellings (`yes`/`no`, `on`/`off`,
45 * `1`/`0`), which the 1.2 core schema this parser uses leaves as strings and
46 * numbers. Verified against the CLI: a skill carrying `user-invocable: no` is
47 * absent from its published slash commands, so a strict `=== false` here would
48 * offer a command the CLI rejects.
49 */
50function parseFrontmatterBoolean(value: unknown): boolean | undefined {
51 if (typeof value === "boolean") return value;
52 if (typeof value === "number") {
53 return value === 1 ? true : value === 0 ? false : undefined;
54 }
55 if (typeof value !== "string") return undefined;
56 switch (value.trim().toLowerCase()) {
57 case "true":
58 case "yes":
59 case "on":
60 case "y":
61 return true;
62 case "false":
63 case "no":
64 case "off":
65 case "n":
66 return false;
67 default:
68 return undefined;
69 }
70}
32 71
33function parseSkillFrontmatter(contents: string): SkillFrontmatter {72function parseSkillFrontmatter(contents: string): SkillFrontmatter {
34 const match = FRONTMATTER_PATTERN.exec(contents);73 const match = FRONTMATTER_PATTERN.exec(contents);
@@ -47,15 +86,187 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { · 12 unmodified lines
47 }86 }
48 87
49 const record = parsed as Record<string, unknown>;88 const record = parsed as Record<string, unknown>;
50 const name = typeof record.name === "string" ? record.name.trim() : "";
51 const description = typeof record.description === "string" ? record.description.trim() : "";89 const description = typeof record.description === "string" ? record.description.trim() : "";
52 return {90 return {
53 kind: "parsed",91 kind: "parsed",
54 ...(name ? { name } : {}),
55 ...(description ? { description } : {}),92 ...(description ? { description } : {}),
93 ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true
94 ? { userInvocationOnly: true }
95 : {}),
96 ...(parseFrontmatterBoolean(record["user-invocable"]) === false
97 ? { userInvocable: false }
98 : {}),
56 };99 };
57}100}
58 101
102/**
103 * Where an administrator installs the policy file whose settings outrank every
104 * user and project one. Absent on almost every machine, which is why a missing
105 * file is the normal case rather than an error.
106 */
107export function claudeManagedSettingsPath(
108 path: Path.Path,
109 platform: NodeJS.Platform,
110 environment: NodeJS.ProcessEnv,
111): string | undefined {
112 if (platform === "darwin") {
113 return "/Library/Application Support/ClaudeCode/managed-settings.json";
114 }
115 if (platform === "win32") {
116 const programData = environment.PROGRAMDATA?.trim();
117 return programData ? path.join(programData, "ClaudeCode", "managed-settings.json") : undefined;
118 }
119 return "/etc/claude-code/managed-settings.json";
120}
121
122/**
123 * Settings files Claude Code merges for `skillOverrides`, in increasing
124 * precedence: user, project, project-local, then the administrator's managed
125 * policy, which wins outright. When the workspace sits inside a git
126 * repository, the repository root's `settings.local.json` is read too and
127 * outranks the workspace's own local file. Verified against the CLI from a
128 * nested cwd: a root local file switching a skill off wins over a cwd one
129 * switching it on, the root's plain `settings.json` is not consulted, and
130 * without a `.git` above the cwd no root file is read. A skill the user
131 * switched off is reported disabled rather than dropped, so the picker can
132 * grey it out instead of silently losing it.
133 */
134export function skillOverrideSettingsPaths(
135 path: Path.Path,
136 configDirPath: string,
137 cwd: string | undefined,
138 platform: NodeJS.Platform,
139 environment: NodeJS.ProcessEnv,
140 repositoryRoot?: string,
141): ReadonlyArray<string> {
142 const managedPath = claudeManagedSettingsPath(path, platform, environment);
143 const root = repositoryRoot !== undefined && repositoryRoot !== cwd ? repositoryRoot : undefined;
144 return [
145 path.join(configDirPath, "settings.json"),
146 ...(cwd
147 ? [
148 path.join(cwd, ".claude", "settings.json"),
149 path.join(cwd, ".claude", "settings.local.json"),
150 ]
151 : []),
152 ...(root ? [path.join(root, ".claude", "settings.local.json")] : []),
153 ...(managedPath ? [managedPath] : []),
154 ];
155}
156
157/**
158 * Nearest ancestor of `cwd` (inclusive) holding a `.git` entry, which is the
159 * boundary Claude Code walks up to for project settings. `undefined` outside
160 * a repository.
161 */
162const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* (
163 cwd: string,
164): Effect.fn.Return<string | undefined, never, FileSystem.FileSystem | Path.Path> {
165 const fileSystem = yield* FileSystem.FileSystem;
166 const path = yield* Path.Path;
167 let current = path.resolve(cwd);
168 while (true) {
169 const isRoot = yield* fileSystem
170 .exists(path.join(current, ".git"))
171 .pipe(Effect.orElseSucceed(() => false));
172 if (isRoot) {
173 return current;
174 }
175 const parent = path.dirname(current);
176 if (parent === current) {
177 return undefined;
178 }
179 current = parent;
180 }
181});
182
183/**
184 * The four states Claude Code accepts. The CLI validates the whole map, not
185 * each entry: verified against it, one entry with an unknown value (or a
186 * boolean) makes it drop every override in that file, so this schema does the
187 * same rather than applying the valid siblings the CLI ignores.
188 */
189const SkillOverrideValue = Schema.Literals(["on", "name-only", "user-invocable-only", "off"]);
190
191// Lenient because these settings files are hand-edited and Claude Code itself
192// tolerates comments and trailing commas in them.
193const SkillOverrideSettings = fromLenientJson(
194 Schema.Struct({
195 skillOverrides: Schema.optional(Schema.Record(Schema.String, SkillOverrideValue)),
196 }),
197);
198const decodeSkillOverrideSettings = Schema.decodeUnknownEffect(SkillOverrideSettings);
199
200/**
201 * What a `skillOverrides` entry says about one skill. `"user-invocable-only"`
202 * hides it from the agent exactly as `disable-model-invocation` does, so it is
203 * kept apart from a plain on/off decision rather than collapsed into one.
204 */
205type SkillOverride = {
206 readonly enabled: boolean;
207 readonly userInvocationOnly: boolean;
208};
209
210function parseSkillOverride(value: typeof SkillOverrideValue.Type): SkillOverride {
211 switch (value) {
212 case "off":
213 return { enabled: false, userInvocationOnly: false };
214 case "user-invocable-only":
215 return { enabled: true, userInvocationOnly: true };
216 case "on":
217 case "name-only":
218 return { enabled: true, userInvocationOnly: false };
219 }
220}
221
222const readSkillOverrides = Effect.fn("readSkillOverrides")(function* (
223 configDirPath: string,
224 cwd: string | undefined,
225 environment: NodeJS.ProcessEnv,
226): Effect.fn.Return<ReadonlyMap<string, SkillOverride>, never, FileSystem.FileSystem | Path.Path> {
227 const fileSystem = yield* FileSystem.FileSystem;
228 const path = yield* Path.Path;
229 const platform = yield* HostProcessPlatform;
230 const overridesByName = new Map<string, SkillOverride>();
231 const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd);
232
233 for (const settingsPath of skillOverrideSettingsPaths(
234 path,
235 configDirPath,
236 cwd,
237 platform,
238 environment,
239 repositoryRoot,
240 )) {
241 const contents = yield* fileSystem
242 .readFileString(settingsPath)
243 .pipe(Effect.orElseSucceed(() => undefined));
244 if (contents === undefined) {
245 continue;
246 }
247
248 const parsed = yield* decodeSkillOverrideSettings(contents).pipe(
249 Effect.tapError((cause) =>
250 Effect.logDebug("claude settings file is unreadable; ignoring skillOverrides", {
251 path: settingsPath,
252 cause,
253 }),
254 ),
255 Effect.orElseSucceed(() => undefined),
256 );
257 const overrides = parsed?.skillOverrides;
258 if (!overrides) {
259 continue;
260 }
261
262 for (const [name, value] of Object.entries(overrides)) {
263 overridesByName.set(name, parseSkillOverride(value));
264 }
265 }
266
267 return overridesByName;
268});
269
59/**270/**
60 * Resolve the Claude config directory the CLI would use, matching the271 * Resolve the Claude config directory the CLI would use, matching the
61 * precedence the spawned CLI sees: the instance's `homePath` (exported as272 * precedence the spawned CLI sees: the instance's `homePath` (exported as
@@ -85,12 +296,14 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct · 23 unmodified lines
85});296});
86 297
87/**298/**
88 * Enumerate Claude Code skills from the user config dir, workspace299 * Enumerate Claude Code skills from the user config dir and the workspace
89 * `.agents/skills`, and workspace `.claude/skills`, in that order. Discovery300 * `.claude/skills`. Discovery is best-effort: unreadable roots and malformed
90 * is best-effort: unreadable roots and malformed skill entries are skipped so301 * skill entries are skipped so a broken skill never degrades the provider
91 * a broken skill never degrades the provider snapshot. On name collisions,302 * snapshot. Roots are listed highest precedence first and the first hit for a
92 * later roots win: `.agents` beats user and `.claude` beats `.agents`, matching303 * name wins, matching Claude Code: verified against the CLI with the same
93 * Claude Code's resolution.304 * skill name in both scopes, the user copy is the one that runs. Reporting the
305 * project copy instead would attach its invocation metadata to a command
306 * Claude Code resolves elsewhere.
94 */307 */
95export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (308export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (
96 config: Pick<ClaudeSettings, "homePath">,309 config: Pick<ClaudeSettings, "homePath">,
@@ -100,15 +313,11 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* · 3 unmodified lines
100 const fileSystem = yield* FileSystem.FileSystem;313 const fileSystem = yield* FileSystem.FileSystem;
101 const path = yield* Path.Path;314 const path = yield* Path.Path;
102 const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd);315 const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd);
316 const skillOverrides = yield* readSkillOverrides(configDirPath, cwd, environment ?? process.env);
103 317
104 const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [318 const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [
105 { directory: path.join(configDirPath, "skills"), scope: "user" },319 { directory: path.join(configDirPath, "skills"), scope: "user" },
106 ...(cwd320 ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []),
107 ? [
108 { directory: path.join(cwd, ".agents", "skills"), scope: "project" as const },
109 { directory: path.join(cwd, ".claude", "skills"), scope: "project" as const },
110 ]
111 : []),
112 ];321 ];
113 322
114 const skillsByName = new Map<string, ServerProviderSkill>();323 const skillsByName = new Map<string, ServerProviderSkill>();
@@ -134,19 +343,39 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* · 19 unmodified lines
134 continue;343 continue;
135 }344 }
136 345
137 const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim();346 // Claude Code identifies a skill by its directory, not by the
347 // frontmatter `name`: verified against the CLI, a skill in `probe-alias/`
348 // declaring `name: probe-alias-frontmatter` is published as
349 // `probe-alias`, and only `skillOverrides["probe-alias"]` switches it
350 // off. Keying off the frontmatter name would report a command that does
351 // not exist and miss the override that disables it.
352 const name = entry.trim();
138 if (!name) {353 if (!name) {
139 continue;354 continue;
140 }355 }
141 356
357 // First root wins, so a later root never displaces a higher-precedence
358 // skill of the same name.
359 if (skillsByName.has(name)) {
360 continue;
361 }
362
363 const override = skillOverrides.get(name);
364 const userInvocationOnly =
365 (frontmatter.kind === "parsed" && frontmatter.userInvocationOnly === true) ||
366 override?.userInvocationOnly === true;
142 skillsByName.set(name, {367 skillsByName.set(name, {
143 name,368 name,
144 path: skillPath,369 path: skillPath,
145 enabled: true,370 enabled: override?.enabled ?? true,
146 scope: root.scope,371 scope: root.scope,
147 ...(frontmatter.kind === "parsed" && frontmatter.description372 ...(frontmatter.kind === "parsed" && frontmatter.description
148 ? { description: frontmatter.description }373 ? { description: frontmatter.description }
149 : {}),374 : {}),
375 ...(userInvocationOnly ? { userInvocationOnly: true } : {}),
376 ...(frontmatter.kind === "parsed" && frontmatter.userInvocable === false
377 ? { userInvocable: false }
378 : {}),
150 });379 });
151 }380 }
152 }381 }
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+132−0
@@ -810,6 +810,138 @@ describe("ClaudeAdapterLive", () => { · 809 unmodified lines
810 );810 );
811 });811 });
812 812
813 it.effect("dispatches a $skill mention as a trailing slash command block", () => {
814 // Claude Code only runs `/name` from the message's last text block, so a
815 // chip picked mid-prompt is moved there and the surrounding prose kept.
816 const homeDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skills-home-"));
817 NodeFS.mkdirSync(NodePath.join(homeDir, "skills", "implement"), { recursive: true });
818 NodeFS.writeFileSync(
819 NodePath.join(homeDir, "skills", "implement", "SKILL.md"),
820 "---\ndescription: Implement the tickets.\n---\n# Body\n",
821 );
822 const harness = makeHarness({ claudeConfig: { homePath: homeDir } });
823 return Effect.gen(function* () {
824 yield* Effect.addFinalizer(() =>
825 Effect.sync(() => NodeFS.rmSync(homeDir, { recursive: true, force: true })),
826 );
827 const adapter = yield* ClaudeAdapter;
828 const session = yield* adapter.startSession({
829 threadId: THREAD_ID,
830 provider: ProviderDriverKind.make("claudeAgent"),
831 runtimeMode: "full-access",
832 });
833
834 yield* adapter.sendTurn({
835 threadId: session.threadId,
836 input: "ok, now $implement all the tickets\nstart with auth",
837 attachments: [],
838 });
839
840 const promptMessage = yield* Effect.promise(() =>
841 readFirstPromptMessage(harness.getLastCreateQueryInput()),
842 );
843 assert.deepEqual(promptMessage?.message.content, [
844 { type: "text", text: "ok, now" },
845 { type: "text", text: "/implement all the tickets\nstart with auth" },
846 ]);
847 }).pipe(
848 Effect.provideService(Random.Random, makeDeterministicRandomService()),
849 Effect.provide(harness.layer),
850 );
851 });
852
853 it.effect("keeps the skill command block after image attachments", () => {
854 // A command block followed by an image is not expanded by the CLI; the
855 // image must come first.
856 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skill-image-"));
857 const homeDir = NodePath.join(baseDir, "claude-home");
858 NodeFS.mkdirSync(NodePath.join(homeDir, "skills", "review"), { recursive: true });
859 NodeFS.writeFileSync(
860 NodePath.join(homeDir, "skills", "review", "SKILL.md"),
861 "---\ndescription: Review.\n---\n# Body\n",
862 );
863 const harness = makeHarness({ baseDir, claudeConfig: { homePath: homeDir } });
864 return Effect.gen(function* () {
865 yield* Effect.addFinalizer(() =>
866 Effect.sync(() => NodeFS.rmSync(baseDir, { recursive: true, force: true })),
867 );
868 const adapter = yield* ClaudeAdapter;
869 const { attachmentsDir } = yield* ServerConfig;
870 const attachment = {
871 type: "image" as const,
872 id: "thread-claude-attachment-12345678-1234-1234-1234-123456789abc",
873 name: "diagram.png",
874 mimeType: "image/png",
875 sizeBytes: 4,
876 };
877 const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)!);
878 NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true });
879 NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4]));
880
881 const session = yield* adapter.startSession({
882 threadId: THREAD_ID,
883 provider: ProviderDriverKind.make("claudeAgent"),
884 runtimeMode: "full-access",
885 });
886 yield* adapter.sendTurn({
887 threadId: session.threadId,
888 input: "$review this screenshot",
889 attachments: [attachment],
890 });
891
892 const promptMessage = yield* Effect.promise(() =>
893 readFirstPromptMessage(harness.getLastCreateQueryInput()),
894 );
895 assert.isDefined(promptMessage);
896 const blocks = promptMessage.message.content as Array<{ type: string; text?: string }>;
897 assert.deepEqual(
898 blocks.map((block) => (block.type === "text" ? block.text : block.type)),
899 ["image", "/review this screenshot"],
900 );
901 }).pipe(
902 Effect.provideService(Random.Random, makeDeterministicRandomService()),
903 Effect.provide(harness.layer),
904 );
905 });
906
907 it.effect("leaves a $ mention of an unknown or disabled skill as prose", () => {
908 const homeDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skills-off-"));
909 NodeFS.mkdirSync(NodePath.join(homeDir, "skills", "deploy"), { recursive: true });
910 NodeFS.writeFileSync(
911 NodePath.join(homeDir, "skills", "deploy", "SKILL.md"),
912 "---\ndescription: Deploy.\n---\n# Body\n",
913 );
914 NodeFS.writeFileSync(
915 NodePath.join(homeDir, "settings.json"),
916 JSON.stringify({ skillOverrides: { deploy: "off" } }),
917 );
918 const harness = makeHarness({ claudeConfig: { homePath: homeDir } });
919 return Effect.gen(function* () {
920 yield* Effect.addFinalizer(() =>
921 Effect.sync(() => NodeFS.rmSync(homeDir, { recursive: true, force: true })),
922 );
923 const adapter = yield* ClaudeAdapter;
924 const session = yield* adapter.startSession({
925 threadId: THREAD_ID,
926 provider: ProviderDriverKind.make("claudeAgent"),
927 runtimeMode: "full-access",
928 });
929 yield* adapter.sendTurn({
930 threadId: session.threadId,
931 input: "run $deploy and echo $HOME",
932 attachments: [],
933 });
934
935 const promptText = yield* Effect.promise(() =>
936 readFirstPromptText(harness.getLastCreateQueryInput()),
937 );
938 assert.equal(promptText, "run $deploy and echo $HOME");
939 }).pipe(
940 Effect.provideService(Random.Random, makeDeterministicRandomService()),
941 Effect.provide(harness.layer),
942 );
943 });
944
813 it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => {945 it.effect("maps Claude stream/runtime messages to canonical provider runtime events", () => {
814 const harness = makeHarness();946 const harness = makeHarness();
815 return Effect.gen(function* () {947 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+38−1
@@ -79,6 +79,8 @@ import { ServerConfig } from "../../config.ts"; · 78 unmodified lines
79import * as McpProviderSession from "../../mcp/McpProviderSession.ts";79import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
80import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";80import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
81import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";81import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
82import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts";
83import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts";
82import {84import {
83 BUNDLED_CLAUDE_MODEL_CATALOG,85 BUNDLED_CLAUDE_MODEL_CATALOG,
84 type ClaudeModelCatalog,86 type ClaudeModelCatalog,
@@ -1276,12 +1278,23 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( · 1191 unmodified lines
1276 readonly attachmentsDir: string;1278 readonly attachmentsDir: string;
1277 readonly boundInstanceId: ProviderInstanceId;1279 readonly boundInstanceId: ProviderInstanceId;
1278 readonly modelCatalog: ClaudeModelCatalog;1280 readonly modelCatalog: ClaudeModelCatalog;
1281 /** Names of the skills Claude Code can run for this session's cwd. */
1282 readonly skillNames: ReadonlySet<string>;
1279 },1283 },
1280) {1284) {
1281 const text = buildPromptText(input, dependencies.boundInstanceId, dependencies.modelCatalog);1285 const text = buildPromptText(input, dependencies.boundInstanceId, dependencies.modelCatalog);
1282 const sdkContent: Array<Record<string, unknown>> = [];1286 const sdkContent: Array<Record<string, unknown>> = [];
1283 1287
1284 if (text.length > 0) {1288 // Claude Code expands a skill only from the LAST text block, and only when
1289 // `/name` is its first character. A `$skill` chip anywhere in the prompt is
1290 // therefore split into [leading text, "/name trailing text"] so the CLI
1291 // runs it natively and the prose around it survives. See ClaudeSkillDispatch.
1292 const dispatch = planClaudeSkillDispatch(text, dependencies.skillNames);
1293 if (dispatch) {
1294 if (dispatch.leadingText !== undefined) {
1295 sdkContent.push({ type: "text", text: dispatch.leadingText });
1296 }
1297 } else if (text.length > 0) {
1285 sdkContent.push({ type: "text", text });1298 sdkContent.push({ type: "text", text });
1286 }1299 }
1287 1300
@@ -1332,6 +1345,12 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( · 44 unmodified lines
1332 );1345 );
1333 }1346 }
1334 1347
1348 // Images go before the command block: a text block after them still
1349 // expands, a command block followed by an image does not.
1350 if (dispatch) {
1351 sdkContent.push({ type: "text", text: dispatch.commandText });
1352 }
1353
1335 return buildUserMessage({ sdkContent });1354 return buildUserMessage({ sdkContent });
1336});1355});
1337 1356
@@ -4602,11 +4621,29 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( · 3264 unmodified lines
4602 });4621 });
4603 }4622 }
4604 4623
4624 // Re-scan on every send: skills are added and switched off mid-session,
4625 // and the scan is a few directory reads. A skill switched off via
4626 // skillOverrides, or reserved for the agent with `user-invocable: false`,
4627 // is left as prose: the CLI would answer `/name` with a notice instead of
4628 // running it.
4629 const skills = yield* discoverClaudeSkills(
4630 claudeSettings,
4631 context.session.cwd,
4632 claudeEnvironment,
4633 ).pipe(
4634 Effect.provideService(FileSystem.FileSystem, fileSystem),
4635 Effect.provideService(Path.Path, path),
4636 );
4605 const message = yield* buildUserMessageEffect(input, {4637 const message = yield* buildUserMessageEffect(input, {
4606 fileSystem,4638 fileSystem,
4607 attachmentsDir: serverConfig.attachmentsDir,4639 attachmentsDir: serverConfig.attachmentsDir,
4608 boundInstanceId,4640 boundInstanceId,
4609 modelCatalog,4641 modelCatalog,
4642 skillNames: new Set(
4643 skills
4644 .filter((skill) => skill.enabled && skill.userInvocable !== false)
4645 .map((skill) => skill.name),
4646 ),
4610 });4647 });
4611 4648
4612 yield* Queue.offer(context.promptQueue, {4649 yield* Queue.offer(context.promptQueue, {