| author | |
| committer | |
| log | ad47d2347c6917f7db33e6e3902e1e8e5d5281ec |
| tree | f228855eb468fac1337fe576b6f54cf81d65c37b |
| parent | 684d703b0a8a0632a18c8453277f7e5e6312b200 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 125 insertions(+), 12 deletions(-)
apps/server/src/provider/Drivers/ClaudeSkills.test.ts+99| ... | @@ -66,6 +66,105 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { | ... | @@ -66,6 +66,105 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { |
| 66 | }), | 66 | }), |
| 67 | ); | 67 | ); |
| 68 | 68 | ||
| 69 | it.effect("discovers project skills from the workspace .agents directory", () => | ||
| 70 | Effect.gen(function* () { | ||
| 71 | const fs = yield* FileSystem.FileSystem; | ||
| 72 | const path = yield* Path.Path; | ||
| 73 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | ||
| 74 | const configDir = path.join(tempDir, "claude-home"); | ||
| 75 | const workspace = path.join(tempDir, "workspace"); | ||
| 76 | |||
| 77 | yield* writeSkill( | ||
| 78 | path.join(workspace, ".agents", "skills"), | ||
| 79 | "review", | ||
| 80 | ["---", "name: review", "description: Review the changes.", "---"].join("\n"), | ||
| 81 | ); | ||
| 82 | |||
| 83 | const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); | ||
| 84 | |||
| 85 | 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 | }), | ||
| 95 | ); | ||
| 96 | |||
| 97 | it.effect("prefers workspace .claude skills on three-way name collisions", () => | ||
| 98 | Effect.gen(function* () { | ||
| 99 | const fs = yield* FileSystem.FileSystem; | ||
| 100 | const path = yield* Path.Path; | ||
| 101 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); | ||
| 102 | const configDir = path.join(tempDir, "claude-home"); | ||
| 103 | const workspace = path.join(tempDir, "workspace"); | ||
| 104 | |||
| 105 | yield* writeSkill( | ||
| 106 | path.join(configDir, "skills"), | ||
| 107 | "deploy", | ||
| 108 | ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), | ||
| 109 | ); | ||
| 110 | yield* writeSkill( | ||
| 111 | path.join(workspace, ".agents", "skills"), | ||
| 112 | "deploy", | ||
| 113 | ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), | ||
| 114 | ); | ||
| 115 | yield* writeSkill( | ||
| 116 | path.join(workspace, ".claude", "skills"), | ||
| 117 | "deploy", | ||
| 118 | ["---", "name: deploy", "description: Claude deploy.", "---"].join("\n"), | ||
| 119 | ); | ||
| 120 | |||
| 121 | const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); | ||
| 122 | |||
| 123 | assert.deepEqual(skills, [ | ||
| 124 | { | ||
| 125 | name: "deploy", | ||
| 126 | path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), | ||
| 127 | enabled: true, | ||
| 128 | scope: "project", | ||
| 129 | description: "Claude 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 | }, | ||
| 164 | ]); | ||
| 165 | }), | ||
| 166 | ); | ||
| 167 | |||
| 69 | it.effect("prefers project skills over user skills on name collisions", () => | 168 | it.effect("prefers project skills over user skills on name collisions", () => |
| 70 | Effect.gen(function* () { | 169 | Effect.gen(function* () { |
| 71 | const fs = yield* FileSystem.FileSystem; | 170 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/provider/Drivers/ClaudeSkills.ts+19-12| ... | @@ -1,12 +1,13 @@ | ... | @@ -1,12 +1,13 @@ |
| 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) and | 4 | * Claude Code loads skills from `<config dir>/skills` (user scope), then |
| 5 | * `<cwd>/.claude/skills` (project scope), one directory per skill with a | 5 | * `<cwd>/.agents/skills` and `<cwd>/.claude/skills` (project scope), one |
| 6 | * `SKILL.md` carrying YAML frontmatter. The Agent SDK init handshake surfaces | 6 | * directory per skill with a `SKILL.md` carrying YAML frontmatter. Later roots |
| 7 | * skills only as slash commands without their filesystem paths, so the | 7 | * win on name collisions, so precedence is user, `.agents`, then `.claude`. |
| 8 | * provider snapshot scans the same locations directly, mirroring how the | 8 | * The Agent SDK init handshake surfaces skills only as slash commands without |
| 9 | * Codex app-server reports its skills. | 9 | * their filesystem paths, so the provider snapshot scans the same locations |
| 10 | * directly, mirroring how the Codex app-server reports its skills. | ||
| 10 | * | 11 | * |
| 11 | * @module provider/Drivers/ClaudeSkills | 12 | * @module provider/Drivers/ClaudeSkills |
| 12 | */ | 13 | */ |
| ... | @@ -84,11 +85,12 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct | ... | @@ -84,11 +85,12 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct |
| 84 | }); | 85 | }); |
| 85 | 86 | ||
| 86 | /** | 87 | /** |
| 87 | * Enumerate Claude Code skills from the user config dir and the workspace. | 88 | * Enumerate Claude Code skills from the user config dir, workspace |
| 88 | * Discovery is best-effort: unreadable roots and malformed skill entries are | 89 | * `.agents/skills`, and workspace `.claude/skills`, in that order. Discovery |
| 89 | * skipped so a broken skill never degrades the provider snapshot. On name | 90 | * is best-effort: unreadable roots and malformed skill entries are skipped so |
| 90 | * collisions the project-scoped skill wins, matching Claude Code's | 91 | * a broken skill never degrades the provider snapshot. On name collisions, |
| 91 | * most-specific-wins resolution. | 92 | * later roots win: `.agents` beats user and `.claude` beats `.agents`, matching |
| 93 | * Claude Code's resolution. | ||
| 92 | */ | 94 | */ |
| 93 | export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( | 95 | export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( |
| 94 | config: Pick<ClaudeSettings, "homePath">, | 96 | config: Pick<ClaudeSettings, "homePath">, |
| ... | @@ -101,7 +103,12 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* | ... | @@ -101,7 +103,12 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* |
| 101 | 103 | ||
| 102 | const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ | 104 | const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ |
| 103 | { directory: path.join(configDirPath, "skills"), scope: "user" }, | 105 | { directory: path.join(configDirPath, "skills"), scope: "user" }, |
| 104 | ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), | 106 | ...(cwd |
| 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 | : []), | ||
| 105 | ]; | 112 | ]; |
| 106 | 113 | ||
| 107 | const skillsByName = new Map<string, ServerProviderSkill>(); | 114 | const skillsByName = new Map<string, ServerProviderSkill>(); |
docs/user/providers-claude.md+7| ... | @@ -34,6 +34,13 @@ When you set this field, T3 Code points Claude Code at that directory with the | ... | @@ -34,6 +34,13 @@ When you set this field, T3 Code points Claude Code at that directory with the |
| 34 | `CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and | 34 | `CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and |
| 35 | the rest of your environment stay as they are. | 35 | the rest of your environment stay as they are. |
| 36 | 36 | ||
| 37 | ## Where Claude Skills Are Loaded | ||
| 38 | |||
| 39 | T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then | ||
| 40 | `<workspace>/.agents/skills`, then `<workspace>/.claude/skills`. | ||
| 41 | |||
| 42 | If the same skill name exists in more than one folder, the later folder wins. | ||
| 43 | |||
| 37 | ## I Want Work And Personal Claude Accounts | 44 | ## I Want Work And Personal Claude Accounts |
| 38 | 45 | ||
| 39 | Use a different Claude config directory for each account. | 46 | Use a different Claude config directory for each account. |