From 7ba5c55d5415ce8c9e73e9050def867540f39c76 Mon Sep 17 00:00:00 2001 From: Marcus Hohlbein Date: Wed, 12 Aug 2026 23:41:45 +0200 Subject: [PATCH 1/3] feat(server): surface portable .agents/skills across provider snapshots Providers without a native skill inventory (Cursor, Grok, OpenCode) did not surface cross-agent portable skills in the `$` picker, so a skill placed under `~/.agents/skills` or `/.agents/skills` was invisible to them. Extract the filesystem scanner (frontmatter parse + best-effort root scan) out of ClaudeSkills.ts into a shared scanFilesystemSkillRoots/discoverAgentSkills module; Claude reuses it and layers its `.claude` roots, while the three skill-less providers augment their snapshot draft via a new augmentProviderSnapshotWithAgentSkills helper. Resolution stays most-specific-wins, extended to two axes: project beats user, and within a scope the Claude-native `.claude/skills` root beats a portable `.agents` namesake. Codex is intentionally not augmented: it reports skills natively via its app-server. Generated with Claude Code (anthropic) inside the T3 Code harness. --- .../src/provider/Drivers/AgentSkills.test.ts | 130 ++++++++++++++++ .../src/provider/Drivers/AgentSkills.ts | 120 ++++++++++++++ .../src/provider/Drivers/ClaudeSkills.test.ts | 146 ++++++++++++++++-- .../src/provider/Drivers/ClaudeSkills.ts | 113 +++----------- .../src/provider/Drivers/CursorDriver.ts | 3 + .../server/src/provider/Drivers/GrokDriver.ts | 7 + .../src/provider/Drivers/OpenCodeDriver.ts | 11 +- ...entProviderSnapshotWithAgentSkills.test.ts | 91 +++++++++++ .../augmentProviderSnapshotWithAgentSkills.ts | 15 ++ 9 files changed, 535 insertions(+), 101 deletions(-) create mode 100644 apps/server/src/provider/Drivers/AgentSkills.test.ts create mode 100644 apps/server/src/provider/Drivers/AgentSkills.ts create mode 100644 apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.test.ts create mode 100644 apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.ts diff --git a/apps/server/src/provider/Drivers/AgentSkills.test.ts b/apps/server/src/provider/Drivers/AgentSkills.test.ts new file mode 100644 index 00000000000..96666e06016 --- /dev/null +++ b/apps/server/src/provider/Drivers/AgentSkills.test.ts @@ -0,0 +1,130 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverAgentSkills } from "./AgentSkills.ts"; + +const writeSkill = Effect.fn(function* ( + skillsDir: string, + directoryName: string, + contents: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDir = path.join(skillsDir, directoryName); + yield* fs.makeDirectory(skillDir, { recursive: true }); + yield* fs.writeFileString(path.join(skillDir, "SKILL.md"), contents); +}); + +const isolatedDiscoveryOptions = (tempDir: string) => ({ + homeDirectory: `${tempDir}/isolated-home`, +}); + +it.layer(NodeServices.layer)("discoverAgentSkills", (it) => { + it.effect("discovers user and project skills with frontmatter metadata", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" }); + const workspace = path.join(tempDir, "workspace"); + const agentsHome = path.join(tempDir, "agents-home"); + + yield* writeSkill( + path.join(agentsHome, ".agents", "skills"), + "agent-browser", + ["---", "name: agent-browser", "description: Browser automation.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "test-t3-app", + ["---", "name: test-t3-app", "description: Test the web app.", "---"].join("\n"), + ); + + const skills = yield* discoverAgentSkills(workspace, { homeDirectory: agentsHome }); + + assert.deepEqual(skills, [ + { + name: "agent-browser", + path: path.join(agentsHome, ".agents", "skills", "agent-browser", "SKILL.md"), + enabled: true, + scope: "user", + description: "Browser automation.", + }, + { + name: "test-t3-app", + path: path.join(workspace, ".agents", "skills", "test-t3-app", "SKILL.md"), + enabled: true, + scope: "project", + description: "Test the web app.", + }, + ]); + }), + ); + + it.effect("prefers project skills over user skills on name collisions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" }); + const workspace = path.join(tempDir, "workspace"); + const agentsHome = path.join(tempDir, "agents-home"); + + yield* writeSkill( + path.join(agentsHome, ".agents", "skills"), + "shared-skill", + ["---", "name: shared-skill", "description: User agents skill.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "shared-skill", + ["---", "name: shared-skill", "description: Project agents skill.", "---"].join("\n"), + ); + + const skills = yield* discoverAgentSkills(workspace, { homeDirectory: agentsHome }); + + assert.equal(skills.length, 1); + assert.equal(skills[0]?.scope, "project"); + assert.equal(skills[0]?.description, "Project agents skill."); + }), + ); + + it.effect("falls back to the directory name and skips malformed frontmatter", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" }); + const agentsHome = path.join(tempDir, "agents-home"); + const skillsDir = path.join(agentsHome, ".agents", "skills"); + + yield* writeSkill(skillsDir, "no-frontmatter", "# Just a heading\n"); + yield* writeSkill(skillsDir, "broken-yaml", "---\nname: [unclosed\n---\n"); + yield* fs.makeDirectory(skillsDir, { recursive: true }); + yield* fs.writeFileString(path.join(skillsDir, "README.md"), "not a skill"); + + const skills = yield* discoverAgentSkills(undefined, { homeDirectory: agentsHome }); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["no-frontmatter"], + ); + assert.equal(skills[0]?.description, undefined); + }), + ); + + it.effect("returns an empty list when no skill roots exist", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" }); + + const skills = yield* discoverAgentSkills( + path.join(tempDir, "missing-workspace"), + isolatedDiscoveryOptions(tempDir), + ); + + assert.deepEqual(skills, []); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/AgentSkills.ts b/apps/server/src/provider/Drivers/AgentSkills.ts new file mode 100644 index 00000000000..edaf4072c8c --- /dev/null +++ b/apps/server/src/provider/Drivers/AgentSkills.ts @@ -0,0 +1,120 @@ +/** + * AgentSkills — shared filesystem discovery of cross-agent skills for the `$` picker. + * + * Portable skills live under `~/.agents/skills` and `/.agents/skills`, one + * directory per skill with a `SKILL.md` carrying YAML frontmatter. Providers + * without native skill inventory (Cursor, Grok, OpenCode, …) use this scanner; + * Claude layers vendor-specific roots (`/skills`, `/.claude/skills`) + * on the same scanner. Codex reports skills natively via its app-server. + * + * @module provider/Drivers/AgentSkills + */ +import * as NodeOS from "node:os"; + +import type { ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { parse as parseYamlDocument } from "yaml"; + +export type FilesystemSkillScope = "user" | "project"; + +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; + +type SkillFrontmatter = + | { readonly kind: "missing" } + | { readonly kind: "malformed" } + | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; + +function parseSkillFrontmatter(contents: string): SkillFrontmatter { + const match = FRONTMATTER_PATTERN.exec(contents); + if (!match) { + return { kind: "missing" }; + } + + let parsed: unknown; + try { + parsed = parseYamlDocument(match[1] ?? ""); + } catch { + return { kind: "malformed" }; + } + if (typeof parsed !== "object" || parsed === null) { + return { kind: "malformed" }; + } + + const record = parsed as Record; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + return { + kind: "parsed", + ...(name ? { name } : {}), + ...(description ? { description } : {}), + }; +} + +/** + * Scan explicit skill roots. Discovery is best-effort: unreadable roots and + * malformed entries are skipped. Later roots overwrite earlier ones on name + * collisions so project-scoped skills beat user-scoped ones. + */ +export const scanFilesystemSkillRoots = Effect.fn("scanFilesystemSkillRoots")(function* ( + roots: ReadonlyArray<{ directory: string; scope: FilesystemSkillScope }>, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillsByName = new Map(); + + for (const root of roots) { + const entries = yield* fileSystem + .readDirectory(root.directory) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + for (const entry of [...entries].sort()) { + const skillPath = path.join(root.directory, entry, "SKILL.md"); + const contents = yield* fileSystem + .readFileString(skillPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (contents === undefined) { + continue; + } + + const frontmatter = parseSkillFrontmatter(contents); + if (frontmatter.kind === "malformed") { + continue; + } + + const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); + if (!name) { + continue; + } + + skillsByName.set(name, { + name, + path: skillPath, + enabled: true, + scope: root.scope, + ...(frontmatter.kind === "parsed" && frontmatter.description + ? { description: frontmatter.description } + : {}), + }); + } + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +}); + +/** + * Enumerate portable skills from the user home and optional workspace cwd. + */ +export const discoverAgentSkills = Effect.fn("discoverAgentSkills")(function* ( + cwd?: string, + options?: { readonly homeDirectory?: string }, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const path = yield* Path.Path; + const homeDirectory = options?.homeDirectory ?? NodeOS.homedir(); + + return yield* scanFilesystemSkillRoots([ + { directory: path.join(homeDirectory, ".agents", "skills"), scope: "user" }, + ...(cwd ? [{ directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }] : []), + ]); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 1ad843d7573..834b24086e6 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -18,6 +18,10 @@ const writeSkill = Effect.fn(function* ( yield* fs.writeFileString(path.join(skillDir, "SKILL.md"), contents); }); +const isolatedDiscoveryOptions = (tempDir: string) => ({ + homeDirectory: `${tempDir}/isolated-home`, +}); + it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { it.effect("discovers user and project skills with frontmatter metadata", () => Effect.gen(function* () { @@ -45,7 +49,12 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { ["---", "name: deploy", "description: Deploy the app.", "---", "", "# Deploy"].join("\n"), ); - const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + const skills = yield* discoverClaudeSkills( + { homePath: configDir }, + workspace, + undefined, + isolatedDiscoveryOptions(tempDir), + ); assert.deepEqual(skills, [ { @@ -66,6 +75,92 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("prefers project .agents/skills over user .claude/skills on name collisions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "shared-skill", + ["---", "name: shared-skill", "description: Claude user skill.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "shared-skill", + ["---", "name: shared-skill", "description: Project agents skill.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills( + { homePath: configDir }, + workspace, + undefined, + isolatedDiscoveryOptions(tempDir), + ); + + assert.equal(skills.length, 1); + assert.equal(skills[0]?.scope, "project"); + assert.equal(skills[0]?.description, "Project agents skill."); + }), + ); + + it.effect( + "prefers Claude-native .claude/skills over portable .agents/skills within a scope", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + const agentsHome = `${tempDir}/isolated-home`; + + yield* writeSkill( + path.join(configDir, "skills"), + "claude-user", + ["---", "name: claude-user", "description: Claude user skill.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(agentsHome, ".agents", "skills"), + "claude-user", + ["---", "name: claude-user", "description: Portable user skill.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "claude-project", + ["---", "name: claude-project", "description: Claude project skill.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "claude-project", + ["---", "name: claude-project", "description: Portable project skill.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills( + { homePath: configDir }, + workspace, + undefined, + isolatedDiscoveryOptions(tempDir), + ); + const byName = new Map(skills.map((skill) => [skill.name, skill])); + + assert.equal(byName.size, 2); + assert.equal(byName.get("claude-user")?.scope, "user"); + assert.equal( + byName.get("claude-user")?.path, + path.join(configDir, "skills", "claude-user", "SKILL.md"), + ); + assert.equal(byName.get("claude-project")?.scope, "project"); + assert.equal( + byName.get("claude-project")?.path, + path.join(workspace, ".claude", "skills", "claude-project", "SKILL.md"), + ); + }), + ); + it.effect("prefers project skills over user skills on name collisions", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -85,7 +180,12 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { ["---", "name: deploy", "description: Project deploy.", "---"].join("\n"), ); - const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + const skills = yield* discoverClaudeSkills( + { homePath: configDir }, + workspace, + undefined, + isolatedDiscoveryOptions(tempDir), + ); assert.equal(skills.length, 1); assert.equal(skills[0]?.scope, "project"); @@ -107,7 +207,12 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { yield* fs.makeDirectory(skillsDir, { recursive: true }); yield* fs.writeFileString(path.join(skillsDir, "README.md"), "not a skill"); - const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined); + const skills = yield* discoverClaudeSkills( + { homePath: configDir }, + undefined, + undefined, + isolatedDiscoveryOptions(tempDir), + ); // A skill with no frontmatter falls back to its directory name; a skill // whose frontmatter fails to parse is skipped entirely (Claude Code @@ -133,9 +238,14 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { ["---", "name: env-skill", "description: From env config dir.", "---"].join("\n"), ); - const skills = yield* discoverClaudeSkills({ homePath: "" }, undefined, { - CLAUDE_CONFIG_DIR: environmentConfigDir, - }); + const skills = yield* discoverClaudeSkills( + { homePath: "" }, + undefined, + { + CLAUDE_CONFIG_DIR: environmentConfigDir, + }, + isolatedDiscoveryOptions(tempDir), + ); assert.deepEqual( skills.map((skill) => skill.name), @@ -150,9 +260,14 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { "explicit-skill", ["---", "name: explicit-skill", "---"].join("\n"), ); - const explicitSkills = yield* discoverClaudeSkills({ homePath: explicitHome }, undefined, { - CLAUDE_CONFIG_DIR: environmentConfigDir, - }); + const explicitSkills = yield* discoverClaudeSkills( + { homePath: explicitHome }, + undefined, + { + CLAUDE_CONFIG_DIR: environmentConfigDir, + }, + isolatedDiscoveryOptions(tempDir), + ); assert.deepEqual( explicitSkills.map((skill) => skill.name), ["explicit-skill"], @@ -176,9 +291,14 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { ["---", "name: relative-skill", "---"].join("\n"), ); - const skills = yield* discoverClaudeSkills({ homePath: "" }, workspace, { - CLAUDE_CONFIG_DIR: "relative-config", - }); + const skills = yield* discoverClaudeSkills( + { homePath: "" }, + workspace, + { + CLAUDE_CONFIG_DIR: "relative-config", + }, + isolatedDiscoveryOptions(tempDir), + ); assert.deepEqual( skills.map((skill) => skill.name), @@ -197,6 +317,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const skills = yield* discoverClaudeSkills( { homePath: path.join(tempDir, "missing-home") }, path.join(tempDir, "missing-workspace"), + undefined, + isolatedDiscoveryOptions(tempDir), ); assert.deepEqual(skills, []); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 335c3d4681d..94d95a3e321 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -2,9 +2,10 @@ * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker. * * Claude Code loads skills from `/skills` (user scope) and - * `/.claude/skills` (project scope), one directory per skill with a - * `SKILL.md` carrying YAML frontmatter. The Agent SDK init handshake surfaces - * skills only as slash commands without their filesystem paths, so the + * `/.claude/skills` (project scope). Cross-agent skills live under + * `~/.agents/skills` and `/.agents/skills`. Each skill is one directory + * with a `SKILL.md` carrying YAML frontmatter. The Agent SDK init handshake + * surfaces skills only as slash commands without their filesystem paths, so the * provider snapshot scans the same locations directly, mirroring how the * Codex app-server reports its skills. * @@ -16,44 +17,9 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { parse as parseYamlDocument } from "yaml"; import { expandHomePath } from "../../pathExpansion.ts"; - -type ClaudeSkillScope = "user" | "project"; - -const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; - -type SkillFrontmatter = - | { readonly kind: "missing" } - | { readonly kind: "malformed" } - | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; - -function parseSkillFrontmatter(contents: string): SkillFrontmatter { - const match = FRONTMATTER_PATTERN.exec(contents); - if (!match) { - return { kind: "missing" }; - } - - let parsed: unknown; - try { - parsed = parseYamlDocument(match[1] ?? ""); - } catch { - return { kind: "malformed" }; - } - if (typeof parsed !== "object" || parsed === null) { - return { kind: "malformed" }; - } - - const record = parsed as Record; - const name = typeof record.name === "string" ? record.name.trim() : ""; - const description = typeof record.description === "string" ? record.description.trim() : ""; - return { - kind: "parsed", - ...(name ? { name } : {}), - ...(description ? { description } : {}), - }; -} +import { scanFilesystemSkillRoots, type FilesystemSkillScope } from "./AgentSkills.ts"; /** * Resolve the Claude config directory the CLI would use, matching the @@ -84,65 +50,36 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct }); /** - * Enumerate Claude Code skills from the user config dir and the workspace. - * Discovery is best-effort: unreadable roots and malformed skill entries are - * skipped so a broken skill never degrades the provider snapshot. On name - * collisions the project-scoped skill wins, matching Claude Code's - * most-specific-wins resolution. + * Enumerate Claude Code skills from the user config dir, shared `.agents/skills` + * locations, and the workspace. Discovery is best-effort: unreadable roots and + * malformed skill entries are skipped so a broken skill never degrades the + * provider snapshot. Resolution is most-specific-wins on two axes: project + * scope beats user scope, and within a scope the Claude-native `.claude/skills` + * root beats the portable `.agents/skills` root of the same name — so a + * Claude-specific skill keeps running even when a portable namesake exists. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick, cwd?: string, environment?: NodeJS.ProcessEnv, + options?: { readonly homeDirectory?: string }, ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); + const homeDirectory = options?.homeDirectory ?? NodeOS.homedir(); - const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ + // Order is load-bearing: scanFilesystemSkillRoots lets later roots win on + // collisions, so `.agents` precedes `.claude` within each scope by design. + const roots: ReadonlyArray<{ directory: string; scope: FilesystemSkillScope }> = [ + { directory: path.join(homeDirectory, ".agents", "skills"), scope: "user" }, { directory: path.join(configDirPath, "skills"), scope: "user" }, - ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), + ...(cwd + ? [ + { directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }, + { directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }, + ] + : []), ]; - const skillsByName = new Map(); - for (const root of roots) { - const entries = yield* fileSystem - .readDirectory(root.directory) - .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); - - for (const entry of [...entries].sort()) { - const skillPath = path.join(root.directory, entry, "SKILL.md"); - const contents = yield* fileSystem - .readFileString(skillPath) - .pipe(Effect.orElseSucceed(() => undefined)); - if (contents === undefined) { - continue; - } - - const frontmatter = parseSkillFrontmatter(contents); - // Malformed frontmatter means the skill won't load in Claude Code - // either — skip it rather than surfacing a broken entry under its - // directory name. - if (frontmatter.kind === "malformed") { - continue; - } - - const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); - if (!name) { - continue; - } - - skillsByName.set(name, { - name, - path: skillPath, - enabled: true, - scope: root.scope, - ...(frontmatter.kind === "parsed" && frontmatter.description - ? { description: frontmatter.description } - : {}), - }); - } - } - - return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); + return yield* scanFilesystemSkillRoots(roots); }); diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 2101664d5cb..e6598cfa5bf 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -23,6 +23,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { augmentProviderSnapshotWithAgentSkills } from "../augmentProviderSnapshotWithAgentSkills.ts"; import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; @@ -125,6 +126,7 @@ export const CursorDriver: ProviderDriver = { env: processEnv, }); + const { cwd } = yield* ServerConfig; const adapter = yield* makeCursorAdapter(effectiveConfig, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), @@ -133,6 +135,7 @@ export const CursorDriver: ProviderDriver = { const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + Effect.flatMap((draft) => augmentProviderSnapshotWithAgentSkills(draft, cwd)), Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 112f1101316..3bf28625c21 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -10,6 +10,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { augmentProviderSnapshotWithAgentSkills } from "../augmentProviderSnapshotWithAgentSkills.ts"; import { makeGrokTextGeneration } from "../../textGeneration/GrokTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeGrokAdapter } from "../Layers/GrokAdapter.ts"; @@ -86,6 +87,9 @@ export const GrokDriver: ProviderDriver = { Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; @@ -114,9 +118,12 @@ export const GrokDriver: ProviderDriver = { const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( + Effect.flatMap((draft) => augmentProviderSnapshotWithAgentSkills(draft, cwd)), Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index a01e414f811..09962c2034d 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -21,6 +21,7 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { augmentProviderSnapshotWithAgentSkills } from "../augmentProviderSnapshotWithAgentSkills.ts"; import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; @@ -116,6 +117,8 @@ export const OpenCodeDriver: ProviderDriver Effect.gen(function* () { const openCodeRuntime = yield* OpenCodeRuntime; const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; @@ -147,7 +150,13 @@ export const OpenCodeDriver: ProviderDriver effectiveConfig, serverConfig.cwd, processEnv, - ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); + ).pipe( + Effect.flatMap((draft) => augmentProviderSnapshotWithAgentSkills(draft, serverConfig.cwd)), + Effect.map(stampIdentity), + Effect.provideService(OpenCodeRuntime, openCodeRuntime), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>( diff --git a/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.test.ts b/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.test.ts new file mode 100644 index 00000000000..bfd8f750282 --- /dev/null +++ b/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.test.ts @@ -0,0 +1,91 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { buildServerProvider } from "./providerSnapshot.ts"; +import { augmentProviderSnapshotWithAgentSkills } from "./augmentProviderSnapshotWithAgentSkills.ts"; + +const writeSkill = Effect.fn(function* ( + skillsDir: string, + directoryName: string, + contents: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDir = path.join(skillsDir, directoryName); + yield* fs.makeDirectory(skillDir, { recursive: true }); + yield* fs.writeFileString(path.join(skillDir, "SKILL.md"), contents); +}); + +const emptyDraft = () => + buildServerProvider({ + presentation: { displayName: "Cursor" }, + enabled: true, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + probe: { + installed: true, + version: null, + status: "ready", + auth: { status: "unauthenticated" }, + }, + }); + +it.layer(NodeServices.layer)("augmentProviderSnapshotWithAgentSkills", (it) => { + it.effect("returns the input draft unchanged when no agent skills are found", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-augment-skills-" }); + const agentsHome = path.join(tempDir, "agents-home"); + const workspace = path.join(tempDir, "workspace"); + + const draft = emptyDraft(); + const result = yield* augmentProviderSnapshotWithAgentSkills(draft, workspace, { + homeDirectory: agentsHome, + }); + + // Same reference: the no-op branch must not allocate a new draft, so + // downstream change-detection sees no update. + assert.strictEqual(result, draft); + assert.deepEqual(result.skills, []); + }), + ); + + it.effect("attaches discovered skills with project winning on collision", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-augment-skills-" }); + const agentsHome = path.join(tempDir, "agents-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(agentsHome, ".agents", "skills"), + "shared-skill", + ["---", "name: shared-skill", "description: User agents skill.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "shared-skill", + ["---", "name: shared-skill", "description: Project agents skill.", "---"].join("\n"), + ); + + const draft = emptyDraft(); + const result = yield* augmentProviderSnapshotWithAgentSkills(draft, workspace, { + homeDirectory: agentsHome, + }); + + assert.notStrictEqual(result, draft); + assert.equal(result.skills.length, 1); + assert.equal(result.skills[0]?.scope, "project"); + assert.equal(result.skills[0]?.description, "Project agents skill."); + assert.equal( + result.skills[0]?.path, + path.join(workspace, ".agents", "skills", "shared-skill", "SKILL.md"), + ); + }), + ); +}); diff --git a/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.ts b/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.ts new file mode 100644 index 00000000000..eb08874f4c3 --- /dev/null +++ b/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.ts @@ -0,0 +1,15 @@ +import type { ServerProviderDraft } from "./providerSnapshot.ts"; +import { discoverAgentSkills } from "./Drivers/AgentSkills.ts"; +import * as Effect from "effect/Effect"; + +/** Attach portable `.agents/skills` entries to a provider snapshot draft. */ +export const augmentProviderSnapshotWithAgentSkills = Effect.fn( + "augmentProviderSnapshotWithAgentSkills", +)(function* ( + draft: ServerProviderDraft, + cwd?: string, + options?: { readonly homeDirectory?: string }, +) { + const skills = yield* discoverAgentSkills(cwd, options); + return skills.length === 0 ? draft : { ...draft, skills }; +}); From 05135156f23fb527fd20acf65f75177cae3cbcd4 Mon Sep 17 00:00:00 2001 From: Marcus Hohlbein Date: Thu, 13 Aug 2026 01:00:07 +0200 Subject: [PATCH 2/3] feat(server): per-project skill resolution RPC + discovery primitives The provider snapshot is environment-level, so project-scoped skill discovery keyed on ServerConfig.cwd (the launch dir) leaks the startup project's skills to every project and hides each project's own `/.agents/skills`. This adds the server foundation for per-project resolution (web wiring + snapshot switch follow in a later change): - Split discovery into user-only / project-only primitives (`discoverUserAgentSkills`, `discoverProjectAgentSkills`, `discoverClaudeProjectSkills`) alongside the existing combined scanner. - New `WorkspaceAgentSkills` service resolves project skills for a workspace root + provider (Claude adds `/.claude/skills`; others portable only), reusing `WorkspacePaths.normalizeWorkspaceRoot`. - New `projects.listAgentSkills` RPC (contract + ws handler + auth scope) so the client can fetch skills for the active thread's workspace root. - No behavior change yet: the snapshot still carries skills as before; this RPC is additive until the client merges its results and the snapshot drops project roots. Generated with Claude Code (anthropic) inside the T3 Code harness. --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/provider/Drivers/AgentSkills.test.ts | 68 ++++++++++++++- .../src/provider/Drivers/AgentSkills.ts | 29 +++++++ .../src/provider/Drivers/ClaudeSkills.test.ts | 45 +++++++++- .../src/provider/Drivers/ClaudeSkills.ts | 17 ++++ apps/server/src/server.test.ts | 2 + apps/server/src/server.ts | 6 ++ .../workspace/WorkspaceAgentSkills.test.ts | 86 +++++++++++++++++++ .../src/workspace/WorkspaceAgentSkills.ts | 58 +++++++++++++ apps/server/src/ws.ts | 18 ++++ packages/contracts/src/project.ts | 22 +++++ packages/contracts/src/rpc.ts | 11 +++ 12 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/workspace/WorkspaceAgentSkills.test.ts create mode 100644 apps/server/src/workspace/WorkspaceAgentSkills.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 36f348d6370..670057119b8 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -76,6 +76,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, + [WS_METHODS.projectsListAgentSkills]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, diff --git a/apps/server/src/provider/Drivers/AgentSkills.test.ts b/apps/server/src/provider/Drivers/AgentSkills.test.ts index 96666e06016..5ae65559231 100644 --- a/apps/server/src/provider/Drivers/AgentSkills.test.ts +++ b/apps/server/src/provider/Drivers/AgentSkills.test.ts @@ -4,7 +4,11 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverAgentSkills } from "./AgentSkills.ts"; +import { + discoverAgentSkills, + discoverProjectAgentSkills, + discoverUserAgentSkills, +} from "./AgentSkills.ts"; const writeSkill = Effect.fn(function* ( skillsDir: string, @@ -127,4 +131,66 @@ it.layer(NodeServices.layer)("discoverAgentSkills", (it) => { assert.deepEqual(skills, []); }), ); + + it.effect("discoverUserAgentSkills scans only the user home, not a workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" }); + const agentsHome = path.join(tempDir, "agents-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(agentsHome, ".agents", "skills"), + "home-skill", + ["---", "name: home-skill", "description: From home.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "workspace-skill", + ["---", "name: workspace-skill", "description: From workspace.", "---"].join("\n"), + ); + + const skills = yield* discoverUserAgentSkills({ homeDirectory: agentsHome }); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["home-skill"], + ); + assert.equal(skills[0]?.scope, "user"); + }), + ); + + it.effect("discoverProjectAgentSkills scans only the given workspace root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" }); + const agentsHome = path.join(tempDir, "agents-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(agentsHome, ".agents", "skills"), + "home-skill", + ["---", "name: home-skill", "description: From home.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "workspace-skill", + ["---", "name: workspace-skill", "description: From workspace.", "---"].join("\n"), + ); + + const skills = yield* discoverProjectAgentSkills(workspace); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["workspace-skill"], + ); + assert.equal(skills[0]?.scope, "project"); + assert.equal( + skills[0]?.path, + path.join(workspace, ".agents", "skills", "workspace-skill", "SKILL.md"), + ); + }), + ); }); diff --git a/apps/server/src/provider/Drivers/AgentSkills.ts b/apps/server/src/provider/Drivers/AgentSkills.ts index edaf4072c8c..7fb2f2d0ab9 100644 --- a/apps/server/src/provider/Drivers/AgentSkills.ts +++ b/apps/server/src/provider/Drivers/AgentSkills.ts @@ -103,6 +103,35 @@ export const scanFilesystemSkillRoots = Effect.fn("scanFilesystemSkillRoots")(fu return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); }); +/** + * Enumerate portable skills under the user home only (`~/.agents/skills`). + * Use this for environment-level (project-agnostic) snapshots; pair with + * `discoverProjectAgentSkills` for per-workspace resolution. + */ +export const discoverUserAgentSkills = Effect.fn("discoverUserAgentSkills")(function* (options?: { + readonly homeDirectory?: string; +}): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const path = yield* Path.Path; + const homeDirectory = options?.homeDirectory ?? NodeOS.homedir(); + return yield* scanFilesystemSkillRoots([ + { directory: path.join(homeDirectory, ".agents", "skills"), scope: "user" }, + ]); +}); + +/** + * Enumerate portable skills under a single workspace root only + * (`/.agents/skills`). Resolve this per active project so a + * project's skills follow the project, not the server's launch directory. + */ +export const discoverProjectAgentSkills = Effect.fn("discoverProjectAgentSkills")(function* ( + workspaceRoot: string, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const path = yield* Path.Path; + return yield* scanFilesystemSkillRoots([ + { directory: path.join(workspaceRoot, ".agents", "skills"), scope: "project" }, + ]); +}); + /** * Enumerate portable skills from the user home and optional workspace cwd. */ diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 834b24086e6..7a502a690a6 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverClaudeSkills } from "./ClaudeSkills.ts"; +import { discoverClaudeProjectSkills, discoverClaudeSkills } from "./ClaudeSkills.ts"; const writeSkill = Effect.fn(function* ( skillsDir: string, @@ -324,4 +324,47 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { assert.deepEqual(skills, []); }), ); + + it.effect( + "discoverClaudeProjectSkills scans only the workspace and prefers .claude over .agents", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + // User-scope roots must be ignored by the project-only resolver. + yield* writeSkill( + path.join(configDir, "skills"), + "user-skill", + ["---", "name: user-skill", "description: User config skill.", "---"].join("\n"), + ); + // Same name in both project roots: Claude-native wins. + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "deploy", + ["---", "name: deploy", "description: Portable project skill.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "deploy", + ["---", "name: deploy", "description: Claude project skill.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeProjectSkills(workspace); + + assert.deepEqual( + skills.map((skill) => skill.name), + ["deploy"], + ); + assert.equal(skills[0]?.scope, "project"); + assert.equal(skills[0]?.description, "Claude project skill."); + assert.equal( + skills[0]?.path, + path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), + ); + }), + ); }); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 94d95a3e321..866a7615fc4 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -83,3 +83,20 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* return yield* scanFilesystemSkillRoots(roots); }); + +/** + * Enumerate Claude Code PROJECT skills for a single workspace root: the portable + * `/.agents/skills` plus the Claude-native `/.claude/skills`. + * Vendor-native wins on a same-scope collision (`.claude` roots come last). + * Resolve per active project so project skills follow the project, not the + * server launch directory. + */ +export const discoverClaudeProjectSkills = Effect.fn("discoverClaudeProjectSkills")(function* ( + workspaceRoot: string, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const path = yield* Path.Path; + return yield* scanFilesystemSkillRoots([ + { directory: path.join(workspaceRoot, ".agents", "skills"), scope: "project" }, + { directory: path.join(workspaceRoot, ".claude", "skills"), scope: "project" }, + ]); +}); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3f63eb4dbef..022dbd0bdb0 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -128,6 +128,7 @@ import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; +import * as WorkspaceAgentSkills from "./workspace/WorkspaceAgentSkills.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -569,6 +570,7 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePaths.layer), Layer.provide(workspaceEntriesLayer), ), + WorkspaceAgentSkills.layer.pipe(Layer.provide(WorkspacePaths.layer)), ProjectFaviconResolver.layer.pipe( Layer.provide(WorkspacePaths.layer), Layer.provide(T3ProjectFileLoader.layer), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 32bcaaa8b96..33086e7588f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -66,6 +66,7 @@ import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; +import * as WorkspaceAgentSkills from "./workspace/WorkspaceAgentSkills.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -336,10 +337,15 @@ const WorkspaceFileSystemLayerLive = WorkspaceFileSystem.layer.pipe( Layer.provide(WorkspaceEntriesLayerLive), ); +const WorkspaceAgentSkillsLayerLive = WorkspaceAgentSkills.layer.pipe( + Layer.provide(WorkspacePaths.layer), +); + const WorkspaceLayerLive = Layer.mergeAll( WorkspacePaths.layer, WorkspaceEntriesLayerLive, WorkspaceFileSystemLayerLive, + WorkspaceAgentSkillsLayerLive, ); const ProjectFaviconResolverLayerLive = ProjectFaviconResolver.layer.pipe( diff --git a/apps/server/src/workspace/WorkspaceAgentSkills.test.ts b/apps/server/src/workspace/WorkspaceAgentSkills.test.ts new file mode 100644 index 00000000000..1090269154b --- /dev/null +++ b/apps/server/src/workspace/WorkspaceAgentSkills.test.ts @@ -0,0 +1,86 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../config.ts"; +import * as WorkspaceAgentSkills from "./WorkspaceAgentSkills.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; + +const ProjectLayer = WorkspaceAgentSkills.layer.pipe(Layer.provide(WorkspacePaths.layer)); + +const TestLayer = Layer.empty.pipe( + Layer.provideMerge(ProjectLayer), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provide( + ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-workspace-agent-skills-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +const CLAUDE = ProviderDriverKind.make("claudeAgent"); +const CURSOR = ProviderDriverKind.make("cursorAgent"); + +const writeSkill = Effect.fn(function* ( + skillsDir: string, + directoryName: string, + description: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDir = path.join(skillsDir, directoryName); + yield* fs.makeDirectory(skillDir, { recursive: true }); + yield* fs.writeFileString( + path.join(skillDir, "SKILL.md"), + ["---", `name: ${directoryName}`, `description: ${description}`, "---"].join("\n"), + ); +}); + +it.layer(TestLayer, { excludeTestServices: true })("WorkspaceAgentSkills", (it) => { + it.effect("Claude resolves portable + native project skills, native wins on collision", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-was-claude-" }); + + yield* writeSkill(path.join(cwd, ".agents", "skills"), "portable", "Portable."); + // Same name in both roots: Claude-native (.claude) must win. + yield* writeSkill(path.join(cwd, ".agents", "skills"), "deploy", "Portable deploy."); + yield* writeSkill(path.join(cwd, ".claude", "skills"), "deploy", "Claude deploy."); + + const { skills } = yield* (function* () { + const workspaceAgentSkills = yield* WorkspaceAgentSkills.WorkspaceAgentSkills; + return yield* workspaceAgentSkills.list({ cwd, provider: CLAUDE }); + })(); + const byName = new Map(skills.map((skill) => [skill.name, skill])); + + expect(new Set(byName.keys())).toEqual(new Set(["portable", "deploy"])); + expect(byName.get("deploy")?.description).toBe("Claude deploy."); + expect(byName.get("deploy")?.scope).toBe("project"); + }), + ); + + it.effect("non-Claude providers resolve portable project skills only", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-was-cursor-" }); + + yield* writeSkill(path.join(cwd, ".agents", "skills"), "portable", "Portable."); + // A Claude-native project skill must not leak to other providers. + yield* writeSkill(path.join(cwd, ".claude", "skills"), "claude-only", "Claude only."); + + const { skills } = yield* (function* () { + const workspaceAgentSkills = yield* WorkspaceAgentSkills.WorkspaceAgentSkills; + return yield* workspaceAgentSkills.list({ cwd, provider: CURSOR }); + })(); + + expect(skills.map((skill) => skill.name)).toEqual(["portable"]); + }), + ); +}); diff --git a/apps/server/src/workspace/WorkspaceAgentSkills.ts b/apps/server/src/workspace/WorkspaceAgentSkills.ts new file mode 100644 index 00000000000..92a66ce0a05 --- /dev/null +++ b/apps/server/src/workspace/WorkspaceAgentSkills.ts @@ -0,0 +1,58 @@ +/** + * WorkspaceAgentSkills - Effect service for per-project skill discovery. + * + * Resolves project-scoped skills (`/.agents/skills`, plus Claude's + * `/.claude/skills`) for a specific workspace root and provider, so a + * project's skills follow the project rather than the server's launch + * directory. User-scoped skills still travel on the environment-level provider + * snapshot; this service supplies only the project-scoped half. + * + * @module WorkspaceAgentSkills + */ +import type { ProjectListAgentSkillsInput, ProjectListAgentSkillsResult } from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { discoverProjectAgentSkills } from "../provider/Drivers/AgentSkills.ts"; +import { discoverClaudeProjectSkills } from "../provider/Drivers/ClaudeSkills.ts"; +import * as WorkspacePaths from "./WorkspacePaths.ts"; + +// Claude layers vendor-specific project skills (`/.claude/skills`) on top +// of the portable `/.agents/skills`; every other driver is portable-only. +const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); + +/** Service tag for per-project skill discovery. */ +export class WorkspaceAgentSkills extends Context.Service< + WorkspaceAgentSkills, + { + readonly list: ( + input: ProjectListAgentSkillsInput, + ) => Effect.Effect< + ProjectListAgentSkillsResult, + WorkspacePaths.WorkspacePathsError, + FileSystem.FileSystem | Path.Path + >; + } +>()("t3/workspace/WorkspaceAgentSkills") {} + +export const make = Effect.gen(function* () { + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + + const list: WorkspaceAgentSkills["Service"]["list"] = Effect.fn("WorkspaceAgentSkills.list")( + function* (input) { + const root = yield* workspacePaths.normalizeWorkspaceRoot(input.cwd); + const skills = yield* input.provider === CLAUDE_DRIVER_KIND + ? discoverClaudeProjectSkills(root) + : discoverProjectAgentSkills(root); + return { skills }; + }, + ); + + return WorkspaceAgentSkills.of({ list }); +}); + +export const layer = Layer.effect(WorkspaceAgentSkills, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 173c89ecabf..e4046b03d3d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -40,6 +40,7 @@ import { ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, + ProjectListAgentSkillsError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, type ServerSelfUpdateError, @@ -90,6 +91,7 @@ import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; +import * as WorkspaceAgentSkills from "./workspace/WorkspaceAgentSkills.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import { readWorkflowScript } from "./orchestration/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; @@ -376,6 +378,7 @@ const makeWsRpcLayer = ( const serverSettings = yield* ServerSettings.ServerSettingsService; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const workspaceAgentSkills = yield* WorkspaceAgentSkills.WorkspaceAgentSkills; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; @@ -1820,6 +1823,21 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsListAgentSkills]: (input) => + observeRpcEffect( + WS_METHODS.projectsListAgentSkills, + workspaceAgentSkills.list(input).pipe( + Effect.mapError( + (cause) => + new ProjectListAgentSkillsError({ + cwd: input.cwd, + message: `Failed to list project agent skills in '${input.cwd}'.`, + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.shellOpenInEditor]: (input) => observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { "rpc.aggregate": "workspace", diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 757c000a065..e8faf69784b 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -5,6 +5,8 @@ import { TrimmedNonEmptyString, TrimmedString, } from "./baseSchemas.ts"; +import { ProviderDriverKind } from "./providerInstance.ts"; +import { ServerProviderSkill } from "./server.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; @@ -298,3 +300,23 @@ export class ProjectWriteFileError extends Schema.TaggedErrorClass()( + "ProjectListAgentSkillsError", + { + cwd: Schema.optional(TrimmedNonEmptyString), + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b5bd91cad59..df9e36c8cfb 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -109,6 +109,9 @@ import { ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, + ProjectListAgentSkillsError, + ProjectListAgentSkillsInput, + ProjectListAgentSkillsResult, ProjectWriteFileError, ProjectWriteFileInput, ProjectWriteFileResult, @@ -200,6 +203,7 @@ export const WS_METHODS = { projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + projectsListAgentSkills: "projects.listAgentSkills", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -638,6 +642,12 @@ export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]), }); +export const WsProjectsListAgentSkillsRpc = Rpc.make(WS_METHODS.projectsListAgentSkills, { + payload: ProjectListAgentSkillsInput, + success: ProjectListAgentSkillsResult, + error: Schema.Union([ProjectListAgentSkillsError, EnvironmentAuthorizationError]), +}); + export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -1018,6 +1028,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, + WsProjectsListAgentSkillsRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, WsAssetsCreateUrlRpc, From eee651d8054b11d141011febb08cec18bdb28e0f Mon Sep 17 00:00:00 2001 From: Marcus Hohlbein Date: Thu, 13 Aug 2026 08:47:42 +0200 Subject: [PATCH 3/3] fix(server,web): resolve project skills per active workspace, not launch dir The provider snapshot is environment-level, so baking project-scoped skill discovery off ServerConfig.cwd (the launch dir) leaked the startup project's skills to every project and hid each project's own `/.agents/skills`. Project skills now follow the project: - Snapshot carries user-scoped skills only: augmentProviderSnapshotWithAgentSkills scans `~/.agents/skills` and Claude's discoverClaudeSkills is called without a cwd at the snapshot site. The redundant combined discoverAgentSkills is gone. - The web composer (ChatView) queries the new projects.listAgentSkills RPC for the active thread's workspaceRoot + provider driver and merges project skills over the snapshot's user skills (project wins on name collisions). Mobile (React Native) parity for the per-project skills query is the remaining surface. Generated with Claude Code (anthropic) inside the T3 Code harness. --- .../src/provider/Drivers/AgentSkills.test.ts | 38 ++++++++----------- .../src/provider/Drivers/AgentSkills.ts | 16 -------- .../src/provider/Drivers/CursorDriver.ts | 3 +- .../server/src/provider/Drivers/GrokDriver.ts | 3 +- .../src/provider/Drivers/OpenCodeDriver.ts | 2 +- .../src/provider/Layers/ClaudeProvider.ts | 2 +- ...entProviderSnapshotWithAgentSkills.test.ts | 22 ++++------- .../augmentProviderSnapshotWithAgentSkills.ts | 17 +++++---- apps/web/src/components/ChatView.tsx | 28 +++++++++++++- .../src/state/projectCommands.ts | 6 +++ 10 files changed, 70 insertions(+), 67 deletions(-) diff --git a/apps/server/src/provider/Drivers/AgentSkills.test.ts b/apps/server/src/provider/Drivers/AgentSkills.test.ts index 5ae65559231..5d3d75e558c 100644 --- a/apps/server/src/provider/Drivers/AgentSkills.test.ts +++ b/apps/server/src/provider/Drivers/AgentSkills.test.ts @@ -5,9 +5,9 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { - discoverAgentSkills, discoverProjectAgentSkills, discoverUserAgentSkills, + scanFilesystemSkillRoots, } from "./AgentSkills.ts"; const writeSkill = Effect.fn(function* ( @@ -26,13 +26,12 @@ const isolatedDiscoveryOptions = (tempDir: string) => ({ homeDirectory: `${tempDir}/isolated-home`, }); -it.layer(NodeServices.layer)("discoverAgentSkills", (it) => { - it.effect("discovers user and project skills with frontmatter metadata", () => +it.layer(NodeServices.layer)("AgentSkills", (it) => { + it.effect("discoverUserAgentSkills parses frontmatter metadata for user skills", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" }); - const workspace = path.join(tempDir, "workspace"); const agentsHome = path.join(tempDir, "agents-home"); yield* writeSkill( @@ -40,13 +39,8 @@ it.layer(NodeServices.layer)("discoverAgentSkills", (it) => { "agent-browser", ["---", "name: agent-browser", "description: Browser automation.", "---"].join("\n"), ); - yield* writeSkill( - path.join(workspace, ".agents", "skills"), - "test-t3-app", - ["---", "name: test-t3-app", "description: Test the web app.", "---"].join("\n"), - ); - const skills = yield* discoverAgentSkills(workspace, { homeDirectory: agentsHome }); + const skills = yield* discoverUserAgentSkills({ homeDirectory: agentsHome }); assert.deepEqual(skills, [ { @@ -56,13 +50,6 @@ it.layer(NodeServices.layer)("discoverAgentSkills", (it) => { scope: "user", description: "Browser automation.", }, - { - name: "test-t3-app", - path: path.join(workspace, ".agents", "skills", "test-t3-app", "SKILL.md"), - enabled: true, - scope: "project", - description: "Test the web app.", - }, ]); }), ); @@ -86,7 +73,10 @@ it.layer(NodeServices.layer)("discoverAgentSkills", (it) => { ["---", "name: shared-skill", "description: Project agents skill.", "---"].join("\n"), ); - const skills = yield* discoverAgentSkills(workspace, { homeDirectory: agentsHome }); + const skills = yield* scanFilesystemSkillRoots([ + { directory: path.join(agentsHome, ".agents", "skills"), scope: "user" }, + { directory: path.join(workspace, ".agents", "skills"), scope: "project" as const }, + ]); assert.equal(skills.length, 1); assert.equal(skills[0]?.scope, "project"); @@ -107,7 +97,7 @@ it.layer(NodeServices.layer)("discoverAgentSkills", (it) => { yield* fs.makeDirectory(skillsDir, { recursive: true }); yield* fs.writeFileString(path.join(skillsDir, "README.md"), "not a skill"); - const skills = yield* discoverAgentSkills(undefined, { homeDirectory: agentsHome }); + const skills = yield* discoverUserAgentSkills({ homeDirectory: agentsHome }); assert.deepEqual( skills.map((skill) => skill.name), @@ -122,12 +112,16 @@ it.layer(NodeServices.layer)("discoverAgentSkills", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agent-skills-" }); + const workspace = path.join(tempDir, "workspace"); - const skills = yield* discoverAgentSkills( - path.join(tempDir, "missing-workspace"), - isolatedDiscoveryOptions(tempDir), + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "project-only", + ["---", "name: project-only", "description: Project.", "---"].join("\n"), ); + const skills = yield* discoverUserAgentSkills(isolatedDiscoveryOptions(tempDir)); + assert.deepEqual(skills, []); }), ); diff --git a/apps/server/src/provider/Drivers/AgentSkills.ts b/apps/server/src/provider/Drivers/AgentSkills.ts index 7fb2f2d0ab9..52877b401f3 100644 --- a/apps/server/src/provider/Drivers/AgentSkills.ts +++ b/apps/server/src/provider/Drivers/AgentSkills.ts @@ -131,19 +131,3 @@ export const discoverProjectAgentSkills = Effect.fn("discoverProjectAgentSkills" { directory: path.join(workspaceRoot, ".agents", "skills"), scope: "project" }, ]); }); - -/** - * Enumerate portable skills from the user home and optional workspace cwd. - */ -export const discoverAgentSkills = Effect.fn("discoverAgentSkills")(function* ( - cwd?: string, - options?: { readonly homeDirectory?: string }, -): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { - const path = yield* Path.Path; - const homeDirectory = options?.homeDirectory ?? NodeOS.homedir(); - - return yield* scanFilesystemSkillRoots([ - { directory: path.join(homeDirectory, ".agents", "skills"), scope: "user" }, - ...(cwd ? [{ directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }] : []), - ]); -}); diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index e6598cfa5bf..2bc52254629 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -126,7 +126,6 @@ export const CursorDriver: ProviderDriver = { env: processEnv, }); - const { cwd } = yield* ServerConfig; const adapter = yield* makeCursorAdapter(effectiveConfig, { environment: processEnv, ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), @@ -135,7 +134,7 @@ export const CursorDriver: ProviderDriver = { const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( - Effect.flatMap((draft) => augmentProviderSnapshotWithAgentSkills(draft, cwd)), + Effect.flatMap((draft) => augmentProviderSnapshotWithAgentSkills(draft)), Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 3bf28625c21..ac71a4114da 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -89,7 +89,6 @@ export const GrokDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; @@ -118,7 +117,7 @@ export const GrokDriver: ProviderDriver = { const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( - Effect.flatMap((draft) => augmentProviderSnapshotWithAgentSkills(draft, cwd)), + Effect.flatMap((draft) => augmentProviderSnapshotWithAgentSkills(draft)), Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 09962c2034d..1222c7236a8 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -151,7 +151,7 @@ export const OpenCodeDriver: ProviderDriver serverConfig.cwd, processEnv, ).pipe( - Effect.flatMap((draft) => augmentProviderSnapshotWithAgentSkills(draft, serverConfig.cwd)), + Effect.flatMap((draft) => augmentProviderSnapshotWithAgentSkills(draft)), Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime), Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 0e019f003c7..9792f125675 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -910,7 +910,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; - const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); + const skills = yield* discoverClaudeSkills(claudeSettings, undefined, resolvedEnvironment); const slashCommands = capabilities?.slashCommands ?? []; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); diff --git a/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.test.ts b/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.test.ts index bfd8f750282..d1ed1a7d30e 100644 --- a/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.test.ts +++ b/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.test.ts @@ -43,7 +43,7 @@ it.layer(NodeServices.layer)("augmentProviderSnapshotWithAgentSkills", (it) => { const workspace = path.join(tempDir, "workspace"); const draft = emptyDraft(); - const result = yield* augmentProviderSnapshotWithAgentSkills(draft, workspace, { + const result = yield* augmentProviderSnapshotWithAgentSkills(draft, { homeDirectory: agentsHome, }); @@ -54,37 +54,31 @@ it.layer(NodeServices.layer)("augmentProviderSnapshotWithAgentSkills", (it) => { }), ); - it.effect("attaches discovered skills with project winning on collision", () => + it.effect("attaches discovered user skills to the draft", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-augment-skills-" }); const agentsHome = path.join(tempDir, "agents-home"); - const workspace = path.join(tempDir, "workspace"); yield* writeSkill( path.join(agentsHome, ".agents", "skills"), - "shared-skill", - ["---", "name: shared-skill", "description: User agents skill.", "---"].join("\n"), - ); - yield* writeSkill( - path.join(workspace, ".agents", "skills"), - "shared-skill", - ["---", "name: shared-skill", "description: Project agents skill.", "---"].join("\n"), + "agent-browser", + ["---", "name: agent-browser", "description: Browser automation.", "---"].join("\n"), ); const draft = emptyDraft(); - const result = yield* augmentProviderSnapshotWithAgentSkills(draft, workspace, { + const result = yield* augmentProviderSnapshotWithAgentSkills(draft, { homeDirectory: agentsHome, }); assert.notStrictEqual(result, draft); assert.equal(result.skills.length, 1); - assert.equal(result.skills[0]?.scope, "project"); - assert.equal(result.skills[0]?.description, "Project agents skill."); + assert.equal(result.skills[0]?.scope, "user"); + assert.equal(result.skills[0]?.description, "Browser automation."); assert.equal( result.skills[0]?.path, - path.join(workspace, ".agents", "skills", "shared-skill", "SKILL.md"), + path.join(agentsHome, ".agents", "skills", "agent-browser", "SKILL.md"), ); }), ); diff --git a/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.ts b/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.ts index eb08874f4c3..ea26c881ba0 100644 --- a/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.ts +++ b/apps/server/src/provider/augmentProviderSnapshotWithAgentSkills.ts @@ -1,15 +1,16 @@ import type { ServerProviderDraft } from "./providerSnapshot.ts"; -import { discoverAgentSkills } from "./Drivers/AgentSkills.ts"; +import { discoverUserAgentSkills } from "./Drivers/AgentSkills.ts"; import * as Effect from "effect/Effect"; -/** Attach portable `.agents/skills` entries to a provider snapshot draft. */ +/** + * Attach user-scoped portable `.agents/skills` entries to a provider snapshot + * draft. Project-scoped skills are resolved per active workspace via the + * `projects.listAgentSkills` RPC and merged on the client, not baked into this + * environment-level snapshot. + */ export const augmentProviderSnapshotWithAgentSkills = Effect.fn( "augmentProviderSnapshotWithAgentSkills", -)(function* ( - draft: ServerProviderDraft, - cwd?: string, - options?: { readonly homeDirectory?: string }, -) { - const skills = yield* discoverAgentSkills(cwd, options); +)(function* (draft: ServerProviderDraft, options?: { readonly homeDirectory?: string }) { + const skills = yield* discoverUserAgentSkills(options); return skills.length === 0 ? draft : { ...draft, skills }; }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1f00c177c30..b564d6a8490 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -350,6 +350,19 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; + +/** Merge user-scoped snapshot skills with project-scoped skills (project wins on name). */ +function mergeProviderSkills( + user: ServerProvider["skills"] | undefined, + project: ServerProvider["skills"] | undefined, +): ServerProvider["skills"] { + if (!project?.length) return user ?? EMPTY_PROVIDER_SKILLS; + if (!user?.length) return project; + const byName = new Map(); + for (const skill of user) byName.set(skill.name, skill); + for (const skill of project) byName.set(skill.name, skill); + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); @@ -2659,6 +2672,19 @@ function ChatViewContent(props: ChatViewProps) { const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; + const activeProviderDriver = activeProviderStatus?.driver; + const projectSkillsQuery = useEnvironmentQuery( + activeThreadEnvironmentId && activeWorkspaceRoot && activeProviderDriver + ? projectEnvironment.listAgentSkills({ + environmentId: activeThreadEnvironmentId, + input: { cwd: activeWorkspaceRoot, provider: activeProviderDriver }, + }) + : null, + ); + const composerSkills = useMemo( + () => mergeProviderSkills(activeProviderStatus?.skills, projectSkillsQuery.data?.skills), + [activeProviderStatus?.skills, projectSkillsQuery.data?.skills], + ); const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. @@ -6244,7 +6270,7 @@ function ChatViewContent(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} - skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + skills={composerSkills} anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} contentInsetEndAdjustment={composerOverlayHeight} diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc32154..75e30b3b8fe 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -72,6 +72,12 @@ export function createProjectEnvironmentAtoms( staleTimeMs: 30_000, idleTtlMs: 5 * 60_000, }), + listAgentSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:projects:list-agent-skills", + tag: WS_METHODS.projectsListAgentSkills, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, + }), optimisticFile: (target: OptimisticProjectFileTarget) => optimisticFileFamily(optimisticProjectFileKey(target)), create: createEnvironmentCommand(runtime, {