From 2031ea648b9b703782d29b2bc14fd2284c908ad9 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:08:30 -0400 Subject: [PATCH 001/108] feat(server): list the skills each enabled agent can use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `server.listSkills` and `server.getSkill`, which read the skill folders of the enabled provider instances straight from disk: no agent is asked to rescan, and nothing is written. An agent loads one skill per name, the first it finds in its folders, so the catalog walks each instance's folders in that order and a shadowed copy is `none` for that instance. Copies of a name that differ, in one scope or across scopes, are reported. A SKILL.md header that Claude Code can't parse marks the skill as skipped by Claude, using Claude's own header parser. Folders follow each instance's config: a Claude instance's config directory or `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, and `GROK_HOME`. Folders that exist but can't be read are returned with the list. Descriptions are cut at 160 characters with a trailing "…". A SKILL.md that is a link out of the skill's folder isn't read, and the file walk is bounded in files, folders and entries per folder. The Claude, Cursor and Antigravity scanners now read their folders from the same table, and tests pin each scanner's folders and their order. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/auth/RpcAuthorization.ts | 2 + .../src/observability/RpcInstrumentation.ts | 2 + .../Drivers/AntigravitySkills.test.ts | 47 + .../src/provider/Drivers/AntigravitySkills.ts | 29 +- .../src/provider/Drivers/ClaudeSkills.test.ts | 38 + .../src/provider/Drivers/ClaudeSkills.ts | 26 +- apps/server/src/server.ts | 2 + apps/server/src/skills/SkillCatalog.test.ts | 832 ++++++++++++++++++ apps/server/src/skills/SkillCatalog.ts | 623 +++++++++++++ apps/server/src/ws.ts | 4 + packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 17 + packages/contracts/src/skills.ts | 102 +++ packages/provider-core/package.json | 4 + .../src/server/AgentSkillFolders.ts | 169 ++++ .../provider-cursor/src/server/skills.test.ts | 42 + packages/provider-cursor/src/server/skills.ts | 20 +- 17 files changed, 1933 insertions(+), 27 deletions(-) create mode 100644 apps/server/src/skills/SkillCatalog.test.ts create mode 100644 apps/server/src/skills/SkillCatalog.ts create mode 100644 packages/contracts/src/skills.ts create mode 100644 packages/provider-core/src/server/AgentSkillFolders.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 511fd77cd147..db8cd83019d7 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -60,6 +60,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope, + [WS_METHODS.serverListSkills]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetSkill]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope, [WS_METHODS.providerAuthStart]: AuthProvidersManageScope, [WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 2ad903ff792a..cb7236d4e7d1 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -33,6 +33,8 @@ const RPC_AGGREGATES = { [WS_METHODS.serverProbe]: "server", [WS_METHODS.serverGetConfig]: "server", [WS_METHODS.serverRefreshProviders]: "server", + [WS_METHODS.serverListSkills]: "server", + [WS_METHODS.serverGetSkill]: "server", [WS_METHODS.serverUpdateProvider]: "server", [WS_METHODS.providerAuthStart]: "provider", [WS_METHODS.providerConsumeResetCredit]: "provider", diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts index f5bc8ec3f4d3..48b4697a3200 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -342,6 +342,53 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { ); }); +it.layer(NodeServices.layer)("Antigravity skill folders", (it) => { + // The Skills settings page reads the same folders from a shared table, so a change to the + // table that reorders or adds a folder would change what the `$` picker offers. + it.effect("reads the home and project folders in a fixed, interleaved order", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const folders = [ + { label: "home config", base: input.userHome, folder: ".gemini/config/skills" }, + { label: "project .gemini", base: input.cwd, folder: ".gemini/skills" }, + { label: "home cli", base: input.userHome, folder: ".gemini/antigravity-cli/skills" }, + { label: "project .agents", base: input.cwd, folder: ".agents/skills" }, + { label: "project .agent", base: input.cwd, folder: ".agent/skills" }, + ]; + const ignored = [ + { base: input.userHome, folder: ".agents/skills" }, + { base: input.cwd, folder: ".claude/skills" }, + { base: input.cwd, folder: ".codex/skills" }, + ]; + for (const { label, base, folder } of folders) { + yield* writeSkill( + path.join(base, folder, "probe"), + `---\nname: probe\ndescription: ${label}\n---\n`, + ); + } + for (const { base, folder } of ignored) { + yield* writeSkill( + path.join(base, folder, "probe"), + "---\nname: probe\ndescription: ignored\n---\n", + ); + } + + // Each folder wins until its skill is removed, so the order is the folders' order. + for (const { label, base, folder } of folders) { + const found = yield* discoverAntigravitySkills(input); + assert.deepEqual( + found.map((skill) => [skill.name, skill.description]), + [["probe", label]], + ); + yield* fileSystem.remove(path.join(base, folder, "probe"), { recursive: true }); + } + assert.deepEqual(yield* discoverAntigravitySkills(input), []); + }), + ); +}); + it("resolves the home the agent expands ~ against", () => { assert.equal( resolveAntigravityUserHome( diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index 1bc4e370013f..971176063e35 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -1,4 +1,4 @@ -import type { ServerProviderSkill } from "@t3tools/contracts"; +import { ProviderDriverKind, type ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -7,6 +7,13 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { parse as parseYamlDocument } from "yaml"; +import { + ANTIGRAVITY_USER_SKILL_SUBFOLDERS, + skillRootsFor, +} from "@t3tools/provider-core/server/AgentSkillFolders"; + +const ANTIGRAVITY_DRIVER = ProviderDriverKind.make("antigravity"); + /** * The home directory the agent expands `~` against, matching Python's * `os.path.expanduser` in the launch environment T3 hands the process: @@ -41,10 +48,8 @@ export function antigravityUserSkillDirectories( path: Path.Path, geminiHome: string, ): readonly [configSkills: string, cliSkills: string] { - return [ - path.join(geminiHome, "config", "skills"), - path.join(geminiHome, "antigravity-cli", "skills"), - ]; + const [configSkills, cliSkills] = ANTIGRAVITY_USER_SKILL_SUBFOLDERS; + return [path.join(geminiHome, configSkills), path.join(geminiHome, cliSkills)]; } const MAX_SKILL_BYTES = 1_000_000; @@ -166,17 +171,11 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")( > { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const [configSkills, cliSkills] = antigravityUserSkillDirectories( - path, - path.join(input.userHome, ".gemini"), + const roots = skillRootsFor(ANTIGRAVITY_DRIVER).map((root) => + root.scope === "global" + ? { directory: path.join(input.userHome, root.folder), scope: "user" } + : { directory: path.resolve(input.cwd, root.folder), scope: "project" }, ); - const roots = [ - { directory: configSkills, scope: "user" }, - { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" }, - { directory: cliSkills, scope: "user" }, - { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" }, - { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" }, - ]; const budget: ScanBudget = { remainingBytes: MAX_SCAN_BYTES, remainingEntries: MAX_SCAN_ENTRIES, diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index c4b328cdc337..6d9c7489cf7b 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -724,6 +724,44 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + // The Skills settings page reads the same folders from a shared table, so a change to the + // table that reorders or adds a folder would change what the `$` picker offers. + it.effect("reads the config folder first, then the project's .claude/skills, and no others", () => + 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 folders = [ + { label: "config", skills: path.join(configDir, "skills") }, + { label: "project .claude", skills: path.join(workspace, ".claude", "skills") }, + ]; + const ignored = [".agents/skills", ".codex/skills", ".cursor/skills", ".gemini/skills"]; + for (const { label, skills } of folders) { + yield* writeSkill(skills, "probe", `---\ndescription: ${label}\n---\n`); + } + for (const folder of ignored) { + yield* writeSkill( + path.join(workspace, folder), + "probe", + "---\ndescription: ignored\n---\n", + ); + } + + // Each folder wins until its skill is removed, so the order is the folders' order. + for (const { label, skills } of folders) { + const found = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + assert.deepEqual( + found.map((skill) => [skill.name, skill.description]), + [["probe", label]], + ); + yield* fs.remove(path.join(skills, "probe"), { recursive: true }); + } + assert.deepEqual(yield* discoverClaudeSkills({ homePath: configDir }, workspace), []); + }), + ); + it.effect("returns an empty list when no skill roots exist", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 40ab84d65ed6..0ac7f15798f3 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -14,7 +14,11 @@ * @module provider/Drivers/ClaudeSkills */ -import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; +import { + ProviderDriverKind, + type ClaudeSettings, + type ServerProviderSkill, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -24,9 +28,12 @@ import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { parse as parseYamlDocument } from "yaml"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { skillFoldersFor } from "@t3tools/provider-core/server/AgentSkillFolders"; type ClaudeSkillScope = "user" | "project"; +const CLAUDE_DRIVER = ProviderDriverKind.make("claudeAgent"); + const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; type SkillFrontmatter = @@ -68,7 +75,11 @@ function parseFrontmatterBoolean(value: unknown): boolean | undefined { } } -function parseSkillFrontmatter(contents: string): SkillFrontmatter { +/** + * How Claude Code reads a SKILL.md header. `malformed` skills don't load there; the Skills + * settings page reads headers the same way, so it reports what Claude would skip. + */ +export function parseSkillFrontmatter(contents: string): SkillFrontmatter { const match = FRONTMATTER_PATTERN.exec(contents); if (!match) { return { kind: "missing" }; @@ -289,7 +300,7 @@ const readSkillOverrides = Effect.fn("readSkillOverrides")(function* ( * `CLAUDE_CONFIG_DIR` by `makeClaudeEnvironment`), then a `CLAUDE_CONFIG_DIR` * already present in the process environment, then `~/.claude`. */ -const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* ( +export const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(function* ( config: Pick, environment: NodeJS.ProcessEnv, cwd?: string, @@ -332,9 +343,16 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); const skillOverrides = yield* readSkillOverrides(configDirPath, cwd, environment ?? process.env); + // The user folder follows the config dir, which is `~/.claude` unless overridden; the project + // folder comes from the shared table. const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, - ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), + ...(cwd + ? skillFoldersFor(CLAUDE_DRIVER, "project").map((folder) => ({ + directory: path.join(cwd, folder), + scope: "project" as const, + })) + : []), ]; const skillsByName = new Map(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 98a541409380..e114d438dfb2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -87,6 +87,7 @@ import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; +import * as SkillCatalog from "./skills/SkillCatalog.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; @@ -643,6 +644,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( ), ), Layer.provideMerge(layerWorkspace), + Layer.provideMerge(SkillCatalog.layer), Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), Layer.provideMerge(layerRepositoryIdentityResolver), diff --git a/apps/server/src/skills/SkillCatalog.test.ts b/apps/server/src/skills/SkillCatalog.test.ts new file mode 100644 index 000000000000..9db87d4de2c4 --- /dev/null +++ b/apps/server/src/skills/SkillCatalog.test.ts @@ -0,0 +1,832 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it, describe, expect } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + SkillGetResult, + SkillListResult, + type SkillAgentAccess, + type SkillSummary, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +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 Schema from "effect/Schema"; + +import * as Settings from "../serverSettings.ts"; +import * as SkillCatalog from "./SkillCatalog.ts"; + +const encodeList = Schema.encodeUnknownEffect(SkillListResult); +const encodeGet = Schema.encodeUnknownEffect(SkillGetResult); + +const skillFile = (name: string, description: string) => + `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`; + +/** A temp home and project laid out like a real machine: a synced library linked into two folders. */ +const makeMachine = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-skill-catalog-" }); + const home = yield* fs.realPath(root); + const project = path.join(home, "repos/app"); + const write = (relative: string, contents: string, executable = false) => + Effect.gen(function* () { + const target = path.join(home, relative); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, contents); + if (executable) yield* fs.chmod(target, 0o755); + }); + const link = (target: string, from: string) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.dirname(path.join(home, from)), { recursive: true }); + yield* fs.symlink(path.join(home, target), path.join(home, from)); + }); + + // Global: a synced library linked into the standard folder, and into Claude's folder for one. + for (const name of ["architect", "grill"]) + yield* write(`Knowledge/skills/${name}/SKILL.md`, skillFile(name, `The ${name} skill.`)); + yield* write("Knowledge/skills/tdd/SKILL.md", skillFile("tdd", "Global test-first loop.")); + yield* write("Knowledge/skills/shared/SKILL.md", skillFile("shared", "Same everywhere.")); + yield* write("Knowledge/skills/architect/refs/principles.md", "# principles\n"); + for (const name of ["architect", "grill", "tdd", "shared"]) + yield* link(`Knowledge/skills/${name}`, `.agents/skills/${name}`); + yield* link("Knowledge/skills/architect", ".claude/skills/architect"); + yield* link("missing/skills/gone", ".agents/skills/broken"); + yield* write( + ".claude/skills/cloudflare/SKILL.md", + skillFile("cloudflare", "Deploy to Cloudflare."), + ); + yield* write(".claude/skills/not-a-skill/notes.txt", "no SKILL.md here"); + yield* write(".claude/skills/.hidden/SKILL.md", skillFile("hidden", "Hidden.")); + + // Project: a real standard folder, a skill that only Claude reads, and a copy of a global name. + yield* write( + "repos/app/.agents/skills/verify/SKILL.md", + "---\nname: verify\ndescription: >-\n Drive the app in a browser\n and capture evidence.\n---\n\n# verify\n", + ); + yield* write("repos/app/.agents/skills/verify/bin/run", "#!/usr/bin/env bash\n", true); + yield* write("repos/app/.agents/skills/verify/lib/serve.mjs", "export {};\n"); + yield* write("repos/app/.agents/skills/tdd/SKILL.md", skillFile("tdd", "Project test loop.")); + yield* write("repos/app/.agents/skills/shared/SKILL.md", skillFile("shared", "Same everywhere.")); + yield* write("repos/app/.claude/skills/own-copy/SKILL.md", skillFile("own-copy", "Claude only.")); + yield* link("repos/app/.agents/skills/verify", "repos/app/.claude/skills/verify"); + return { home, project, write, link }; +}); + +/** Cursor, Grok, OpenCode, Antigravity and Pi are off until the user turns them on. */ +const ALL_AGENTS_ENABLED = Object.fromEntries( + ["cursor", "grok", "opencode", "antigravity", "pi"].map((driver) => [ + ProviderInstanceId.make(driver), + { driver: ProviderDriverKind.make(driver), enabled: true }, + ]), +); + +/** The catalog as it sees a machine whose home is `home`, with these server settings. */ +const withCatalog = ( + home: string, + use: (catalog: SkillCatalog.SkillCatalog["Service"]) => Effect.Effect, + options: { + readonly settings?: Parameters[0]; + readonly env?: NodeJS.ProcessEnv; + } = {}, +) => + Effect.gen(function* () { + return yield* use(yield* SkillCatalog.SkillCatalog); + }).pipe( + Effect.provide( + SkillCatalog.layer.pipe( + Layer.provide( + Settings.layerTest({ + ...options.settings, + providerInstances: { ...ALL_AGENTS_ENABLED, ...options.settings?.providerInstances }, + }), + ), + ), + ), + Effect.provideService(HostProcess.Environment, { HOME: home, ...options.env }), + Effect.provideService(HostProcess.HomeDirectory, home), + ); + +const byKey = (skills: readonly SkillSummary[]) => + new Map(skills.map((skill) => [`${skill.scope}:${skill.name}`, skill])); +const accessOf = (skill: SkillSummary | undefined) => + Object.fromEntries( + (skill?.access ?? []).map((entry: SkillAgentAccess) => [ + entry.instanceId, + { state: entry.state, folder: entry.folder }, + ]), + ); +const states = (skill: SkillSummary | undefined) => + Object.fromEntries(Object.entries(accessOf(skill)).map(([agent, { state }]) => [agent, state])); + +const NOT_FOUND = { + home: null, + description: "", + contents: null, + files: [], + filesTruncated: false, +}; + +it.layer(NodeServices.layer, { excludeTestServices: true })("SkillCatalog", (it) => { + describe("list", () => { + it.effect.skipIf(!symlinksSupported)( + "tells how each agent reaches a skill: shared folder, link, own folder or not at all", + () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project })); + const byName = byKey(skills); + + const architect = byName.get("global:architect"); + expect(architect).toMatchObject({ + home: "~/Knowledge/skills/architect", + description: "The architect skill.", + }); + expect(accessOf(architect)).toEqual({ + // Claude only reads its own folder, so the library skill reaches it through a link. + claudeAgent: { state: "link", folder: "~/.claude/skills" }, + codex: { state: "direct", folder: "~/.agents/skills" }, + cursor: { state: "direct", folder: "~/.agents/skills" }, + grok: { state: "direct", folder: "~/.agents/skills" }, + opencode: { state: "direct", folder: "~/.agents/skills" }, + // Antigravity reads `.agents/skills` in a project, but not in the global level. + antigravity: { state: "none", folder: "~/.gemini/config/skills" }, + pi: { state: "direct", folder: "~/.agents/skills" }, + }); + expect(accessOf(byName.get("global:grill")).claudeAgent).toEqual({ + state: "none", + folder: "~/.claude/skills", + }); + + const cloudflare = byName.get("global:cloudflare"); + expect(cloudflare?.home).toBe("~/.claude/skills/cloudflare"); + expect(accessOf(cloudflare)).toMatchObject({ + claudeAgent: { state: "direct", folder: "~/.claude/skills" }, + cursor: { state: "direct", folder: "~/.claude/skills" }, + opencode: { state: "direct", folder: "~/.claude/skills" }, + codex: { state: "none", folder: "~/.agents/skills" }, + }); + + const verify = byName.get("project:verify"); + expect(verify?.home).toBe(".agents/skills/verify"); + expect(accessOf(verify)).toEqual({ + claudeAgent: { state: "link", folder: ".claude/skills" }, + codex: { state: "direct", folder: ".agents/skills" }, + cursor: { state: "direct", folder: ".agents/skills" }, + grok: { state: "none", folder: ".grok/skills" }, + opencode: { state: "direct", folder: ".agents/skills" }, + antigravity: { state: "direct", folder: ".agents/skills" }, + pi: { state: "direct", folder: ".agents/skills" }, + }); + expect(accessOf(byName.get("project:own-copy"))).toMatchObject({ + claudeAgent: { state: "direct", folder: ".claude/skills" }, + codex: { state: "none", folder: ".agents/skills" }, + pi: { state: "none", folder: ".pi/skills" }, + }); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "skips folders that aren't skills and links that point nowhere", + () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + // Only SKILL.md makes a skill: a lowercase file, a folder named SKILL.md and a + // dot-folder don't. + yield* write(".agents/skills/lowercase/skill.md", skillFile("lowercase", "Lower.")); + yield* write(".agents/skills/odd/SKILL.md/inner.txt", "a folder, not a file"); + yield* write(".agents/skills/.dotted/SKILL.md", skillFile("dotted", "Dotted.")); + yield* write(".agents/skills/some file.txt", "not a folder"); + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project })); + const names = skills + .filter((skill) => skill.scope === "global") + .map((skill) => skill.name); + for (const skipped of ["lowercase", "broken", "odd", ".dotted", "not-a-skill", ".hidden"]) + expect(names).not.toContain(skipped); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "accepts the folder names the agents' scanners accept, such as ones with spaces", + () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + for (const name of ["my skill", "Name_1.2", "plus+sign", "ünï"]) + yield* write(`.agents/skills/${name}/SKILL.md`, skillFile(name, "Odd name.")); + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({})); + const names = skills.map((skill) => skill.name); + for (const name of ["my skill", "Name_1.2", "plus+sign", "ünï"]) + expect(names).toContain(name); + const detail = yield* withCatalog(home, (catalog) => + catalog.get({ scope: "global", name: "my skill", home: "~/.agents/skills/my skill" }), + ); + expect(detail.home).toBe(`${home}/.agents/skills/my skill`); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "reads descriptions the way Claude Code reads a header", + () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + const header = (name: string, body: string) => + write(`.agents/skills/${name}/SKILL.md`, `---\nname: ${name}\n${body}\n---\nBody\n`); + yield* header("quoted", 'description: "Say \\"hi\\" often"'); + yield* header("folded", "description: >-\n one\n two"); + yield* header("literal", "description: |\n line one\n line two"); + // YAML rejects an unquoted colon; Claude Code, and so the page, still reads it. + yield* header("colon", "description: Use when: testing"); + yield* header("spaced", "description: spaced out"); + yield* header("none", "other: value"); + yield* write(".agents/skills/no-header/SKILL.md", "# No header\n"); + yield* write( + ".agents/skills/windows/SKILL.md", + "---\r\nname: x\r\ndescription: crlf\r\n---\r\n", + ); + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({})); + const described = Object.fromEntries( + skills.map((skill) => [skill.name, skill.description]), + ); + expect(described).toMatchObject({ + quoted: 'Say "hi" often', + folded: "one two", + literal: "line one line two", + colon: "Use when: testing", + spaced: "spaced out", + none: "", + "no-header": "", + windows: "crlf", + }); + expect(skills.every((skill) => skill.invalidHeader === undefined)).toBe(true); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "reports a header Claude Code can't read, and Claude doesn't load the skill", + () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write( + ".claude/skills/broken-header/SKILL.md", + "---\nname: broken-header\ndescription: [never closed\n---\nBody\n", + ); + // A header Claude skips doesn't shadow a later copy of the same name. + yield* write( + ".claude/skills/dup/SKILL.md", + "---\ndescription: [never closed\n---\nGlobal.\n", + ); + yield* write("repos/app/.claude/skills/dup/SKILL.md", skillFile("dup", "Project copy.")); + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project })); + const byName = byKey(skills); + + const broken = byName.get("global:broken-header"); + expect(broken).toMatchObject({ invalidHeader: true, description: "" }); + expect(states(broken)).toMatchObject({ claudeAgent: "none", cursor: "direct" }); + + expect(byName.get("global:dup")).toMatchObject({ invalidHeader: true }); + expect(states(byName.get("global:dup")).claudeAgent).toBe("none"); + expect(states(byName.get("project:dup")).claudeAgent).toBe("direct"); + expect(byName.get("project:dup")?.invalidHeader).toBeUndefined(); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "flags a name that exists more than once, and whether the copies are identical", + () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project })); + const byName = byKey(skills); + expect(byName.get("project:tdd")?.copies).toEqual([ + { scope: "global", home: "~/Knowledge/skills/tdd", same: false }, + ]); + expect(byName.get("global:tdd")?.copies).toEqual([ + { scope: "project", home: ".agents/skills/tdd", same: false }, + ]); + expect(byName.get("project:shared")?.copies).toEqual([ + { scope: "global", home: "~/Knowledge/skills/shared", same: true }, + ]); + expect(byName.get("global:shared")?.copies).toEqual([ + { scope: "project", home: ".agents/skills/shared", same: true }, + ]); + expect(byName.get("project:verify")?.copies).toEqual([]); + expect(byName.get("global:architect")?.copies).toEqual([]); + + // Without a project there is no second scope to compare with. + const globalOnly = yield* withCatalog(home, (catalog) => catalog.list({})); + expect(globalOnly.skills.every((skill) => skill.scope === "global")).toBe(true); + expect(globalOnly.skills.every((skill) => skill.copies.length === 0)).toBe(true); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "gives a copy to an agent only when the agent loads it, and flags copies that differ", + () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write( + "repos/app/.claude/skills/tdd/SKILL.md", + skillFile("tdd", "Claude's own tdd."), + ); + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project })); + const tdds = skills.filter((skill) => skill.scope === "project" && skill.name === "tdd"); + expect(tdds.map((skill) => skill.home).toSorted()).toEqual([ + ".agents/skills/tdd", + ".claude/skills/tdd", + ]); + const shared = tdds.find((skill) => skill.home === ".agents/skills/tdd"); + const claudes = tdds.find((skill) => skill.home === ".claude/skills/tdd"); + + // Claude reads its own folder, so the copy in `.claude/skills` is the one it loads. + expect(accessOf(claudes).claudeAgent).toEqual({ + state: "direct", + folder: ".claude/skills", + }); + expect(accessOf(shared).claudeAgent?.state).toBe("none"); + // Cursor looks in the project's `.agents/skills` before its `.claude/skills`. + expect(accessOf(shared).cursor).toEqual({ state: "direct", folder: ".agents/skills" }); + expect(accessOf(claudes).cursor?.state).toBe("none"); + // Codex loads every copy of a name, from the folders it reads: the project's shared one + // and the global one, but not `.claude/skills`. + expect(accessOf(shared).codex).toEqual({ state: "direct", folder: ".agents/skills" }); + expect(accessOf(claudes).codex?.state).toBe("none"); + expect(accessOf(byKey(skills).get("global:tdd")).codex).toEqual({ + state: "direct", + folder: "~/.agents/skills", + }); + + // All three project and global copies differ from each other, so each is a conflict. + for (const copy of [...tdds, byKey(skills).get("global:tdd")]) { + expect(copy?.copies.length).toBe(2); + expect(copy?.copies.every((other) => !other.same)).toBe(true); + } + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "loads one copy of a name for agents that take the first, and every copy for the others", + () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project })); + const byName = byKey(skills); + // Cursor and Pi look in the project first and take the first copy; Antigravity doesn't + // read the global standard folder. Codex and OpenCode list every copy, and Grok reads + // no project `.agents/skills`. + expect(states(byName.get("project:tdd"))).toMatchObject({ + cursor: "direct", + antigravity: "direct", + pi: "direct", + codex: "direct", + opencode: "direct", + grok: "none", + }); + expect(states(byName.get("global:tdd"))).toMatchObject({ + cursor: "none", + antigravity: "none", + pi: "none", + codex: "direct", + opencode: "direct", + grok: "direct", + }); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "gives a project and a global skill of one name to Codex, which loads both, and flags them", + () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write( + "repos/app/.agents/skills/grill-me/SKILL.md", + skillFile("grill-me", "Project grilling."), + ); + yield* write( + ".agents/skills/grill-me/SKILL.md", + skillFile("grill-me", "Global grilling."), + ); + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project })); + const byName = byKey(skills); + const projectCopy = byName.get("project:grill-me"); + const globalCopy = byName.get("global:grill-me"); + + for (const copy of [projectCopy, globalCopy]) { + expect(accessOf(copy).codex).toEqual({ + state: "direct", + folder: copy?.scope === "project" ? ".agents/skills" : "~/.agents/skills", + }); + expect(copy?.copies.map((other) => other.same)).toEqual([false]); + } + // Cursor and Pi take the project copy and not the global one. + for (const agent of ["cursor", "pi"] as const) { + expect(states(projectCopy)[agent]).toBe("direct"); + expect(states(globalCopy)[agent]).toBe("none"); + } + // Grok doesn't read the project's standard folder, so only the global copy reaches it. + expect(states(projectCopy).grok).toBe("none"); + expect(states(globalCopy).grok).toBe("direct"); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "follows the config folders that enabled provider instances move, one entry per instance", + () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + yield* write("work-claude/skills/work-only/SKILL.md", skillFile("work-only", "Work.")); + yield* write("env-claude/skills/env-only/SKILL.md", skillFile("env-only", "Env.")); + yield* write("codex-alt/skills/codex-only/SKILL.md", skillFile("codex-only", "Codex.")); + yield* write("grok-alt/skills/grok-only/SKILL.md", skillFile("grok-only", "Grok.")); + yield* write(".pi/agent/skills/pi-only/SKILL.md", skillFile("pi-only", "Pi.")); + const settings = { + providerInstances: { + // The instance's own setting wins over CLAUDE_CONFIG_DIR. + [ProviderInstanceId.make("claude_work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + displayName: "Claude Work", + config: { homePath: `${home}/work-claude` }, + }, + // A disabled instance isn't an agent here, and its folders aren't read. + [ProviderInstanceId.make("pi")]: { + driver: ProviderDriverKind.make("pi"), + enabled: false, + }, + }, + }; + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({}), { + settings, + env: { + CLAUDE_CONFIG_DIR: `${home}/env-claude`, + CODEX_HOME: `${home}/codex-alt`, + GROK_HOME: `${home}/grok-alt`, + }, + }); + const byName = byKey(skills); + + expect(skills[0]?.access.map((entry) => entry.instanceId)).toEqual([ + "claude_work", + "claudeAgent", + "codex", + "cursor", + "grok", + "opencode", + "antigravity", + ]); + expect(accessOf(byName.get("global:work-only"))).toMatchObject({ + claude_work: { state: "direct", folder: "~/work-claude/skills" }, + claudeAgent: { state: "none", folder: "~/env-claude/skills" }, + }); + expect(accessOf(byName.get("global:env-only"))).toMatchObject({ + claude_work: { state: "none", folder: "~/work-claude/skills" }, + claudeAgent: { state: "direct", folder: "~/env-claude/skills" }, + }); + expect(accessOf(byName.get("global:codex-only")).codex).toEqual({ + state: "direct", + folder: "~/codex-alt/skills", + }); + expect(accessOf(byName.get("global:grok-only")).grok).toEqual({ + state: "direct", + folder: "~/grok-alt/skills", + }); + expect(byName.has("global:pi-only")).toBe(false); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "caps the description at 160 characters and marks the cut", + () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + yield* write(".agents/skills/wordy/SKILL.md", skillFile("wordy", `"${"x".repeat(300)}"`)); + yield* write(".agents/skills/exact/SKILL.md", skillFile("exact", `"${"x".repeat(160)}"`)); + // The cap counts characters, not UTF-16 units: an emoji is one. + yield* write( + ".agents/skills/emoji/SKILL.md", + skillFile("emoji", `"${"🙂".repeat(200)}"`), + ); + // A description longer than the first read still finishes its header. + yield* write(".agents/skills/epic/SKILL.md", skillFile("epic", `"${"y".repeat(5_000)}"`)); + // One that outgrows even the second read has no description, but the skill stays. + yield* write( + ".agents/skills/endless/SKILL.md", + skillFile("endless", `"${"z".repeat(40_000)}"`), + ); + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({})); + const byName = byKey(skills); + expect(byName.get("global:wordy")?.description).toBe(`${"x".repeat(160)}…`); + expect(byName.get("global:exact")?.description).toBe("x".repeat(160)); + expect(byName.get("global:emoji")?.description).toBe(`${"🙂".repeat(160)}…`); + expect(byName.get("global:epic")?.description).toBe(`${"y".repeat(160)}…`); + expect(byName.get("global:endless")?.description).toBe(""); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "reports folders it can't read, but not ones that don't exist", + () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + // A file where the folder should be can't be listed, on any platform and for any user. + yield* write(".gemini/config/skills", "not a folder"); + yield* write("repos/app/.pi/skills", "not a folder"); + const result = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project })); + expect(result.unreadable).toEqual( + expect.arrayContaining([ + { scope: "global", folder: "~/.gemini/config/skills" }, + { scope: "project", folder: ".pi/skills" }, + ]), + ); + // `.codex/skills`, `.grok/skills` and the others simply aren't there. + expect(result.unreadable).toHaveLength(2); + // The rest of the list is unaffected. + expect(byKey(result.skills).has("global:architect")).toBe(true); + expect(byKey(result.skills).has("project:verify")).toBe(true); + }), + ); + + it.effect.skipIf(!symlinksSupported)("reads at most 1000 skill folders from one folder", () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + yield* Effect.forEach( + Array.from({ length: 1_005 }, (_, index) => `bulk-${String(index).padStart(4, "0")}`), + (name) => write(`.codex/skills/${name}/SKILL.md`, skillFile(name, "Bulk.")), + { concurrency: 16, discard: true }, + ); + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({})); + expect(skills.filter((skill) => skill.name.startsWith("bulk-"))).toHaveLength(1_000); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "refuses a SKILL.md that is a link out of the skill, and reads one that stays inside", + () => + Effect.gen(function* () { + const { home, write, link } = yield* makeMachine; + yield* write( + "outside/SKILL.md", + "---\ndescription: Secret from outside.\n---\nTop secret.\n", + ); + yield* write(".agents/skills/escaping/notes.md", "# notes\n"); + yield* link("outside/SKILL.md", ".agents/skills/escaping/SKILL.md"); + yield* write( + ".agents/skills/inside/README.md", + "---\ndescription: Linked inside.\n---\nBody.\n", + ); + yield* link(".agents/skills/inside/README.md", ".agents/skills/inside/SKILL.md"); + + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({})); + const byName = byKey(skills); + expect(byName.has("global:escaping")).toBe(false); + expect(byName.get("global:inside")?.description).toBe("Linked inside."); + + const escaping = yield* withCatalog(home, (catalog) => + catalog.get({ scope: "global", name: "escaping", home: "~/.agents/skills/escaping" }), + ); + expect(escaping.contents).toBeNull(); + expect(escaping.description).toBe(""); + const inside = yield* withCatalog(home, (catalog) => + catalog.get({ scope: "global", name: "inside", home: "~/.agents/skills/inside" }), + ); + expect(inside.contents).toContain("Linked inside."); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "never writes, and returns results the RPC success schema can encode", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { home, project } = yield* makeMachine; + const before = yield* fs.readDirectory(home, { recursive: true }); + const result = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project })); + const encoded = yield* encodeList(result); + expect(encoded.skills).toHaveLength(result.skills.length); + expect(encoded.skills[0]?.access.map((entry) => entry.instanceId)).toEqual([ + "claudeAgent", + "codex", + "cursor", + "grok", + "opencode", + "antigravity", + "pi", + ]); + expect(encoded.skills[0]?.access.map((entry) => entry.driver)).toEqual([ + "claudeAgent", + "codex", + "cursor", + "grok", + "opencode", + "antigravity", + "pi", + ]); + const detail = yield* withCatalog(home, (catalog) => + catalog.get({ + cwd: project, + scope: "project", + name: "verify", + home: ".agents/skills/verify", + }), + ); + expect((yield* encodeGet(detail)).files).toHaveLength(3); + expect(yield* fs.readDirectory(home, { recursive: true })).toEqual(before); + }), + ); + }); + + describe("get", () => { + it.effect.skipIf(!symlinksSupported)( + "returns the full SKILL.md, the file list and which files can run", + () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + const detail = yield* withCatalog(home, (catalog) => + catalog.get({ + cwd: project, + scope: "project", + name: "verify", + home: ".agents/skills/verify", + }), + ); + expect(detail.home).toBe(`${project}/.agents/skills/verify`); + expect(detail.description).toBe("Drive the app in a browser and capture evidence."); + expect(detail.contents).toContain("# verify"); + expect(detail.files).toEqual([ + { path: "SKILL.md", size: expect.any(Number), executable: false }, + { path: "bin/run", size: expect.any(Number), executable: true }, + { path: "lib/serve.mjs", size: expect.any(Number), executable: false }, + ]); + expect(detail.filesTruncated).toBe(false); + // A skill reached through a link resolves to the library folder. + const linked = yield* withCatalog(home, (catalog) => + catalog.get({ + scope: "global", + name: "architect", + home: "~/Knowledge/skills/architect", + }), + ); + expect(linked.home).toBe(`${home}/Knowledge/skills/architect`); + expect(linked.files.map((file) => file.path)).toEqual(["SKILL.md", "refs/principles.md"]); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "picks the skill that matches the home the list returned", + () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write( + "repos/app/.claude/skills/tdd/SKILL.md", + skillFile("tdd", "Claude's own tdd."), + ); + const read = (folder: string) => + withCatalog(home, (catalog) => + catalog.get({ cwd: project, scope: "project", name: "tdd", home: `${folder}/tdd` }), + ); + expect((yield* read(".claude/skills")).contents).toContain("Claude's own tdd."); + expect((yield* read(".agents/skills")).contents).toContain("Project test loop."); + expect((yield* read(".pi/skills")).home).toBeNull(); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "bounds the files it lists and skips folders it shouldn't walk", + () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + yield* write(".agents/skills/big/SKILL.md", skillFile("big", "Many files.")); + yield* Effect.forEach( + Array.from( + { length: 520 }, + (_, index) => `refs/note-${String(index).padStart(3, "0")}.md`, + ), + (file) => write(`.agents/skills/big/${file}`, "note\n"), + { concurrency: 16, discard: true }, + ); + yield* write(".agents/skills/big/node_modules/dep/index.js", "module.exports = {};\n"); + yield* write(".agents/skills/big/.git/HEAD", "ref: refs/heads/main\n"); + const detail = yield* withCatalog(home, (catalog) => + catalog.get({ scope: "global", name: "big", home: "~/.agents/skills/big" }), + ); + expect(detail.files).toHaveLength(500); + expect(detail.filesTruncated).toBe(true); + expect(detail.files.some((file) => /node_modules|\.git/.test(file.path))).toBe(false); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "stops walking after a fixed number of folders, however many a skill has", + () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + yield* write(".agents/skills/wide/SKILL.md", skillFile("wide", "Many folders.")); + yield* Effect.forEach( + Array.from({ length: 300 }, (_, index) => `d-${String(index).padStart(3, "0")}`), + (folder) => write(`.agents/skills/wide/${folder}/note.md`, "note\n"), + { concurrency: 16, discard: true }, + ); + const detail = yield* withCatalog(home, (catalog) => + catalog.get({ scope: "global", name: "wide", home: "~/.agents/skills/wide" }), + ); + // SKILL.md and the first 199 folders' files: 200 folders are walked in all, root included. + expect(detail.files).toHaveLength(200); + expect(detail.files.some((file) => file.path.startsWith("d-198/"))).toBe(true); + expect(detail.files.some((file) => file.path.startsWith("d-199/"))).toBe(false); + expect(detail.filesTruncated).toBe(true); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "stops at the file limit in one huge folder instead of looking at every entry", + () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + yield* write(".agents/skills/flat/SKILL.md", skillFile("flat", "One big folder.")); + yield* Effect.forEach( + Array.from({ length: 1_100 }, (_, index) => `n-${String(index).padStart(4, "0")}.md`), + (file) => write(`.agents/skills/flat/${file}`, "note\n"), + { concurrency: 16, discard: true }, + ); + const detail = yield* withCatalog(home, (catalog) => + catalog.get({ scope: "global", name: "flat", home: "~/.agents/skills/flat" }), + ); + expect(detail.files).toHaveLength(500); + expect(detail.files[0]?.path).toBe("SKILL.md"); + expect(detail.files.at(-1)?.path).toBe("n-0498.md"); + expect(detail.filesTruncated).toBe(true); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "lists a link inside a skill as one file and doesn't follow it", + () => + Effect.gen(function* () { + const { home, link } = yield* makeMachine; + yield* link("Knowledge/skills/architect", "Knowledge/skills/grill/shortcut"); + const detail = yield* withCatalog(home, (catalog) => + catalog.get({ scope: "global", name: "grill", home: "~/Knowledge/skills/grill" }), + ); + expect(detail.files).toEqual([ + { path: "SKILL.md", size: expect.any(Number), executable: false }, + { path: "shortcut", size: 0, executable: false }, + ]); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "shows no SKILL.md text when the file is too large, but still lists the skill", + () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + yield* write( + ".agents/skills/huge/SKILL.md", + `${skillFile("huge", "Huge file.")}${"x".repeat(1024 * 1024)}`, + ); + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({})); + expect(byKey(skills).get("global:huge")?.description).toBe("Huge file."); + const detail = yield* withCatalog(home, (catalog) => + catalog.get({ scope: "global", name: "huge", home: "~/.agents/skills/huge" }), + ); + expect(detail.contents).toBeNull(); + expect(detail.description).toBe("Huge file."); + expect(detail.files.map((file) => file.path)).toEqual(["SKILL.md"]); + }), + ); + + it.effect.skipIf(!symlinksSupported)("only reads names the agents' scanners accept", () => + Effect.gen(function* () { + const { home } = yield* makeMachine; + const names = [ + "../Knowledge/skills/architect", + "..", + ".", + "", + ".hidden", + "a/b", + "a\\b", + "nul\0name", + "nope", + ]; + for (const name of names) { + const detail = yield* withCatalog(home, (catalog) => + catalog.get({ scope: "global", name, home: `~/.agents/skills/${name}` }), + ); + expect(detail).toEqual(NOT_FOUND); + } + // A project scope with no project asks for nothing. + const noProject = yield* withCatalog(home, (catalog) => + catalog.get({ scope: "project", name: "verify", home: ".agents/skills/verify" }), + ); + expect(noProject.home).toBeNull(); + // The home to match is a label the list returned, not a path to read. + const elsewhere = yield* withCatalog(home, (catalog) => + catalog.get({ scope: "global", name: "cloudflare", home: "/etc" }), + ); + expect(elsewhere.home).toBeNull(); + expect((yield* encodeGet(elsewhere)).home).toBeNull(); + }), + ); + }); +}); diff --git a/apps/server/src/skills/SkillCatalog.ts b/apps/server/src/skills/SkillCatalog.ts new file mode 100644 index 000000000000..d8e3023d2bc4 --- /dev/null +++ b/apps/server/src/skills/SkillCatalog.ts @@ -0,0 +1,623 @@ +/** + * SkillCatalog - a read-only look at the skills in the folders the enabled agents read. + * + * Skills are found by reading those folders directly, never by asking an agent to look. Nothing + * is cached, watched, spawned or written, and every read is bounded. A link that points nowhere + * and a folder without a SKILL.md are skipped rather than reported as failures, so one bad entry + * never hides the rest; a folder that exists but can't be read is reported with the list. + * + * Agents differ on skills that share a name (see `SkillCollision`): some load only the first + * copy in their folder order, so a copy that another folder shadows is `none` for that instance, + * and others load every copy. + * + * @module SkillCatalog + */ +import { + ProviderInstanceId, + resolveProviderInstanceEnabled, + type ProviderDriverKind, + type ProviderInstanceConfig, + type SkillAgentAccess, + type SkillCopy, + type SkillFile, + type SkillFolderProblem, + type SkillGetInput, + type SkillGetResult, + type SkillListInput, + type SkillListResult, + type SkillScope, + type SkillSummary, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +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 Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + AGENT_SKILL_FOLDERS, + STANDARD_SKILL_FOLDER, + skillCollisionFor, + skillRootsFor, + type AgentSkillFolderList, +} from "@t3tools/provider-core/server/AgentSkillFolders"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; + +import { + parseSkillFrontmatter, + resolveClaudeConfigDirPath, +} from "../provider/Drivers/ClaudeSkills.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts"; +import * as Settings from "../serverSettings.ts"; + +const SKILL_FILE = "SKILL.md"; +const MAX_FOLDER_ENTRIES = 1_000; +const MAX_FILES = 500; +/** Folders walked inside one skill, and entries looked at in each. */ +const MAX_DIRECTORIES = 200; +const MAX_DIRECTORY_ENTRIES = 1_000; +const HEAD_BYTES = 4_096; +/** A long description can push the closing `---` of the header past the first read. */ +const LONG_HEAD_BYTES = 32_768; +const MAX_SKILL_BYTES = 1024 * 1024; +const DESCRIPTION_CHARS = 160; +const SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]); +const CONCURRENCY = 16; + +const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; +const decodeHomePath = Schema.decodeUnknownOption( + Schema.Struct({ homePath: Schema.optional(Schema.String) }), +); + +/** + * A skill's folder name is whatever the agents' scanners accept, short of what could leave the + * folder (`.`, `..`, separators, NUL) or hides it (a leading dot). + */ +const isSkillFolderName = (name: string) => + name !== "" && !name.startsWith(".") && !/[\\/\0]/.test(name); + +const capDescription = (description: string) => { + const chars = [...description]; + return chars.length > DESCRIPTION_CHARS + ? `${chars.slice(0, DESCRIPTION_CHARS).join("").trimEnd()}…` + : description; +}; + +/** A folder an agent reads skills from. */ +interface ReadRoot { + readonly scope: SkillScope; + readonly directory: string; + /** `~/.claude/skills` or `.agents/skills`, as shown to the user. */ + readonly label: string; + /** The folder most agents share. */ + readonly standard: boolean; +} + +const rootKey = (root: Pick) => `${root.scope}\0${root.directory}`; + +/** An enabled provider instance and the folders it reads, in the order it looks. */ +interface AgentInstance { + readonly instanceId: ProviderInstanceId; + readonly driver: ProviderDriverKind; + readonly reads: readonly ReadRoot[]; +} + +/** One folder entry that holds a skill: a real directory, or a link to one. */ +interface FolderEntry { + readonly root: ReadRoot; + readonly name: string; + readonly link: boolean; + /** Absolute path after following links. */ + readonly home: string; +} + +interface SkillHeader { + readonly description: string; + /** Claude Code can't read the header, so it skips the skill. */ + readonly invalid: boolean; +} + +/** The same skill reached through several folders. */ +interface SkillGroup { + readonly scope: SkillScope; + readonly name: string; + readonly home: string; + readonly entries: readonly FolderEntry[]; + readonly header: SkillHeader; +} + +const NOT_FOUND: SkillGetResult = { + home: null, + description: "", + contents: null, + files: [], + filesTruncated: false, +}; + +export class SkillCatalog extends Context.Service< + SkillCatalog, + { + /** + * One compact record per skill home, in the project (when `cwd` is given) and in the user's + * home folder. + */ + readonly list: (input: SkillListInput) => Effect.Effect; + /** The full SKILL.md text and the file list of one skill from `list`. */ + readonly get: (input: SkillGetInput) => Effect.Effect; + } +>()("t3/skills/SkillCatalog") {} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcess.Environment; + const homeDirectory = yield* HostProcess.HomeDirectory; + const serverSettings = yield* Settings.ServerSettingsService; + + /** The text at the start of a regular file, at most `maxBytes` of it. */ + const readPrefix = Effect.fnUntraced(function* (file: string, maxBytes: number) { + const info = yield* fileSystem.stat(file).pipe(Effect.orElseSucceed(() => undefined)); + if (info?.type !== "File") return undefined; + const size = Number(info.size); + if (size === 0) return { text: "", truncated: false }; + const chunks = yield* fileSystem.stream(file, { bytesToRead: Math.min(size, maxBytes) }).pipe( + Stream.runCollect, + Effect.orElseSucceed(() => undefined), + ); + return chunks + ? { text: Buffer.concat(chunks).toString("utf8"), truncated: size > maxBytes } + : undefined; + }); + + /** SKILL.md under a skill's real folder, unless it is a link that leaves the folder. */ + const readSkillFile = Effect.fnUntraced(function* (home: string, maxBytes: number) { + const file = path.join(home, SKILL_FILE); + const real = yield* fileSystem.realPath(file).pipe(Effect.orElseSucceed(() => undefined)); + if (real === undefined || !real.startsWith(`${home}${path.sep}`)) return undefined; + return yield* readPrefix(real, maxBytes); + }); + + /** The skill's header, or undefined when the folder has no readable SKILL.md. */ + const readHeader = Effect.fnUntraced(function* (home: string) { + const head = yield* readSkillFile(home, HEAD_BYTES); + if (!head) return undefined; + const longer = + head.truncated && head.text.startsWith("---") && !FRONTMATTER.test(head.text) + ? yield* readSkillFile(home, LONG_HEAD_BYTES) + : undefined; + const header = parseSkillFrontmatter((longer ?? head).text); + return { + description: + header.kind === "parsed" ? (header.description ?? "").replace(/\s+/g, " ").trim() : "", + invalid: header.kind === "malformed", + } satisfies SkillHeader; + }); + + /** The folder `name` in a root, when it is a directory or a link to one. */ + const entryAt = Effect.fnUntraced(function* (root: ReadRoot, name: string) { + const entryPath = path.join(root.directory, name); + const info = yield* fileSystem.stat(entryPath).pipe(Effect.orElseSucceed(() => undefined)); + if (info?.type !== "Directory") return undefined; + const home = yield* fileSystem.realPath(entryPath).pipe(Effect.orElseSucceed(() => undefined)); + if (home === undefined) return undefined; + const link = yield* fileSystem.readLink(entryPath).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + return { root, name, link, home } satisfies FolderEntry; + }); + + /** The skill folders in a root. A root that is missing is empty; one that can't be read says so. */ + const scanRoot = Effect.fnUntraced(function* (root: ReadRoot) { + const listed = yield* fileSystem.readDirectory(root.directory).pipe( + Effect.map((names) => ({ names, unreadable: false })), + Effect.catchTags({ + PlatformError: (error) => + Effect.succeed({ names: [] as string[], unreadable: error.reason._tag !== "NotFound" }), + }), + ); + const entries = yield* Effect.forEach( + listed.names.filter(isSkillFolderName).toSorted().slice(0, MAX_FOLDER_ENTRIES), + (name) => entryAt(root, name), + { concurrency: CONCURRENCY }, + ); + return { root, unreadable: listed.unreadable, entries: entries.filter((e) => e !== undefined) }; + }); + + /** A folder as given and as it really is, since either can prefix a real path. */ + const rootsOf = Effect.fnUntraced(function* (directory: string) { + const real = yield* fileSystem.realPath(directory).pipe(Effect.orElseSucceed(() => directory)); + return [...new Set([real, directory])]; + }); + + /** The roots paths are shown against. */ + const displayRootsOf = Effect.fnUntraced(function* (cwd: string | undefined) { + return { + project: cwd ? yield* rootsOf(cwd) : [], + home: yield* rootsOf(homeDirectory), + }; + }); + + /** Relative to the project, or `~/...` under the home directory. */ + const displayPath = ( + absolute: string, + roots: { readonly project: readonly string[]; readonly home: readonly string[] }, + ) => { + for (const root of roots.project) { + if (absolute === root) return "."; + if (absolute.startsWith(`${root}${path.sep}`)) { + return path.relative(root, absolute).replaceAll("\\", "/"); + } + } + for (const root of roots.home) { + if (absolute === root) return "~"; + if (absolute.startsWith(`${root}${path.sep}`)) { + return `~/${path.relative(root, absolute).replaceAll("\\", "/")}`; + } + } + return absolute; + }; + + const absoluteCwd = (cwd: string | undefined) => + cwd !== undefined && path.isAbsolute(cwd) ? cwd : undefined; + + /** A global folder as shown to the user: `~/...` under the home directory, else its path. */ + const globalLabel = (directory: string) => { + const relative = path.relative(homeDirectory, directory); + if (relative === "") return "~"; + return relative.startsWith("..") || path.isAbsolute(relative) + ? directory + : `~/${relative.replaceAll("\\", "/")}`; + }; + + /** + * Where an instance keeps its config, which its own global skill folder lives under. This + * follows the setting or variable that moves the agent's home, in the order the agent applies + * them; an agent without one stays at its default folder under the home directory. + */ + const configHomeOf = Effect.fnUntraced(function* ( + instance: ProviderInstanceConfig, + table: AgentSkillFolderList, + cwd: string | undefined, + ) { + const env = yield* mergeProviderInstanceEnvironment(instance.environment, environment).pipe( + Effect.provideService(HostProcess.HomeDirectory, homeDirectory), + ); + const setting = Option.getOrUndefined(decodeHomePath(instance.config))?.homePath?.trim() ?? ""; + const fallback = path.join(homeDirectory, table.configHome ?? ""); + const absoluteOr = (value: string | undefined) => + value && path.isAbsolute(value) ? value : fallback; + if (instance.driver === "claudeAgent") { + return yield* resolveClaudeConfigDirPath({ homePath: setting }, env, cwd).pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(HostProcess.HomeDirectory, homeDirectory), + ); + } + if (instance.driver === "codex") { + return absoluteOr(expandHomePath(setting || (env.CODEX_HOME?.trim() ?? ""), homeDirectory)); + } + if (instance.driver === "grok") return absoluteOr(env.GROK_HOME?.trim()); + return fallback; + }); + + /** The enabled provider instances whose folders T3 Code knows, in the table's order. */ + const loadInstances = Effect.fnUntraced(function* (cwd: string | undefined) { + const settings = yield* serverSettings.getSettings.pipe(Effect.option); + if (Option.isNone(settings)) return []; + const configs = Object.entries(deriveProviderInstanceConfigMap(settings.value)); + const instances: AgentInstance[] = []; + for (const table of AGENT_SKILL_FOLDERS) { + for (const [instanceId, config] of configs) { + if (config.driver !== table.agent || !resolveProviderInstanceEnabled(config)) continue; + const configHome = yield* configHomeOf(config, table, cwd); + const reads = skillRootsFor(table.agent).flatMap((root): ReadRoot[] => { + const standard = root.folder === STANDARD_SKILL_FOLDER; + if (root.scope === "project") { + return cwd + ? [ + { + scope: "project", + directory: path.join(cwd, root.folder), + label: root.folder, + standard, + }, + ] + : []; + } + const prefix = table.configHome === undefined ? undefined : `${table.configHome}/`; + const directory = + prefix !== undefined && root.folder.startsWith(prefix) + ? path.join(configHome, root.folder.slice(prefix.length)) + : path.join(homeDirectory, root.folder); + return [{ scope: "global", directory, label: globalLabel(directory), standard }]; + }); + instances.push({ + instanceId: ProviderInstanceId.make(instanceId), + driver: table.agent, + reads, + }); + } + } + return instances; + }); + + /** The shared folders, then every folder an enabled instance reads, each once. */ + const rootsFor = (cwd: string | undefined, instances: readonly AgentInstance[]) => { + const standard: ReadRoot[] = [ + { + scope: "global", + directory: path.join(homeDirectory, STANDARD_SKILL_FOLDER), + label: globalLabel(path.join(homeDirectory, STANDARD_SKILL_FOLDER)), + standard: true, + }, + ...(cwd + ? [ + { + scope: "project" as const, + directory: path.join(cwd, STANDARD_SKILL_FOLDER), + label: STANDARD_SKILL_FOLDER, + standard: true, + }, + ] + : []), + ]; + const byKey = new Map(); + for (const root of [...standard, ...instances.flatMap((instance) => instance.reads)]) { + if (!byKey.has(rootKey(root))) byKey.set(rootKey(root), root); + } + return [...byKey.values()]; + }; + + /** SKILL.md text of each skill, read once, and `undefined` when it can't be shown whole. */ + const makeTextReader = () => { + const texts = new Map(); + return Effect.fnUntraced(function* (home: string) { + if (!texts.has(home)) { + const file = yield* readSkillFile(home, MAX_SKILL_BYTES); + texts.set(home, file && !file.truncated ? file.text : undefined); + } + return texts.get(home); + }); + }; + + /** The other skills that share a name with each skill, and whether their text is identical. */ + const compareCopies = Effect.fnUntraced(function* ( + groups: readonly SkillGroup[], + roots: { readonly project: readonly string[]; readonly home: readonly string[] }, + ) { + const textOf = makeTextReader(); + const result = new Map(); + for (const members of Map.groupBy(groups, (group) => group.name).values()) { + for (const group of members) { + const copies: SkillCopy[] = []; + for (const other of members.filter((member) => member !== group)) { + let same = other.home === group.home; + if (!same) { + const [left, right] = yield* Effect.all([textOf(group.home), textOf(other.home)]); + same = left !== undefined && left === right; + } + copies.push({ scope: other.scope, home: displayPath(other.home, roots), same }); + } + if (copies.length > 0) { + result.set( + group, + copies.toSorted( + (a, b) => + Number(b.scope === "project") - Number(a.scope === "project") || + a.home.localeCompare(b.home), + ), + ); + } + } + } + return result; + }); + + const list: SkillCatalog["Service"]["list"] = Effect.fn("SkillCatalog.list")(function* (input) { + const cwd = absoluteCwd(input.cwd); + const displayRoots = yield* displayRootsOf(cwd); + const instances = yield* loadInstances(cwd); + const roots = rootsFor(cwd, instances); + const scanned = yield* Effect.forEach(roots, scanRoot, { concurrency: CONCURRENCY }); + + // Group by what is really on disk: the same folder reached through several links is one skill. + const grouped = new Map>(); + for (const { entries } of scanned) { + for (const entry of entries) { + const key = `${entry.root.scope}\0${entry.name}\0${entry.home}`; + const existing = grouped.get(key); + grouped.set( + key, + existing + ? { ...existing, entries: [...existing.entries, entry] } + : { scope: entry.root.scope, name: entry.name, home: entry.home, entries: [entry] }, + ); + } + } + + const headers = new Map( + yield* Effect.forEach( + new Set([...grouped.values()].map((group) => group.home)), + (home) => readHeader(home).pipe(Effect.map((header) => [home, header] as const)), + { concurrency: CONCURRENCY }, + ), + ); + // A folder without a SKILL.md isn't a skill, whatever links to it. + const groups = [...grouped.values()].flatMap((group): SkillGroup[] => { + const header = headers.get(group.home); + return header === undefined ? [] : [{ ...group, header }]; + }); + const groupOf = new Map( + groups.flatMap((group) => group.entries.map((e) => [e, group] as const)), + ); + const entryAtRoot = new Map( + scanned.map( + ({ root, entries }) => [rootKey(root), new Map(entries.map((e) => [e.name, e]))] as const, + ), + ); + const copies = yield* compareCopies(groups, displayRoots); + + /** How one instance reaches a skill: through the folders it loads it from, else `none`. */ + const accessFor = (group: SkillGroup, instance: AgentInstance): SkillAgentAccess => { + const found = instance.reads.flatMap((root) => { + const entry = entryAtRoot.get(rootKey(root))?.get(group.name); + const owner = entry && groupOf.get(entry); + // Claude skips a skill whose header it can't read, and it doesn't shadow a later one. + const skipped = instance.driver === "claudeAgent" && owner?.header.invalid === true; + return entry && owner && !skipped ? [{ entry, owner }] : []; + }); + // A first-wins agent loads only the first copy in its order; the others load every copy. + const firstWins = skillCollisionFor(instance.driver) === "first-wins"; + const loaded = + firstWins && found[0]?.owner !== group ? [] : found.filter((f) => f.owner === group); + // One copy can be reached through several of the agent's folders; the shared one is shown. + const via = (loaded.find((f) => f.entry.root.standard) ?? loaded[0])?.entry; + if (via) { + return { + instanceId: instance.instanceId, + driver: instance.driver, + state: via.root.standard || !via.link ? "direct" : "link", + folder: via.root.label, + }; + } + const looksIn = instance.reads.find((root) => root.scope === group.scope); + return { + instanceId: instance.instanceId, + driver: instance.driver, + state: "none", + folder: + looksIn?.label ?? + (group.scope === "global" + ? globalLabel(path.join(homeDirectory, STANDARD_SKILL_FOLDER)) + : STANDARD_SKILL_FOLDER), + }; + }; + + const skills = groups.map((group): SkillSummary => ({ + name: group.name, + scope: group.scope, + home: displayPath(group.home, displayRoots), + description: capDescription(group.header.description), + ...(group.header.invalid ? { invalidHeader: true } : {}), + copies: copies.get(group) ?? [], + access: instances.map((instance) => accessFor(group, instance)), + })); + + const unreadable = new Map(); + for (const { root, unreadable: failed } of scanned) { + if (failed) + unreadable.set(`${root.scope}\0${root.label}`, { scope: root.scope, folder: root.label }); + } + + return { + skills: skills.toSorted( + (a, b) => + a.name.localeCompare(b.name) || + Number(b.scope === "project") - Number(a.scope === "project"), + ), + unreadable: [...unreadable.values()], + }; + }); + + /** + * Relative paths and sizes of the files under a skill's folder, breadth first. It stops at the + * file limit, and bounds the folders it enters and the entries it looks at in each, so a skill + * with a huge or deeply branching tree costs a fixed amount of work. + */ + const walkSkillFiles = Effect.fnUntraced(function* (root: string) { + const files: SkillFile[] = []; + const pending = [""]; + let visited = 0; + let truncated = false; + let full = false; + while (pending.length > 0 && !full) { + const relative = pending.shift() ?? ""; + visited += 1; + const names = (yield* fileSystem + .readDirectory(path.join(root, relative)) + .pipe(Effect.orElseSucceed((): string[] => []))).toSorted(); + if (names.length > MAX_DIRECTORY_ENTRIES) truncated = true; + const looked = names.slice(0, MAX_DIRECTORY_ENTRIES); + for (let start = 0; start < looked.length && !full; start += CONCURRENCY) { + const children = yield* Effect.forEach( + looked.slice(start, start + CONCURRENCY), + (name) => + Effect.gen(function* () { + const absolute = path.join(root, relative, name); + const link = yield* fileSystem.readLink(absolute).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + const info = link + ? undefined + : yield* fileSystem.stat(absolute).pipe(Effect.orElseSucceed(() => undefined)); + return { name, link, info }; + }), + { concurrency: CONCURRENCY }, + ); + for (const { name, link, info } of children) { + const childPath = relative ? `${relative}/${name}` : name; + if (info?.type === "Directory") { + if (SKIPPED_DIRECTORIES.has(name)) continue; + if (visited + pending.length >= MAX_DIRECTORIES) truncated = true; + else pending.push(childPath); + continue; + } + // Links count as files and are never followed; other special files aren't shown. + if (!link && info?.type !== "File") continue; + if (files.length >= MAX_FILES) { + truncated = true; + full = true; + break; + } + files.push({ + path: childPath, + size: info ? Number(info.size) : 0, + executable: info !== undefined && (info.mode & 0o111) !== 0, + }); + } + } + } + return { + files: files.toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)), + truncated, + }; + }); + + const get: SkillCatalog["Service"]["get"] = Effect.fn("SkillCatalog.get")(function* (input) { + const cwd = absoluteCwd(input.cwd); + const base = input.scope === "project" ? cwd : homeDirectory; + if (!base || !isSkillFolderName(input.name)) return NOT_FOUND; + // Only the folders agents read are looked in, so the request can't name an arbitrary path. + const roots = rootsFor(cwd, yield* loadInstances(cwd)).filter( + (root) => root.scope === input.scope, + ); + const candidates = yield* Effect.forEach(roots, (root) => entryAt(root, input.name), { + concurrency: CONCURRENCY, + }); + const displayRoots = yield* displayRootsOf(cwd); + const chosen = candidates.find( + (entry) => entry !== undefined && displayPath(entry.home, displayRoots) === input.home, + ); + if (!chosen) return NOT_FOUND; + const header = yield* readHeader(chosen.home); + const skillFile = yield* readSkillFile(chosen.home, MAX_SKILL_BYTES); + const { files, truncated } = yield* walkSkillFiles(chosen.home); + return { + home: chosen.home, + description: header?.description ?? "", + contents: skillFile && !skillFile.truncated ? skillFile.text : null, + files, + filesTruncated: truncated, + }; + }); + + return SkillCatalog.of({ list, get }); +}); + +export const layer = Layer.effect(SkillCatalog, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fce520ca7e3b..f3b4961e1e29 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -190,6 +190,7 @@ import { parseBase64DataUrl } from "./imageMime.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; +import * as SkillCatalog from "./skills/SkillCatalog.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; @@ -1284,6 +1285,7 @@ const layerWsRpc = ( const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const skillCatalog = yield* SkillCatalog.SkillCatalog; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); @@ -2172,6 +2174,8 @@ const layerWsRpc = ( }), ), ), + [WS_METHODS.serverListSkills]: (input) => skillCatalog.list(input), + [WS_METHODS.serverGetSkill]: (input) => skillCatalog.get(input), [WS_METHODS.serverRefreshProviders]: (input) => Effect.gen(function* () { // Only explicit catalog refreshes bypass T3's caches. Workspace diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d738ef79909e..cf64ff9170a0 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -28,6 +28,7 @@ export * from "./model.ts"; export * from "./keybindings.ts"; export * from "./server.ts"; export * from "./settings.ts"; +export * from "./skills.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 36afee2f9efa..09eb76c26ffb 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -323,6 +323,7 @@ import { ServerSettingsError, ServerSettingsPatch, } from "./settings.ts"; +import { SkillGetInput, SkillGetResult, SkillListInput, SkillListResult } from "./skills.ts"; import { ScheduledTaskDeleteInput, ScheduledTaskDeleteResult, @@ -467,6 +468,8 @@ export const WS_METHODS = { serverProbe: "server.probe", serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", + serverListSkills: "server.listSkills", + serverGetSkill: "server.getSkill", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", @@ -598,6 +601,18 @@ const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), }); +const WsServerListSkillsRpc = Rpc.make(WS_METHODS.serverListSkills, { + payload: SkillListInput, + success: SkillListResult, + error: EnvironmentAuthorizationError, +}); + +const WsServerGetSkillRpc = Rpc.make(WS_METHODS.serverGetSkill, { + payload: SkillGetInput, + success: SkillGetResult, + error: EnvironmentAuthorizationError, +}); + const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** @@ -1831,6 +1846,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, WsServerRefreshProvidersRpc, + WsServerListSkillsRpc, + WsServerGetSkillRpc, WsServerUpdateProviderRpc, WsProviderConsumeResetCreditRpc, WsProviderAuthStartRpc, diff --git a/packages/contracts/src/skills.ts b/packages/contracts/src/skills.ts new file mode 100644 index 000000000000..da517591d4f6 --- /dev/null +++ b/packages/contracts/src/skills.ts @@ -0,0 +1,102 @@ +import * as Schema from "effect/Schema"; +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; + +export const SkillScope = Schema.Literals(["project", "global"]); +export type SkillScope = typeof SkillScope.Type; + +export const SkillListInput = Schema.Struct({ + /** A project whose own skill folders are read besides the global ones. */ + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillListInput = typeof SkillListInput.Type; + +/** + * How one agent reaches a skill. `direct`: it reads a real folder holding the skill (its own + * folder, or one shared with other agents). `link`: a link in a folder it reads points at the + * skill. `none`: it doesn't load this copy of the skill, because it can't see it or because + * another skill of the same name comes first in its folders. + */ +export const SkillAgentState = Schema.Literals(["direct", "link", "none"]); +export type SkillAgentState = typeof SkillAgentState.Type; + +export const SkillAgentAccess = Schema.Struct({ + /** The enabled provider instance this is about. Instances of unknown drivers are never listed. */ + instanceId: ProviderInstanceId, + driver: ProviderDriverKind, + state: SkillAgentState, + /** Where the agent reads the skill from, or for `none` where it looks for skills. */ + folder: Schema.String, +}); +export type SkillAgentAccess = typeof SkillAgentAccess.Type; + +/** Another skill with the same name, and whether its SKILL.md text is identical. */ +export const SkillCopy = Schema.Struct({ + scope: SkillScope, + /** The same display path as `SkillSummary.home`. */ + home: Schema.String, + same: Schema.Boolean, +}); +export type SkillCopy = typeof SkillCopy.Type; + +export const SkillSummary = Schema.Struct({ + /** The skill's folder name, which is what an agent invokes it by. */ + name: Schema.String, + scope: SkillScope, + /** Where the files really are, after following links: relative to the project, or `~/…`. */ + home: Schema.String, + /** The description cut to 160 characters, with a trailing `…` when it was cut. */ + description: Schema.String, + /** SKILL.md's header can't be read the way Claude Code reads it, so Claude skips the skill. */ + invalidHeader: Schema.optional(Schema.Boolean), + /** The other skills with the same name, in either scope. */ + copies: Schema.Array(SkillCopy), + access: Schema.Array(SkillAgentAccess), +}); +export type SkillSummary = typeof SkillSummary.Type; + +/** A skill folder that exists but couldn't be read. */ +export const SkillFolderProblem = Schema.Struct({ + scope: SkillScope, + /** The same label as `SkillAgentAccess.folder`. */ + folder: Schema.String, +}); +export type SkillFolderProblem = typeof SkillFolderProblem.Type; + +export const SkillListResult = Schema.Struct({ + skills: Schema.Array(SkillSummary), + /** Folders that couldn't be read; a folder that doesn't exist isn't one. */ + unreadable: Schema.Array(SkillFolderProblem), +}); +export type SkillListResult = typeof SkillListResult.Type; + +export const SkillGetInput = Schema.Struct({ + cwd: Schema.optional(TrimmedNonEmptyString), + scope: SkillScope, + name: TrimmedNonEmptyString, + /** The `home` the list returned, to tell apart two skills that share a name. */ + home: TrimmedNonEmptyString, +}); +export type SkillGetInput = typeof SkillGetInput.Type; + +export const SkillFile = Schema.Struct({ + /** Relative to the skill's folder. */ + path: Schema.String, + size: NonNegativeInt, + executable: Schema.Boolean, +}); +export type SkillFile = typeof SkillFile.Type; + +export const SkillGetResult = Schema.Struct({ + /** Absolute path of the skill's folder; null when the skill wasn't found. */ + home: Schema.NullOr(Schema.String), + /** The whole description, which the list cuts short. */ + description: Schema.String, + /** SKILL.md text; null when it is missing or too large to show. */ + contents: Schema.NullOr(Schema.String), + /** Files under the home, up to a limit. */ + files: Schema.Array(SkillFile), + /** Some files aren't listed: there were more files, folders or entries than the limits allow. */ + filesTruncated: Schema.Boolean, +}); +export type SkillGetResult = typeof SkillGetResult.Type; diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index c45de9a9b149..7451324d3aaf 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -11,6 +11,10 @@ "types": "./src/server/adapterDriver.ts", "import": "./src/server/adapterDriver.ts" }, + "./server/AgentSkillFolders": { + "types": "./src/server/AgentSkillFolders.ts", + "import": "./src/server/AgentSkillFolders.ts" + }, "./server/attachmentPrompt": { "types": "./src/server/attachmentPrompt.ts", "import": "./src/server/attachmentPrompt.ts" diff --git a/packages/provider-core/src/server/AgentSkillFolders.ts b/packages/provider-core/src/server/AgentSkillFolders.ts new file mode 100644 index 000000000000..0d543e89e8f8 --- /dev/null +++ b/packages/provider-core/src/server/AgentSkillFolders.ts @@ -0,0 +1,169 @@ +/** + * AgentSkillFolders - the folders each agent reads skills from. + * + * One table, shared by the Skills page and the provider skill scanners that read the folders + * themselves (Claude, Cursor, Antigravity), so a folder is defined once. Paths are relative to + * the user's home or to the project root. Each agent's list is in the order it looks. + * + * Agents differ on two skills sharing a name (`SkillCollision`), so each entry records which: + * - `first-wins`: only the first copy in the agent's order loads. Claude, Cursor and Antigravity, + * as T3 Code's own scanners model them: `ClaudeSkills.ts` ("First root wins"), provider-cursor's + * `skills.ts` (`if (!skillsByName.has(skill.name))`) and `AntigravitySkills.ts` ("The first + * valid same-name skill wins"). Pi too: https://github.com/earendil-works/pi/blob/43d3763991/packages/coding-agent/src/core/skills.ts + * (`addSkills` keeps the existing skill and reports a collision), over the order of + * `resourcePrecedenceRank` in `package-manager.ts`: project folders before user folders. + * - `all`: every copy loads. Codex removes duplicate roots by path and never by name, and the + * plain name `$skill` selects the first of them while a skill picked by path is always its own: + * `dedupe_skill_roots_by_path` in host_roots.rs, the test + * `resolved_config_and_repo_roots_preserve_order_and_dedupe_paths_not_names`, and + * `collect_explicit_skill_mentions` in selection.rs (https://github.com/openai/codex/tree/8e23d1836f/codex-rs/ext/skills/src). + * OpenCode and Grok are listed as `all` because the evidence doesn't give a first-wins rule: + * OpenCode keeps one copy per name but overwrites in an order that isn't fixed (`add` in + * skill/index.ts logs "duplicate skill name" and assigns, while the files load concurrently), + * and Grok's skills page says nothing about duplicates. Claiming `all` never tells a user an + * agent can't use a skill it might load. + * + * Codex, Grok, OpenCode and Pi have no scanner here: their skills reach T3 Code through the + * agent itself. Their folders follow the agent's documentation and source: + * - Codex: https://developers.openai.com/codex/skills and + * https://github.com/openai/codex/blob/8e23d1836f/codex-rs/ext/skills/src/host_roots.rs + * (`~/.codex/skills` is the deprecated user location; a project's `.codex` folder is read too). + * - Grok: https://docs.x.ai/build/features/skills-plugins-marketplaces.md (`.grok/skills`, + * `~/.grok/skills`, and `~/.agents/skills` under "Agents.md compatibility"). It also reads + * Claude Code skills, but the docs don't say which folders, so none are listed. + * - OpenCode: https://opencode.ai/docs/skills/ and + * https://github.com/anomalyco/opencode/blob/4ac0d9c3d1/packages/opencode/src/skill/index.ts + * (`.opencode/skills`, `~/.config/opencode/skills`, and the `.claude` and `.agents` folders). + * - Pi: https://github.com/earendil-works/pi/blob/43d3763991/packages/coding-agent/docs/skills.md and + * https://github.com/earendil-works/pi/blob/43d3763991/packages/coding-agent/src/core/package-manager.ts + * (`.pi/skills`, `~/.pi/agent/skills`, and the `.agents` folders). + * Only a project's top folder is read here; some agents also look in the folders above it. + * + * An agent's own config folder moves with the setting or variable that moves the agent's home: + * `CLAUDE_CONFIG_DIR`, `CODEX_HOME` and `GROK_HOME` (see `configHome`). OpenCode and Pi can be + * moved too, but their docs don't say how the skill folders follow, so theirs stay at the default. + * + * @module AgentSkillFolders + */ +import { ProviderDriverKind, type SkillScope } from "@t3tools/contracts"; + +/** The shared folder that Codex, Pi and most other agents read. */ +export const STANDARD_SKILL_FOLDER = ".agents/skills"; + +/** Antigravity's user folders, under `~/.gemini`: shared with its IDE, and where `agy` installs. */ +export const ANTIGRAVITY_USER_SKILL_SUBFOLDERS = [ + "config/skills", + "antigravity-cli/skills", +] as const; + +export interface SkillRoot { + readonly scope: SkillScope; + /** Relative to the home directory (`global`) or the project root (`project`). */ + readonly folder: string; +} + +const inProject = (folder: string): SkillRoot => ({ scope: "project", folder }); +const inHome = (folder: string): SkillRoot => ({ scope: "global", folder }); + +/** What an agent does when skills in its folders share a name. */ +export type SkillCollision = "first-wins" | "all"; + +export interface AgentSkillFolderList { + readonly agent: ProviderDriverKind; + readonly collision: SkillCollision; + /** + * The agent's own config folder under the home directory, for agents whose instance settings or + * environment can move it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `GROK_HOME`). Its global roots + * below it move with it. + */ + readonly configHome?: string; + /** In the order the agent looks. */ + readonly reads: readonly SkillRoot[]; +} + +export const AGENT_SKILL_FOLDERS: ReadonlyArray = [ + { + agent: ProviderDriverKind.make("claudeAgent"), + collision: "first-wins", + configHome: ".claude", + reads: [inHome(".claude/skills"), inProject(".claude/skills")], + }, + { + agent: ProviderDriverKind.make("codex"), + collision: "all", + configHome: ".codex", + reads: [ + inHome(STANDARD_SKILL_FOLDER), + inHome(".codex/skills"), + inProject(STANDARD_SKILL_FOLDER), + inProject(".codex/skills"), + ], + }, + { + agent: ProviderDriverKind.make("cursor"), + collision: "first-wins", + reads: [ + inProject(".cursor/skills"), + inProject(STANDARD_SKILL_FOLDER), + inProject(".codex/skills"), + inProject(".claude/skills"), + inHome(".cursor/skills"), + inHome(STANDARD_SKILL_FOLDER), + inHome(".codex/skills"), + inHome(".claude/skills"), + ], + }, + { + agent: ProviderDriverKind.make("grok"), + collision: "all", + configHome: ".grok", + reads: [inHome(".grok/skills"), inHome(STANDARD_SKILL_FOLDER), inProject(".grok/skills")], + }, + { + agent: ProviderDriverKind.make("opencode"), + collision: "all", + reads: [ + inHome(".config/opencode/skills"), + inHome(".claude/skills"), + inHome(STANDARD_SKILL_FOLDER), + inProject(".opencode/skills"), + inProject(".claude/skills"), + inProject(STANDARD_SKILL_FOLDER), + ], + }, + { + agent: ProviderDriverKind.make("antigravity"), + collision: "first-wins", + reads: [ + inHome(`.gemini/${ANTIGRAVITY_USER_SKILL_SUBFOLDERS[0]}`), + inProject(".gemini/skills"), + inHome(`.gemini/${ANTIGRAVITY_USER_SKILL_SUBFOLDERS[1]}`), + inProject(STANDARD_SKILL_FOLDER), + inProject(".agent/skills"), + ], + }, + { + agent: ProviderDriverKind.make("pi"), + collision: "first-wins", + reads: [ + inProject(".pi/skills"), + inProject(STANDARD_SKILL_FOLDER), + inHome(".pi/agent/skills"), + inHome(STANDARD_SKILL_FOLDER), + ], + }, +]; + +/** Everything an agent reads, in the order it looks across both scopes. */ +export const skillRootsFor = (agent: ProviderDriverKind): readonly SkillRoot[] => + AGENT_SKILL_FOLDERS.find((entry) => entry.agent === agent)?.reads ?? []; + +/** How an agent treats skills that share a name. */ +export const skillCollisionFor = (agent: ProviderDriverKind): SkillCollision => + AGENT_SKILL_FOLDERS.find((entry) => entry.agent === agent)?.collision ?? "all"; + +/** What one agent reads in one scope, in the order it looks. */ +export const skillFoldersFor = (agent: ProviderDriverKind, scope: SkillScope): readonly string[] => + skillRootsFor(agent) + .filter((root) => root.scope === scope) + .map((root) => root.folder); diff --git a/packages/provider-cursor/src/server/skills.test.ts b/packages/provider-cursor/src/server/skills.test.ts index 7831e5909e0f..e4d9331fa61b 100644 --- a/packages/provider-cursor/src/server/skills.test.ts +++ b/packages/provider-cursor/src/server/skills.test.ts @@ -157,6 +157,48 @@ describe("Cursor skills", () => { ), ); + // The Skills settings page reads the same folders from a shared table, so a change to the + // table that reorders or adds a folder would change what the `$` picker offers. + it("reads the project's folders before the home folders, each in a fixed order", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem + .makeTempDirectoryScoped({ directory: NodeOS.tmpdir(), prefix: "cursor-order-home-" }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const workspace = yield* fileSystem + .makeTempDirectoryScoped({ directory: NodeOS.tmpdir(), prefix: "cursor-order-work-" }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const subfolders = [".cursor/skills", ".agents/skills", ".codex/skills", ".claude/skills"]; + const folders = [ + ...subfolders.map((folder) => ({ label: `project ${folder}`, base: workspace, folder })), + ...subfolders.map((folder) => ({ label: `home ${folder}`, base: userHome, folder })), + ]; + const writeProbe = Effect.fn("writeProbe")(function* (directory: string, label: string) { + yield* fileSystem.makeDirectory(path.join(directory, "probe"), { recursive: true }); + yield* fileSystem.writeFileString( + path.join(directory, "probe", "SKILL.md"), + `---\ndescription: ${label}\n---\n`, + ); + }); + for (const { label, base, folder } of folders) { + yield* writeProbe(path.join(base, folder), label); + } + for (const folder of [".gemini/skills", ".pi/skills", ".opencode/skills"]) { + yield* writeProbe(path.join(workspace, folder), "ignored"); + } + + // Each folder wins until its skill is removed, so the order is the folders' order. + for (const { label, base, folder } of folders) { + const found = yield* discoverCursorSkills(workspace, { HOME: userHome }); + expect(found.map((skill) => [skill.name, skill.description])).toEqual([["probe", label]]); + yield* fileSystem.remove(path.join(base, folder, "probe"), { recursive: true }); + } + expect(yield* discoverCursorSkills(workspace, { HOME: userHome })).toEqual([]); + }), + )); + it("rewrites only discovered skill mentions into Cursor slash invocations", () => { expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true); expect(hasCursorSkillMention("please $review this")).toBe(true); diff --git a/packages/provider-cursor/src/server/skills.ts b/packages/provider-cursor/src/server/skills.ts index 371aa75352d6..43759c6c362a 100644 --- a/packages/provider-cursor/src/server/skills.ts +++ b/packages/provider-cursor/src/server/skills.ts @@ -9,7 +9,7 @@ * @module provider/Drivers/CursorSkills */ -import type { ServerProviderSkill } from "@t3tools/contracts"; +import { ProviderDriverKind, type ServerProviderSkill } from "@t3tools/contracts"; import * as ByteSize from "effect/ByteSize"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -19,6 +19,9 @@ import * as Schema from "effect/Schema"; import { parse as parseYamlDocument } from "yaml"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { skillRootsFor } from "@t3tools/provider-core/server/AgentSkillFolders"; + +const CURSOR_DRIVER = ProviderDriverKind.make("cursor"); const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; const SKILL_MENTION_PATTERN = /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; @@ -225,13 +228,14 @@ const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( environment.HOME?.trim() || environment.USERPROFILE?.trim() || (yield* HostProcess.HomeDirectory); - const rootsBelow = (base: string, scope: "user" | "project") => [ - { directory: path.join(base, ".cursor", "skills"), scope }, - { directory: path.join(base, ".agents", "skills"), scope }, - { directory: path.join(base, ".codex", "skills"), scope }, - { directory: path.join(base, ".claude", "skills"), scope }, - ]; - const roots = [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")]; + const roots = skillRootsFor(CURSOR_DRIVER).flatMap( + (root): Array<{ directory: string; scope: "user" | "project" }> => { + if (root.scope === "global") { + return [{ directory: path.join(userHome, root.folder), scope: "user" }]; + } + return cwd ? [{ directory: path.join(cwd, root.folder), scope: "project" }] : []; + }, + ); const skillsByName = new Map(); const budget: CursorSkillScanBudget = { From af8cc980d492c21140b2543454c9f8435c2ee20a Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:08:37 -0400 Subject: [PATCH 002/108] feat(web): add a Skills page to Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Skills lists the skills in This project and Global, and shows which of your enabled agents can use each one. Search and a Needs attention filter surface skills an agent doesn't use, copies that conflict, and skills Claude can't read. Opening a skill shows its description, which agents use it, any scripts it includes, and its files in a read-only viewer, with Copy path in a menu. Escape in a skill returns to the list. The agents are the enabled provider instances, named and drawn like everywhere else in the app, so two instances of one driver are told apart. Co-Authored-By: Claude Sonnet 5.5 --- .../settings/SettingsSidebarNav.tsx | 2 + .../src/components/settings/SkillDetail.tsx | 299 +++++++++++++++ .../src/components/settings/SkillFiles.tsx | 206 +++++++++++ .../web/src/components/settings/SkillList.tsx | 121 +++++++ .../settings/SkillsSettings.logic.test.ts | 314 ++++++++++++++++ .../settings/SkillsSettings.logic.ts | 204 +++++++++++ .../components/settings/SkillsSettings.tsx | 341 ++++++++++++++++++ .../components/settings/settingsLayout.tsx | 11 +- .../src/components/settings/settingsSearch.ts | 12 + .../components/settings/skillAgentIcon.tsx | 76 ++++ apps/web/src/hooks/useAfterDelay.ts | 18 + apps/web/src/routeTree.gen.ts | 21 ++ apps/web/src/routes/settings.skills.tsx | 5 + apps/web/src/routes/settings.tsx | 2 +- packages/client-runtime/src/state/server.ts | 8 + 15 files changed, 1638 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/settings/SkillDetail.tsx create mode 100644 apps/web/src/components/settings/SkillFiles.tsx create mode 100644 apps/web/src/components/settings/SkillList.tsx create mode 100644 apps/web/src/components/settings/SkillsSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/SkillsSettings.logic.ts create mode 100644 apps/web/src/components/settings/SkillsSettings.tsx create mode 100644 apps/web/src/components/settings/skillAgentIcon.tsx create mode 100644 apps/web/src/hooks/useAfterDelay.ts create mode 100644 apps/web/src/routes/settings.skills.tsx diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 048e19b7a6b5..ca4689302dbf 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -12,6 +12,7 @@ import { import { ArchiveIcon, BlocksIcon, + BookOpenIcon, BotIcon, createLucideIcon, CalendarClockIcon, @@ -83,6 +84,7 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/keybindings": KeyboardIcon, "/settings/snap-shot": SnapShotIcon, "/settings/providers": BotIcon, + "/settings/skills": BookOpenIcon, "/settings/integrations": BlocksIcon, "/settings/scheduled-tasks": CalendarClockIcon, "/settings/source-control": GitBranchIcon, diff --git a/apps/web/src/components/settings/SkillDetail.tsx b/apps/web/src/components/settings/SkillDetail.tsx new file mode 100644 index 000000000000..60c7de5e9b6c --- /dev/null +++ b/apps/web/src/components/settings/SkillDetail.tsx @@ -0,0 +1,299 @@ +import type { EnvironmentId, SkillGetResult } from "@t3tools/contracts"; +import { AlertTriangleIcon, ArrowLeftIcon, LockIcon, MoreHorizontalIcon } from "lucide-react"; +import { lazy, Suspense, useEffect, useEffectEvent, useMemo, useState } from "react"; + +import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; +import { useAfterDelay } from "../../hooks/useAfterDelay"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Skeleton } from "../ui/skeleton"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SkillAgentIcon } from "./skillAgentIcon"; +import { + accessOf, + agentSkillPath, + attention, + scriptFiles, + type Skill, + type SkillAgent, + type SkillsContext, +} from "./SkillsSettings.logic"; + +// The tree and viewer pull in the file-tree and highlighter code, so they load when a skill opens. +const SkillFiles = lazy(() => import("./SkillFiles")); + +/** How long the file area waits before showing placeholders for a quick read. */ +const SKELETON_DELAY_MS = 150; + +type DetailState = + | { status: "loading" } + | { status: "ready"; result: SkillGetResult } + | { status: "error" }; + +function BackBar({ scope, onBack }: { scope: string; onBack: () => void }) { + return ( + + ); +} + +/** + * Escape goes from a skill back to the list. Settings leaves the page on Escape from its own + * window listener, so this one runs first, in the capture phase, and keeps Escape from reaching + * it. Escape inside a field, dialog or menu belongs to that control. + */ +function useEscapeToList(onBack: () => void) { + const goBack = useEffectEvent((event: KeyboardEvent) => { + if (event.key !== "Escape" || event.defaultPrevented || event.repeat || event.isComposing) + return; + if ( + event.target instanceof Element && + event.target.closest( + 'input,textarea,select,[contenteditable],[role="dialog"],[role="alertdialog"],[role="menu"]', + ) + ) + return; + event.preventDefault(); + event.stopImmediatePropagation(); + onBack(); + }); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => goBack(event); + window.addEventListener("keydown", onKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", onKeyDown, { capture: true }); + }, []); +} + +export function SkillDetail({ + skill, + ctx, + environmentId, + projectRoot, + onBack, + onReload, +}: { + skill: Skill; + ctx: SkillsContext; + environmentId: EnvironmentId; + projectRoot: string | null; + onBack: () => void; + /** Opens this skill again, which reads its files again. */ + onReload: () => void; +}) { + useEscapeToList(onBack); + const readSkill = useAtomCommand(serverEnvironment.getSkill, { reportFailure: false }); + const [detail, setDetail] = useState({ status: "loading" }); + const { scope, name, home } = skill; + useEffect(() => { + let cancelled = false; + void readSkill({ + environmentId, + input: { scope, name, home, ...(projectRoot ? { cwd: projectRoot } : {}) }, + }).then((result) => { + if (cancelled) return; + setDetail( + result._tag === "Success" && result.value.home + ? { status: "ready", result: result.value } + : { status: "error" }, + ); + }); + return () => { + cancelled = true; + }; + }, [scope, name, home, projectRoot, environmentId, readSkill]); + + const showSkeleton = useAfterDelay(detail.status === "loading", SKELETON_DELAY_MS); + const files = useMemo(() => (detail.status === "ready" ? detail.result.files : []), [detail]); + const scripts = useMemo(() => scriptFiles(files), [files]); + const skillText = detail.status === "ready" ? detail.result.contents : null; + // The list only holds a short preview; the header shows the whole description. + const description = + (detail.status === "ready" ? detail.result.description : "") || skill.description; + // The server's resolved folder, not an agent's link. + const skillFolder = detail.status === "ready" ? detail.result.home : null; + const scopeLabel = skill.scope === "global" ? "Global" : "This project"; + const warning = attention(skill, ctx); + const sameCopies = skill.copies.filter((copy) => copy.same); + + const copyPath = (path: string) => { + void writeTextToClipboard(path, "skill path").then( + (didCopy) => { + if (didCopy) toastManager.add({ type: "success", title: "Path copied", description: path }); + }, + (error: unknown) => { + toastManager.add({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + ); + }; + + return ( +
+ +
+
+

+ + }> + {skill.name} + + + {skill.home} + {sameCopies.map((copy) => ( + + Also in {copy.scope === "global" ? "Global" : "This project"}: {copy.home} + + ))} + + +

+

+ {description || "No description yet."} +

+
+
+ Used by + {ctx.installed.length === 0 && ( + No agents are installed. + )} + {ctx.installed.map((agent) => ( + + ))} + + {skillFolder && ( + + } + > + + + + copyPath(skillFolder)}>Copy path + + + )} +
+
+ {warning && warning.kind !== "missing" && ( + {warning.detail} + )} + {scripts.length > 0 && ( + + + } + > + + Includes scripts + + + {scripts.slice(0, 8).join(", ")} + {scripts.length > 8 && ` and ${scripts.length - 8} more`} + + + )} +
+
+ +
+ {detail.status === "loading" && ( +
+ {showSkeleton ? ( + <> + + + + + ) : ( + + )} +
+ )} + {detail.status === "error" && ( +
+

The skill's files couldn't be read.

+ +
+ )} + {detail.status === "ready" && ( + + + +
+ } + > + + + )} + {detail.status === "ready" && detail.result.filesTruncated && ( +

+ Only some of this skill's files are shown. +

+ )} + +
+ ); +} + +/** One agent: whether it can use the skill, and where it reads it from. */ +function AgentChip({ + skill, + agent, + agents, +}: { + skill: Skill; + agent: SkillAgent; + agents: readonly SkillAgent[]; +}) { + const access = accessOf(skill, agent); + const on = access?.state === "direct" || access?.state === "link"; + const direct = access?.state === "direct"; + return ( + + } + > + + {agent.displayName} + {direct && ( + + )} + + + + {access?.state === "direct" && `${agent.displayName} reads this folder directly.`} + {access?.state === "link" && `${agent.displayName} reads a link to this skill.`} + {!on && `${agent.displayName} doesn't use this skill. It reads skills from:`} + + {agentSkillPath(skill, agent)} + + + ); +} diff --git a/apps/web/src/components/settings/SkillFiles.tsx b/apps/web/src/components/settings/SkillFiles.tsx new file mode 100644 index 000000000000..01749f811e49 --- /dev/null +++ b/apps/web/src/components/settings/SkillFiles.tsx @@ -0,0 +1,206 @@ +import { FileTree, useFileTree } from "@pierre/trees/react"; +import type { EnvironmentId, SkillFile } from "@t3tools/contracts"; +import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import ReactMarkdown, { type Components } from "react-markdown"; + +import { useTheme } from "../../hooks/useTheme"; +import { T3_PIERRE_ICONS } from "../../pierre-icons"; +import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "../../pierre-tree-theme"; +import { Button } from "../ui/button"; +import { useProjectFileQuery } from "../files/projectFilesQueryState"; +import ReadOnlySourcePreview from "../files/ReadOnlySourcePreview"; +import { compareSkillFiles, scriptFiles, skillBody } from "./SkillsSettings.logic"; + +const SKILL_FILE = "SKILL.md"; +/** The viewer and the tree share one height, so the page doesn't jump between files. */ +const PANE_HEIGHT = "h-[26rem]"; + +export default function SkillFiles({ + environmentId, + home, + files, + skillText, +}: { + environmentId: EnvironmentId; + /** Absolute path of the skill's folder. */ + home: string; + files: readonly SkillFile[]; + /** SKILL.md text; null when it is missing or too large to show. */ + skillText: string | null; +}) { + const { resolvedTheme } = useTheme(); + const fileSet = useMemo(() => new Set(files.map((file) => file.path)), [files]); + const [current, setCurrent] = useState( + fileSet.has(SKILL_FILE) ? SKILL_FILE : (files[0]?.path ?? ""), + ); + const [treeOpen, setTreeOpen] = useState(false); + const scripts = useMemo(() => new Set(scriptFiles(files)), [files]); + const { model } = useFileTree({ + paths: [...fileSet], + sort: compareSkillFiles, + density: "compact", + flattenEmptyDirectories: true, + initialExpansion: "open", + initialSelectedPaths: current ? [current] : [], + icons: T3_PIERRE_ICONS, + // Files an agent could run say so, matching "Includes scripts" in the skill's header. + renderRowDecoration: ({ item }) => + scripts.has(item.path) ? { text: "script", title: "Agents can run this" } : null, + search: false, + unsafeCSS: PIERRE_TREE_UNSAFE_CSS, + onSelectionChange: (selected) => { + const path = selected.at(-1); + // Folders end in a slash; only files open. + if (path && fileSet.has(path)) { + setCurrent(path); + setTreeOpen(false); + } + }, + }); + + return ( +
+
+
+ +
+ {/* The tree fills a flex column of fixed height, as it does in the file browser. */} +
+ +
+
+
+ {current === SKILL_FILE ? ( + + ) : ( + + )} +
+
+ ); +} + +/** SKILL.md as rendered text, or as the file reads with its header. */ +function SkillTextPane({ skillText }: { skillText: string | null }) { + const [source, setSource] = useState(false); + return ( +
+
+ {SKILL_FILE} + + + +
+
+ {skillText === null ? ( +

+ SKILL.md is missing or too large to show here. +

+ ) : source ? ( + + ) : ( +
+ + {skillBody(skillText)} + +
+ )} +
+
+ ); +} + +/** Any other file, read from disk when it is clicked. */ +function OtherFilePane({ + environmentId, + home, + path, +}: { + environmentId: EnvironmentId; + home: string; + path: string; +}) { + const file = useProjectFileQuery(environmentId, home, path); + return ( +
+
+ {path} + + Read-only +
+
+ {file.data ? ( + <> + {file.data.truncated && ( +

+ This file is large, so only the start is shown. +

+ )} + + + ) : file.isPending ? ( +

+ Loading {path}… +

+ ) : ( +

+ {file.isNotFile ? "This is a folder." : "This file can't be previewed here."} +

+ )} +
+
+ ); +} + +/** + * The body without its header. Code wraps at phone width. Links and images stay plain text, so + * reading a skill never opens a page or fetches anything. + */ +const SKILL_MARKDOWN_COMPONENTS = { + pre: ({ children }) => ( +
{children}
+ ), + code: ({ children }) => {children}, + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + a: ({ children }) => {children}, + img: ({ alt }) => {alt}, +} satisfies Components; diff --git a/apps/web/src/components/settings/SkillList.tsx b/apps/web/src/components/settings/SkillList.tsx new file mode 100644 index 000000000000..d4675d18cddf --- /dev/null +++ b/apps/web/src/components/settings/SkillList.tsx @@ -0,0 +1,121 @@ +import { InfoIcon } from "lucide-react"; + +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SettingsGroup } from "./SettingsGroup"; +import { SkillAgents } from "./skillAgentIcon"; +import { attention, type Skill, type SkillsContext } from "./SkillsSettings.logic"; + +function SkillRow({ + skill, + ctx, + onOpen, +}: { + skill: Skill; + ctx: SkillsContext; + onOpen: () => void; +}) { + const warning = attention(skill, ctx); + return ( +
  • + +
  • + ); +} + +/** Small info button beside the page heading: where project and global skills live. */ +export function StandardInfo() { + return ( + + } + > + + + +

    + Project skills live in the repo, so anyone who clones it gets them. Global skills are + yours and work in all your projects. +

    +
    +
    + ); +} + +export function SkillSection({ + title, + hint, + detail, + folder, + all, + visible, + ctx, + emptyText, + onOpen, +}: { + title: string; + /** A short muted phrase beside the name, in plain words. */ + hint: string; + /** What the tooltip on the name adds, before the folder. */ + detail?: string; + /** The section's folder, shown in a tooltip on its name. */ + folder: string; + /** Every skill in the section, before search narrows it. */ + all: readonly Skill[]; + /** The skills that match the search and filters. */ + visible: readonly Skill[]; + ctx: SkillsContext; + emptyText: string; + onOpen: (id: string) => void; +}) { + return ( +
    +
    +

    + + }> + {title} · {all.length} + + + {detail && {detail}} + {folder} + + + {hint} +

    +
    + + {visible.length === 0 ? ( +

    {emptyText}

    + ) : ( +
      + {visible.map((skill) => ( + onOpen(skill.id)} /> + ))} +
    + )} +
    +
    + ); +} diff --git a/apps/web/src/components/settings/SkillsSettings.logic.test.ts b/apps/web/src/components/settings/SkillsSettings.logic.test.ts new file mode 100644 index 000000000000..3f28e3b63d05 --- /dev/null +++ b/apps/web/src/components/settings/SkillsSettings.logic.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from "vite-plus/test"; +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import type { ServerProvider, SkillAgentAccess, SkillListResult } from "@t3tools/contracts"; + +import { + agentSkillPath, + attention, + availability, + availabilityNote, + compareSkillFiles, + ingestSkills, + installedAgents, + matchesQuery, + scriptFiles, + skillBody, + unreadableNote, + type Skill, + type SkillAgent, +} from "./SkillsSettings.logic"; + +const agent = (instanceId: string, driver: string, displayName: string): SkillAgent => ({ + instanceId: ProviderInstanceId.make(instanceId), + driverKind: ProviderDriverKind.make(driver), + displayName, + accentColor: undefined, +}); +const claude = agent("claudeAgent", "claudeAgent", "Claude"); +const codex = agent("codex", "codex", "Codex"); +const claudeWork = agent("claude_work", "claudeAgent", "Claude Work"); +const ALL = [claude, codex, agent("cursor", "cursor", "Cursor")]; + +/** A skill the way the server reports it; `reach` says how each listed agent gets to it. */ +function skill( + name: string, + reach: Partial> = {}, + extra: Partial = {}, +): Skill { + const scope = extra.scope ?? "project"; + return { + id: `${scope}\0${name}`, + name, + scope, + home: scope === "global" ? `~/.agents/skills/${name}` : `.agents/skills/${name}`, + description: `The ${name} skill.`, + copies: [], + access: [...ALL, claudeWork].map((entry) => ({ + instanceId: entry.instanceId, + driver: entry.driverKind, + state: reach[entry.instanceId] ?? "none", + folder: scope === "global" ? "~/.agents/skills" : ".agents/skills", + })), + ...extra, + }; +} + +const provider = ( + instanceId: string, + driver: string, + over: Partial< + Pick + > = {}, +) => + ({ + instanceId, + driver, + installed: true, + enabled: true, + status: "ready", + models: [], + skills: [], + ...over, + }) as unknown as ServerProvider; + +describe("ingestSkills", () => { + it("gives every skill home its own id and lists the instances the server knows", () => { + const result: SkillListResult = { + skills: [ + skill("tdd", { codex: "direct" }), + skill("tdd", { codex: "direct" }, { home: ".claude/skills/tdd" }), + skill("tdd", { codex: "direct" }, { scope: "global" }), + ], + unreadable: [{ scope: "global", folder: "~/.claude/skills" }], + }; + const { skills, known, unreadable } = ingestSkills(result); + expect(new Set(skills.map((item) => item.id)).size).toBe(3); + expect([...known].toSorted()).toEqual(["claudeAgent", "claude_work", "codex", "cursor"]); + expect(unreadable).toEqual([{ scope: "global", folder: "~/.claude/skills" }]); + }); +}); + +describe("installedAgents", () => { + const known = new Set( + ["claudeAgent", "claude_work", "codex", "cursor", "pi", "opencode"].map((id) => + ProviderInstanceId.make(id), + ), + ); + + it("keeps instances that are installed, enabled and reachable, each with its own name", () => { + expect( + installedAgents( + [ + provider("claudeAgent", "claudeAgent", { displayName: "Claude" }), + provider("claude_work", "claudeAgent", { displayName: "Claude Work" }), + provider("codex", "codex", { installed: false }), + provider("pi", "pi", { enabled: false }), + provider("opencode", "opencode", { availability: "unavailable" }), + provider("cursor", "cursor"), + ], + known, + ).map((item) => [item.instanceId, item.displayName]), + ).toEqual([ + ["claudeAgent", "Claude"], + ["claude_work", "Claude Work"], + ["cursor", "Cursor"], + ]); + }); + + it("leaves out instances the server has no folders for", () => { + expect( + installedAgents( + [provider("claudeAgent", "claudeAgent"), provider("codex", "codex")], + new Set([ProviderInstanceId.make("claudeAgent")]), + ).map((item) => item.instanceId), + ).toEqual(["claudeAgent"]); + }); +}); + +describe("who can use a skill", () => { + const ctx = { installed: [claude, codex] }; + + it("shows one mark when every installed agent can, whatever the others do", () => { + const value = availability(skill("a", { claudeAgent: "link", codex: "direct" }), ctx); + expect(value).toMatchObject({ everyone: true, missing: [] }); + expect(value.agents).toEqual([claude, codex]); + expect(availabilityNote(value)).toBe("Available to all your agents"); + }); + + it("names the agents that can't", () => { + const value = availability(skill("a", { codex: "direct" }), ctx); + expect(value).toMatchObject({ everyone: false, agents: [codex], missing: [claude] }); + expect(availabilityNote(value)).toBe("Not available to Claude"); + }); + + it("tells two instances of one agent apart by their names", () => { + const both = { installed: [claude, claudeWork] }; + const value = availability(skill("a", { claude_work: "direct" }), both); + expect(value.agents).toEqual([claudeWork]); + expect(availabilityNote(value)).toBe("Not available to Claude"); + expect(availabilityNote(availability(skill("a"), both))).toBe( + "Not available to Claude and Claude Work", + ); + }); + + it("is never everyone when no agent is installed", () => { + const value = availability(skill("a", { codex: "direct" }), { installed: [] }); + expect(value).toMatchObject({ everyone: false, agents: [], missing: [] }); + }); + + it("tells where an agent reads the skill, or where it looks when it can't see it", () => { + const linked = skill("a", { claudeAgent: "link" }); + const withFolder = { + ...linked, + access: linked.access.map((item) => + item.instanceId === "claudeAgent" ? { ...item, folder: ".claude/skills" } : item, + ), + }; + expect(agentSkillPath(withFolder, claude)).toBe(".claude/skills/a"); + expect(agentSkillPath(withFolder, codex)).toBe(".agents/skills"); + expect(agentSkillPath(withFolder, agent("unknown", "pi", "Pi"))).toBeNull(); + }); +}); + +describe("attention", () => { + const ctx = { installed: [claude, codex] }; + const both = { claudeAgent: "link", codex: "direct" } as const; + + it("flags a skill that differs from a copy in the other scope, and names that scope", () => { + const different = [{ scope: "global", home: "~/.agents/skills/tdd", same: false }] as const; + expect(attention(skill("tdd", both, { copies: different }), ctx)).toEqual({ + kind: "conflict", + detail: "Global has a different “tdd”.", + }); + expect( + attention( + skill("tdd", both, { + scope: "global", + copies: [{ scope: "project", home: ".agents/skills/tdd", same: false }], + }), + ctx, + ), + ).toEqual({ kind: "conflict", detail: "This project has a different “tdd”." }); + }); + + it("flags a copy that differs from another one in the same scope", () => { + expect( + attention( + skill("tdd", both, { + copies: [{ scope: "project", home: ".claude/skills/tdd", same: false }], + }), + ctx, + )?.detail, + ).toBe("Another “tdd” in this project is different."); + expect( + attention( + skill("tdd", both, { + scope: "global", + copies: [{ scope: "global", home: "~/.claude/skills/tdd", same: false }], + }), + ctx, + )?.detail, + ).toBe("Another global “tdd” is different."); + }); + + it("doesn't flag an identical copy", () => { + const same = [{ scope: "global", home: "~/.agents/skills/tdd", same: true }] as const; + expect(attention(skill("tdd", both, { copies: same }), ctx)).toBeNull(); + }); + + it("flags a skill an installed agent can't use, and says which", () => { + expect(attention(skill("a", { codex: "direct" }), ctx)).toEqual({ + kind: "missing", + detail: "Not available to Claude", + }); + expect(attention(skill("a", both), ctx)).toBeNull(); + }); + + it("says plainly when Claude can't read the header", () => { + expect(attention(skill("a", { codex: "direct" }, { invalidHeader: true }), ctx)).toEqual({ + kind: "header", + detail: "Claude can't read this skill's header.", + }); + // Without Claude there is nothing to report about its header. + expect( + attention(skill("a", { codex: "direct" }, { invalidHeader: true }), { installed: [codex] }), + ).toBeNull(); + }); + + it("ignores agents that aren't installed, and puts a conflict first", () => { + expect(attention(skill("a", { codex: "direct" }), { installed: [codex] })).toBeNull(); + const conflicting = skill( + "a", + {}, + { copies: [{ scope: "global", home: "~/.agents/skills/a", same: false }] }, + ); + expect(attention(conflicting, ctx)?.kind).toBe("conflict"); + }); +}); + +describe("unreadableNote", () => { + const folder = (name: string) => ({ scope: "global" as const, folder: name }); + + it("names the folders that couldn't be read, briefly", () => { + expect(unreadableNote([])).toBe(""); + expect(unreadableNote([folder("~/.claude/skills")])).toBe("Couldn't read ~/.claude/skills"); + expect(unreadableNote([folder("~/.claude/skills"), folder(".pi/skills")])).toBe( + "Couldn't read ~/.claude/skills and .pi/skills", + ); + expect(unreadableNote(["a", "b", "c", "d"].map(folder))).toBe("Couldn't read a, b and 2 more"); + }); +}); + +describe("search", () => { + it("matches the name and the description", () => { + const item = skill("verify", {}, { description: "Drive the app" }); + expect(matchesQuery(item, "verif")).toBe(true); + expect(matchesQuery(item, "drive")).toBe(true); + expect(matchesQuery(item, "nope")).toBe(false); + expect(matchesQuery(item, "")).toBe(true); + }); +}); + +describe("a skill's files", () => { + it("calls files an agent could run scripts, but never SKILL.md", () => { + expect( + scriptFiles([ + { path: "SKILL.md", executable: true }, + { path: "bin/run", executable: false }, + { path: "lib/serve.mjs", executable: false }, + { path: "refs/notes.md", executable: false }, + { path: "tools/check", executable: true }, + ]), + ).toEqual(["bin/run", "lib/serve.mjs", "tools/check"]); + }); + + it("sorts SKILL.md first, then folders before files, with numbers in order", () => { + const entry = (path: string, isDirectory = false) => ({ + path, + isDirectory, + segments: path.replace(/\/$/, "").split("/"), + }); + const sorted = [ + entry("refs/note-10.md"), + entry("README.md"), + entry("SKILL.md"), + entry("refs/note-2.md"), + entry("refs/", true), + entry("a.txt"), + ].toSorted(compareSkillFiles); + expect(sorted.map((item) => item.path)).toEqual([ + "SKILL.md", + "refs/", + "refs/note-2.md", + "refs/note-10.md", + "a.txt", + "README.md", + ]); + }); + + it("drops the header and the blank lines after it from the rendered text", () => { + expect(skillBody("---\nname: a\n---\n\n\n# Title\n")).toBe("# Title\n"); + expect(skillBody("---\r\nname: a\r\n---\r\n# Title\r\n")).toBe("# Title\r\n"); + expect(skillBody("# No header\n")).toBe("# No header\n"); + }); +}); diff --git a/apps/web/src/components/settings/SkillsSettings.logic.ts b/apps/web/src/components/settings/SkillsSettings.logic.ts new file mode 100644 index 000000000000..1b4192a18441 --- /dev/null +++ b/apps/web/src/components/settings/SkillsSettings.logic.ts @@ -0,0 +1,204 @@ +import type { + ProviderInstanceId, + ServerProvider, + SkillAgentAccess, + SkillListResult, + SkillScope, + SkillSummary, +} from "@t3tools/contracts"; + +import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../../providerInstances"; + +/** An enabled provider instance, named and drawn the way the rest of the app does. */ +export type SkillAgent = Pick< + ProviderInstanceEntry, + "instanceId" | "driverKind" | "displayName" | "accentColor" +>; + +const joinNames = (names: readonly string[]) => + names.length <= 1 + ? (names[0] ?? "") + : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; + +export type Skill = SkillSummary & { + /** Stable across reads, so an open skill survives a refresh. */ + readonly id: string; +}; + +export type SkillsContext = { + /** Provider instances that are installed, enabled and known to the server's folder table. */ + readonly installed: readonly SkillAgent[]; +}; + +export function ingestSkills(result: SkillListResult) { + const skills = result.skills.map((entry): Skill => ({ + ...entry, + id: `${entry.scope}\0${entry.name}\0${entry.home}`, + })); + const known = new Set(skills.flatMap((skill) => skill.access.map((access) => access.instanceId))); + return { skills, unreadable: result.unreadable, known }; +} + +export function installedAgents( + providers: readonly ServerProvider[], + known: ReadonlySet, +): SkillAgent[] { + return deriveProviderInstanceEntries(providers) + .filter( + (entry) => + known.has(entry.instanceId) && entry.installed && entry.enabled && entry.isAvailable, + ) + .map(({ instanceId, driverKind, displayName, accentColor }) => ({ + instanceId, + driverKind, + displayName, + accentColor, + })); +} + +// -- Access ----------------------------------------------------------------------------------- + +export const accessOf = ( + skill: Skill, + agent: Pick, +): SkillAgentAccess | undefined => + skill.access.find((access) => access.instanceId === agent.instanceId); + +const hasAccess = (skill: Skill, agent: SkillAgent) => { + const state = accessOf(skill, agent)?.state; + return state === "direct" || state === "link"; +}; + +/** Where the agent reads the skill from, or the folder it looks in when it can't see it. */ +export const agentSkillPath = (skill: Skill, agent: SkillAgent) => { + const access = accessOf(skill, agent); + if (!access) return null; + return hasAccess(skill, agent) ? `${access.folder}/${skill.name}` : access.folder; +}; + +/** Installed agents that don't load this copy of the skill. */ +const missingAgents = (skill: Skill, ctx: SkillsContext) => + ctx.installed.filter((agent) => !hasAccess(skill, agent)); + +// -- Attention -------------------------------------------------------------------------------- + +type Attention = { + /** A conflict gets a badge on its row; the others only show in the list filter and the skill. */ + kind: "conflict" | "header" | "missing"; + detail: string; +}; + +const scopeName = (scope: SkillScope) => (scope === "global" ? "Global" : "This project"); + +/** One plain sentence on what is wrong, or null for a healthy skill. */ +export function attention(skill: Skill, ctx: SkillsContext): Attention | null { + const other = skill.copies.find((copy) => !copy.same); + if (other) { + const detail = + other.scope !== skill.scope + ? `${scopeName(other.scope)} has a different “${skill.name}”.` + : skill.scope === "global" + ? `Another global “${skill.name}” is different.` + : `Another “${skill.name}” in this project is different.`; + return { kind: "conflict", detail }; + } + const claude = ctx.installed.filter((agent) => agent.driverKind === "claudeAgent"); + if (skill.invalidHeader && claude.length > 0) { + return { + kind: "header", + detail: `${joinNames(claude.map((agent) => agent.displayName))} can't read this skill's header.`, + }; + } + const missing = missingAgents(skill, ctx); + return missing.length === 0 + ? null + : { + kind: "missing", + detail: `Not available to ${joinNames(missing.map((agent) => agent.displayName))}`, + }; +} + +/** Who can use a skill, among the installed agents. */ +type Availability = { + /** Every installed agent can use it. */ + everyone: boolean; + agents: SkillAgent[]; + /** Installed agents that can't. */ + missing: SkillAgent[]; +}; + +export function availability(skill: Skill, ctx: SkillsContext): Availability { + const missing = missingAgents(skill, ctx); + return { + everyone: ctx.installed.length > 0 && missing.length === 0, + agents: ctx.installed.filter((agent) => hasAccess(skill, agent)), + missing, + }; +} + +/** The tooltip on a row's agent icons. */ +export const availabilityNote = (value: Availability) => + value.everyone + ? "Available to all your agents" + : `Not available to ${joinNames(value.missing.map((agent) => agent.displayName))}`; + +/** One short line on the folders the server couldn't read, which would otherwise look empty. */ +export function unreadableNote(folders: SkillListResult["unreadable"]) { + const [first, second, ...rest] = folders.map((item) => item.folder); + if (first === undefined) return ""; + if (second === undefined) return `Couldn't read ${first}`; + return rest.length === 0 + ? `Couldn't read ${first} and ${second}` + : `Couldn't read ${first}, ${second} and ${rest.length} more`; +} + +// -- Search ----------------------------------------------------------------------------------- + +export const matchesQuery = (skill: Skill, needle: string) => + `${skill.name} ${skill.description}`.toLowerCase().includes(needle); + +/** Files an agent could run, shown as a warning in the skill view. */ +const SCRIPT_FILE = /\.(?:sh|mjs|ts|py)$/; +export function scriptFiles(files: ReadonlyArray<{ path: string; executable: boolean }>) { + return files + .filter( + (file) => + file.path !== "SKILL.md" && + (file.path.startsWith("bin/") || SCRIPT_FILE.test(file.path) || file.executable), + ) + .map((file) => file.path); +} + +// -- Files and SKILL.md ----------------------------------------------------------------------- + +/** Sort entry as the file tree hands it over. */ +type FileSortEntry = { path: string; isDirectory: boolean; segments: readonly string[] }; + +/** The tree's usual order (folders first, then names), with the root SKILL.md pinned on top. */ +export function compareSkillFiles(left: FileSortEntry, right: FileSortEntry) { + const pinned = Number(right.path === "SKILL.md") - Number(left.path === "SKILL.md"); + if (pinned !== 0) return pinned; + const shared = Math.min(left.segments.length, right.segments.length); + for (let depth = 0; depth < shared; depth += 1) { + const a = left.segments[depth]!; + const b = right.segments[depth]!; + if (a === b) continue; + const aFolder = depth < left.segments.length - 1 || left.isDirectory; + const bFolder = depth < right.segments.length - 1 || right.isDirectory; + if (aFolder !== bFolder) return aFolder ? -1 : 1; + return ( + a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }) || (a < b ? -1 : 1) + ); + } + return left.segments.length - right.segments.length; +} + +const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; +const BLANK_LINES = /^(?:[ \t]*\r?\n)*/; + +/** The instructions after the frontmatter, without the blank line that separates them. */ +export function skillBody(contents: string) { + const match = FRONTMATTER.exec(contents); + const rest = match ? contents.slice(match[0].length) : contents; + return rest.slice(BLANK_LINES.exec(rest)![0].length); +} diff --git a/apps/web/src/components/settings/SkillsSettings.tsx b/apps/web/src/components/settings/SkillsSettings.tsx new file mode 100644 index 000000000000..37c1c421adb0 --- /dev/null +++ b/apps/web/src/components/settings/SkillsSettings.tsx @@ -0,0 +1,341 @@ +import type { ServerProvider } from "@t3tools/contracts"; +import { BookOpenIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useAfterDelay } from "../../hooks/useAfterDelay"; +import { cn } from "../../lib/utils"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { RefreshIcon } from "../ui/refresh-icon"; +import { Skeleton } from "../ui/skeleton"; +import { SkillDetail } from "./SkillDetail"; +import { SkillSection, StandardInfo } from "./SkillList"; +import { SettingsGroup } from "./SettingsGroup"; +import { SettingsPageContainer } from "./settingsLayout"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { + attention, + ingestSkills, + installedAgents, + matchesQuery, + unreadableNote, + type Skill, + type SkillsContext, +} from "./SkillsSettings.logic"; + +const NO_PROVIDERS: readonly ServerProvider[] = []; +/** A load that finishes sooner than this shows no placeholder at all. */ +const SKELETON_DELAY_MS = 150; +const LOAD_ERROR = "Couldn't read this environment's skill folders."; + +type PickedProject = { id: string; label: string; cwd: string }; +type Loaded = ReturnType; +type View = { kind: "list" } | { kind: "skill"; id: string }; + +export function SkillsSettings() { + const { environment: scopedEnvironment, scope } = useSettingsScope(); + const { environments } = useEnvironments(); + const primaryId = usePrimaryEnvironmentId(); + const environment = + scopedEnvironment ?? + environments.find((item) => item.environmentId === primaryId) ?? + environments[0]; + // The settings scope picker at the top of the page decides what this page shows. + const project = + scope.kind === "checkout" + ? scope.checkout + : scope.kind === "project" + ? scope.members.find((member) => member.environmentId === environment?.environmentId) + : undefined; + const projectName = + scope.kind === "project" || scope.kind === "checkout" ? scope.group.displayName : ""; + const picked = useMemo( + () => (project ? { id: project.id, label: projectName, cwd: project.workspaceRoot } : null), + [project, projectName], + ); + const missingProject = (scope.kind === "project" || scope.kind === "checkout") && !project; + // On a phone, an open skill gets the whole screen under its Back row. + const [subpage, setSubpage] = useState(false); + return ( + +
    +
    + +

    Skills

    + +
    +
    + {!environment ? ( +

    Connect an environment to see its skills.

    + ) : missingProject ? ( +

    This project isn't on {environment.label}.

    + ) : ( + + )} +
    + ); +} + +function EnvironmentSkills({ + environment, + project, + onSubpageChange, +}: { + environment: ReturnType["environments"][number]; + /** The project picked above the page, or null for "All projects". */ + project: PickedProject | null; + /** True while a skill is open instead of the list. */ + onSubpageChange: (open: boolean) => void; +}) { + const listSkills = useAtomCommand(serverEnvironment.listSkills, { reportFailure: false }); + const connected = environment.connection.phase === "connected"; + const providers = environment.serverConfig?.providers ?? NO_PROVIDERS; + const cwd = project?.cwd ?? null; + + const [data, setData] = useState(null); + const [loadError, setLoadError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [view, setView] = useState({ kind: "list" }); + const [query, setQuery] = useState(""); + const [onlyAttention, setOnlyAttention] = useState(false); + const [detailReload, setDetailReload] = useState(0); + const rootRef = useRef(null); + // A new view starts at the top of the page. + const show = (next: View) => { + setView(next); + rootRef.current?.closest("[data-settings-page-scroll]")?.scrollTo({ top: 0 }); + }; + + // The server reads a fixed list of folders each time; no agent is asked to rescan. + const load = useCallback(async () => { + const result = await listSkills({ + environmentId: environment.environmentId, + input: cwd ? { cwd } : {}, + }); + return result._tag === "Success" ? ingestSkills(result.value) : null; + }, [listSkills, environment.environmentId, cwd]); + + useEffect(() => { + if (!connected) return; + let cancelled = false; + void load() + .then((loaded) => { + if (cancelled) return; + setData(loaded); + setLoadError(loaded ? null : LOAD_ERROR); + }) + .catch(() => { + if (!cancelled) setLoadError(LOAD_ERROR); + }); + return () => { + cancelled = true; + }; + }, [connected, load]); + + const refresh = () => { + setRefreshing(true); + setLoadError(null); + void load() + .then((loaded) => { + if (!loaded) { + setLoadError(LOAD_ERROR); + return; + } + setData(loaded); + show({ kind: "list" }); + }) + .catch(() => setLoadError(LOAD_ERROR)) + .finally(() => setRefreshing(false)); + }; + + const skills = data?.skills ?? null; + const installed = useMemo( + () => (data ? installedAgents(providers, data.known) : []), + [data, providers], + ); + const ctx = useMemo(() => ({ installed }), [installed]); + const loading = connected && skills === null && loadError === null; + const showSkeleton = useAfterDelay(loading, SKELETON_DELAY_MS); + + const projectSkills = useMemo( + () => (skills ?? []).filter((skill) => skill.scope === "project"), + [skills], + ); + const globalSkills = useMemo( + () => (skills ?? []).filter((skill) => skill.scope === "global"), + [skills], + ); + const attentionIds = useMemo( + () => + new Set((skills ?? []).filter((skill) => attention(skill, ctx) !== null).map((s) => s.id)), + [skills, ctx], + ); + const needle = query.trim().toLowerCase(); + const visible = (list: readonly Skill[]) => + list.filter( + (skill) => (!onlyAttention || attentionIds.has(skill.id)) && matchesQuery(skill, needle), + ); + + const current = view.kind === "skill" ? skills?.find((skill) => skill.id === view.id) : undefined; + // A view whose skill is gone (a refresh dropped it) falls back to the list. + const skillView = current && data ? { skill: current, data } : null; + const showList = !skillView; + useEffect(() => { + onSubpageChange(!showList); + return () => onSubpageChange(false); + }, [showList, onSubpageChange]); + + const toList = () => show({ kind: "list" }); + const offline = !connected; + const empty = skills !== null && skills.length === 0; + const emptyText = (total: number, none: string) => + total === 0 + ? none + : onlyAttention && !needle + ? "Nothing needs attention here." + : "No matching skills."; + + return ( +
    + {offline && ( +

    + This environment is offline. +

    + )} + {loadError && ( +

    + {loadError} + +

    + )} + + {skillView && ( + setDetailReload((count) => count + 1)} + /> + )} + {showList && ( + <> +
    +
    + setQuery(event.target.value)} + /> +
    + {/* The count depends on the list, so it waits for it instead of showing a false zero. */} + {skills !== null && ( + + )} + +
    + + {loading && ( +

    + Loading skills… +

    + )} + {showSkeleton && } + + {skills !== null && ( + <> + {data && data.unreadable.length > 0 && ( +

    + {unreadableNote(data.unreadable)} +

    + )} + {project && ( + show({ kind: "skill", id })} + /> + )} + show({ kind: "skill", id })} + /> + {empty &&

    No skills yet.

    } + + )} + + )} +
    + ); +} + +/** Placeholder rows for a slow load, laid out like the sections they stand in for. */ +function SkillsSkeleton({ withProject }: { withProject: boolean }) { + return ( +
    + {(withProject ? ["This project", "Global"] : ["Global"]).map((title) => ( +
    +

    + {title} +

    + +
      + {[0, 1, 2, 3].map((row) => ( +
    • + + + + + +
    • + ))} +
    +
    +
    + ))} +
    + ); +} diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 27c6a8fee282..8f642d9c999f 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -543,10 +543,13 @@ export function SettingsPageContainer({ children, className, width = "readable", + hideScopeOnPhone = false, }: { children: ReactNode; className?: string; width?: WorkspacePageWidth; + /** A phone-sized subpage gets the whole screen under its Back row. */ + hideScopeOnPhone?: boolean; }) { const navigate = useNavigate(); const hash = useLocation({ select: (location) => location.hash }); @@ -575,7 +578,13 @@ export function SettingsPageContainer({ data-settings-page-scroll > - + {hideScopeOnPhone ? ( +
    + +
    + ) : ( + + )} {children}
    diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index aff52adeb155..997e0dc43037 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -18,6 +18,7 @@ export type SettingsPath = | "/settings/keybindings" | "/settings/snap-shot" | "/settings/providers" + | "/settings/skills" | "/settings/integrations" | "/settings/scheduled-tasks" | "/settings/source-control" @@ -93,6 +94,7 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/keybindings": "Keybindings", "/settings/snap-shot": "SnapShots", "/settings/providers": "Providers", + "/settings/skills": "Skills", "/settings/integrations": "Integrations", "/settings/scheduled-tasks": "Scheduled Tasks", "/settings/source-control": "Source Control", @@ -616,6 +618,14 @@ export const SETTINGS_SEARCH_ITEMS = [ "agents cli codex claude cursor grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact", ], }, + { + id: "skills", + title: "Skills", + to: "/settings/skills", + searchTerms: [ + "agent skills SKILL.md instructions folder link symlink global project conflict needs attention codex claude cursor grok opencode antigravity pi", + ], + }, { id: "usage-providers", title: "Usage providers", @@ -953,6 +963,8 @@ const SETTINGS_CATEGORY_SCOPES: Readonly + + + ); +} + +/** A tight, never-wrapping run of icons, so a row stays on one line. */ +function IconRow({ label, children }: { label: string; children: ReactNode }) { + return ( + + } + > + {children} + + {label} + + ); +} + +/** + * Who can use a skill: one mark when every installed agent can, otherwise just the agents that + * can. Agents that aren't installed and enabled never show. + */ +export function SkillAgents({ skill, ctx }: { skill: Skill; ctx: SkillsContext }) { + const value = availability(skill, ctx); + const label = availabilityNote(value); + if (value.everyone) + return ( + + + + ); + if (value.agents.length === 0) return null; + return ( + + {value.agents.map((agent) => ( + + ))} + + ); +} diff --git a/apps/web/src/hooks/useAfterDelay.ts b/apps/web/src/hooks/useAfterDelay.ts new file mode 100644 index 000000000000..617f12f3ebb1 --- /dev/null +++ b/apps/web/src/hooks/useAfterDelay.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; + +/** + * True once `active` has stayed true for `delayMs`, and false again as soon as it stops. + * A placeholder that waits for this never flashes for a load that finishes quickly. + */ +export function useAfterDelay(active: boolean, delayMs: number) { + const [elapsed, setElapsed] = useState(false); + useEffect(() => { + if (!active) return; + const timer = setTimeout(() => setElapsed(true), delayMs); + return () => { + clearTimeout(timer); + setElapsed(false); + }; + }, [active, delayMs]); + return active && elapsed; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 2c0b2285d77d..7b09a25fb57e 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsStorageRouteImport } from './routes/settings.storage' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsSnapShotRouteImport } from './routes/settings.snap-shot' +import { Route as SettingsSkillsRouteImport } from './routes/settings.skills' import { Route as SettingsScheduledTasksRouteImport } from './routes/settings.scheduled-tasks' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' @@ -90,6 +91,11 @@ const SettingsSnapShotRoute = SettingsSnapShotRouteImport.update({ path: '/snap-shot', getParentRoute: () => SettingsRoute, } as any) +const SettingsSkillsRoute = SettingsSkillsRouteImport.update({ + id: '/skills', + path: '/skills', + getParentRoute: () => SettingsRoute, +} as any) const SettingsScheduledTasksRoute = SettingsScheduledTasksRouteImport.update({ id: '/scheduled-tasks', path: '/scheduled-tasks', @@ -189,6 +195,7 @@ export interface FileRoutesByFullPath { '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/scheduled-tasks': typeof SettingsScheduledTasksRoute + '/settings/skills': typeof SettingsSkillsRoute '/settings/snap-shot': typeof SettingsSnapShotRoute '/settings/source-control': typeof SettingsSourceControlRoute '/settings/storage': typeof SettingsStorageRoute @@ -215,6 +222,7 @@ export interface FileRoutesByTo { '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/scheduled-tasks': typeof SettingsScheduledTasksRoute + '/settings/skills': typeof SettingsSkillsRoute '/settings/snap-shot': typeof SettingsSnapShotRoute '/settings/source-control': typeof SettingsSourceControlRoute '/settings/storage': typeof SettingsStorageRoute @@ -244,6 +252,7 @@ export interface FileRoutesById { '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/scheduled-tasks': typeof SettingsScheduledTasksRoute + '/settings/skills': typeof SettingsSkillsRoute '/settings/snap-shot': typeof SettingsSnapShotRoute '/settings/source-control': typeof SettingsSourceControlRoute '/settings/storage': typeof SettingsStorageRoute @@ -274,6 +283,7 @@ export interface FileRouteTypes { | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' + | '/settings/skills' | '/settings/snap-shot' | '/settings/source-control' | '/settings/storage' @@ -300,6 +310,7 @@ export interface FileRouteTypes { | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' + | '/settings/skills' | '/settings/snap-shot' | '/settings/source-control' | '/settings/storage' @@ -328,6 +339,7 @@ export interface FileRouteTypes { | '/settings/projects' | '/settings/providers' | '/settings/scheduled-tasks' + | '/settings/skills' | '/settings/snap-shot' | '/settings/source-control' | '/settings/storage' @@ -426,6 +438,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsSnapShotRouteImport parentRoute: typeof SettingsRoute } + '/settings/skills': { + id: '/settings/skills' + path: '/skills' + fullPath: '/settings/skills' + preLoaderRoute: typeof SettingsSkillsRouteImport + parentRoute: typeof SettingsRoute + } '/settings/scheduled-tasks': { id: '/settings/scheduled-tasks' path: '/scheduled-tasks' @@ -562,6 +581,7 @@ interface SettingsRouteChildren { SettingsProjectsRoute: typeof SettingsProjectsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsScheduledTasksRoute: typeof SettingsScheduledTasksRoute + SettingsSkillsRoute: typeof SettingsSkillsRoute SettingsSnapShotRoute: typeof SettingsSnapShotRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute SettingsStorageRoute: typeof SettingsStorageRoute @@ -579,6 +599,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsProjectsRoute: SettingsProjectsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsScheduledTasksRoute: SettingsScheduledTasksRoute, + SettingsSkillsRoute: SettingsSkillsRoute, SettingsSnapShotRoute: SettingsSnapShotRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, SettingsStorageRoute: SettingsStorageRoute, diff --git a/apps/web/src/routes/settings.skills.tsx b/apps/web/src/routes/settings.skills.tsx new file mode 100644 index 000000000000..769f70c4632e --- /dev/null +++ b/apps/web/src/routes/settings.skills.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { SkillsSettings } from "../components/settings/SkillsSettings"; + +export const Route = createFileRoute("/settings/skills")({ component: SkillsSettings }); diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 576307fbe7ad..8bbbed3922ae 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -153,7 +153,7 @@ function SettingsRouteLayout() { return ( { // Send every axis so the retain middleware sees an explicit target // even when the choice is "all", which is the absence of a key. diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 1d49fd71f70c..2c3aabc401d8 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1170,6 +1170,14 @@ export function createServerEnvironmentAtoms( key: ({ environmentId, input }) => JSON.stringify([environmentId, input]), }, }), + listSkills: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:list-skills", + tag: WS_METHODS.serverListSkills, + }), + getSkill: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:get-skill", + tag: WS_METHODS.serverGetSkill, + }), refreshProviders: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-providers", tag: WS_METHODS.serverRefreshProviders, From ae6504f1bee7c221797cac896b2c821f1b165eb4 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:08:40 -0400 Subject: [PATCH 003/108] docs: describe the Skills settings page Co-Authored-By: Claude Sonnet 5.5 --- docs/README.md | 1 + docs/user/skills.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 docs/user/skills.md diff --git a/docs/README.md b/docs/README.md index d7628960cb8b..7c7953dd9bca 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ - [Terminal history](./user/terminal.md) - [Source control](./user/source-control.md) - [Project settings](./user/project-settings.md) +- [Skills](./user/skills.md) - [Appearance and themes](./user/appearance.md) - [Keyboard shortcuts](./user/keybindings.md) - [SnapShots](./user/snap-shot.md) diff --git a/docs/user/skills.md b/docs/user/skills.md new file mode 100644 index 000000000000..ea4284b9866a --- /dev/null +++ b/docs/user/skills.md @@ -0,0 +1,43 @@ +# Skills + +Open **Settings → Skills** on web and desktop to see which skills your agents can use. The page +reads the environment and project chosen at the top of Settings, so with a remote environment you +see that machine's skills. It is read-only: edit skills in your editor, or ask an agent. + +The agents are your enabled provider instances. Two Claude instances show as two agents, each +with its own config folder. + +## Where skills live + +Keep a repo's skills in `.agents/skills` and your own in `~/.agents/skills`. Codex, Cursor, +OpenCode and Pi read both folders, and Grok reads the global one. Other agents read their own +folders instead, such as Claude's `.claude/skills` and `~/.claude/skills`. Antigravity reads the +project's `.agents/skills`, but not the global `~/.agents/skills`; its global folder is +`~/.gemini/config/skills`. A skill reaches an agent that doesn't read the shared folder through a +link or a copy in the folder it does read. The page follows links to the real folder and shows +where each agent reads a skill from. + +Each instance's config folder follows its settings: a Claude instance's config directory (or +`CLAUDE_CONFIG_DIR`), `CODEX_HOME` and `GROK_HOME`. A skill in a folder that none of your enabled +agents reads isn't listed. If a folder exists but can't be read, the page says so above the list +instead of showing it as empty. + +## Needs attention + +**Needs attention** filters the list to skills that need a look. A skill is on it when: + +- an installed and enabled agent doesn't use it. Hover the icons to see which agent. An agent + loads one skill per name, the first it finds in its folders (Codex and OpenCode list every + copy), so a copy that another folder shadows is not used by that agent. +- the same name exists more than once with different text, in **This project**, in **Global**, or + across them. These rows have a **Conflict** badge. +- Claude can't read the skill's header, the YAML between the `---` lines at the top of + `SKILL.md`, so it skips the skill. Quote a value that contains a colon or brackets, for example + a description. + +## Limits + +- Only a project's top folders are read, such as `/.agents/skills`, not the folders + above it that some agents also read. +- A skill's file list stops at 500 files, and `SKILL.md` isn't shown past 1 MB. +- A `SKILL.md` that is a link to a file outside the skill's folder isn't read. From 7fab517d50db599ad96ca9aed9335bd5c8eb4e88 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:51:00 -0400 Subject: [PATCH 004/108] fix(server): read skills under the filesystem read scope Listing skills and reading a skill return folder listings and SKILL.md text, which are file contents. They took the orchestration read scope that thread readers hold, where the other file reads (project files, folder listings, folder browsing) take the filesystem read scope. Use that one for both. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/auth/RpcAuthorization.test.ts | 6 ++++++ apps/server/src/auth/RpcAuthorization.ts | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index d46609edce77..55d84e8013a6 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -60,6 +60,12 @@ describe("RPC authorization scopes", () => { } }); + it("reads skill folders and SKILL.md text under the filesystem read scope", () => { + for (const method of [WS_METHODS.serverListSkills, WS_METHODS.serverGetSkill]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthFilesystemReadScope); + } + }); + it("allows relay status reads without granting relay installation access", () => { expect(requiredScopeForRpcMethod(WS_METHODS.cloudGetRelayClientStatus)).toBe( AuthRelayReadScope, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index db8cd83019d7..3bfa02988260 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -60,8 +60,10 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationReadScope, - [WS_METHODS.serverListSkills]: AuthOrchestrationReadScope, - [WS_METHODS.serverGetSkill]: AuthOrchestrationReadScope, + // A skill's listing and SKILL.md text are file contents, so they take the scope the other file + // reads take, not the orchestration read scope that thread readers hold. + [WS_METHODS.serverListSkills]: AuthFilesystemReadScope, + [WS_METHODS.serverGetSkill]: AuthFilesystemReadScope, [WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope, [WS_METHODS.providerAuthStart]: AuthProvidersManageScope, [WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope, From 56d86d2b58c0bab21de486c8ff9d1e3dce811175 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:51:21 -0400 Subject: [PATCH 005/108] fix(server): read a project's skills only for a registered project The skill list and a single skill took any absolute folder as the project and scanned the skill folders under it, so a client could have the server read the folders of any path on the machine. A given folder must now be the workspace root of a project the environment knows, the same check the skill writes make, or the read is refused with `projectNotRegistered`. Global reads, which have no folder, are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/server.ts | 3 +- apps/server/src/skills/SkillCatalog.test.ts | 108 ++++++++++++++++++-- apps/server/src/skills/SkillCatalog.ts | 39 +++++-- packages/contracts/src/rpc.ts | 12 ++- packages/contracts/src/skills.ts | 15 +++ 5 files changed, 155 insertions(+), 22 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e114d438dfb2..88984b1eeffa 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -577,6 +577,8 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ReplayMarkers.layer, ).pipe( // Core Services + // It checks a project's folder against ProjectService, which the next layer provides. + Layer.provideMerge(SkillCatalog.layer), Layer.provideMerge(layerOrchestrationApplication), Layer.provideMerge(RuntimeLayer.layerEventInfrastructure), Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)), @@ -644,7 +646,6 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( ), ), Layer.provideMerge(layerWorkspace), - Layer.provideMerge(SkillCatalog.layer), Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), Layer.provideMerge(layerRepositoryIdentityResolver), diff --git a/apps/server/src/skills/SkillCatalog.test.ts b/apps/server/src/skills/SkillCatalog.test.ts index 9db87d4de2c4..74fc030e3261 100644 --- a/apps/server/src/skills/SkillCatalog.test.ts +++ b/apps/server/src/skills/SkillCatalog.test.ts @@ -1,10 +1,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; import { + ProjectId, ProviderDriverKind, ProviderInstanceId, SkillGetResult, SkillListResult, + SkillRequestError, + type Project, type SkillAgentAccess, type SkillSummary, } from "@t3tools/contracts"; @@ -13,9 +16,11 @@ import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as ProjectService from "../project/ProjectService.ts"; import * as Settings from "../serverSettings.ts"; import * as SkillCatalog from "./SkillCatalog.ts"; @@ -84,28 +89,63 @@ const ALL_AGENTS_ENABLED = Object.fromEntries( ]), ); -/** The catalog as it sees a machine whose home is `home`, with these server settings. */ +const makeProject = (workspaceRoot: string): Project => ({ + id: ProjectId.make("project-skill-catalog"), + title: "App", + workspaceRoot, + repositoryIdentity: null, + faviconPath: null, + projectIcon: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}); + +/** + * The catalog as it sees a machine whose home is `home`, with these server settings. Only the + * `registered` folders are projects; by default that is the machine's `repos/app`. + */ const withCatalog = ( home: string, use: (catalog: SkillCatalog.SkillCatalog["Service"]) => Effect.Effect, options: { readonly settings?: Parameters[0]; readonly env?: NodeJS.ProcessEnv; + readonly registered?: readonly string[]; } = {}, ) => Effect.gen(function* () { - return yield* use(yield* SkillCatalog.SkillCatalog); - }).pipe( - Effect.provide( - SkillCatalog.layer.pipe( - Layer.provide( - Settings.layerTest({ - ...options.settings, - providerInstances: { ...ALL_AGENTS_ENABLED, ...options.settings?.providerInstances }, - }), + const path = yield* Path.Path; + const registered = options.registered ?? [path.join(home, "repos/app")]; + const projects = Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (root) => + Effect.succeed(registered.includes(root) ? Option.some(makeProject(root)) : Option.none()), + }); + return yield* Effect.gen(function* () { + return yield* use(yield* SkillCatalog.SkillCatalog); + }).pipe( + Effect.provide( + SkillCatalog.layer.pipe( + Layer.provide( + Layer.mergeAll( + projects, + Settings.layerTest({ + ...options.settings, + providerInstances: { + ...ALL_AGENTS_ENABLED, + ...options.settings?.providerInstances, + }, + }), + ), + ), ), ), - ), + ); + }).pipe( Effect.provideService(HostProcess.Environment, { HOME: home, ...options.env }), Effect.provideService(HostProcess.HomeDirectory, home), ); @@ -636,6 +676,52 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillCatalog", (it) ); }); + describe("project folders", () => { + it.effect.skipIf(!symlinksSupported)( + "reads a project's skill folders only when the folder is a registered project", + () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + const refused = new SkillRequestError({ reason: "projectNotRegistered" }); + const get = (cwd: string) => ({ + cwd, + scope: "project" as const, + name: "verify", + home: ".agents/skills/verify", + }); + + // A folder that holds skills but isn't a project (the home, a project's subfolder, a + // relative path, a path that isn't there) is refused for the list and for one skill. + for (const cwd of [home, `${project}/.agents`, "repos/app", `${home}/missing`]) { + const registered = [project]; + expect( + yield* withCatalog(home, (catalog) => catalog.list({ cwd }).pipe(Effect.flip), { + registered, + }), + ).toEqual(refused); + expect( + yield* withCatalog(home, (catalog) => catalog.get(get(cwd)).pipe(Effect.flip), { + registered, + }), + ).toEqual(refused); + } + + // The registered project, and the Global folders without any `cwd`, are read as before. + const listed = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project })); + expect(listed.skills.some((skill) => skill.scope === "project")).toBe(true); + const global = yield* withCatalog(home, (catalog) => catalog.list({}), { + registered: [], + }); + expect(global.skills.map((skill) => skill.scope)).toEqual( + global.skills.map(() => "global"), + ); + expect(global.skills.length).toBeGreaterThan(0); + const detail = yield* withCatalog(home, (catalog) => catalog.get(get(project))); + expect(detail.home).toBe(`${project}/.agents/skills/verify`); + }), + ); + }); + describe("get", () => { it.effect.skipIf(!symlinksSupported)( "returns the full SKILL.md, the file list and which files can run", diff --git a/apps/server/src/skills/SkillCatalog.ts b/apps/server/src/skills/SkillCatalog.ts index d8e3023d2bc4..a5774e5152cc 100644 --- a/apps/server/src/skills/SkillCatalog.ts +++ b/apps/server/src/skills/SkillCatalog.ts @@ -27,6 +27,7 @@ import { type SkillListResult, type SkillScope, type SkillSummary, + SkillRequestError, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; @@ -52,6 +53,7 @@ import { parseSkillFrontmatter, resolveClaudeConfigDirPath, } from "../provider/Drivers/ClaudeSkills.ts"; +import * as ProjectService from "../project/ProjectService.ts"; import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts"; import * as Settings from "../serverSettings.ts"; @@ -144,11 +146,11 @@ export class SkillCatalog extends Context.Service< { /** * One compact record per skill home, in the project (when `cwd` is given) and in the user's - * home folder. + * home folder. A `cwd` that isn't a registered project's workspace root is refused. */ - readonly list: (input: SkillListInput) => Effect.Effect; + readonly list: (input: SkillListInput) => Effect.Effect; /** The full SKILL.md text and the file list of one skill from `list`. */ - readonly get: (input: SkillGetInput) => Effect.Effect; + readonly get: (input: SkillGetInput) => Effect.Effect; } >()("t3/skills/SkillCatalog") {} @@ -158,6 +160,7 @@ const make = Effect.gen(function* () { const environment = yield* HostProcess.Environment; const homeDirectory = yield* HostProcess.HomeDirectory; const serverSettings = yield* Settings.ServerSettingsService; + const projects = yield* ProjectService.ProjectService; /** The text at the start of a regular file, at most `maxBytes` of it. */ const readPrefix = Effect.fnUntraced(function* (file: string, maxBytes: number) { @@ -263,8 +266,30 @@ const make = Effect.gen(function* () { return absolute; }; - const absoluteCwd = (cwd: string | undefined) => - cwd !== undefined && path.isAbsolute(cwd) ? cwd : undefined; + /** + * A project's folders are read only when `cwd` is the workspace root of a project the + * environment knows, so a request can't have the server walk skill folders under any path on + * the machine. Without a `cwd` only the Global folders are read. + */ + const requireProject = Effect.fnUntraced(function* (cwd: string | undefined) { + if (cwd === undefined) return undefined; + // The lookup resolves a relative path against the server's own folder, so it never sees one. + const project = path.isAbsolute(cwd) + ? yield* projects.getByWorkspaceRoot(cwd).pipe( + Effect.catchTags({ + // A folder that is gone or isn't a folder can't be a project's root. + ProjectOperationError: (error) => + error.operation === "normalize-workspace" + ? Effect.succeed(Option.none()) + : Effect.die(error), + }), + ) + : Option.none(); + if (Option.isNone(project)) { + return yield* new SkillRequestError({ reason: "projectNotRegistered" }); + } + return cwd; + }); /** A global folder as shown to the user: `~/...` under the home directory, else its path. */ const globalLabel = (directory: string) => { @@ -419,7 +444,7 @@ const make = Effect.gen(function* () { }); const list: SkillCatalog["Service"]["list"] = Effect.fn("SkillCatalog.list")(function* (input) { - const cwd = absoluteCwd(input.cwd); + const cwd = yield* requireProject(input.cwd); const displayRoots = yield* displayRootsOf(cwd); const instances = yield* loadInstances(cwd); const roots = rootsFor(cwd, instances); @@ -590,7 +615,7 @@ const make = Effect.gen(function* () { }); const get: SkillCatalog["Service"]["get"] = Effect.fn("SkillCatalog.get")(function* (input) { - const cwd = absoluteCwd(input.cwd); + const cwd = yield* requireProject(input.cwd); const base = input.scope === "project" ? cwd : homeDirectory; if (!base || !isSkillFolderName(input.name)) return NOT_FOUND; // Only the folders agents read are looked in, so the request can't name an arbitrary path. diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 09eb76c26ffb..82be36f4a8d5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -323,7 +323,13 @@ import { ServerSettingsError, ServerSettingsPatch, } from "./settings.ts"; -import { SkillGetInput, SkillGetResult, SkillListInput, SkillListResult } from "./skills.ts"; +import { + SkillGetInput, + SkillGetResult, + SkillListInput, + SkillListResult, + SkillRequestError, +} from "./skills.ts"; import { ScheduledTaskDeleteInput, ScheduledTaskDeleteResult, @@ -604,13 +610,13 @@ const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { const WsServerListSkillsRpc = Rpc.make(WS_METHODS.serverListSkills, { payload: SkillListInput, success: SkillListResult, - error: EnvironmentAuthorizationError, + error: Schema.Union([SkillRequestError, EnvironmentAuthorizationError]), }); const WsServerGetSkillRpc = Rpc.make(WS_METHODS.serverGetSkill, { payload: SkillGetInput, success: SkillGetResult, - error: EnvironmentAuthorizationError, + error: Schema.Union([SkillRequestError, EnvironmentAuthorizationError]), }); const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { diff --git a/packages/contracts/src/skills.ts b/packages/contracts/src/skills.ts index da517591d4f6..4ad96cca1eac 100644 --- a/packages/contracts/src/skills.ts +++ b/packages/contracts/src/skills.ts @@ -100,3 +100,18 @@ export const SkillGetResult = Schema.Struct({ filesTruncated: Schema.Boolean, }); export type SkillGetResult = typeof SkillGetResult.Type; + +/** + * A skill read that couldn't be carried out, as opposed to a folder with no skills: a project's + * folders are only read when the environment knows the folder as a project. + */ +export class SkillRequestError extends Schema.TaggedError()( + "SkillRequestError", + { + reason: Schema.Literals(["projectNotRegistered"]), + }, +) { + override get message(): string { + return "That folder isn't a project in this environment."; + } +} From 8079c1874547b296c1351ee93e6e1145b91cefa7 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:51:56 -0400 Subject: [PATCH 006/108] fix(server): show a skill that Claude's settings switch off as not used by Claude The list reported Claude as reaching a skill that Claude's own `skillOverrides` turn off, though the `$` picker already greys out the same skill. The list now reads the overrides the way the picker does (user, project, project-local, then managed policy) and shows such a skill as not used by Claude, the same as for any other copy Claude doesn't load. The override names the skill by folder name, so it applies to every copy of the name. Co-Authored-By: Claude Sonnet 5.5 --- .../src/provider/Drivers/ClaudeSkills.ts | 2 +- apps/server/src/skills/SkillCatalog.test.ts | 43 +++++++++++++++++++ apps/server/src/skills/SkillCatalog.ts | 34 ++++++++++++++- docs/user/skills.md | 3 +- packages/contracts/src/skills.ts | 5 ++- 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 0ac7f15798f3..dbbc9c4dae4e 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -246,7 +246,7 @@ function parseSkillOverride(value: typeof SkillOverrideValue.Type): SkillOverrid } } -const readSkillOverrides = Effect.fn("readSkillOverrides")(function* ( +export const readSkillOverrides = Effect.fn("readSkillOverrides")(function* ( configDirPath: string, cwd: string | undefined, environment: NodeJS.ProcessEnv, diff --git a/apps/server/src/skills/SkillCatalog.test.ts b/apps/server/src/skills/SkillCatalog.test.ts index 74fc030e3261..49c39bd15819 100644 --- a/apps/server/src/skills/SkillCatalog.test.ts +++ b/apps/server/src/skills/SkillCatalog.test.ts @@ -333,6 +333,49 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillCatalog", (it) }), ); + it.effect.skipIf(!symlinksSupported)( + "shows a skill that Claude's own settings switch off as one Claude doesn't use", + () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + // The user's file switches two skills off, by folder name, and keeps one reachable by + // the user only; the project's local file turns one of the two back on and switches off + // a project skill. + yield* write( + ".claude/settings.json", + JSON.stringify({ + skillOverrides: { + cloudflare: "off", + architect: "off", + "user-only": "user-invocable-only", + }, + }), + ); + yield* write( + "repos/app/.claude/settings.local.json", + JSON.stringify({ skillOverrides: { architect: "on", "own-copy": "off" } }), + ); + yield* write(".claude/skills/user-only/SKILL.md", skillFile("user-only", "By hand.")); + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project })); + const byName = byKey(skills); + + // Off: Claude doesn't use it, the others still do. + expect(accessOf(byName.get("global:cloudflare"))).toMatchObject({ + claudeAgent: { state: "none", folder: "~/.claude/skills" }, + cursor: { state: "direct", folder: "~/.claude/skills" }, + }); + // The project's later layer turns the user's "off" back on. + expect(states(byName.get("global:architect")).claudeAgent).toBe("link"); + expect(states(byName.get("project:own-copy")).claudeAgent).toBe("none"); + // The user can still invoke a skill that only the model is kept from. + expect(states(byName.get("global:user-only")).claudeAgent).toBe("direct"); + + // Without the project, only the user's layer applies. + const global = yield* withCatalog(home, (catalog) => catalog.list({})); + expect(states(byKey(global.skills).get("global:architect")).claudeAgent).toBe("none"); + }), + ); + it.effect.skipIf(!symlinksSupported)( "flags a name that exists more than once, and whether the copies are identical", () => diff --git a/apps/server/src/skills/SkillCatalog.ts b/apps/server/src/skills/SkillCatalog.ts index a5774e5152cc..97cc27b3d198 100644 --- a/apps/server/src/skills/SkillCatalog.ts +++ b/apps/server/src/skills/SkillCatalog.ts @@ -51,6 +51,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { parseSkillFrontmatter, + readSkillOverrides, resolveClaudeConfigDirPath, } from "../provider/Drivers/ClaudeSkills.ts"; import * as ProjectService from "../project/ProjectService.ts"; @@ -107,6 +108,11 @@ interface AgentInstance { readonly instanceId: ProviderInstanceId; readonly driver: ProviderDriverKind; readonly reads: readonly ReadRoot[]; + /** + * Skill folder names the agent's own settings switch off: Claude's `skillOverrides`. It lists + * such a skill as disabled and loads none of the copies. Empty for an agent without the setting. + */ + readonly switchedOff: ReadonlySet; } /** One folder entry that holds a skill: a real directory, or a link to one. */ @@ -330,6 +336,26 @@ const make = Effect.gen(function* () { return fallback; }); + /** + * The skills Claude's settings switch off, resolved the way the `$` picker resolves them: the + * user's, the project's and its local file, then the managed policy, last one naming a skill + * wins. + */ + const claudeSwitchedOff = Effect.fnUntraced(function* ( + instance: ProviderInstanceConfig, + configHome: string, + cwd: string | undefined, + ) { + const env = yield* mergeProviderInstanceEnvironment(instance.environment, environment).pipe( + Effect.provideService(HostProcess.HomeDirectory, homeDirectory), + ); + const overrides = yield* readSkillOverrides(configHome, cwd, env).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + return new Set([...overrides].flatMap(([name, override]) => (override.enabled ? [] : [name]))); + }); + /** The enabled provider instances whose folders T3 Code knows, in the table's order. */ const loadInstances = Effect.fnUntraced(function* (cwd: string | undefined) { const settings = yield* serverSettings.getSettings.pipe(Effect.option); @@ -365,6 +391,10 @@ const make = Effect.gen(function* () { instanceId: ProviderInstanceId.make(instanceId), driver: table.agent, reads, + switchedOff: + table.agent === "claudeAgent" + ? yield* claudeSwitchedOff(config, configHome, cwd) + : new Set(), }); } } @@ -499,7 +529,9 @@ const make = Effect.gen(function* () { // A first-wins agent loads only the first copy in its order; the others load every copy. const firstWins = skillCollisionFor(instance.driver) === "first-wins"; const loaded = - firstWins && found[0]?.owner !== group ? [] : found.filter((f) => f.owner === group); + instance.switchedOff.has(group.name) || (firstWins && found[0]?.owner !== group) + ? [] + : found.filter((f) => f.owner === group); // One copy can be reached through several of the agent's folders; the shared one is shown. const via = (loaded.find((f) => f.entry.root.standard) ?? loaded[0])?.entry; if (via) { diff --git a/docs/user/skills.md b/docs/user/skills.md index ea4284b9866a..6df379a48f2e 100644 --- a/docs/user/skills.md +++ b/docs/user/skills.md @@ -28,7 +28,8 @@ instead of showing it as empty. - an installed and enabled agent doesn't use it. Hover the icons to see which agent. An agent loads one skill per name, the first it finds in its folders (Codex and OpenCode list every - copy), so a copy that another folder shadows is not used by that agent. + copy), so a copy that another folder shadows is not used by that agent. Claude doesn't use a + skill that its own `skillOverrides` setting switches off either. - the same name exists more than once with different text, in **This project**, in **Global**, or across them. These rows have a **Conflict** badge. - Claude can't read the skill's header, the YAML between the `---` lines at the top of diff --git a/packages/contracts/src/skills.ts b/packages/contracts/src/skills.ts index 4ad96cca1eac..60325955b44c 100644 --- a/packages/contracts/src/skills.ts +++ b/packages/contracts/src/skills.ts @@ -14,8 +14,9 @@ export type SkillListInput = typeof SkillListInput.Type; /** * How one agent reaches a skill. `direct`: it reads a real folder holding the skill (its own * folder, or one shared with other agents). `link`: a link in a folder it reads points at the - * skill. `none`: it doesn't load this copy of the skill, because it can't see it or because - * another skill of the same name comes first in its folders. + * skill. `none`: it doesn't load this copy of the skill, because it can't see it, because + * another skill of the same name comes first in its folders, or because its own settings switch + * the skill off. */ export const SkillAgentState = Schema.Literals(["direct", "link", "none"]); export type SkillAgentState = typeof SkillAgentState.Type; From 417975a1fe307c3582a3239d9ff92336c504abe2 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:52:06 -0400 Subject: [PATCH 007/108] fix(web): don't show another environment's skills for an offline one When the settings scope's environment was offline, the Skills page fell back to the primary environment (or the first one) and showed its skills as the picked project's, and could send that checkout's folder to the wrong server. The page now stays on the scope's own environment and says it is offline; only a scope that names no environment falls back to the primary one. Co-Authored-By: Claude Sonnet 5.5 --- .../settings/SkillsSettings.logic.test.ts | 49 ++++++++++++++++++- .../settings/SkillsSettings.logic.ts | 26 ++++++++++ .../components/settings/SkillsSettings.tsx | 17 +++++-- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/SkillsSettings.logic.test.ts b/apps/web/src/components/settings/SkillsSettings.logic.test.ts index 3f28e3b63d05..b4dae3b8f911 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.test.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { EnvironmentId, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import type { ServerProvider, SkillAgentAccess, SkillListResult } from "@t3tools/contracts"; import { @@ -13,6 +13,7 @@ import { matchesQuery, scriptFiles, skillBody, + skillsEnvironment, unreadableNote, type Skill, type SkillAgent, @@ -88,6 +89,52 @@ describe("ingestSkills", () => { }); }); +describe("skillsEnvironment", () => { + const home = { environmentId: EnvironmentId.make("home") }; + const work = { environmentId: EnvironmentId.make("work") }; + const all = [home, work]; + + it("uses the scope's connected environment", () => { + expect( + skillsEnvironment({ + connected: work, + scopeEnvironmentIds: [work.environmentId], + environments: all, + primaryId: home.environmentId, + }), + ).toBe(work); + }); + + it("keeps an offline scoped environment instead of showing the primary one's skills", () => { + expect( + skillsEnvironment({ + connected: null, + scopeEnvironmentIds: [work.environmentId], + environments: all, + primaryId: home.environmentId, + }), + ).toBe(work); + }); + + it("has no environment when the scope names one that is gone", () => { + expect( + skillsEnvironment({ + connected: null, + scopeEnvironmentIds: [EnvironmentId.make("gone")], + environments: all, + primaryId: home.environmentId, + }), + ).toBeUndefined(); + }); + + it("falls back to the primary, then the first, when the scope names no environment", () => { + const fallback = { connected: null, scopeEnvironmentIds: [], environments: all }; + expect(skillsEnvironment({ ...fallback, primaryId: work.environmentId })).toBe(work); + expect(skillsEnvironment({ ...fallback, primaryId: null })).toBe(home); + expect(skillsEnvironment({ ...fallback, environments: [], primaryId: null })).toBeUndefined(); + }); +}); + describe("installedAgents", () => { const known = new Set( ["claudeAgent", "claude_work", "codex", "cursor", "pi", "opencode"].map((id) => diff --git a/apps/web/src/components/settings/SkillsSettings.logic.ts b/apps/web/src/components/settings/SkillsSettings.logic.ts index 1b4192a18441..a8bb30947c82 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.ts @@ -1,4 +1,5 @@ import type { + EnvironmentId, ProviderInstanceId, ServerProvider, SkillAgentAccess, @@ -30,6 +31,31 @@ export type SkillsContext = { readonly installed: readonly SkillAgent[]; }; +/** + * The environment the page reads skills from. The settings scope names it, connected or not: an + * offline environment is reported as offline, never swapped for another one, whose skills would + * be shown as the project's and which would be sent the project's folder. Only a scope that names + * no environment falls back to the primary one, then the first. + */ +export function skillsEnvironment(input: { + /** The scope's connected environment, when it has one. */ + readonly connected: T | null; + readonly scopeEnvironmentIds: readonly EnvironmentId[]; + readonly environments: readonly T[]; + readonly primaryId: EnvironmentId | null; +}): T | undefined { + if (input.connected) return input.connected; + if (input.scopeEnvironmentIds.length > 0) { + return input.environments.find((item) => + input.scopeEnvironmentIds.includes(item.environmentId), + ); + } + return ( + input.environments.find((item) => item.environmentId === input.primaryId) ?? + input.environments[0] + ); +} + export function ingestSkills(result: SkillListResult) { const skills = result.skills.map((entry): Skill => ({ ...entry, diff --git a/apps/web/src/components/settings/SkillsSettings.tsx b/apps/web/src/components/settings/SkillsSettings.tsx index 37c1c421adb0..4180c1845ebb 100644 --- a/apps/web/src/components/settings/SkillsSettings.tsx +++ b/apps/web/src/components/settings/SkillsSettings.tsx @@ -21,6 +21,7 @@ import { ingestSkills, installedAgents, matchesQuery, + skillsEnvironment, unreadableNote, type Skill, type SkillsContext, @@ -39,10 +40,12 @@ export function SkillsSettings() { const { environment: scopedEnvironment, scope } = useSettingsScope(); const { environments } = useEnvironments(); const primaryId = usePrimaryEnvironmentId(); - const environment = - scopedEnvironment ?? - environments.find((item) => item.environmentId === primaryId) ?? - environments[0]; + const environment = skillsEnvironment({ + connected: scopedEnvironment, + scopeEnvironmentIds: scope.environmentIds, + environments, + primaryId, + }); // The settings scope picker at the top of the page decides what this page shows. const project = scope.kind === "checkout" @@ -69,7 +72,11 @@ export function SkillsSettings() { {!environment ? ( -

    Connect an environment to see its skills.

    +

    + {scope.environmentIds.length > 0 + ? "This environment isn't available." + : "Connect an environment to see its skills."} +

    ) : missingProject ? (

    This project isn't on {environment.label}.

    ) : ( From bada1291484732db10f44501e4a280230aa99d03 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:52:13 -0400 Subject: [PATCH 008/108] fix(web): ignore a skills refresh that finishes after the page is gone The refresh button's request set state after its page was left, where the first load already drops a result that arrives late. Guard it the same way. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/settings/SkillsSettings.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/SkillsSettings.tsx b/apps/web/src/components/settings/SkillsSettings.tsx index 4180c1845ebb..31b27ab1e2c1 100644 --- a/apps/web/src/components/settings/SkillsSettings.tsx +++ b/apps/web/src/components/settings/SkillsSettings.tsx @@ -115,6 +115,13 @@ function EnvironmentSkills({ const [onlyAttention, setOnlyAttention] = useState(false); const [detailReload, setDetailReload] = useState(0); const rootRef = useRef(null); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); // A new view starts at the top of the page. const show = (next: View) => { setView(next); @@ -152,6 +159,7 @@ function EnvironmentSkills({ setLoadError(null); void load() .then((loaded) => { + if (!mounted.current) return; if (!loaded) { setLoadError(LOAD_ERROR); return; @@ -159,8 +167,12 @@ function EnvironmentSkills({ setData(loaded); show({ kind: "list" }); }) - .catch(() => setLoadError(LOAD_ERROR)) - .finally(() => setRefreshing(false)); + .catch(() => { + if (mounted.current) setLoadError(LOAD_ERROR); + }) + .finally(() => { + if (mounted.current) setRefreshing(false); + }); }; const skills = data?.skills ?? null; From 392047cb8fef1c73e1d5bf6b4df466db4e44dc3a Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:46:18 -0400 Subject: [PATCH 009/108] feat(server): turn skills on or off for each agent by linking A skill reaches an agent through a link in the agent's own folder. Add enable, disable and remove for that: a link is made with a bare create and removed only when it is still the link that was inspected, so a real folder or another skill's link is never replaced or deleted. Every write re-reads the folders, refuses a skill whose home moved since the list was read, and needs a registered project folder for project skills. Results are one outcome per skill, so one bad skill doesn't stop a bulk request. The three RPCs need the operate scope. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/auth/RpcAuthorization.test.ts | 10 + .../src/observability/RpcInstrumentation.ts | 3 + apps/server/src/server.ts | 4 + apps/server/src/skills/SkillCatalog.ts | 199 ++++- apps/server/src/skills/SkillLinks.test.ts | 288 +++++++ apps/server/src/skills/SkillLinks.ts | 208 +++++ apps/server/src/skills/SkillManager.test.ts | 794 ++++++++++++++++++ apps/server/src/skills/SkillManager.ts | 355 ++++++++ apps/server/src/ws.ts | 5 + .../contracts/src/clientRpcPermissions.ts | 4 + packages/contracts/src/rpc.ts | 28 + packages/contracts/src/skills.ts | 87 +- 12 files changed, 1947 insertions(+), 38 deletions(-) create mode 100644 apps/server/src/skills/SkillLinks.test.ts create mode 100644 apps/server/src/skills/SkillLinks.ts create mode 100644 apps/server/src/skills/SkillManager.test.ts create mode 100644 apps/server/src/skills/SkillManager.ts diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 55d84e8013a6..4dc80c3a0bb0 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -66,6 +66,16 @@ describe("RPC authorization scopes", () => { } }); + it("doesn't let a read-only client change which agents use skills", () => { + for (const method of [ + WS_METHODS.serverEnableSkills, + WS_METHODS.serverDisableSkills, + WS_METHODS.serverRemoveSkills, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationOperateScope); + } + }); + it("allows relay status reads without granting relay installation access", () => { expect(requiredScopeForRpcMethod(WS_METHODS.cloudGetRelayClientStatus)).toBe( AuthRelayReadScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index cb7236d4e7d1..fed413eec7ff 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -35,6 +35,9 @@ const RPC_AGGREGATES = { [WS_METHODS.serverRefreshProviders]: "server", [WS_METHODS.serverListSkills]: "server", [WS_METHODS.serverGetSkill]: "server", + [WS_METHODS.serverEnableSkills]: "server", + [WS_METHODS.serverDisableSkills]: "server", + [WS_METHODS.serverRemoveSkills]: "server", [WS_METHODS.serverUpdateProvider]: "server", [WS_METHODS.providerAuthStart]: "provider", [WS_METHODS.providerConsumeResetCredit]: "provider", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 88984b1eeffa..8a58701a9e38 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -88,6 +88,7 @@ import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as SkillCatalog from "./skills/SkillCatalog.ts"; +import * as SkillManager from "./skills/SkillManager.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; @@ -575,6 +576,9 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ProviderUsageLimitsIngestion.layer, layerProviderInstallationRefresh, ReplayMarkers.layer, + // It reads through SkillCatalog and checks folders against ProjectService, both provided + // below; being here makes it one instance, so skill writes run one request at a time. + SkillManager.layer, ).pipe( // Core Services // It checks a project's folder against ProjectService, which the next layer provides. diff --git a/apps/server/src/skills/SkillCatalog.ts b/apps/server/src/skills/SkillCatalog.ts index 97cc27b3d198..d8e3acb5beaa 100644 --- a/apps/server/src/skills/SkillCatalog.ts +++ b/apps/server/src/skills/SkillCatalog.ts @@ -18,6 +18,7 @@ import { type ProviderDriverKind, type ProviderInstanceConfig, type SkillAgentAccess, + type SkillAgentState, type SkillCopy, type SkillFile, type SkillFolderProblem, @@ -45,6 +46,7 @@ import { skillCollisionFor, skillRootsFor, type AgentSkillFolderList, + type SkillCollision, } from "@t3tools/provider-core/server/AgentSkillFolders"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; @@ -119,7 +121,8 @@ interface AgentInstance { interface FolderEntry { readonly root: ReadRoot; readonly name: string; - readonly link: boolean; + /** What the link points at, as written; undefined for a real directory. */ + readonly target: string | undefined; /** Absolute path after following links. */ readonly home: string; } @@ -139,6 +142,44 @@ interface SkillGroup { readonly header: SkillHeader; } +/** + * One skill as the folders hold it, with what it takes to change who reads it. `list` shows the + * same facts as a summary. + */ +export interface ResolvedSkill { + readonly scope: SkillScope; + readonly name: string; + /** The same display path as `SkillSummary.home`. */ + readonly displayHome: string; + /** Absolute path of the skill's folder, after following links. */ + readonly home: string; + /** Every entry in the agents' folders that reaches the skill: a real folder, or a link. */ + readonly entries: ReadonlyArray<{ + readonly path: string; + /** The folder the entry is in. */ + readonly directory: string; + /** What the link points at, as written; undefined for a real folder. */ + readonly target: string | undefined; + }>; + readonly agents: ReadonlyArray<{ + readonly instanceId: ProviderInstanceId; + readonly driver: ProviderDriverKind; + readonly collision: SkillCollision; + readonly state: SkillAgentState; + /** Paths of the entries it loads the skill from; empty when `state` is `none`. */ + readonly via: readonly string[]; + /** The folders it reads, in the order it looks, across both scopes. */ + readonly reads: ReadonlyArray<{ + readonly scope: SkillScope; + readonly directory: string; + readonly label: string; + readonly standard: boolean; + /** The agent would load a different skill with this name from here. */ + readonly rival: boolean; + }>; + }>; +} + const NOT_FOUND: SkillGetResult = { home: null, description: "", @@ -157,6 +198,14 @@ export class SkillCatalog extends Context.Service< readonly list: (input: SkillListInput) => Effect.Effect; /** The full SKILL.md text and the file list of one skill from `list`. */ readonly get: (input: SkillGetInput) => Effect.Effect; + /** + * Every skill in the agents' folders with this scope and name, as the folders hold it now. + * A project skill needs `cwd`. Nothing is written. + */ + readonly resolve: (input: { + readonly cwd?: string | undefined; + readonly skills: ReadonlyArray<{ readonly scope: SkillScope; readonly name: string }>; + }) => Effect.Effect>; } >()("t3/skills/SkillCatalog") {} @@ -214,15 +263,18 @@ const make = Effect.gen(function* () { if (info?.type !== "Directory") return undefined; const home = yield* fileSystem.realPath(entryPath).pipe(Effect.orElseSucceed(() => undefined)); if (home === undefined) return undefined; - const link = yield* fileSystem.readLink(entryPath).pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), + const target = yield* fileSystem.readLink(entryPath).pipe( + Effect.map((value): string | undefined => value), + Effect.orElseSucceed(() => undefined), ); - return { root, name, link, home } satisfies FolderEntry; + return { root, name, target, home } satisfies FolderEntry; }); - /** The skill folders in a root. A root that is missing is empty; one that can't be read says so. */ - const scanRoot = Effect.fnUntraced(function* (root: ReadRoot) { + /** + * The skill folders in a root, or only those named in `only`. A root that is missing is empty; + * one that can't be read says so. + */ + const scanRoot = Effect.fnUntraced(function* (root: ReadRoot, only?: ReadonlySet) { const listed = yield* fileSystem.readDirectory(root.directory).pipe( Effect.map((names) => ({ names, unreadable: false })), Effect.catchTags({ @@ -231,7 +283,10 @@ const make = Effect.gen(function* () { }), ); const entries = yield* Effect.forEach( - listed.names.filter(isSkillFolderName).toSorted().slice(0, MAX_FOLDER_ENTRIES), + listed.names + .filter((name) => isSkillFolderName(name) && (only === undefined || only.has(name))) + .toSorted() + .slice(0, MAX_FOLDER_ENTRIES), (name) => entryAt(root, name), { concurrency: CONCURRENCY }, ); @@ -297,6 +352,10 @@ const make = Effect.gen(function* () { return cwd; }); + /** A project's folder as `resolve` takes it: a relative one names no project. */ + const absoluteCwd = (cwd: string | undefined) => + cwd !== undefined && path.isAbsolute(cwd) ? cwd : undefined; + /** A global folder as shown to the user: `~/...` under the home directory, else its path. */ const globalLabel = (directory: string) => { const relative = path.relative(homeDirectory, directory); @@ -473,12 +532,20 @@ const make = Effect.gen(function* () { return result; }); - const list: SkillCatalog["Service"]["list"] = Effect.fn("SkillCatalog.list")(function* (input) { - const cwd = yield* requireProject(input.cwd); + /** + * What the agents' folders hold, grouped by what each folder really holds, and how each + * instance reaches every group. `only` narrows the scan to skills with those names. + */ + const scanSkills = Effect.fnUntraced(function* ( + cwd: string | undefined, + only?: ReadonlySet, + ) { const displayRoots = yield* displayRootsOf(cwd); const instances = yield* loadInstances(cwd); const roots = rootsFor(cwd, instances); - const scanned = yield* Effect.forEach(roots, scanRoot, { concurrency: CONCURRENCY }); + const scanned = yield* Effect.forEach(roots, (root) => scanRoot(root, only), { + concurrency: CONCURRENCY, + }); // Group by what is really on disk: the same folder reached through several links is one skill. const grouped = new Map>(); @@ -515,16 +582,21 @@ const make = Effect.gen(function* () { ({ root, entries }) => [rootKey(root), new Map(entries.map((e) => [e.name, e]))] as const, ), ); - const copies = yield* compareCopies(groups, displayRoots); + + /** What an instance would load from one folder for this name, if anything. */ + const loadableAt = (group: SkillGroup, instance: AgentInstance, root: ReadRoot) => { + const entry = entryAtRoot.get(rootKey(root))?.get(group.name); + const owner = entry && groupOf.get(entry); + // Claude skips a skill whose header it can't read, and it doesn't shadow a later one. + const skipped = instance.driver === "claudeAgent" && owner?.header.invalid === true; + return entry && owner && !skipped ? { entry, owner } : undefined; + }; /** How one instance reaches a skill: through the folders it loads it from, else `none`. */ - const accessFor = (group: SkillGroup, instance: AgentInstance): SkillAgentAccess => { + const accessFor = (group: SkillGroup, instance: AgentInstance) => { const found = instance.reads.flatMap((root) => { - const entry = entryAtRoot.get(rootKey(root))?.get(group.name); - const owner = entry && groupOf.get(entry); - // Claude skips a skill whose header it can't read, and it doesn't shadow a later one. - const skipped = instance.driver === "claudeAgent" && owner?.header.invalid === true; - return entry && owner && !skipped ? [{ entry, owner }] : []; + const loadable = loadableAt(group, instance, root); + return loadable ? [loadable] : []; }); // A first-wins agent loads only the first copy in its order; the others load every copy. const firstWins = skillCollisionFor(instance.driver) === "first-wins"; @@ -534,27 +606,42 @@ const make = Effect.gen(function* () { : found.filter((f) => f.owner === group); // One copy can be reached through several of the agent's folders; the shared one is shown. const via = (loaded.find((f) => f.entry.root.standard) ?? loaded[0])?.entry; + const loadedEntries = loaded.map((f) => f.entry); if (via) { return { - instanceId: instance.instanceId, - driver: instance.driver, - state: via.root.standard || !via.link ? "direct" : "link", - folder: via.root.label, + loadedEntries, + access: { + instanceId: instance.instanceId, + driver: instance.driver, + state: via.root.standard || via.target === undefined ? "direct" : "link", + folder: via.root.label, + } satisfies SkillAgentAccess, }; } const looksIn = instance.reads.find((root) => root.scope === group.scope); return { - instanceId: instance.instanceId, - driver: instance.driver, - state: "none", - folder: - looksIn?.label ?? - (group.scope === "global" - ? globalLabel(path.join(homeDirectory, STANDARD_SKILL_FOLDER)) - : STANDARD_SKILL_FOLDER), + loadedEntries, + access: { + instanceId: instance.instanceId, + driver: instance.driver, + state: "none", + folder: + looksIn?.label ?? + (group.scope === "global" + ? globalLabel(path.join(homeDirectory, STANDARD_SKILL_FOLDER)) + : STANDARD_SKILL_FOLDER), + } satisfies SkillAgentAccess, }; }; + return { displayRoots, instances, scanned, groups, accessFor, loadableAt }; + }); + + const list: SkillCatalog["Service"]["list"] = Effect.fn("SkillCatalog.list")(function* (input) { + const cwd = yield* requireProject(input.cwd); + const { displayRoots, instances, scanned, groups, accessFor } = yield* scanSkills(cwd); + const copies = yield* compareCopies(groups, displayRoots); + const skills = groups.map((group): SkillSummary => ({ name: group.name, scope: group.scope, @@ -562,7 +649,7 @@ const make = Effect.gen(function* () { description: capDescription(group.header.description), ...(group.header.invalid ? { invalidHeader: true } : {}), copies: copies.get(group) ?? [], - access: instances.map((instance) => accessFor(group, instance)), + access: instances.map((instance) => accessFor(group, instance).access), })); const unreadable = new Map(); @@ -581,6 +668,54 @@ const make = Effect.gen(function* () { }; }); + const resolve: SkillCatalog["Service"]["resolve"] = Effect.fn("SkillCatalog.resolve")( + function* (input) { + const cwd = absoluteCwd(input.cwd); + const wanted = input.skills.filter( + (skill) => isSkillFolderName(skill.name) && (skill.scope === "global" || cwd !== undefined), + ); + if (wanted.length === 0) return []; + const { displayRoots, instances, groups, accessFor, loadableAt } = yield* scanSkills( + cwd, + new Set(wanted.map((skill) => skill.name)), + ); + const wantedKeys = new Set(wanted.map((skill) => `${skill.scope}\0${skill.name}`)); + return groups + .filter((group) => wantedKeys.has(`${group.scope}\0${group.name}`)) + .map((group): ResolvedSkill => ({ + scope: group.scope, + name: group.name, + displayHome: displayPath(group.home, displayRoots), + home: group.home, + entries: group.entries.map((entry) => ({ + path: path.join(entry.root.directory, entry.name), + directory: entry.root.directory, + target: entry.target, + })), + agents: instances.map((instance) => { + const { access, loadedEntries } = accessFor(group, instance); + return { + instanceId: instance.instanceId, + driver: instance.driver, + collision: skillCollisionFor(instance.driver), + state: access.state, + via: loadedEntries.map((entry) => path.join(entry.root.directory, entry.name)), + reads: instance.reads.map((root) => { + const loadable = loadableAt(group, instance, root); + return { + scope: root.scope, + directory: root.directory, + label: root.label, + standard: root.standard, + rival: loadable !== undefined && loadable.owner !== group, + }; + }), + }; + }), + })); + }, + ); + /** * Relative paths and sizes of the files under a skill's folder, breadth first. It stops at the * file limit, and bounds the folders it enters and the entries it looks at in each, so a skill @@ -674,7 +809,7 @@ const make = Effect.gen(function* () { }; }); - return SkillCatalog.of({ list, get }); + return SkillCatalog.of({ list, get, resolve }); }); export const layer = Layer.effect(SkillCatalog, make); diff --git a/apps/server/src/skills/SkillLinks.test.ts b/apps/server/src/skills/SkillLinks.test.ts new file mode 100644 index 000000000000..31aa11377718 --- /dev/null +++ b/apps/server/src/skills/SkillLinks.test.ts @@ -0,0 +1,288 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { createLink, linkSpec, removeLink, SkillLinkError } from "./SkillLinks.ts"; + +/** A temp folder holding one project and one library folder, with their paths made real. */ +const makeFolders = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "t3code-links-" })); + const project = path.join(root, "app"); + const library = path.join(root, "library"); + const skill = (folder: string, name: string) => + Effect.gen(function* () { + const home = path.join(folder, name); + yield* fs.makeDirectory(home, { recursive: true }); + yield* fs.writeFileString(path.join(home, "SKILL.md"), `---\nname: ${name}\n---\n`); + yield* fs.writeFileString(path.join(home, "notes.txt"), "keep me"); + return home; + }); + return { fs, path, root, project, library, skill }; +}); + +describe("linkSpec", () => { + const home = "/data/skills/review"; + + it.each([ + { + name: "a project link on any system is relative when the skill is in the project", + input: { platform: "linux", scope: "project", home, relative: "../../.agents/skills/review" }, + expected: { type: "dir", target: "../../.agents/skills/review" }, + }, + { + name: "a project link is absolute when the skill is outside the project", + input: { platform: "linux", scope: "project", home, relative: undefined }, + expected: { type: "dir", target: home }, + }, + { + name: "a global link is absolute", + input: { platform: "darwin", scope: "global", home, relative: undefined }, + expected: { type: "dir", target: home }, + }, + { + name: "a global link on Windows is a junction, which needs no privilege and an absolute path", + input: { platform: "win32", scope: "global", home, relative: undefined }, + expected: { type: "junction", target: home }, + }, + { + name: "a project link on Windows stays a relative symlink, since a junction can't be committed", + input: { platform: "win32", scope: "project", home, relative: "../review" }, + expected: { type: "dir", target: "../review" }, + }, + ] as const)("$name", ({ input, expected }) => { + expect(linkSpec(input)).toEqual(expected); + }); +}); + +it.layer(NodeServices.layer, { excludeTestServices: true })("SkillLinks", (it) => { + describe("createLink", () => { + it.effect.skipIf(!symlinksSupported)( + "makes a project link relative, creating the folder it goes in", + () => + Effect.gen(function* () { + const { fs, path, project, skill } = yield* makeFolders; + const home = yield* skill(path.join(project, ".agents/skills"), "review"); + const link = path.join(project, ".claude/skills/review"); + + const result = yield* createLink({ + link, + home, + scope: "project", + platform: "linux", + projectRoot: project, + }); + + expect(result).toBe("created"); + expect(yield* fs.readLink(link)).toBe("../../.agents/skills/review"); + expect(yield* fs.realPath(link)).toBe(home); + }), + ); + + it.effect.skipIf(!symlinksSupported)("makes a global link absolute", () => + Effect.gen(function* () { + const { fs, path, root, library, skill } = yield* makeFolders; + const home = yield* skill(library, "review"); + const link = path.join(root, "home/.claude/skills/review"); + + expect(yield* createLink({ link, home, scope: "global", platform: "linux" })).toBe( + "created", + ); + expect(yield* fs.readLink(link)).toBe(home); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "makes a global link on Windows the way it would there: absolute, to the same folder", + () => + Effect.gen(function* () { + const { fs, path, root, library, skill } = yield* makeFolders; + const home = yield* skill(library, "review"); + const link = path.join(root, "home/.claude/skills/review"); + + // Node ignores the `junction` type away from Windows, so this makes a plain symlink. + expect(yield* createLink({ link, home, scope: "global", platform: "win32" })).toBe( + "created", + ); + expect(yield* fs.readLink(link)).toBe(home); + expect(yield* fs.realPath(link)).toBe(home); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "keeps a project link working when the project folder is moved", + () => + Effect.gen(function* () { + const { fs, path, root, project, skill } = yield* makeFolders; + const home = yield* skill(path.join(project, ".agents/skills"), "review"); + yield* createLink({ + link: path.join(project, ".claude/skills/review"), + home, + scope: "project", + platform: "linux", + projectRoot: project, + }); + + const moved = path.join(root, "app-renamed"); + yield* fs.rename(project, moved); + + expect(yield* fs.realPath(path.join(moved, ".claude/skills/review"))).toBe( + path.join(moved, ".agents/skills/review"), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "reads a relative target from where the link's folder really is", + () => + Effect.gen(function* () { + const { fs, path, root, project, skill } = yield* makeFolders; + const home = yield* skill(path.join(project, ".agents/skills"), "review"); + // `.claude` is a link to a dotfiles folder elsewhere, so `../..` means something else there. + const dotfiles = path.join(root, "dotfiles/claude"); + yield* fs.makeDirectory(path.join(dotfiles, "skills"), { recursive: true }); + yield* fs.symlink(dotfiles, path.join(project, ".claude")); + const link = path.join(project, ".claude/skills/review"); + + expect( + yield* createLink({ + link, + home, + scope: "project", + platform: "linux", + projectRoot: project, + }), + ).toBe("created"); + expect(yield* fs.realPath(link)).toBe(home); + }), + ); + + it.effect.skipIf(!symlinksSupported)("leaves a real folder alone and says it is taken", () => + Effect.gen(function* () { + const { fs, path, root, library, skill } = yield* makeFolders; + const home = yield* skill(library, "review"); + const link = path.join(root, "home/.claude/skills/review"); + yield* fs.makeDirectory(link, { recursive: true }); + yield* fs.writeFileString(path.join(link, "mine.md"), "my own notes"); + + expect(yield* createLink({ link, home, scope: "global", platform: "linux" })).toBe("taken"); + + expect(yield* fs.readLink(link).pipe(Effect.flip)).toBeDefined(); + expect(yield* fs.readFileString(path.join(link, "mine.md"))).toBe("my own notes"); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "leaves a link to another folder alone and says it is taken", + () => + Effect.gen(function* () { + const { fs, path, root, library, skill } = yield* makeFolders; + const home = yield* skill(library, "review"); + const other = yield* skill(library, "review-copy"); + const link = path.join(root, "home/.claude/skills/review"); + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* fs.symlink(other, link); + + expect(yield* createLink({ link, home, scope: "global", platform: "linux" })).toBe( + "taken", + ); + expect(yield* fs.readLink(link)).toBe(other); + }), + ); + + it.effect.skipIf(!symlinksSupported)("says a link that is already there is unchanged", () => + Effect.gen(function* () { + const { path, root, library, skill } = yield* makeFolders; + const home = yield* skill(library, "review"); + const link = path.join(root, "home/.claude/skills/review"); + + const input = { link, home, scope: "global", platform: "linux" } as const; + expect(yield* createLink(input)).toBe("created"); + expect(yield* createLink(input)).toBe("unchanged"); + }), + ); + }); + + describe("removeLink", () => { + it.effect.skipIf(!symlinksSupported)( + "removes the link and leaves the folder it pointed at", + () => + Effect.gen(function* () { + const { fs, path, root, library, skill } = yield* makeFolders; + const home = yield* skill(library, "review"); + const link = path.join(root, "home/.claude/skills/review"); + yield* createLink({ link, home, scope: "global", platform: "linux" }); + + expect(yield* removeLink({ path: link, expectedTarget: home })).toBe("removed"); + + expect(yield* fs.exists(link)).toBe(false); + expect(yield* fs.readFileString(path.join(home, "notes.txt"))).toBe("keep me"); + }), + ); + + it.effect.skipIf(!symlinksSupported)("says nothing to remove when the link is gone", () => + Effect.gen(function* () { + const { path, root } = yield* makeFolders; + expect( + yield* removeLink({ path: path.join(root, "nothing-here"), expectedTarget: "/x" }), + ).toBe("gone"); + }), + ); + + it.effect.skipIf(!symlinksSupported)("leaves a real folder alone, whatever it holds", () => + Effect.gen(function* () { + const { fs, path, library, skill } = yield* makeFolders; + const home = yield* skill(library, "review"); + + expect(yield* removeLink({ path: home, expectedTarget: home })).toBe("changed"); + + expect(yield* fs.readFileString(path.join(home, "notes.txt"))).toBe("keep me"); + }), + ); + + it.effect.skipIf(!symlinksSupported)("leaves a link that points somewhere else", () => + Effect.gen(function* () { + const { fs, path, root, library, skill } = yield* makeFolders; + const home = yield* skill(library, "review"); + const other = yield* skill(library, "review-copy"); + const link = path.join(root, "home/.claude/skills/review"); + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* fs.symlink(other, link); + + // The link was inspected when it pointed at `home`; it has been repointed since. + expect(yield* removeLink({ path: link, expectedTarget: home })).toBe("changed"); + + expect(yield* fs.readLink(link)).toBe(other); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "fails instead of deleting when a folder takes the link's place right after the check", + () => + Effect.gen(function* () { + const { fs, path, library, skill } = yield* makeFolders; + const home = yield* skill(library, "review"); + // The check sees the link it expected; by the time of the remove it is a folder. + const swapped = FileSystem.FileSystem.of({ + ...fs, + readLink: (target) => + target === home ? Effect.succeed("expected-target") : fs.readLink(target), + }); + + const error = yield* removeLink({ path: home, expectedTarget: "expected-target" }).pipe( + Effect.provideService(FileSystem.FileSystem, swapped), + Effect.flip, + ); + + expect(error).toBeInstanceOf(SkillLinkError); + expect(error.operation).toBe("remove"); + expect(yield* fs.readFileString(path.join(home, "notes.txt"))).toBe("keep me"); + expect(yield* fs.exists(path.join(home, "SKILL.md"))).toBe(true); + }), + ); + }); +}); diff --git a/apps/server/src/skills/SkillLinks.ts b/apps/server/src/skills/SkillLinks.ts new file mode 100644 index 000000000000..f3cae66ce571 --- /dev/null +++ b/apps/server/src/skills/SkillLinks.ts @@ -0,0 +1,208 @@ +/** + * SkillLinks - the two filesystem writes that give an agent a skill: making a link in the agent's + * own folder, and removing one. + * + * Both are built so the operating system, not an earlier check, is the last guard: + * - A link is made with a bare create. Something already at the path makes it fail; it is never + * removed first, so a real folder or another skill's link can't be replaced. + * - A link is removed only after it is read again and found to be the one that was inspected, and + * with a non-recursive remove. If a folder took its place in between, the remove fails. + * + * @module SkillLinks + */ +// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation. +import * as NodeFSP from "node:fs/promises"; + +import type { SkillScope } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +export class SkillLinkError extends Schema.TaggedError()("SkillLinkError", { + operation: Schema.Literals([ + "makeDirectory", + "realPath", + "symlink", + "verify", + "readLink", + "remove", + ]), + path: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `Skill link operation '${this.operation}' failed.`; + } +} + +/** + * The kind of link to make and what it points at. + * + * A project's links are meant to be committed, so they point at a path relative to the link's own + * folder and survive a clone or a move. A global link points at an absolute path: nobody clones + * `~/.claude`, and the skill often lives outside the home folder. Windows can't make a symlink + * without Developer Mode or elevation, but a junction needs neither, so a global link there is one; + * a junction can't be committed as a link, so a project's stays a symlink and is refused without + * the privilege. + */ +export const linkSpec = (input: { + readonly platform: NodeJS.Platform; + readonly scope: SkillScope; + readonly home: string; + /** From the link's real folder to the home, when the home is inside the project. */ + readonly relative: string | undefined; +}) => { + const junction = input.platform === "win32" && input.scope === "global"; + return { + type: junction ? "junction" : "dir", + target: !junction && input.scope === "project" ? (input.relative ?? input.home) : input.home, + } as const; +}; + +export type CreateLinkResult = + /** The link was made. */ + | "created" + /** What is there already is the skill's folder. */ + | "unchanged" + /** Something else is there. It was left alone. */ + | "taken" + /** The system doesn't allow links here. */ + | "notAllowed"; + +const NOT_ALLOWED_CODES = new Set(["EPERM", "EACCES", "EROFS"]); + +const errorCode = (error: unknown) => + typeof error === "object" && error !== null && "code" in error ? error.code : undefined; + +/** What a path is, as far as links go. `stat` follows links, so only `readLink` can tell. */ +const readLinkTarget = Effect.fnUntraced(function* (link: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.readLink(link).pipe( + Effect.map((target): { readonly _tag: "Link"; readonly target: string } => ({ + _tag: "Link", + target, + })), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" + ? Effect.succeed({ _tag: "Missing" } as const) + : isNotLinkError(error) + ? Effect.succeed({ _tag: "NotLink" } as const) + : Effect.fail(new SkillLinkError({ operation: "readLink", path: link, cause: error })), + }), + ); +}); + +/** Reading a path that isn't a link fails with EINVAL; this is how CodexHomeLayout tells too. */ +function isNotLinkError(error: PlatformError.PlatformError) { + return error.reason._tag === "Unknown" && errorCode(error.reason.cause) === "EINVAL"; +} + +const isInside = (path: Path.Path, folder: string, inner: string) => { + const relative = path.relative(folder, inner); + return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative); +}; + +export type RemoveLinkResult = + /** The link was removed. */ + | "removed" + /** Nothing was there. */ + | "gone" + /** What is there isn't the link that was inspected: a folder, a file or a link to elsewhere. */ + | "changed"; + +/** + * Removes `path` only if it is still a link with the target it was inspected with. The remove is + * not recursive, so a folder that took the link's place makes it fail instead of being deleted. + */ +export const removeLink = Effect.fn("SkillLinks.removeLink")(function* (input: { + readonly path: string; + readonly expectedTarget: string; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const state = yield* readLinkTarget(input.path); + if (state._tag === "Missing") return "gone" as const satisfies RemoveLinkResult; + if (state._tag === "NotLink" || state.target !== input.expectedTarget) { + return "changed" as const satisfies RemoveLinkResult; + } + yield* fileSystem + .remove(input.path) + .pipe( + Effect.mapError( + (cause) => new SkillLinkError({ operation: "remove", path: input.path, cause }), + ), + ); + return "removed" as const satisfies RemoveLinkResult; +}); + +/** + * Makes `link` point at `home`. Nothing is replaced: an existing entry fails the create, and it + * counts as `unchanged` only when it already is the skill's folder. + */ +export const createLink = Effect.fn("SkillLinks.createLink")(function* (input: { + readonly link: string; + /** Absolute and real: the skill's folder after following links. */ + readonly home: string; + readonly scope: SkillScope; + readonly platform: NodeJS.Platform; + /** The project's real folder, when a link may be written relative to it. */ + readonly projectRoot?: string | undefined; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = path.dirname(input.link); + yield* fileSystem + .makeDirectory(parent, { recursive: true }) + .pipe( + Effect.mapError( + (cause) => new SkillLinkError({ operation: "makeDirectory", path: parent, cause }), + ), + ); + // A relative target is read from where the link's folder really is, not from the path it was reached by. + const realParent = yield* fileSystem + .realPath(parent) + .pipe( + Effect.mapError( + (cause) => new SkillLinkError({ operation: "realPath", path: parent, cause }), + ), + ); + const spec = linkSpec({ + platform: input.platform, + scope: input.scope, + home: input.home, + relative: + input.projectRoot !== undefined && isInside(path, input.projectRoot, input.home) + ? path.relative(realParent, input.home) + : undefined, + }); + + const outcome = yield* Effect.tryPromise({ + try: () => NodeFSP.symlink(spec.target, input.link, spec.type), + catch: (cause) => new SkillLinkError({ operation: "symlink", path: input.link, cause }), + }).pipe( + Effect.as("created" as CreateLinkResult), + Effect.catchTags({ + SkillLinkError: (error) => { + const code = errorCode(error.cause); + if (code === "EEXIST") { + return fileSystem.realPath(input.link).pipe( + Effect.map((real): CreateLinkResult => (real === input.home ? "unchanged" : "taken")), + Effect.orElseSucceed((): CreateLinkResult => "taken"), + ); + } + return typeof code === "string" && NOT_ALLOWED_CODES.has(code) + ? Effect.succeed("notAllowed" as CreateLinkResult) + : Effect.fail(error); + }, + }), + ); + if (outcome !== "created") return outcome; + + // The link has to lead where it was meant to; if it doesn't, take back what was just made. + const real = yield* fileSystem.realPath(input.link).pipe(Effect.orElseSucceed(() => undefined)); + if (real === input.home) return outcome; + yield* removeLink({ path: input.link, expectedTarget: spec.target }).pipe(Effect.ignore); + return yield* new SkillLinkError({ operation: "verify", path: input.link }); +}); diff --git a/apps/server/src/skills/SkillManager.test.ts b/apps/server/src/skills/SkillManager.test.ts new file mode 100644 index 000000000000..38f0d7a3a54c --- /dev/null +++ b/apps/server/src/skills/SkillManager.test.ts @@ -0,0 +1,794 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + SkillBatchResult, + SkillDisableInput, + SkillEnableInput, + SkillRemoveInput, + SkillRequestError, + type Project, + type SkillRef, + type SkillScope, + type SkillSummary, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as Settings from "../serverSettings.ts"; +import * as SkillCatalog from "./SkillCatalog.ts"; +import * as SkillManager from "./SkillManager.ts"; +import { planEnable } from "./SkillManager.ts"; + +const encodeResult = Schema.encodeUnknownEffect(SkillBatchResult); +const agent = ProviderInstanceId.make; +const ALL_AGENTS = ["claudeAgent", "codex", "cursor", "grok", "opencode", "antigravity", "pi"].map( + (id) => agent(id), +); + +const skillFile = (name: string) => `---\nname: ${name}\ndescription: The ${name} skill.\n---\n`; + +/** A made-up machine: a synced library linked into the shared folder, and a project in a repo. */ +const makeMachine = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "t3code-manager-" })); + const project = path.join(home, "repos/app"); + const write = (relative: string, contents: string) => + Effect.gen(function* () { + const target = path.join(home, relative); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, contents); + }); + const link = (target: string, from: string) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.dirname(path.join(home, from)), { recursive: true }); + yield* fs.symlink(path.join(home, target), path.join(home, from)); + }); + for (const name of ["alpha", "beta"]) { + yield* write(`library/skills/${name}/SKILL.md`, skillFile(name)); + yield* write(`library/skills/${name}/notes.md`, `notes on ${name}`); + } + yield* link("library/skills/alpha", ".agents/skills/alpha"); + yield* write(".claude/skills/solo/SKILL.md", skillFile("solo")); + yield* write("repos/app/.agents/skills/verify/SKILL.md", skillFile("verify")); + yield* write("repos/app/.agents/skills/verify/run.sh", "echo ok"); + return { fs, path, home, project, write, link }; +}); + +const makeProject = (workspaceRoot: string): Project => ({ + id: ProjectId.make("project-skill-manager"), + title: "App", + workspaceRoot, + repositoryIdentity: null, + faviconPath: null, + projectIcon: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}); + +/** The manager and catalog on a machine whose home is `home`; only `registered` folders are projects. */ +const withManager = ( + home: string, + registered: readonly string[], + use: (services: { + readonly manager: SkillManager.SkillManager["Service"]; + readonly catalog: SkillCatalog.SkillCatalog["Service"]; + }) => Effect.Effect, +) => + Effect.gen(function* () { + return yield* use({ + manager: yield* SkillManager.SkillManager, + catalog: yield* SkillCatalog.SkillCatalog, + }); + }).pipe( + Effect.provide( + SkillManager.layer.pipe( + Layer.provideMerge( + SkillCatalog.layer.pipe( + Layer.provide( + Settings.layerTest({ + providerInstances: Object.fromEntries( + ["cursor", "grok", "opencode", "antigravity", "pi"].map((driver) => [ + ProviderInstanceId.make(driver), + { driver: ProviderDriverKind.make(driver), enabled: true }, + ]), + ), + }), + ), + ), + ), + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (root) => + Effect.succeed( + registered.includes(root) ? Option.some(makeProject(root)) : Option.none(), + ), + }), + ), + ), + ), + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.HomeDirectory, home), + ); + +const refOf = (skills: readonly SkillSummary[], scope: SkillScope, name: string): SkillRef => { + const skill = skills.find((item) => item.scope === scope && item.name === name); + if (!skill) throw new Error(`No ${scope} skill ${name} in the list`); + return { scope, name, home: skill.home }; +}; + +const stateOf = (skills: readonly SkillSummary[], scope: SkillScope, name: string) => + Object.fromEntries( + (skills.find((item) => item.scope === scope && item.name === name)?.access ?? []).map( + (entry) => [entry.instanceId, entry.state], + ), + ); + +it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) => { + describe("enable", () => { + it.effect.skipIf(!symlinksSupported)( + "gives one agent a global skill through an absolute link in its own folder", + () => + Effect.gen(function* () { + const { fs, path, home } = yield* makeMachine; + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + expect(stateOf(skills, "global", "alpha").claudeAgent).toBe("none"); + + const result = yield* manager.enable({ + skills: [refOf(skills, "global", "alpha")], + agents: [agent("claudeAgent")], + }); + + expect(result.outcomes).toEqual([ + { + skill: refOf(skills, "global", "alpha"), + status: "changed", + blocked: [], + affected: [], + }, + ]); + yield* encodeResult(result); + expect(yield* fs.readLink(path.join(home, ".claude/skills/alpha"))).toBe( + path.join(home, "library/skills/alpha"), + ); + const after = (yield* catalog.list({})).skills; + expect(stateOf(after, "global", "alpha")).toMatchObject({ + claudeAgent: "link", + codex: "direct", + antigravity: "none", + }); + // Nobody else's folders changed. + expect(yield* fs.exists(path.join(home, ".gemini"))).toBe(false); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "gives an agent a project skill through a relative link, creating the folder", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + const verify = refOf(skills, "project", "verify"); + + const result = yield* manager.enable({ + cwd: project, + skills: [verify], + agents: [agent("claudeAgent")], + }); + + expect(result.outcomes[0]?.status).toBe("changed"); + const link = path.join(project, ".claude/skills/verify"); + expect(yield* fs.readLink(link)).toBe("../../.agents/skills/verify"); + expect(yield* fs.realPath(link)).toBe(path.join(project, ".agents/skills/verify")); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "keeps a project's links working after the project folder is moved", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + yield* manager.enable({ + cwd: project, + skills: [refOf(skills, "project", "verify")], + agents: [agent("claudeAgent")], + }); + }), + ); + + const moved = path.join(home, "repos/app-renamed"); + yield* fs.rename(project, moved); + + expect(yield* fs.realPath(path.join(moved, ".claude/skills/verify"))).toBe( + path.join(moved, ".agents/skills/verify"), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "turns on for all agents, one link where agents share a folder, and names who else gained it", + () => + Effect.gen(function* () { + const { fs, path, home } = yield* makeMachine; + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + const solo = refOf(skills, "global", "solo"); + // `solo` only lives in Claude's folder, which Cursor and OpenCode read too. + expect(stateOf(skills, "global", "solo")).toMatchObject({ + claudeAgent: "direct", + cursor: "direct", + opencode: "direct", + codex: "none", + grok: "none", + pi: "none", + }); + + const result = yield* manager.enable({ skills: [solo], agents: ALL_AGENTS }); + + // Codex, Grok and Pi share ~/.agents/skills, so a single link serves them. + expect(yield* fs.readLink(path.join(home, ".agents/skills/solo"))).toBe( + path.join(home, ".claude/skills/solo"), + ); + expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + // Antigravity reads neither folder, so it gets its own link. + expect(yield* fs.readLink(path.join(home, ".gemini/config/skills/solo"))).toBe( + path.join(home, ".claude/skills/solo"), + ); + const states = stateOf((yield* catalog.list({})).skills, "global", "solo"); + expect(Object.values(states).every((state) => state !== "none")).toBe(true); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "says which agents that weren't asked for gained the skill from a shared folder", + () => + Effect.gen(function* () { + const { home } = yield* makeMachine; + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + + const result = yield* manager.enable({ + skills: [refOf(skills, "global", "solo")], + agents: [agent("codex")], + }); + + expect(result.outcomes[0]?.affected).toEqual([agent("grok"), agent("pi")]); + yield* encodeResult(result); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)("is a no-op the second time", () => + Effect.gen(function* () { + const { home } = yield* makeMachine; + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + const input = { + skills: [refOf(skills, "global", "alpha")], + agents: [agent("claudeAgent")], + }; + + const first = yield* manager.enable(input); + const second = yield* manager.enable(input); + + expect(first.outcomes[0]?.status).toBe("changed"); + expect(second.outcomes[0]).toMatchObject({ status: "unchanged", blocked: [] }); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "makes one link when two requests ask at once, and neither fails", + () => + Effect.gen(function* () { + const { home } = yield* makeMachine; + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + const input = { + skills: [refOf(skills, "global", "alpha")], + agents: [agent("claudeAgent")], + }; + + const results = yield* Effect.all([manager.enable(input), manager.enable(input)], { + concurrency: "unbounded", + }); + + expect(results.map((result) => result.outcomes[0]?.status).toSorted()).toEqual([ + "changed", + "unchanged", + ]); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "never replaces a real folder or file where the link would go", + () => + Effect.gen(function* () { + const { fs, path, home, write } = yield* makeMachine; + // Claude's folder holds its own `alpha` without a SKILL.md, and a file named `beta`. + yield* write(".claude/skills/alpha/mine.md", "my own notes"); + yield* write(".claude/skills/beta", "a file"); + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + const beta = path.join(home, ".agents/skills/beta"); + yield* fs.symlink(path.join(home, "library/skills/beta"), beta); + const listed = (yield* catalog.list({})).skills; + + const result = yield* manager.enable({ + skills: [refOf(skills, "global", "alpha"), refOf(listed, "global", "beta")], + agents: [agent("claudeAgent")], + }); + + expect(result.outcomes.map(({ status, blocked }) => ({ status, blocked }))).toEqual([ + { + status: "skipped", + blocked: [{ instanceId: "claudeAgent", reason: "entryTaken" }], + }, + { + status: "skipped", + blocked: [{ instanceId: "claudeAgent", reason: "entryTaken" }], + }, + ]); + yield* encodeResult(result); + expect( + yield* fs.readFileString(path.join(home, ".claude/skills/alpha/mine.md")), + ).toBe("my own notes"); + expect(yield* fs.readFileString(path.join(home, ".claude/skills/beta"))).toBe( + "a file", + ); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)("never points a link that is there somewhere else", () => + Effect.gen(function* () { + const { fs, path, home, link } = yield* makeMachine; + // Claude's own `alpha` is already a link, to the other skill. + yield* link("library/skills/beta", ".claude/skills/alpha"); + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + + const result = yield* manager.enable({ + skills: [refOf(skills, "global", "alpha")], + agents: [agent("claudeAgent")], + }); + + expect(result.outcomes[0]?.blocked).toEqual([ + { instanceId: "claudeAgent", reason: "entryTaken" }, + ]); + expect(yield* fs.readLink(path.join(home, ".claude/skills/alpha"))).toBe( + path.join(home, "library/skills/beta"), + ); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "doesn't link a skill the agent would never load because another comes first", + () => + Effect.gen(function* () { + const { fs, path, home, project, write } = yield* makeMachine; + // Claude reads its global folder before the project's, so a global `verify` wins. + yield* write(".claude/skills/verify/SKILL.md", skillFile("verify")); + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + + const result = yield* manager.enable({ + cwd: project, + skills: [refOf(skills, "project", "verify")], + agents: [agent("claudeAgent"), agent("codex")], + }); + + expect(result.outcomes[0]).toMatchObject({ + status: "skipped", + blocked: [{ instanceId: "claudeAgent", reason: "shadowed" }], + }); + expect(yield* fs.exists(path.join(project, ".claude"))).toBe(false); + }), + ); + }), + ); + }); + + describe("disable", () => { + it.effect.skipIf(!symlinksSupported)( + "removes the agent's link and leaves the skill's own folder and every other link", + () => + Effect.gen(function* () { + const { fs, path, home } = yield* makeMachine; + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + const alpha = refOf(skills, "global", "alpha"); + yield* manager.enable({ skills: [alpha], agents: [agent("claudeAgent")] }); + + const result = yield* manager.disable({ + skills: [alpha], + agents: [agent("claudeAgent")], + }); + + expect(result.outcomes[0]).toEqual({ + skill: alpha, + status: "changed", + blocked: [], + affected: [], + }); + yield* encodeResult(result); + expect(yield* fs.exists(path.join(home, ".claude/skills/alpha"))).toBe(false); + expect( + yield* fs.readFileString(path.join(home, "library/skills/alpha/notes.md")), + ).toBe("notes on alpha"); + expect(yield* fs.exists(path.join(home, ".agents/skills/alpha"))).toBe(true); + expect(stateOf((yield* catalog.list({})).skills, "global", "alpha")).toMatchObject({ + claudeAgent: "none", + codex: "direct", + }); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "refuses for an agent that reads the skill's folder, changing nothing", + () => + Effect.gen(function* () { + const { fs, path, home } = yield* makeMachine; + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + + const result = yield* manager.disable({ + skills: [refOf(skills, "global", "alpha"), refOf(skills, "global", "solo")], + // Codex reads the shared folder the alpha link is in; Claude reads solo's own folder. + agents: [agent("codex"), agent("claudeAgent")], + }); + + expect(result.outcomes.map(({ status, blocked }) => ({ status, blocked }))).toEqual([ + { + status: "skipped", + blocked: [{ instanceId: "codex", reason: "alwaysOn" }], + }, + { + status: "skipped", + blocked: [{ instanceId: "claudeAgent", reason: "alwaysOn" }], + }, + ]); + expect(yield* fs.exists(path.join(home, ".agents/skills/alpha"))).toBe(true); + expect(yield* fs.exists(path.join(home, ".claude/skills/solo/SKILL.md"))).toBe(true); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "names the other agents that lose the skill with the link", + () => + Effect.gen(function* () { + const { fs, path, home, link } = yield* makeMachine; + // `beta` is only linked in Claude's folder, which Cursor and OpenCode read too. + yield* link("library/skills/beta", ".claude/skills/beta"); + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + expect(stateOf(skills, "global", "beta")).toMatchObject({ + claudeAgent: "link", + cursor: "link", + opencode: "link", + }); + + const result = yield* manager.disable({ + skills: [refOf(skills, "global", "beta")], + agents: [agent("claudeAgent")], + }); + + expect(result.outcomes[0]).toMatchObject({ + status: "changed", + affected: [agent("cursor"), agent("opencode")], + }); + expect(yield* fs.exists(path.join(home, "library/skills/beta/SKILL.md"))).toBe(true); + }), + ); + }), + ); + }); + + describe("remove", () => { + it.effect.skipIf(!symlinksSupported)( + "removes every link to the skill and never the original folder", + () => + Effect.gen(function* () { + const { fs, path, home } = yield* makeMachine; + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + const alpha = refOf(skills, "global", "alpha"); + yield* manager.enable({ skills: [alpha], agents: [agent("claudeAgent")] }); + + const result = yield* manager.remove({ skills: [alpha] }); + + expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + expect(result.outcomes[0]?.affected).toContain(agent("claudeAgent")); + expect(result.outcomes[0]?.affected).toContain(agent("codex")); + yield* encodeResult(result); + expect(yield* fs.exists(path.join(home, ".agents/skills/alpha"))).toBe(false); + expect(yield* fs.exists(path.join(home, ".claude/skills/alpha"))).toBe(false); + expect( + yield* fs.readFileString(path.join(home, "library/skills/alpha/SKILL.md")), + ).toBe(skillFile("alpha")); + expect((yield* catalog.list({})).skills.some((skill) => skill.name === "alpha")).toBe( + false, + ); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)("does nothing to a skill that is a real folder", () => + Effect.gen(function* () { + const { fs, path, home } = yield* makeMachine; + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + + const result = yield* manager.remove({ skills: [refOf(skills, "global", "solo")] }); + + expect(result.outcomes[0]).toMatchObject({ status: "unchanged", blocked: [] }); + expect(yield* fs.exists(path.join(home, ".claude/skills/solo/SKILL.md"))).toBe(true); + }), + ); + }), + ); + }); + + describe("requests", () => { + it.effect.skipIf(!symlinksSupported)( + "refuses to write when the skill is no longer where the list said, or gone", + () => + Effect.gen(function* () { + const { fs, path, home } = yield* makeMachine; + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + const alpha = refOf(skills, "global", "alpha"); + // The shared folder's `alpha` now leads to another folder. + yield* fs.remove(path.join(home, ".agents/skills/alpha")); + yield* fs.symlink( + path.join(home, "library/skills/beta"), + path.join(home, ".agents/skills/alpha"), + ); + + const result = yield* manager.enable({ + skills: [alpha, { scope: "global", name: "ghost", home: "~/ghost" }], + agents: [agent("claudeAgent")], + }); + + expect(result.outcomes.map(({ status, reason }) => ({ status, reason }))).toEqual([ + { status: "skipped", reason: "changed" }, + { status: "skipped", reason: "notFound" }, + ]); + yield* encodeResult(result); + expect(yield* fs.exists(path.join(home, ".claude/skills/alpha"))).toBe(false); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "refuses a project folder the environment doesn't know, and an agent it doesn't have", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* withManager(home, [], ({ manager }) => + Effect.gen(function* () { + // The list itself refuses a folder that isn't a project, so name the skill as a + // client holding an older list would. + const verify: SkillRef = { + scope: "project", + name: "verify", + home: ".agents/skills/verify", + }; + + const unregistered = yield* manager + .enable({ cwd: project, skills: [verify], agents: [agent("claudeAgent")] }) + .pipe(Effect.flip); + expect(unregistered).toEqual( + new SkillRequestError({ reason: "projectNotRegistered" }), + ); + expect(yield* fs.exists(path.join(project, ".claude"))).toBe(false); + }), + ); + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + + const unknown = yield* manager + .enable({ + cwd: project, + skills: [refOf(skills, "project", "verify")], + agents: [agent("not-an-agent")], + }) + .pipe(Effect.flip); + expect(unknown).toEqual(new SkillRequestError({ reason: "unknownAgent" })); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "tells what happened to each skill in a bulk request, and one bad skill doesn't stop the rest", + () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + const alpha = refOf(skills, "global", "alpha"); + const ghost: SkillRef = { scope: "global", name: "ghost", home: "~/ghost" }; + const verify = refOf(skills, "project", "verify"); + const solo = refOf(skills, "global", "solo"); + + const result = yield* manager.enable({ + cwd: project, + skills: [alpha, ghost, verify, solo], + agents: [agent("claudeAgent")], + }); + + expect( + result.outcomes.map(({ skill, status }) => [skill.name, status] as const), + ).toEqual([ + ["alpha", "changed"], + ["ghost", "skipped"], + ["verify", "changed"], + // Claude reads solo's folder itself. + ["solo", "unchanged"], + ]); + yield* encodeResult(result); + }), + ); + }), + ); + }); +}); + +describe("the request and result schemas", () => { + const ref = { scope: "global", name: "alpha", home: "~/alpha" } as const; + const decodes = (schema: Schema.Decoder, input: unknown) => + Schema.decodeUnknownOption(schema)(input).pipe(Option.isSome); + + it("accepts a request for some skills and agents", () => { + expect(decodes(SkillEnableInput, { skills: [ref], agents: ["claudeAgent"] })).toBe(true); + expect(decodes(SkillDisableInput, { cwd: "/repo", skills: [ref], agents: ["codex"] })).toBe( + true, + ); + expect(decodes(SkillRemoveInput, { skills: [ref] })).toBe(true); + }); + + it("rejects a request for no skills, no agents or too many skills", () => { + expect(decodes(SkillEnableInput, { skills: [], agents: ["codex"] })).toBe(false); + expect(decodes(SkillEnableInput, { skills: [ref], agents: [] })).toBe(false); + expect(decodes(SkillRemoveInput, { skills: Array.from({ length: 201 }, () => ref) })).toBe( + false, + ); + expect(decodes(SkillRemoveInput, { skills: Array.from({ length: 200 }, () => ref) })).toBe( + true, + ); + }); +}); + +describe("planEnable", () => { + const read = (directory: string, scope: SkillScope, rival = false, standard = false) => ({ + scope, + directory, + label: directory, + standard, + rival, + }); + const skill = ( + agents: SkillCatalog.ResolvedSkill["agents"], + entries: SkillCatalog.ResolvedSkill["entries"] = [], + ): SkillCatalog.ResolvedSkill => ({ + scope: "project", + name: "verify", + displayHome: ".agents/skills/verify", + home: "/repo/.agents/skills/verify", + entries, + agents, + }); + const member = ( + instanceId: string, + collision: "first-wins" | "all", + reads: ReturnType[], + ) => ({ + instanceId: agent(instanceId), + driver: ProviderInstanceId.make(instanceId) as never, + collision, + state: "none" as const, + via: [], + reads, + }); + const everyone = new Set(["a", "b"].map((id) => agent(id))); + + it("links in the shared folder when the agent reads it, even when its own folder is first", () => { + const plan = planEnable( + skill([ + member("a", "first-wins", [ + read("/repo/.pi/skills", "project"), + read("/repo/.agents/skills", "project", false, true), + ]), + ]), + everyone, + ); + expect(plan.links).toEqual([{ directory: "/repo/.agents/skills", agents: [agent("a")] }]); + }); + + it("makes one link for agents that read the same folder, and none where it is there already", () => { + const shared = [read("/repo/.agents/skills", "project", false, true)]; + expect( + planEnable(skill([member("a", "all", shared), member("b", "first-wins", shared)]), everyone) + .links, + ).toEqual([{ directory: "/repo/.agents/skills", agents: [agent("a"), agent("b")] }]); + expect( + planEnable( + skill( + [member("a", "all", shared)], + [{ path: "/repo/.agents/skills/verify", directory: "/repo/.agents/skills", target: "x" }], + ), + everyone, + ).links, + ).toEqual([]); + }); + + it("holds back an agent that loads another skill with the name first, unless it loads them all", () => { + const reads = [ + read("/home/.claude/skills", "global", true), + read("/repo/.claude/skills", "project"), + ]; + const plan = planEnable( + skill([member("a", "first-wins", reads), member("b", "all", reads)]), + everyone, + ); + expect(plan.blocked).toEqual([{ instanceId: agent("a"), reason: "shadowed" }]); + expect(plan.links).toEqual([{ directory: "/repo/.claude/skills", agents: [agent("b")] }]); + }); +}); diff --git a/apps/server/src/skills/SkillManager.ts b/apps/server/src/skills/SkillManager.ts new file mode 100644 index 000000000000..a6f2abb054eb --- /dev/null +++ b/apps/server/src/skills/SkillManager.ts @@ -0,0 +1,355 @@ +/** + * SkillManager - turns skills on or off for each agent by making and removing links. + * + * A skill has one home, a real folder. An agent reads it either because the agent reads that + * folder itself (`direct`) or because a link in a folder the agent reads points at it (`link`). + * Turning a skill on makes such a link in the agent's own folder; turning it off removes it. The + * only things written are links this service can show lead to the skill's home: a real folder is + * never replaced, moved or deleted here. + * + * Every write starts from what the folders hold now, not from what a client last saw: a skill + * whose home is not where the client said is refused, and each link is checked again right + * before it is made or removed (see `SkillLinks`). Writes run one request at a time. + * + * @module SkillManager + */ +import { + SkillRequestError, + type ProviderInstanceId, + type SkillBatchResult, + type SkillDisableInput, + type SkillEnableInput, + type SkillOutcome, + type SkillOutcomeReason, + type SkillRef, + type SkillRemoveInput, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +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 Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Semaphore from "effect/Semaphore"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as SkillCatalog from "./SkillCatalog.ts"; +import { createLink, removeLink, type RemoveLinkResult } from "./SkillLinks.ts"; + +type Blocked = SkillOutcome["blocked"][number]; + +/** What was done to one skill, before it is told to a client. */ +interface SkillChange { + /** A link was made or removed. */ + readonly wrote: boolean; + /** Agents the change didn't reach. */ + readonly blocked: readonly Blocked[]; + /** Something about the skill as a whole kept the change from being complete. */ + readonly reason?: SkillOutcomeReason | undefined; +} + +/** + * Links to make so each requested agent that doesn't use the skill yet gets it. An agent gets its + * link in the shared folder when it reads that, else in its own first folder for the skill's + * scope; agents that read the same folder share one link. + */ +export const planEnable = ( + skill: SkillCatalog.ResolvedSkill, + requested: ReadonlySet, +) => { + const links = new Map(); + const blocked: Blocked[] = []; + for (const agent of skill.agents) { + if (!requested.has(agent.instanceId) || agent.state !== "none") continue; + const shared = agent.reads.findIndex((read) => read.scope === skill.scope && read.standard); + const index = + shared >= 0 ? shared : agent.reads.findIndex((read) => read.scope === skill.scope); + const root = agent.reads[index]; + if (root === undefined) { + blocked.push({ instanceId: agent.instanceId, reason: "failed" }); + continue; + } + // A link would never load if the agent finds another skill with this name first. + if ( + agent.collision === "first-wins" && + agent.reads.slice(0, index).some((read) => read.rival) + ) { + blocked.push({ instanceId: agent.instanceId, reason: "shadowed" }); + continue; + } + // Linked there already, though the agent doesn't load it (Claude can't read its header). + if (skill.entries.some((entry) => entry.directory === root.directory)) continue; + const link = links.get(root.directory); + if (link) link.agents.push(agent.instanceId); + else links.set(root.directory, { directory: root.directory, agents: [agent.instanceId] }); + } + return { links: [...links.values()], blocked }; +}; + +/** + * Links to remove so each requested agent stops using the skill. An agent that reads the + * skill's own folder, or a link in the shared folder that serves other agents too, stays on. + */ +const planDisable = ( + skill: SkillCatalog.ResolvedSkill, + requested: ReadonlySet, +) => { + const unlinks = new Map(); + const blocked: Blocked[] = []; + for (const agent of skill.agents) { + if (!requested.has(agent.instanceId) || agent.state === "none") continue; + const entries = skill.entries.filter((entry) => agent.via.includes(entry.path)); + if (agent.state === "direct" || entries.some((entry) => entry.target === undefined)) { + blocked.push({ instanceId: agent.instanceId, reason: "alwaysOn" }); + continue; + } + for (const entry of entries) { + if (entry.target === undefined) continue; + const unlink = unlinks.get(entry.path); + if (unlink) unlink.agents.push(agent.instanceId); + else + unlinks.set(entry.path, { + path: entry.path, + target: entry.target, + agents: [agent.instanceId], + }); + } + } + return { unlinks: [...unlinks.values()], blocked }; +}; + +const hasSkill = (state: "direct" | "link" | "none") => state !== "none"; + +export class SkillManager extends Context.Service< + SkillManager, + { + /** Make a link in each agent's own folder so it can use each skill. */ + readonly enable: ( + input: SkillEnableInput, + ) => Effect.Effect; + /** Remove each agent's link to each skill. */ + readonly disable: ( + input: SkillDisableInput, + ) => Effect.Effect; + /** Remove every link to each skill. The skills' own folders are never touched. */ + readonly remove: ( + input: SkillRemoveInput, + ) => Effect.Effect; + } +>()("t3/skills/SkillManager") {} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const catalog = yield* SkillCatalog.SkillCatalog; + const projects = yield* ProjectService.ProjectService; + const writeLock = yield* Semaphore.make(1); + // The link primitives take the filesystem from their environment. + const filesystemContext = yield* Effect.context(); + + /** Links are only written under a folder the environment knows as a project. */ + const requireProject = (cwd: string) => + projects.getByWorkspaceRoot(cwd).pipe( + Effect.orDie, + Effect.filterOrFail( + Option.isSome, + () => new SkillRequestError({ reason: "projectNotRegistered" }), + ), + ); + + const removeAll = Effect.fnUntraced(function* ( + entries: ReadonlyArray<{ readonly path: string; readonly target: string }>, + ) { + const results = new Map(); + for (const entry of entries) { + results.set( + entry.path, + yield* removeLink({ path: entry.path, expectedTarget: entry.target }).pipe( + Effect.provideContext(filesystemContext), + Effect.catchTags({ SkillLinkError: () => Effect.succeed("failed" as const) }), + ), + ); + } + return results; + }); + + const enableOne = Effect.fnUntraced(function* ( + skill: SkillCatalog.ResolvedSkill, + requested: ReadonlySet, + projectRoot: string | undefined, + ) { + const plan = planEnable(skill, requested); + const blocked: Blocked[] = [...plan.blocked]; + let wrote = false; + for (const link of plan.links) { + const result = yield* createLink({ + link: path.join(link.directory, skill.name), + home: skill.home, + scope: skill.scope, + platform, + projectRoot, + }).pipe( + Effect.provideContext(filesystemContext), + Effect.catchTags({ SkillLinkError: () => Effect.succeed("failed" as const) }), + ); + if (result === "created") wrote = true; + else if (result !== "unchanged") { + const reason: SkillOutcomeReason = + result === "taken" ? "entryTaken" : result === "notAllowed" ? "linkNotAllowed" : "failed"; + for (const instanceId of link.agents) blocked.push({ instanceId, reason }); + } + } + return { wrote, blocked } satisfies SkillChange; + }); + + const disableOne = Effect.fnUntraced(function* ( + skill: SkillCatalog.ResolvedSkill, + requested: ReadonlySet, + ) { + const plan = planDisable(skill, requested); + const results = yield* removeAll(plan.unlinks); + const blocked: Blocked[] = [...plan.blocked]; + let wrote = false; + for (const unlink of plan.unlinks) { + const result = results.get(unlink.path); + if (result === "removed") wrote = true; + else if (result === "changed" || result === "failed") { + for (const instanceId of unlink.agents) { + blocked.push({ instanceId, reason: result }); + } + } + } + return { wrote, blocked } satisfies SkillChange; + }); + + const removeOne = Effect.fnUntraced(function* (skill: SkillCatalog.ResolvedSkill) { + const links = skill.entries.flatMap((entry) => + entry.target === undefined ? [] : [{ path: entry.path, target: entry.target }], + ); + const results = new Set((yield* removeAll(links)).values()); + const reason: SkillOutcomeReason | undefined = results.has("failed") + ? "failed" + : results.has("changed") + ? "changed" + : undefined; + return { wrote: results.has("removed"), blocked: [], reason } satisfies SkillChange; + }); + + /** + * Looks every skill up as the folders hold it now, applies `change` to those that are still + * where the client said, and tells what happened to each. An agent that gained or lost a skill + * without being asked is found by reading the folders again afterwards. + */ + const run = (input: { + readonly cwd: string | undefined; + readonly skills: ReadonlyArray; + readonly agents: ReadonlySet; + readonly change: ( + skill: SkillCatalog.ResolvedSkill, + projectRoot: string | undefined, + ) => Effect.Effect; + }) => + writeLock.withPermits(1)( + Effect.gen(function* () { + if (input.cwd !== undefined) yield* requireProject(input.cwd); + const before = yield* catalog.resolve({ cwd: input.cwd, skills: input.skills }); + const known = new Set((before[0]?.agents ?? []).map((agent) => agent.instanceId)); + if (known.size > 0 && [...input.agents].some((id) => !known.has(id))) { + return yield* new SkillRequestError({ reason: "unknownAgent" }); + } + const projectRoot = + input.cwd === undefined + ? undefined + : yield* fileSystem.realPath(input.cwd).pipe(Effect.orElseSucceed(() => input.cwd)); + + const changes = yield* Effect.forEach(input.skills, (ref) => + Effect.gen(function* () { + const candidates = before.filter( + (skill) => skill.scope === ref.scope && skill.name === ref.name, + ); + const found = candidates.find((skill) => skill.displayHome === ref.home); + if (found === undefined) { + const reason = candidates.length > 0 ? "changed" : "notFound"; + return { ref, found, change: { wrote: false, blocked: [], reason } as SkillChange }; + } + return { ref, found, change: yield* input.change(found, projectRoot) }; + }), + ); + + const after = changes.some((entry) => entry.change.wrote) + ? yield* catalog.resolve({ cwd: input.cwd, skills: input.skills }) + : before; + return { + outcomes: changes.map(({ ref, found, change }): SkillOutcome => { + const now = after.find( + (skill) => + skill.scope === ref.scope && + skill.name === ref.name && + skill.displayHome === ref.home, + ); + const affected = + found === undefined + ? [] + : found.agents + .filter( + (agent) => + !input.agents.has(agent.instanceId) && + hasSkill(agent.state) !== + hasSkill( + now?.agents.find((other) => other.instanceId === agent.instanceId) + ?.state ?? "none", + ), + ) + .map((agent) => agent.instanceId); + return { + skill: ref, + status: change.wrote + ? "changed" + : change.reason !== undefined || change.blocked.length > 0 + ? "skipped" + : "unchanged", + ...(change.reason === undefined ? {} : { reason: change.reason }), + blocked: change.blocked.filter( + (item, index, all) => + all.findIndex((other) => other.instanceId === item.instanceId) === index, + ), + affected, + }; + }), + } satisfies SkillBatchResult; + }), + ); + + return SkillManager.of({ + enable: Effect.fn("SkillManager.enable")(function* (input) { + const agents = new Set(input.agents); + return yield* run({ + cwd: input.cwd, + skills: input.skills, + agents, + change: (skill, projectRoot) => enableOne(skill, agents, projectRoot), + }); + }), + disable: Effect.fn("SkillManager.disable")(function* (input) { + const agents = new Set(input.agents); + return yield* run({ + cwd: input.cwd, + skills: input.skills, + agents, + change: (skill) => disableOne(skill, agents), + }); + }), + remove: Effect.fn("SkillManager.remove")(function* (input) { + return yield* run({ + cwd: input.cwd, + skills: input.skills, + agents: new Set(), + change: (skill) => removeOne(skill), + }); + }), + }); +}); + +export const layer = Layer.effect(SkillManager, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f3b4961e1e29..ff5e769bd298 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -191,6 +191,7 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as SkillCatalog from "./skills/SkillCatalog.ts"; +import * as SkillManager from "./skills/SkillManager.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; @@ -1286,6 +1287,7 @@ const layerWsRpc = ( const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const skillCatalog = yield* SkillCatalog.SkillCatalog; + const skillManager = yield* SkillManager.SkillManager; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); @@ -2176,6 +2178,9 @@ const layerWsRpc = ( ), [WS_METHODS.serverListSkills]: (input) => skillCatalog.list(input), [WS_METHODS.serverGetSkill]: (input) => skillCatalog.get(input), + [WS_METHODS.serverEnableSkills]: (input) => skillManager.enable(input), + [WS_METHODS.serverDisableSkills]: (input) => skillManager.disable(input), + [WS_METHODS.serverRemoveSkills]: (input) => skillManager.remove(input), [WS_METHODS.serverRefreshProviders]: (input) => Effect.gen(function* () { // Only explicit catalog refreshes bypass T3's caches. Workspace diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index c84169e6e33f..30254cdd24dc 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -36,6 +36,10 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.vcsSwitchRef]: AuthSourceControlWriteScope, [WS_METHODS.vcsInit]: AuthSourceControlWriteScope, + [WS_METHODS.serverEnableSkills]: AuthOrchestrationOperateScope, + [WS_METHODS.serverDisableSkills]: AuthOrchestrationOperateScope, + [WS_METHODS.serverRemoveSkills]: AuthOrchestrationOperateScope, + [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 82be36f4a8d5..3be843ca2fe4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -324,10 +324,14 @@ import { ServerSettingsPatch, } from "./settings.ts"; import { + SkillBatchResult, + SkillDisableInput, + SkillEnableInput, SkillGetInput, SkillGetResult, SkillListInput, SkillListResult, + SkillRemoveInput, SkillRequestError, } from "./skills.ts"; import { @@ -476,6 +480,9 @@ export const WS_METHODS = { serverRefreshProviders: "server.refreshProviders", serverListSkills: "server.listSkills", serverGetSkill: "server.getSkill", + serverEnableSkills: "server.enableSkills", + serverDisableSkills: "server.disableSkills", + serverRemoveSkills: "server.removeSkills", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", @@ -619,6 +626,24 @@ const WsServerGetSkillRpc = Rpc.make(WS_METHODS.serverGetSkill, { error: Schema.Union([SkillRequestError, EnvironmentAuthorizationError]), }); +const WsServerEnableSkillsRpc = Rpc.make(WS_METHODS.serverEnableSkills, { + payload: SkillEnableInput, + success: SkillBatchResult, + error: Schema.Union([SkillRequestError, EnvironmentAuthorizationError]), +}); + +const WsServerDisableSkillsRpc = Rpc.make(WS_METHODS.serverDisableSkills, { + payload: SkillDisableInput, + success: SkillBatchResult, + error: Schema.Union([SkillRequestError, EnvironmentAuthorizationError]), +}); + +const WsServerRemoveSkillsRpc = Rpc.make(WS_METHODS.serverRemoveSkills, { + payload: SkillRemoveInput, + success: SkillBatchResult, + error: Schema.Union([SkillRequestError, EnvironmentAuthorizationError]), +}); + const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** @@ -1854,6 +1879,9 @@ export const WsRpcGroup = RpcGroup.make( WsServerRefreshProvidersRpc, WsServerListSkillsRpc, WsServerGetSkillRpc, + WsServerEnableSkillsRpc, + WsServerDisableSkillsRpc, + WsServerRemoveSkillsRpc, WsServerUpdateProviderRpc, WsProviderConsumeResetCreditRpc, WsProviderAuthStartRpc, diff --git a/packages/contracts/src/skills.ts b/packages/contracts/src/skills.ts index 60325955b44c..1dee7aaa3e7e 100644 --- a/packages/contracts/src/skills.ts +++ b/packages/contracts/src/skills.ts @@ -102,17 +102,92 @@ export const SkillGetResult = Schema.Struct({ }); export type SkillGetResult = typeof SkillGetResult.Type; -/** - * A skill read that couldn't be carried out, as opposed to a folder with no skills: a project's - * folders are only read when the environment knows the folder as a project. - */ +/** The skill an action is about, as the list returned it. */ +export const SkillRef = Schema.Struct({ + scope: SkillScope, + name: TrimmedNonEmptyString, + /** The `home` the list returned. The action is refused when the skill is no longer there. */ + home: TrimmedNonEmptyString, +}); +export type SkillRef = typeof SkillRef.Type; + +const SkillRefs = Schema.Array(SkillRef).check(Schema.isMinLength(1), Schema.isMaxLength(200)); +const SkillAgents = Schema.Array(ProviderInstanceId).check( + Schema.isMinLength(1), + Schema.isMaxLength(64), +); + +/** Make a link in each agent's own skill folder, so the agent can use the skill. */ +export const SkillEnableInput = Schema.Struct({ + /** A registered project's folder, for project skills and for the order its folders are read in. */ + cwd: Schema.optional(TrimmedNonEmptyString), + skills: SkillRefs, + agents: SkillAgents, +}); +export type SkillEnableInput = typeof SkillEnableInput.Type; + +/** Remove each agent's link to the skill. An agent that reads the skill's folder itself stays on. */ +export const SkillDisableInput = SkillEnableInput; +export type SkillDisableInput = typeof SkillDisableInput.Type; + +/** Remove every link to the skills. The skills' own folders are never touched. */ +export const SkillRemoveInput = Schema.Struct({ + cwd: Schema.optional(TrimmedNonEmptyString), + skills: SkillRefs, +}); +export type SkillRemoveInput = typeof SkillRemoveInput.Type; + +/** Why a skill or an agent was left as it was. A client words each one. */ +export const SkillOutcomeReason = Schema.Literals([ + /** The skill isn't in the agents' folders any more. */ + "notFound", + /** The skill, or a link the list showed, is no longer what the list said it was. */ + "changed", + /** The agent reads the skill's own folder, so there is no link to remove. */ + "alwaysOn", + /** A folder, file or link to something else is where the link would go. */ + "entryTaken", + /** The agent loads another skill with this name first, so a link wouldn't be used. */ + "shadowed", + /** The system won't let T3 Code make links there. */ + "linkNotAllowed", + /** The folder couldn't be written. */ + "failed", +]); +export type SkillOutcomeReason = typeof SkillOutcomeReason.Type; + +export const SkillOutcome = Schema.Struct({ + skill: SkillRef, + /** + * `changed`: a link was made or removed, even if some agents were left out (see `blocked`). + * `unchanged`: it was already as asked. `skipped`: nothing was changed. + */ + status: Schema.Literals(["changed", "unchanged", "skipped"]), + /** Why the whole skill was skipped: `notFound` or `changed`. */ + reason: Schema.optional(SkillOutcomeReason), + /** Agents the change didn't reach, with why. */ + blocked: Schema.Array( + Schema.Struct({ instanceId: ProviderInstanceId, reason: SkillOutcomeReason }), + ), + /** Agents that weren't asked for but gained or lost the skill, because they read the same folder. */ + affected: Schema.Array(ProviderInstanceId), +}); +export type SkillOutcome = typeof SkillOutcome.Type; + +/** One outcome per skill asked for, in the order asked. One bad skill never stops the rest. */ +export const SkillBatchResult = Schema.Struct({ outcomes: Schema.Array(SkillOutcome) }); +export type SkillBatchResult = typeof SkillBatchResult.Type; + +/** A whole request that couldn't be carried out, as opposed to a skill that was skipped. */ export class SkillRequestError extends Schema.TaggedError()( "SkillRequestError", { - reason: Schema.Literals(["projectNotRegistered"]), + reason: Schema.Literals(["unknownAgent", "projectNotRegistered"]), }, ) { override get message(): string { - return "That folder isn't a project in this environment."; + return this.reason === "unknownAgent" + ? "That agent isn't enabled in this environment." + : "That folder isn't a project in this environment."; } } From 45c516700a5d5fc29d0d289f00828d24ea921628 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:46:21 -0400 Subject: [PATCH 010/108] feat(web): turn skills on or off for each agent in Settings The "Used by" chips in a skill become switches, rows get checkboxes with a bulk bar (turn on for all agents, turn off for one agent, remove), and a row in Needs attention offers a one-click turn on. Turning off asks first only when another agent loses the skill too, and remove always asks. After every change the page reads the list from the server again instead of predicting it. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/settings/SkillBulkBar.tsx | 145 ++++++++ .../src/components/settings/SkillDetail.tsx | 91 +++-- .../web/src/components/settings/SkillList.tsx | 106 +++++- .../settings/SkillsSettings.logic.test.ts | 319 +++++++++++++++++- .../settings/SkillsSettings.logic.ts | 264 +++++++++++++++ .../components/settings/SkillsSettings.tsx | 143 +++++++- .../src/state/commandPermissions.test.ts | 26 ++ packages/client-runtime/src/state/server.ts | 12 + 8 files changed, 1080 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/components/settings/SkillBulkBar.tsx diff --git a/apps/web/src/components/settings/SkillBulkBar.tsx b/apps/web/src/components/settings/SkillBulkBar.tsx new file mode 100644 index 000000000000..d24a2538f7d4 --- /dev/null +++ b/apps/web/src/components/settings/SkillBulkBar.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; + +import { + AlertDialog, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { SkillAgentIcon } from "./skillAgentIcon"; +import { + planRemove, + planTurnOff, + planTurnOnAll, + type Skill, + type SkillPlan, + type SkillsContext, +} from "./SkillsSettings.logic"; + +/** Acts on every ticked row. It sticks to the bottom of the page, so it is in reach on a phone. */ +export function BulkBar({ + selected, + ctx, + busy, + onClear, + onPlan, +}: { + selected: readonly Skill[]; + ctx: SkillsContext; + /** A change is being made, so nothing else can start. */ + busy: boolean; + onClear: () => void; + onPlan: (plan: SkillPlan) => void; +}) { + const turnOn = planTurnOnAll(selected, ctx); + const remove = planRemove(selected, ctx); + return ( +
    +
    + {selected.length} selected + + +
    + + + }> + Turn off for… + + + {ctx.installed.map((agent) => { + const plan = planTurnOff(selected, agent, ctx); + return ( + plan && onPlan(plan)} + > + + {agent.displayName} + {plan && plan.affected > 0 && ( + · {plan.affected} + )} + + ); + })} + + + {remove && ( + + )} +
    +
    +
    + ); +} + +/** Asks before a plan changes anything, with the same plain words for one skill or many. */ +export function ConfirmPlan({ + plan, + onCancel, + onConfirm, +}: { + /** The plan to confirm; a plan without a confirmation never opens the dialog. */ + plan: SkillPlan | null; + onCancel: () => void; + onConfirm: () => void; +}) { + // Keep the last text while the dialog closes, so it doesn't blank out first. + const [shown, setShown] = useState(plan?.confirmation); + if (plan?.confirmation && plan.confirmation !== shown) setShown(plan.confirmation); + return ( + { + if (!open) onCancel(); + }} + > + + + {shown?.title} + {shown?.body} + + {shown && shown.notes.length > 0 && ( +
      + {shown.notes.map((note) => ( +
    • {note}
    • + ))} +
    + )} + + + + +
    +
    + ); +} diff --git a/apps/web/src/components/settings/SkillDetail.tsx b/apps/web/src/components/settings/SkillDetail.tsx index 60c7de5e9b6c..c0f2dda8cfd1 100644 --- a/apps/web/src/components/settings/SkillDetail.tsx +++ b/apps/web/src/components/settings/SkillDetail.tsx @@ -6,9 +6,8 @@ import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; import { useAfterDelay } from "../../hooks/useAfterDelay"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; -import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; import { Skeleton } from "../ui/skeleton"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -17,9 +16,14 @@ import { accessOf, agentSkillPath, attention, + planRemove, + planToggle, + planTurnOnAll, scriptFiles, + switchBlocker, type Skill, type SkillAgent, + type SkillPlan, type SkillsContext, } from "./SkillsSettings.logic"; @@ -79,14 +83,20 @@ export function SkillDetail({ ctx, environmentId, projectRoot, + busy, onBack, + onPlan, onReload, }: { skill: Skill; ctx: SkillsContext; environmentId: EnvironmentId; projectRoot: string | null; + /** A change is being made, so nothing else can start. */ + busy: boolean; onBack: () => void; + /** Turns an agent on or off, or removes the skill's links; a plan with a confirmation asks first. */ + onPlan: (plan: SkillPlan) => void; /** Opens this skill again, which reads its files again. */ onReload: () => void; }) { @@ -124,6 +134,8 @@ export function SkillDetail({ const scopeLabel = skill.scope === "global" ? "Global" : "This project"; const warning = attention(skill, ctx); const sameCopies = skill.copies.filter((copy) => copy.same); + const turnOnAll = planTurnOnAll([skill], ctx); + const remove = planRemove([skill], ctx); const copyPath = (path: string) => { void writeTextToClipboard(path, "skill path").then( @@ -170,10 +182,20 @@ export function SkillDetail({ No agents are installed. )} {ctx.installed.map((agent) => ( - + { + const plan = planToggle(skill, agent, ctx); + if (plan) onPlan(plan); + }} + /> ))} - {skillFolder && ( + {(skillFolder || turnOnAll || remove) && ( } @@ -181,7 +203,22 @@ export function SkillDetail({ - copyPath(skillFolder)}>Copy path + {skillFolder && ( + copyPath(skillFolder)}>Copy path + )} + {turnOnAll && ( + onPlan(turnOnAll)}> + Turn on for all agents + + )} + {remove && ( + <> + + onPlan(remove)}> + Remove… + + + )} )} @@ -262,36 +299,52 @@ export function SkillDetail({ ); } -/** One agent: whether it can use the skill, and where it reads it from. */ +/** One agent: click to switch it on or off, unless it reads the skill's folder directly. */ function AgentChip({ skill, agent, - agents, + ctx, + busy, + onToggle, }: { skill: Skill; agent: SkillAgent; - agents: readonly SkillAgent[]; + ctx: SkillsContext; + busy: boolean; + onToggle: () => void; }) { const access = accessOf(skill, agent); const on = access?.state === "direct" || access?.state === "link"; - const direct = access?.state === "direct"; + const blocker = switchBlocker(skill, agent); return ( } + render={ + + {fix && ( + + )} ); } @@ -72,6 +135,10 @@ export function SkillSection({ visible, ctx, emptyText, + selected, + busy, + rowFix, + onSelectedChange, onOpen, }: { title: string; @@ -87,11 +154,32 @@ export function SkillSection({ visible: readonly Skill[]; ctx: SkillsContext; emptyText: string; + /** The ids of the ticked rows, across both sections. */ + selected: ReadonlySet; + /** A change is being made, so nothing else can start. */ + busy: boolean; + /** The one-click change a row offers, if any. */ + rowFix: (skill: Skill) => RowFix | null; + onSelectedChange: (ids: readonly string[], checked: boolean) => void; onOpen: (id: string) => void; }) { + const anySelected = selected.size > 0; + const selectedCount = visible.filter((skill) => selected.has(skill.id)).length; return (
    -
    +
    + 0 && selectedCount === visible.length} + indeterminate={selectedCount > 0 && selectedCount < visible.length} + visible={anySelected} + onChange={(checked) => + onSelectedChange( + visible.map((skill) => skill.id), + checked, + ) + } + />

    }> @@ -111,7 +199,17 @@ export function SkillSection({ ) : (
      {visible.map((skill) => ( - onOpen(skill.id)} /> + onSelectedChange([skill.id], checked)} + onOpen={() => onOpen(skill.id)} + /> ))}
    )} diff --git a/apps/web/src/components/settings/SkillsSettings.logic.test.ts b/apps/web/src/components/settings/SkillsSettings.logic.test.ts index b4dae3b8f911..79d773d47d5a 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.test.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; -import type { ServerProvider, SkillAgentAccess, SkillListResult } from "@t3tools/contracts"; +import type { + ServerProvider, + SkillAgentAccess, + SkillListResult, + SkillOutcome, +} from "@t3tools/contracts"; import { agentSkillPath, @@ -8,12 +13,19 @@ import { availability, availabilityNote, compareSkillFiles, + describeResult, ingestSkills, installedAgents, matchesQuery, + planFix, + planRemove, + planToggle, + planTurnOff, + planTurnOnAll, scriptFiles, skillBody, skillsEnvironment, + switchBlocker, unreadableNote, type Skill, type SkillAgent, @@ -359,3 +371,308 @@ describe("a skill's files", () => { expect(skillBody("# No header\n")).toBe("# No header\n"); }); }); + +// -- Turning skills on and off -------------------------------------------------------------------- + +/** A skill whose agents each read it from their own folder, the way the server reports links. */ +function reached( + name: string, + access: Record, + home = `~/library/skills/${name}`, +): Skill { + return { + ...skill(name, {}, { scope: "global", home }), + access: Object.entries(access).map(([instanceId, { state, folder }]) => ({ + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make(instanceId), + state, + folder, + })), + }; +} +const ref = (name: string, home = `~/library/skills/${name}`) => ({ + scope: "global" as const, + name, + home, +}); +const ctx = { installed: ALL }; +const outcome = (over: Partial & { name: string }): SkillOutcome => ({ + skill: ref(over.name), + status: "changed", + blocked: [], + affected: [], + ...over, +}); + +describe("an agent's switch", () => { + it("is locked only when the agent reads the skill's folder itself", () => { + const tdd = reached("tdd", { + claudeAgent: { state: "direct", folder: "~/.claude/skills" }, + codex: { state: "link", folder: "~/.codex/skills" }, + cursor: { state: "none", folder: "~/.cursor/skills" }, + }); + expect(switchBlocker(tdd, claude)).toBe("Always on. It reads this folder directly."); + expect(switchBlocker(tdd, codex)).toBeNull(); + expect(switchBlocker(tdd, ALL[2]!)).toBeNull(); + }); + + it("turns on for the agent that was clicked, and off for one that has it", () => { + const tdd = reached("tdd", { + claudeAgent: { state: "none", folder: "~/.claude/skills" }, + codex: { state: "link", folder: "~/.codex/skills" }, + }); + expect(planToggle(tdd, claude, ctx)).toEqual({ + change: { kind: "enable", skills: [ref("tdd")], agents: ["claudeAgent"] }, + affected: 1, + }); + expect(planToggle(tdd, codex, ctx)?.change).toEqual({ + kind: "disable", + skills: [ref("tdd")], + agents: ["codex"], + }); + }); +}); + +describe("turning on for all agents", () => { + const cursor = ALL[2]!; + it("asks for the agents that some selected skill is missing, and never asks first", () => { + const first = reached("first", { + claudeAgent: { state: "direct", folder: "~/.claude/skills" }, + codex: { state: "none", folder: "~/.agents/skills" }, + cursor: { state: "direct", folder: "~/.agents/skills" }, + }); + const second = reached("second", { + claudeAgent: { state: "none", folder: "~/.claude/skills" }, + codex: { state: "direct", folder: "~/.agents/skills" }, + cursor: { state: "direct", folder: "~/.agents/skills" }, + }); + const done = reached("done", { + claudeAgent: { state: "link", folder: "~/.claude/skills" }, + codex: { state: "direct", folder: "~/.agents/skills" }, + cursor: { state: "direct", folder: "~/.agents/skills" }, + }); + + const plan = planTurnOnAll([first, second, done], ctx); + + expect(plan?.change).toEqual({ + kind: "enable", + skills: [ref("first"), ref("second")], + agents: ["claudeAgent", "codex"], + }); + expect(plan?.affected).toBe(2); + expect(plan?.confirmation).toBeUndefined(); + expect(planTurnOnAll([done], ctx)).toBeNull(); + }); + + it("leaves out agents that aren't installed", () => { + const tdd = reached("tdd", { + claudeAgent: { state: "direct", folder: "~/.claude/skills" }, + codex: { state: "none", folder: "~/.agents/skills" }, + cursor: { state: "none", folder: "~/.cursor/skills" }, + }); + expect(planTurnOnAll([tdd], { installed: [claude, cursor] })?.change).toMatchObject({ + agents: ["cursor"], + }); + }); + + it("gives one skill the same one-click fix, naming the agent when only one is missing", () => { + const some = reached("tdd", { + claudeAgent: { state: "none", folder: "~/.claude/skills" }, + codex: { state: "direct", folder: "~/.agents/skills" }, + cursor: { state: "direct", folder: "~/.agents/skills" }, + }); + const most = reached("tdd", { + claudeAgent: { state: "none", folder: "~/.claude/skills" }, + codex: { state: "none", folder: "~/.agents/skills" }, + cursor: { state: "direct", folder: "~/.agents/skills" }, + }); + expect(planFix(some, ctx)?.label).toBe("Turn on for Claude"); + expect(planFix(most, ctx)?.label).toBe("Turn on for all agents"); + expect(planFix(most, ctx)?.plan.change).toMatchObject({ agents: ["claudeAgent", "codex"] }); + expect( + planFix(reached("ok", { claudeAgent: { state: "link", folder: "~/.claude/skills" } }), { + installed: [claude], + }), + ).toBeNull(); + }); +}); + +describe("turning off for one agent", () => { + const workCodex = agent("codex_work", "codex", "Codex Work"); + const both = { installed: [codex, workCodex, claude] }; + const linked = (name: string, ...others: Array<[string, "link" | "direct" | "none"]>) => + reached(name, { + codex: { state: "link", folder: "~/.codex/skills" }, + codex_work: { state: "link", folder: "~/.codex/skills" }, + claudeAgent: { state: "none", folder: "~/.claude/skills" }, + ...Object.fromEntries( + others.map(([id, state]) => [id, { state, folder: "~/.claude/skills" }]), + ), + }); + + it("asks first when another agent reads the same link and loses the skill too", () => { + const plan = planTurnOff([linked("tdd"), linked("grill")], codex, both); + + expect(plan?.change).toEqual({ + kind: "disable", + skills: [ref("tdd"), ref("grill")], + agents: ["codex"], + }); + expect(plan?.confirmation).toEqual({ + title: "Turn off for Codex?", + body: "Removes Codex's link for 2 skills.", + notes: ["Codex Work loses these too."], + confirm: "Turn off", + destructive: false, + }); + }); + + it("goes ahead without asking when nothing else is lost", () => { + const alone = reached("tdd", { + codex: { state: "link", folder: "~/.codex/skills" }, + codex_work: { state: "none", folder: "~/.codex/skills" }, + }); + const plan = planTurnOff([alone], codex, both); + expect(plan?.affected).toBe(1); + expect(plan?.confirmation).toBeUndefined(); + }); + + it("says which skills stay on because the agent reads their folder", () => { + const stays = reached("stays", { codex: { state: "direct", folder: "~/.agents/skills" } }); + const plan = planTurnOff([linked("tdd"), stays], codex, { installed: [codex] }); + expect(plan?.change).toMatchObject({ skills: [ref("tdd")] }); + expect(plan?.confirmation?.notes).toEqual(["1 skill stays on because Codex reads its folder."]); + }); + + it("offers nothing for an agent that uses none of the skills", () => { + expect(planTurnOff([linked("tdd")], claude, both)).toBeNull(); + const onlyStuck = planTurnOff( + [reached("stays", { codex: { state: "direct", folder: "~/.agents/skills" } })], + codex, + both, + ); + expect(onlyStuck?.affected).toBe(0); + }); +}); + +describe("removing skills from the agents", () => { + it("names who stops using one skill, and who keeps it from its own folder", () => { + const tdd = reached( + "tdd", + { + claudeAgent: { state: "link", folder: "~/.claude/skills" }, + codex: { state: "direct", folder: "~/.agents/skills" }, + cursor: { state: "none", folder: "~/.cursor/skills" }, + }, + "~/.agents/skills/tdd", + ); + const plan = planRemove([tdd], ctx); + expect(plan?.change).toEqual({ kind: "remove", skills: [ref("tdd", "~/.agents/skills/tdd")] }); + expect(plan?.confirmation).toEqual({ + title: "Remove tdd from your agents?", + body: "Claude will stop using it; the original stays.", + notes: ["Codex still uses it from its own folder."], + confirm: "Remove", + destructive: true, + }); + }); + + it("counts skills for a bulk removal and leaves out those with no link to remove", () => { + const linked = (name: string) => + reached(name, { claudeAgent: { state: "link", folder: "~/.claude/skills" } }); + const own = reached( + "own", + { claudeAgent: { state: "direct", folder: "~/.claude/skills" } }, + "~/.claude/skills/own", + ); + const plan = planRemove([linked("a"), linked("b"), own], ctx); + expect(plan?.affected).toBe(2); + expect(plan?.change).toMatchObject({ skills: [ref("a"), ref("b")] }); + expect(plan?.confirmation).toMatchObject({ + title: "Remove 2 skills from your agents?", + body: "Agents will stop using them; the originals stay.", + notes: ["1 skill is only in its own folder, so nothing changes there."], + destructive: true, + }); + expect(planRemove([own], ctx)).toBeNull(); + }); +}); + +describe("telling what a change did", () => { + it("says who got a skill, and who else did because they share a folder", () => { + expect( + describeResult( + { kind: "enable", skills: [ref("a"), ref("b")], agents: [claude.instanceId] }, + [outcome({ name: "a", affected: [codex.instanceId] }), outcome({ name: "b" })], + ctx, + ), + ).toBe("Turned on 2 skills for Claude. Codex gets them too."); + expect( + describeResult( + { kind: "disable", skills: [ref("a")], agents: [codex.instanceId] }, + [outcome({ name: "a", affected: [claude.instanceId, ALL[2]!.instanceId] })], + ctx, + ), + ).toBe("Turned off 1 skill for Codex. Claude and Cursor lose it too."); + expect( + describeResult({ kind: "remove", skills: [ref("a")] }, [outcome({ name: "a" })], ctx), + ).toBe("Removed 1 skill from your agents."); + }); + + it("says why a skill or an agent was skipped, in the person's words", () => { + expect( + describeResult( + { kind: "enable", skills: [ref("a"), ref("b"), ref("c")], agents: [claude.instanceId] }, + [ + outcome({ name: "a" }), + outcome({ + name: "b", + status: "skipped", + blocked: [{ instanceId: claude.instanceId, reason: "entryTaken" }], + }), + outcome({ name: "c", status: "skipped", reason: "changed" }), + ], + ctx, + ), + ).toBe( + "Turned on 1 skill for Claude. Claude already has a different “b”. “c” changed since the list was read.", + ); + }); + + it("names an agent the page doesn't list by its id, and cuts a long list short", () => { + const blocked = (name: string, reason: SkillOutcome["blocked"][number]["reason"]) => + outcome({ + name, + status: "skipped", + blocked: [{ instanceId: "pi" as never, reason }], + }); + expect( + describeResult( + { kind: "enable", skills: [], agents: [] }, + [ + blocked("a", "shadowed"), + blocked("b", "shadowed"), + blocked("c", "alwaysOn"), + blocked("d", "failed"), + blocked("e", "failed"), + ], + ctx, + ), + ).toBe( + "pi loads another “a” first. pi loads another “b” first. pi reads “c” directly, so it stays on. 2 more couldn't be changed.", + ); + }); + + it("says plainly when there was nothing to do", () => { + const unchanged = [outcome({ name: "a", status: "unchanged" })]; + expect(describeResult({ kind: "enable", skills: [], agents: [] }, unchanged, ctx)).toBe( + "Already on.", + ); + expect(describeResult({ kind: "disable", skills: [], agents: [] }, unchanged, ctx)).toBe( + "Already off.", + ); + expect(describeResult({ kind: "remove", skills: [] }, unchanged, ctx)).toBe( + "Nothing to remove.", + ); + }); +}); diff --git a/apps/web/src/components/settings/SkillsSettings.logic.ts b/apps/web/src/components/settings/SkillsSettings.logic.ts index a8bb30947c82..21c34cde1427 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.ts @@ -4,6 +4,9 @@ import type { ServerProvider, SkillAgentAccess, SkillListResult, + SkillOutcome, + SkillOutcomeReason, + SkillRef, SkillScope, SkillSummary, } from "@t3tools/contracts"; @@ -21,6 +24,9 @@ const joinNames = (names: readonly string[]) => ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; +const plural = (count: number, one: string, many = `${one}s`) => + `${count} ${count === 1 ? one : many}`; + export type Skill = SkillSummary & { /** Stable across reads, so an open skill survives a refresh. */ readonly id: string; @@ -178,6 +184,264 @@ export function unreadableNote(folders: SkillListResult["unreadable"]) { : `Couldn't read ${first}, ${second} and ${rest.length} more`; } +// -- Turning skills on and off ------------------------------------------------------------------ + +/** What to ask the server for. */ +export type SkillChange = + | { + readonly kind: "enable" | "disable"; + readonly skills: readonly SkillRef[]; + readonly agents: readonly ProviderInstanceId[]; + } + | { readonly kind: "remove"; readonly skills: readonly SkillRef[] }; + +export type SkillPlan = { + readonly change: SkillChange; + /** How many skills it changes. */ + readonly affected: number; + /** Present when the change should be confirmed first, in plain words. */ + readonly confirmation?: { + readonly title: string; + readonly body: string; + /** Lines under the body, such as what stays on and why. */ + readonly notes: readonly string[]; + readonly confirm: string; + readonly destructive: boolean; + }; +}; + +const skillRef = (skill: Skill): SkillRef => ({ + scope: skill.scope, + name: skill.name, + home: skill.home, +}); + +const enablePlan = (skills: readonly Skill[], agents: readonly SkillAgent[]): SkillPlan => ({ + change: { + kind: "enable", + skills: skills.map(skillRef), + agents: agents.map((agent) => agent.instanceId), + }, + affected: skills.length, +}); + +/** Why an agent's switch can't be flipped, or null when it can. */ +export function switchBlocker(skill: Skill, agent: SkillAgent) { + return accessOf(skill, agent)?.state === "direct" + ? "Always on. It reads this folder directly." + : null; +} + +/** Turning one agent on for one skill, or off. Off asks first when other agents lose it too. */ +export function planToggle(skill: Skill, agent: SkillAgent, ctx: SkillsContext) { + return hasAccess(skill, agent) ? planTurnOff([skill], agent, ctx) : enablePlan([skill], [agent]); +} + +/** Every installed agent that lacks one of the skills gets a link; nothing asks first. */ +export function planTurnOnAll(selected: readonly Skill[], ctx: SkillsContext): SkillPlan | null { + const targets = selected.filter((skill) => missingAgents(skill, ctx).length > 0); + if (targets.length === 0) return null; + const agents = ctx.installed.filter((agent) => targets.some((skill) => !hasAccess(skill, agent))); + return enablePlan(targets, agents); +} + +/** Other installed agents that lose the skill when this agent's link goes: same folder, same link. */ +function alsoLosesOnTurnOff(skill: Skill, agent: SkillAgent, ctx: SkillsContext) { + const target = accessOf(skill, agent); + if (target?.state !== "link") return []; + return ctx.installed.filter((other) => { + const access = accessOf(skill, other); + return ( + other.instanceId !== agent.instanceId && + access?.state === "link" && + access.folder === target.folder + ); + }); +} + +/** Turning one agent off for the skills it uses through a link. Others stay on. */ +export function planTurnOff( + selected: readonly Skill[], + agent: SkillAgent, + ctx: SkillsContext, +): SkillPlan | null { + const targets = selected.filter((skill) => accessOf(skill, agent)?.state === "link"); + const stuck = selected.filter((skill) => accessOf(skill, agent)?.state === "direct"); + if (targets.length === 0 && stuck.length === 0) return null; + const alsoLose = new Map( + targets + .flatMap((skill) => alsoLosesOnTurnOff(skill, agent, ctx)) + .map((other) => [other.instanceId, other] as const), + ); + const notes: string[] = []; + if (alsoLose.size > 0) { + notes.push( + `${joinNames([...alsoLose.values()].map((other) => other.displayName))} ${alsoLose.size === 1 ? "loses" : "lose"} ${targets.length === 1 ? "it" : "these"} too.`, + ); + } + if (stuck.length > 0) { + notes.push( + `${plural(stuck.length, "skill")} ${stuck.length === 1 ? "stays" : "stay"} on because ${agent.displayName} reads ${stuck.length === 1 ? "its" : "their"} folder.`, + ); + } + return { + change: { + kind: "disable", + skills: targets.map(skillRef), + agents: [agent.instanceId], + }, + affected: targets.length, + ...(notes.length > 0 && targets.length > 0 + ? { + confirmation: { + title: `Turn off for ${agent.displayName}?`, + body: `Removes ${agent.displayName}'s link for ${plural(targets.length, "skill")}.`, + notes, + confirm: "Turn off", + destructive: false, + }, + } + : {}), + }; +} + +/** Agents that read the skill through a link, not from the skill's own folder. */ +const readsThroughLink = (skill: Skill) => + skill.access.filter( + (access) => access.state !== "none" && `${access.folder}/${skill.name}` !== skill.home, + ); + +/** Removing every link to the skills. The skills' own folders stay, so some agents may keep them. */ +export function planRemove(selected: readonly Skill[], ctx: SkillsContext): SkillPlan | null { + const targets = selected.filter((skill) => readsThroughLink(skill).length > 0); + if (targets.length === 0) return null; + const change: SkillChange = { kind: "remove", skills: targets.map(skillRef) }; + const notes: string[] = []; + const idle = selected.length - targets.length; + if (idle > 0) { + notes.push( + `${plural(idle, "skill")} ${idle === 1 ? "is" : "are"} only in ${idle === 1 ? "its" : "their"} own folder, so nothing changes there.`, + ); + } + if (targets.length === 1) { + const skill = targets[0]!; + const linked = new Set(readsThroughLink(skill).map((access) => access.instanceId)); + const losing = ctx.installed.filter((agent) => linked.has(agent.instanceId)); + const keeping = ctx.installed.filter( + (agent) => hasAccess(skill, agent) && !linked.has(agent.instanceId), + ); + if (keeping.length > 0) { + notes.push( + `${joinNames(keeping.map((agent) => agent.displayName))} still ${keeping.length === 1 ? "uses" : "use"} it from its own folder.`, + ); + } + return { + change, + affected: 1, + confirmation: { + title: `Remove ${skill.name} from your agents?`, + body: `${joinNames(losing.map((agent) => agent.displayName)) || "No agent"} will stop using it; the original stays.`, + notes, + confirm: "Remove", + destructive: true, + }, + }; + } + return { + change, + affected: targets.length, + confirmation: { + title: `Remove ${targets.length} skills from your agents?`, + body: "Agents will stop using them; the originals stay.", + notes, + confirm: "Remove", + destructive: true, + }, + }; +} + +/** A one-click fix for a skill that installed agents can't use yet. */ +export function planFix(skill: Skill, ctx: SkillsContext) { + const missing = missingAgents(skill, ctx); + if (missing.length === 0) return null; + return { + label: + missing.length === 1 ? `Turn on for ${missing[0]!.displayName}` : "Turn on for all agents", + plan: enablePlan([skill], missing), + }; +} + +const problemText = (reason: SkillOutcomeReason, name: string, who: string | undefined) => { + switch (reason) { + case "notFound": + return `“${name}” isn't there any more.`; + case "changed": + return `“${name}” changed since the list was read.`; + case "alwaysOn": + return `${who ?? "An agent"} reads “${name}” directly, so it stays on.`; + case "entryTaken": + return `${who ?? "An agent"} already has a different “${name}”.`; + case "shadowed": + return `${who ?? "An agent"} loads another “${name}” first.`; + case "linkNotAllowed": + return "Your system doesn't let T3 Code make links there. On Windows, turn on Developer Mode."; + case "failed": + return who === undefined + ? `Couldn't change “${name}”.` + : `Couldn't change ${who}'s folder for “${name}”.`; + } +}; + +const MAX_PROBLEMS = 3; + +/** One status line on what a change did, from what the server says happened to each skill. */ +export function describeResult( + change: SkillChange, + outcomes: readonly SkillOutcome[], + ctx: SkillsContext, +) { + const nameOf = (id: ProviderInstanceId) => + ctx.installed.find((agent) => agent.instanceId === id)?.displayName ?? id; + const changed = outcomes.filter((outcome) => outcome.status === "changed"); + const also = [...new Set(changed.flatMap((outcome) => outcome.affected.map(nameOf)))]; + const alsoNames = joinNames(also); + const them = changed.length === 1 ? "it" : "them"; + const lead = (() => { + if (changed.length === 0) return ""; + const count = plural(changed.length, "skill"); + switch (change.kind) { + case "enable": + return `Turned on ${count} for ${joinNames(change.agents.map(nameOf))}.${also.length > 0 ? ` ${alsoNames} ${also.length === 1 ? "gets" : "get"} ${them} too.` : ""}`; + case "disable": + return `Turned off ${count} for ${joinNames(change.agents.map(nameOf))}.${also.length > 0 ? ` ${alsoNames} ${also.length === 1 ? "loses" : "lose"} ${them} too.` : ""}`; + case "remove": + return `Removed ${count} from your agents.`; + } + })(); + const problems = [ + ...new Set( + outcomes.flatMap((outcome) => [ + ...(outcome.reason ? [problemText(outcome.reason, outcome.skill.name, undefined)] : []), + ...outcome.blocked.map((blocked) => + problemText(blocked.reason, outcome.skill.name, nameOf(blocked.instanceId)), + ), + ]), + ), + ]; + if (lead === "" && problems.length === 0) { + return change.kind === "enable" + ? "Already on." + : change.kind === "disable" + ? "Already off." + : "Nothing to remove."; + } + const shown = problems.slice(0, MAX_PROBLEMS); + if (problems.length > shown.length) { + shown.push(`${problems.length - shown.length} more couldn't be changed.`); + } + return [lead, ...shown].filter((part) => part !== "").join(" "); +} + // -- Search ----------------------------------------------------------------------------------- export const matchesQuery = (skill: Skill, needle: string) => diff --git a/apps/web/src/components/settings/SkillsSettings.tsx b/apps/web/src/components/settings/SkillsSettings.tsx index 31b27ab1e2c1..1544765d41f5 100644 --- a/apps/web/src/components/settings/SkillsSettings.tsx +++ b/apps/web/src/components/settings/SkillsSettings.tsx @@ -1,5 +1,6 @@ import type { ServerProvider } from "@t3tools/contracts"; -import { BookOpenIcon } from "lucide-react"; +import { useAtomValue } from "@effect/atom-react"; +import { BookOpenIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAfterDelay } from "../../hooks/useAfterDelay"; @@ -11,19 +12,23 @@ import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { RefreshIcon } from "../ui/refresh-icon"; import { Skeleton } from "../ui/skeleton"; +import { BulkBar, ConfirmPlan } from "./SkillBulkBar"; import { SkillDetail } from "./SkillDetail"; -import { SkillSection, StandardInfo } from "./SkillList"; +import { SkillSection, StandardInfo, type RowFix } from "./SkillList"; import { SettingsGroup } from "./SettingsGroup"; import { SettingsPageContainer } from "./settingsLayout"; import { useSettingsScope } from "./SettingsScopeContext"; import { attention, + describeResult, ingestSkills, installedAgents, matchesQuery, + planFix, skillsEnvironment, unreadableNote, type Skill, + type SkillPlan, type SkillsContext, } from "./SkillsSettings.logic"; @@ -31,6 +36,7 @@ const NO_PROVIDERS: readonly ServerProvider[] = []; /** A load that finishes sooner than this shows no placeholder at all. */ const SKELETON_DELAY_MS = 150; const LOAD_ERROR = "Couldn't read this environment's skill folders."; +const CHANGE_ERROR = "Couldn't change the skills here."; type PickedProject = { id: string; label: string; cwd: string }; type Loaded = ReturnType; @@ -103,6 +109,19 @@ function EnvironmentSkills({ onSubpageChange: (open: boolean) => void; }) { const listSkills = useAtomCommand(serverEnvironment.listSkills, { reportFailure: false }); + const enableSkills = useAtomCommand(serverEnvironment.enableSkills, { reportFailure: false }); + const disableSkills = useAtomCommand(serverEnvironment.disableSkills, { reportFailure: false }); + const removeSkills = useAtomCommand(serverEnvironment.removeSkills, { reportFailure: false }); + // Reading the list needs no grant; each change needs its command's. + const canEnable = useAtomValue( + serverEnvironment.enableSkills.permissionAtom(environment.environmentId), + ); + const canDisable = useAtomValue( + serverEnvironment.disableSkills.permissionAtom(environment.environmentId), + ); + const canRemove = useAtomValue( + serverEnvironment.removeSkills.permissionAtom(environment.environmentId), + ); const connected = environment.connection.phase === "connected"; const providers = environment.serverConfig?.providers ?? NO_PROVIDERS; const cwd = project?.cwd ?? null; @@ -114,6 +133,14 @@ function EnvironmentSkills({ const [query, setQuery] = useState(""); const [onlyAttention, setOnlyAttention] = useState(false); const [detailReload, setDetailReload] = useState(0); + const [selected, setSelected] = useState>(new Set()); + /** A change that is waiting for the person to confirm it. */ + const [confirming, setConfirming] = useState(null); + /** A change is being made and the list read again; nothing else can start meanwhile. */ + const [busy, setBusy] = useState(false); + /** The controls that change skills are off while a change runs or the grant is missing. */ + const locked = busy || !(canEnable && canDisable && canRemove); + const [notice, setNotice] = useState(null); const rootRef = useRef(null); const mounted = useRef(true); useEffect(() => { @@ -213,6 +240,76 @@ function EnvironmentSkills({ }, [showList, onSubpageChange]); const toList = () => show({ kind: "list" }); + + /** + * Asks the server to make the change, then reads the folders again: the page shows what is on + * disk, never what the change was expected to do. + */ + const apply = async (plan: SkillPlan) => { + setConfirming(null); + setBusy(true); + const { change } = plan; + const base = { environmentId: environment.environmentId } as const; + const scoped = cwd ? { cwd } : {}; + try { + const result = + change.kind === "enable" + ? await enableSkills({ + ...base, + input: { ...scoped, skills: change.skills, agents: change.agents }, + }) + : change.kind === "disable" + ? await disableSkills({ + ...base, + input: { ...scoped, skills: change.skills, agents: change.agents }, + }) + : await removeSkills({ ...base, input: { ...scoped, skills: change.skills } }); + setNotice( + result._tag === "Success" + ? describeResult(change, result.value.outcomes, ctx) + : CHANGE_ERROR, + ); + } catch { + setNotice(CHANGE_ERROR); + } + try { + const loaded = await load(); + if (loaded) { + setData(loaded); + setLoadError(null); + } else { + setLoadError(LOAD_ERROR); + } + } catch { + setLoadError(LOAD_ERROR); + } + setSelected(new Set()); + setBusy(false); + }; + /** A plan that needs confirming waits for the dialog; any other goes ahead. */ + const runPlan = (plan: SkillPlan) => { + if (plan.confirmation) setConfirming(plan); + else void apply(plan); + }; + const chosen = useMemo( + () => (skills ?? []).filter((skill) => selected.has(skill.id)), + [skills, selected], + ); + const setSelection = (ids: readonly string[], checked: boolean) => + setSelected((current) => { + const next = new Set(current); + for (const id of ids) { + if (checked) next.add(id); + else next.delete(id); + } + return next; + }); + /** Only the Needs attention list offers it, where the missing link is the point of the row. */ + const rowFix = (skill: Skill): RowFix | null => { + if (!onlyAttention || attention(skill, ctx)?.kind !== "missing") return null; + const fix = planFix(skill, ctx); + return fix ? { label: fix.label, run: () => runPlan(fix.plan) } : null; + }; const offline = !connected; const empty = skills !== null && skills.length === 0; const emptyText = (total: number, none: string) => @@ -241,6 +338,23 @@ function EnvironmentSkills({

    )} + {notice && ( +

    + {notice} + +

    + )} + {skillView && ( setDetailReload((count) => count + 1)} /> )} @@ -309,6 +425,10 @@ function EnvironmentSkills({ visible={visible(projectSkills)} ctx={ctx} emptyText={emptyText(projectSkills.length, "No skills in this project.")} + selected={selected} + busy={locked} + rowFix={rowFix} + onSelectedChange={setSelection} onOpen={(id) => show({ kind: "skill", id })} /> )} @@ -320,13 +440,32 @@ function EnvironmentSkills({ visible={visible(globalSkills)} ctx={ctx} emptyText={emptyText(globalSkills.length, "No Global skills yet.")} + selected={selected} + busy={locked} + rowFix={rowFix} + onSelectedChange={setSelection} onOpen={(id) => show({ kind: "skill", id })} /> {empty &&

    No skills yet.

    } + {chosen.length > 0 && ( + setSelected(new Set())} + onPlan={runPlan} + /> + )} )} )} + + setConfirming(null)} + onConfirm={() => confirming && void apply(confirming)} + />

    ); } diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index b2c6933fabb7..9eda1d53ad6d 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -288,3 +288,29 @@ it.effect("rejects protected unary and streamed RPCs outside a guarded command", expect(writes).toBe(0); }), ); + +it.effect("needs the operate grant to change skills, but not to list or read them", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + for (const method of [ + WS_METHODS.serverEnableSkills, + WS_METHODS.serverDisableSkills, + WS_METHODS.serverRemoveSkills, + ]) { + const change = createCommandPermissions(runtime, method); + registry.set(sessions(env), AsyncResult.success(grant(false))); + expect(registry.get(change.permissionAtom(env))).toBe(false); + expect((yield* change.authorize(registry, env).pipe(Effect.flip)).requiredScope).toBe( + AuthOrchestrationOperateScope, + ); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(change.permissionAtom(env))).toBe(true); + yield* change.authorize(registry, env); + } + for (const method of [WS_METHODS.serverListSkills, WS_METHODS.serverGetSkill]) { + expect(createCommandPermissions(runtime, method).requiredScopes()).toEqual([]); + } + }), + ), +); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 2c3aabc401d8..b4004761076b 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1178,6 +1178,18 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:get-skill", tag: WS_METHODS.serverGetSkill, }), + enableSkills: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:enable-skills", + tag: WS_METHODS.serverEnableSkills, + }), + disableSkills: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:disable-skills", + tag: WS_METHODS.serverDisableSkills, + }), + removeSkills: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:remove-skills", + tag: WS_METHODS.serverRemoveSkills, + }), refreshProviders: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-providers", tag: WS_METHODS.serverRefreshProviders, From 388eb118196d210efcf2efd95d5d5a14b5689802 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:46:23 -0400 Subject: [PATCH 011/108] docs: describe turning skills on or off for an agent Co-Authored-By: Claude Sonnet 5.5 --- docs/user/skills.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/user/skills.md b/docs/user/skills.md index 6df379a48f2e..c829b0209c0b 100644 --- a/docs/user/skills.md +++ b/docs/user/skills.md @@ -2,7 +2,8 @@ Open **Settings → Skills** on web and desktop to see which skills your agents can use. The page reads the environment and project chosen at the top of Settings, so with a remote environment you -see that machine's skills. It is read-only: edit skills in your editor, or ask an agent. +see that machine's skills. You can turn each skill on or off for each agent here. To change what a +skill says, edit it in your editor or ask an agent. The agents are your enabled provider instances. Two Claude instances show as two agents, each with its own config folder. @@ -22,11 +23,33 @@ Each instance's config folder follows its settings: a Claude instance's config d agents reads isn't listed. If a folder exists but can't be read, the page says so above the list instead of showing it as empty. +## Turning a skill on or off for an agent + +Open a skill and click an agent under **Used by**. Turning a skill on makes a link in that agent's +own folder that points at the skill's real folder, so the files stay in one place. Turning it off +removes that link and nothing else. + +- An agent that reads the skill's own folder directly shows a lock: it is always on. To stop it + using the skill, move the skill out of that folder yourself. +- Agents that read the same folder share one link, so turning a skill on or off for one can change + it for the others. T3 Code says who else is affected. +- If something is already in the agent's folder under that name, such as a real folder, a file or + a link to a different skill, T3 Code leaves it alone and says so. It never replaces anything. +- A project's links to skills inside the project are relative, so they keep working when the + project moves. They show in `git status`; commit them to give everyone who clones the project + the skill. On Windows, global links are junctions, and project links need Developer Mode or + administrator rights. + +Tick the boxes beside skills to act on several at once: turn them on for all agents, turn them off +for one agent, or remove them. **Remove** takes away every link to the skills so agents stop using +them. The original skill folders are never deleted. + ## Needs attention **Needs attention** filters the list to skills that need a look. A skill is on it when: -- an installed and enabled agent doesn't use it. Hover the icons to see which agent. An agent +- an installed and enabled agent doesn't use it. Hover the icons to see which agent, or use the + button on the row to turn the skill on for it. An agent loads one skill per name, the first it finds in its folders (Codex and OpenCode list every copy), so a copy that another folder shadows is not used by that agent. Claude doesn't use a skill that its own `skillOverrides` setting switches off either. From b5b2d9c4852e09faddf994462fd9874e44f35d4c Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:18:57 -0400 Subject: [PATCH 012/108] feat(server): move and delete skills, and refresh the $ picker after skill changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Skills can only turn skills on or off for an agent. Moving a skill between a project and Global, or deleting it, meant doing it by hand, and the chat composer's `$` picker could lag behind any change. `server.moveSkills` moves a skill's folder to the other scope's shared folder: one rename on one filesystem; across filesystems a copy next to the destination under a hidden name, checked against the original, then renamed into place before the original goes. It never merges into or replaces a skill with the same name, and a failed copy leaves no half-made skill. The agents' old links are removed and recreated at the new scope with the same rules as turning a skill on. `server.deleteSkills` deletes a skill's own folder and the links to it. Both only act on a folder that sits in an agent's skill folder itself, never a synced library behind a link, and both need Operate scope. After any write that changed what an agent can use, the agents involved have their composer skill list refreshed in the background, once, and nothing is refreshed when nothing was written. The skill list says which skills have a real folder, so the page can offer a move only where it works. `server.skillsTracked` says which project skills git tracks, with one `git ls-files` for the skills in a confirmation, so the page promises an undo with git only where there is one. Listing still spawns nothing. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/auth/RpcAuthorization.test.ts | 5 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/observability/RpcInstrumentation.ts | 3 + apps/server/src/server.ts | 8 +- apps/server/src/skills/SkillCatalog.test.ts | 34 + apps/server/src/skills/SkillCatalog.ts | 52 +- apps/server/src/skills/SkillManager.test.ts | 724 +++++++++++++++++- apps/server/src/skills/SkillManager.ts | 276 ++++++- apps/server/src/skills/SkillMove.test.ts | 275 +++++++ apps/server/src/skills/SkillMove.ts | 234 ++++++ apps/server/src/skills/SkillTracking.test.ts | 235 ++++++ apps/server/src/skills/SkillTracking.ts | 96 +++ apps/server/src/ws.ts | 5 + .../src/state/commandPermissions.test.ts | 8 +- packages/client-runtime/src/state/server.ts | 12 + .../contracts/src/clientRpcPermissions.ts | 2 + packages/contracts/src/rpc.ts | 28 + packages/contracts/src/skills.ts | 42 + 18 files changed, 1966 insertions(+), 74 deletions(-) create mode 100644 apps/server/src/skills/SkillMove.test.ts create mode 100644 apps/server/src/skills/SkillMove.ts create mode 100644 apps/server/src/skills/SkillTracking.test.ts create mode 100644 apps/server/src/skills/SkillTracking.ts diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 4dc80c3a0bb0..4e1c1ff84645 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -64,6 +64,9 @@ describe("RPC authorization scopes", () => { for (const method of [WS_METHODS.serverListSkills, WS_METHODS.serverGetSkill]) { expect(requiredScopeForRpcMethod(method)).toBe(AuthFilesystemReadScope); } + expect(requiredScopeForRpcMethod(WS_METHODS.serverSkillsTracked)).toBe( + AuthOrchestrationReadScope, + ); }); it("doesn't let a read-only client change which agents use skills", () => { @@ -71,6 +74,8 @@ describe("RPC authorization scopes", () => { WS_METHODS.serverEnableSkills, WS_METHODS.serverDisableSkills, WS_METHODS.serverRemoveSkills, + WS_METHODS.serverMoveSkills, + WS_METHODS.serverDeleteSkills, ]) { expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationOperateScope); } diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 3bfa02988260..8100cd7bf29d 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -64,6 +64,7 @@ export const RPC_REQUIRED_SCOPES = { // reads take, not the orchestration read scope that thread readers hold. [WS_METHODS.serverListSkills]: AuthFilesystemReadScope, [WS_METHODS.serverGetSkill]: AuthFilesystemReadScope, + [WS_METHODS.serverSkillsTracked]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope, [WS_METHODS.providerAuthStart]: AuthProvidersManageScope, [WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index fed413eec7ff..1164e18f3a75 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -38,6 +38,9 @@ const RPC_AGGREGATES = { [WS_METHODS.serverEnableSkills]: "server", [WS_METHODS.serverDisableSkills]: "server", [WS_METHODS.serverRemoveSkills]: "server", + [WS_METHODS.serverMoveSkills]: "server", + [WS_METHODS.serverDeleteSkills]: "server", + [WS_METHODS.serverSkillsTracked]: "server", [WS_METHODS.serverUpdateProvider]: "server", [WS_METHODS.providerAuthStart]: "provider", [WS_METHODS.providerConsumeResetCredit]: "provider", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8a58701a9e38..c365e05696dd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -89,6 +89,7 @@ import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as SkillCatalog from "./skills/SkillCatalog.ts"; import * as SkillManager from "./skills/SkillManager.ts"; +import * as SkillTracking from "./skills/SkillTracking.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; @@ -576,9 +577,12 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ProviderUsageLimitsIngestion.layer, layerProviderInstallationRefresh, ReplayMarkers.layer, - // It reads through SkillCatalog and checks folders against ProjectService, both provided - // below; being here makes it one instance, so skill writes run one request at a time. + // It reads through SkillCatalog, checks folders against ProjectService and refreshes the + // composer's skill lists through ProviderRegistry, all provided below; being here makes it one + // instance, so skill writes run one request at a time. SkillManager.layer, + // Reads through SkillCatalog and runs git through VcsProcess. + SkillTracking.layer, ).pipe( // Core Services // It checks a project's folder against ProjectService, which the next layer provides. diff --git a/apps/server/src/skills/SkillCatalog.test.ts b/apps/server/src/skills/SkillCatalog.test.ts index 49c39bd15819..0080eae9eb83 100644 --- a/apps/server/src/skills/SkillCatalog.test.ts +++ b/apps/server/src/skills/SkillCatalog.test.ts @@ -765,6 +765,40 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillCatalog", (it) ); }); + describe("what can be moved or deleted", () => { + it.effect.skipIf(!symlinksSupported)( + "marks a skill whose folder sits in an agent's folder, and not one reached through a link", + () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project })); + const byName = byKey(skills); + + expect(byName.get("project:verify")?.realFolder).toBe(true); + expect(byName.get("project:own-copy")?.realFolder).toBe(true); + expect(byName.get("global:cloudflare")?.realFolder).toBe(true); + // The library's skills are linked into the shared folder, so they live elsewhere. + expect(byName.get("global:architect")?.realFolder).toBeUndefined(); + expect(byName.get("global:tdd")?.realFolder).toBeUndefined(); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "doesn't call a folder reached through a linked skills folder a real one", + () => + Effect.gen(function* () { + const { home, project, write, link } = yield* makeMachine; + yield* write("elsewhere/relay/SKILL.md", skillFile("relay", "Reached through a link.")); + // The project's whole `.cursor/skills` folder is a link to another folder. + yield* link("elsewhere", "repos/app/.cursor/skills"); + const { skills } = yield* withCatalog(home, (catalog) => catalog.list({ cwd: project })); + + expect(byKey(skills).get("project:relay")).toMatchObject({ name: "relay" }); + expect(byKey(skills).get("project:relay")?.realFolder).toBeUndefined(); + }), + ); + }); + describe("get", () => { it.effect.skipIf(!symlinksSupported)( "returns the full SKILL.md, the file list and which files can run", diff --git a/apps/server/src/skills/SkillCatalog.ts b/apps/server/src/skills/SkillCatalog.ts index d8e3acb5beaa..7b13c2b5fe7a 100644 --- a/apps/server/src/skills/SkillCatalog.ts +++ b/apps/server/src/skills/SkillCatalog.ts @@ -153,6 +153,13 @@ export interface ResolvedSkill { readonly displayHome: string; /** Absolute path of the skill's folder, after following links. */ readonly home: string; + /** + * The home is a real folder in one of the agents' skill folders, reached without a link on the + * way. Only such a skill is T3 Code's to move or delete; a synced library's skill is not. + */ + readonly own: boolean; + /** The shared folder of each scope, where a moved skill lands; a project's needs `cwd`. */ + readonly standardFolders: Readonly>; /** Every entry in the agents' folders that reaches the skill: a real folder, or a link. */ readonly entries: ReadonlyArray<{ readonly path: string; @@ -547,6 +554,34 @@ const make = Effect.gen(function* () { concurrency: CONCURRENCY, }); + // A folder is plain when it is where its path says, with no link on the way below the base. + const bases = { + project: cwd === undefined ? undefined : { given: cwd, real: displayRoots.project[0] ?? cwd }, + global: { given: homeDirectory, real: displayRoots.home[0] ?? homeDirectory }, + }; + const plainRoots = new Set(); + yield* Effect.forEach( + roots, + (root) => + Effect.gen(function* () { + const base = bases[root.scope]; + const real = yield* fileSystem + .realPath(root.directory) + .pipe(Effect.orElseSucceed(() => undefined)); + if (base === undefined || real === undefined) return; + const relative = path.relative(base.given, root.directory); + const inside = !relative.startsWith("..") && !path.isAbsolute(relative); + if (real === (inside ? path.join(base.real, relative) : root.directory)) { + plainRoots.add(rootKey(root)); + } + }), + { concurrency: CONCURRENCY, discard: true }, + ); + const isOwn = (group: Pick) => + group.entries.some( + (entry) => entry.target === undefined && plainRoots.has(rootKey(entry.root)), + ); + // Group by what is really on disk: the same folder reached through several links is one skill. const grouped = new Map>(); for (const { entries } of scanned) { @@ -634,12 +669,12 @@ const make = Effect.gen(function* () { }; }; - return { displayRoots, instances, scanned, groups, accessFor, loadableAt }; + return { displayRoots, instances, scanned, groups, accessFor, loadableAt, isOwn, roots }; }); const list: SkillCatalog["Service"]["list"] = Effect.fn("SkillCatalog.list")(function* (input) { const cwd = yield* requireProject(input.cwd); - const { displayRoots, instances, scanned, groups, accessFor } = yield* scanSkills(cwd); + const { displayRoots, instances, scanned, groups, accessFor, isOwn } = yield* scanSkills(cwd); const copies = yield* compareCopies(groups, displayRoots); const skills = groups.map((group): SkillSummary => ({ @@ -648,6 +683,7 @@ const make = Effect.gen(function* () { home: displayPath(group.home, displayRoots), description: capDescription(group.header.description), ...(group.header.invalid ? { invalidHeader: true } : {}), + ...(isOwn(group) ? { realFolder: true } : {}), copies: copies.get(group) ?? [], access: instances.map((instance) => accessFor(group, instance).access), })); @@ -675,10 +711,12 @@ const make = Effect.gen(function* () { (skill) => isSkillFolderName(skill.name) && (skill.scope === "global" || cwd !== undefined), ); if (wanted.length === 0) return []; - const { displayRoots, instances, groups, accessFor, loadableAt } = yield* scanSkills( - cwd, - new Set(wanted.map((skill) => skill.name)), - ); + const { displayRoots, instances, groups, accessFor, loadableAt, isOwn, roots } = + yield* scanSkills(cwd, new Set(wanted.map((skill) => skill.name))); + const standardFolders = { + project: roots.find((root) => root.scope === "project" && root.standard)?.directory, + global: roots.find((root) => root.scope === "global" && root.standard)?.directory, + }; const wantedKeys = new Set(wanted.map((skill) => `${skill.scope}\0${skill.name}`)); return groups .filter((group) => wantedKeys.has(`${group.scope}\0${group.name}`)) @@ -687,6 +725,8 @@ const make = Effect.gen(function* () { name: group.name, displayHome: displayPath(group.home, displayRoots), home: group.home, + own: isOwn(group), + standardFolders, entries: group.entries.map((entry) => ({ path: path.join(entry.root.directory, entry.name), directory: entry.root.directory, diff --git a/apps/server/src/skills/SkillManager.test.ts b/apps/server/src/skills/SkillManager.test.ts index 38f0d7a3a54c..3aa73c20c66d 100644 --- a/apps/server/src/skills/SkillManager.test.ts +++ b/apps/server/src/skills/SkillManager.test.ts @@ -5,8 +5,10 @@ import { ProviderDriverKind, ProviderInstanceId, SkillBatchResult, + SkillDeleteInput, SkillDisableInput, SkillEnableInput, + SkillMoveInput, SkillRemoveInput, SkillRequestError, type Project, @@ -21,9 +23,12 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as Settings from "../serverSettings.ts"; import * as SkillCatalog from "./SkillCatalog.ts"; import * as SkillManager from "./SkillManager.ts"; @@ -81,47 +86,69 @@ const makeProject = (workspaceRoot: string): Project => ({ deletedAt: null, }); -/** The manager and catalog on a machine whose home is `home`; only `registered` folders are projects. */ +/** A skill-list refresh the manager asked the provider registry for. */ +type Refresh = { + readonly instanceId: ProviderInstanceId; + /** Absent when only the agent's machine-wide list was refreshed. */ + readonly cwd: string | undefined; + readonly fresh: boolean | undefined; +}; + +/** + * The manager and catalog on a machine whose home is `home`; only `registered` folders are + * projects. The provider registry is a stand-in that queues each refresh it is asked for. + */ const withManager = ( home: string, registered: readonly string[], use: (services: { readonly manager: SkillManager.SkillManager["Service"]; readonly catalog: SkillCatalog.SkillCatalog["Service"]; + readonly refreshes: Queue.Queue; }) => Effect.Effect, ) => Effect.gen(function* () { - return yield* use({ - manager: yield* SkillManager.SkillManager, - catalog: yield* SkillCatalog.SkillCatalog, + const refreshes = yield* Queue.unbounded(); + const registry = Layer.mock(ProviderRegistry.ProviderRegistry)({ + refreshInstance: (instanceId) => + Queue.offer(refreshes, { instanceId, cwd: undefined, fresh: undefined }).pipe( + Effect.as([]), + ), + refreshWorkspaceSnapshot: ({ instanceId, cwd, fresh }) => + Queue.offer(refreshes, { instanceId, cwd, fresh }).pipe(Effect.as([])), }); - }).pipe( - Effect.provide( - SkillManager.layer.pipe( - Layer.provideMerge( - SkillCatalog.layer.pipe( - Layer.provide( - Settings.layerTest({ - providerInstances: Object.fromEntries( - ["cursor", "grok", "opencode", "antigravity", "pi"].map((driver) => [ - ProviderInstanceId.make(driver), - { driver: ProviderDriverKind.make(driver), enabled: true }, - ]), - ), - }), - ), + const projects = Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (root) => + Effect.succeed(registered.includes(root) ? Option.some(makeProject(root)) : Option.none()), + }); + const catalog = SkillCatalog.layer.pipe( + Layer.provide( + Settings.layerTest({ + providerInstances: Object.fromEntries( + ["cursor", "grok", "opencode", "antigravity", "pi"].map((driver) => [ + ProviderInstanceId.make(driver), + { driver: ProviderDriverKind.make(driver), enabled: true }, + ]), ), - ), - Layer.provide( - Layer.mock(ProjectService.ProjectService)({ - getByWorkspaceRoot: (root) => - Effect.succeed( - registered.includes(root) ? Option.some(makeProject(root)) : Option.none(), - ), - }), + }), + ), + ); + return yield* Effect.gen(function* () { + return yield* use({ + manager: yield* SkillManager.SkillManager, + catalog: yield* SkillCatalog.SkillCatalog, + refreshes, + }); + }).pipe( + Effect.provide( + SkillManager.layer.pipe( + Layer.provideMerge(catalog), + Layer.provide(projects), + Layer.provide(registry), ), ), - ), + ); + }).pipe( Effect.provideService(HostProcess.Environment, { HOME: home }), Effect.provideService(HostProcess.HomeDirectory, home), ); @@ -581,6 +608,619 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) ); }); + describe("move", () => { + /** Claude reads a project skill through a link made by turning it on. */ + const withClaudeOnVerify = ( + manager: SkillManager.SkillManager["Service"], + project: string, + verify: SkillRef, + ) => manager.enable({ cwd: project, skills: [verify], agents: [agent("claudeAgent")] }); + + it.effect.skipIf(!symlinksSupported)( + "moves a project skill to Global, and each agent's link follows", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const verify = refOf( + (yield* catalog.list({ cwd: project })).skills, + "project", + "verify", + ); + yield* withClaudeOnVerify(manager, project, verify); + expect(yield* fs.readLink(path.join(project, ".claude/skills/verify"))).toBe( + "../../.agents/skills/verify", + ); + + const result = yield* manager.move({ cwd: project, skills: [verify], to: "global" }); + + // Grok reads the shared global folder but not the project's, so it gets the skill. + expect(result.outcomes).toEqual([ + { skill: verify, status: "changed", blocked: [], affected: [agent("grok")] }, + ]); + yield* encodeResult(result); + const moved = path.join(home, ".agents/skills/verify"); + expect(yield* fs.readFileString(path.join(moved, "run.sh"))).toBe("echo ok"); + expect(yield* fs.exists(path.join(project, ".agents/skills/verify"))).toBe(false); + // The project's link would lead nowhere; the agents that need one get it in Global. + expect(yield* fs.readDirectory(path.join(project, ".claude/skills"))).toEqual([]); + expect(yield* fs.readLink(path.join(home, ".claude/skills/verify"))).toBe(moved); + expect(yield* fs.readLink(path.join(home, ".gemini/config/skills/verify"))).toBe( + moved, + ); + const after = (yield* catalog.list({ cwd: project })).skills; + expect( + after.some((skill) => skill.scope === "project" && skill.name === "verify"), + ).toBe(false); + expect(stateOf(after, "global", "verify")).toEqual({ + claudeAgent: "link", + codex: "direct", + cursor: "direct", + grok: "direct", + opencode: "direct", + antigravity: "link", + pi: "direct", + }); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "moves a skill in an agent's own folder to this project, and the agent keeps it", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const solo = refOf((yield* catalog.list({ cwd: project })).skills, "global", "solo"); + + const result = yield* manager.move({ cwd: project, skills: [solo], to: "project" }); + + expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + // Codex, Antigravity and Pi read the project's shared folder; they hadn't the skill. + expect(result.outcomes[0]?.affected.toSorted()).toEqual( + [agent("antigravity"), agent("codex"), agent("pi")].toSorted(), + ); + const moved = path.join(project, ".agents/skills/solo"); + expect(yield* fs.exists(path.join(moved, "SKILL.md"))).toBe(true); + expect(yield* fs.exists(path.join(home, ".claude/skills/solo"))).toBe(false); + // A project's link is relative, so it survives a clone. + expect(yield* fs.readLink(path.join(project, ".claude/skills/solo"))).toBe( + "../../.agents/skills/solo", + ); + expect( + stateOf((yield* catalog.list({ cwd: project })).skills, "project", "solo"), + ).toMatchObject({ claudeAgent: "link", codex: "direct", opencode: "direct" }); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "never merges into or replaces a skill of the same name in the other scope", + () => + Effect.gen(function* () { + const { fs, path, home, project, write } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const verify = refOf( + (yield* catalog.list({ cwd: project })).skills, + "project", + "verify", + ); + yield* withClaudeOnVerify(manager, project, verify); + // First something that isn't even a skill is in the way, then a real skill. + yield* fs.makeDirectory(path.join(home, ".agents/skills/verify"), { + recursive: true, + }); + + const folder = yield* manager.move({ cwd: project, skills: [verify], to: "global" }); + yield* write(".agents/skills/verify/SKILL.md", skillFile("theirs")); + const skill = yield* manager.move({ cwd: project, skills: [verify], to: "global" }); + + for (const result of [folder, skill]) { + expect(result.outcomes[0]).toMatchObject({ + status: "skipped", + reason: "destinationTaken", + }); + } + expect( + yield* fs.readFileString(path.join(home, ".agents/skills/verify/SKILL.md")), + ).toBe(skillFile("theirs")); + expect( + yield* fs.readFileString(path.join(project, ".agents/skills/verify/run.sh")), + ).toBe("echo ok"); + expect(yield* fs.exists(path.join(project, ".claude/skills/verify"))).toBe(true); + expect(yield* fs.exists(path.join(home, ".claude/skills/verify"))).toBe(false); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "leaves a skill alone that is only reached through a link, such as a synced library's", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + const alpha = refOf(skills, "global", "alpha"); + + const result = yield* manager.move({ cwd: project, skills: [alpha], to: "project" }); + + expect(result.outcomes[0]).toMatchObject({ status: "skipped", reason: "linked" }); + expect(yield* fs.readLink(path.join(home, ".agents/skills/alpha"))).toBe( + path.join(home, "library/skills/alpha"), + ); + expect(yield* fs.exists(path.join(project, ".agents/skills/alpha"))).toBe(false); + expect(yield* fs.exists(path.join(home, "library/skills/alpha/SKILL.md"))).toBe(true); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "tells what happened to each skill in a bulk move, and one that can't move doesn't stop the rest", + () => + Effect.gen(function* () { + const { fs, path, home, project, link } = yield* makeMachine; + yield* link("library/skills/beta", "repos/app/.agents/skills/synced"); + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + const ghost: SkillRef = { + scope: "project", + name: "ghost", + home: ".agents/skills/ghost", + }; + + const result = yield* manager.move({ + cwd: project, + skills: [ + refOf(skills, "project", "synced"), + ghost, + refOf(skills, "project", "verify"), + ], + to: "global", + }); + + expect( + result.outcomes.map(({ skill, status, reason }) => [skill.name, status, reason]), + ).toEqual([ + ["synced", "skipped", "linked"], + ["ghost", "skipped", "notFound"], + ["verify", "changed", undefined], + ]); + yield* encodeResult(result); + expect(yield* fs.exists(path.join(home, ".agents/skills/verify/SKILL.md"))).toBe( + true, + ); + expect(yield* fs.readLink(path.join(project, ".agents/skills/synced"))).toBe( + path.join(home, "library/skills/beta"), + ); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "refuses a skill that isn't where the list said, and a skill that is there already", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + const solo = refOf(skills, "global", "solo"); + const verify = refOf(skills, "project", "verify"); + // `solo` now leads to another folder, so it is no longer what the list showed. + yield* fs.remove(path.join(home, ".claude/skills/solo"), { recursive: true }); + yield* fs.symlink( + path.join(home, "library/skills/beta"), + path.join(home, ".claude/skills/solo"), + ); + + const result = yield* manager.move({ + cwd: project, + skills: [solo, verify], + to: "project", + }); + + expect(result.outcomes.map(({ status, reason }) => ({ status, reason }))).toEqual([ + { status: "skipped", reason: "changed" }, + // It is in this project already. + { status: "unchanged", reason: undefined }, + ]); + expect(yield* fs.exists(path.join(project, ".agents/skills/solo"))).toBe(false); + expect(yield* fs.exists(path.join(project, ".agents/skills/verify/run.sh"))).toBe( + true, + ); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "refuses a project folder the environment doesn't know", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* withManager(home, [], ({ manager }) => + Effect.gen(function* () { + // The list itself refuses a folder that isn't a project, so name the skill as a + // client holding an older list would. + const verify: SkillRef = { + scope: "project", + name: "verify", + home: ".agents/skills/verify", + }; + + const error = yield* manager + .move({ cwd: project, skills: [verify], to: "global" }) + .pipe(Effect.flip); + + expect(error).toEqual(new SkillRequestError({ reason: "projectNotRegistered" })); + expect(yield* fs.exists(path.join(project, ".agents/skills/verify/SKILL.md"))).toBe( + true, + ); + expect(yield* fs.exists(path.join(home, ".agents/skills/verify"))).toBe(false); + }), + ); + }), + ); + + describe("across filesystems", () => { + /** The skill's own folder can't be renamed, as when the project is on another disk. */ + const onAnotherDisk = (fs: FileSystem.FileSystem, from: string) => + FileSystem.FileSystem.of({ + ...fs, + rename: (oldPath, newPath) => + oldPath === from + ? Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "rename", + pathOrDescriptor: oldPath, + cause: Object.assign(new Error("EXDEV"), { code: "EXDEV" }), + }), + ) + : fs.rename(oldPath, newPath), + }); + + it.effect.skipIf(!symlinksSupported)( + "copies the skill over, and the agents' links follow just the same", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + const from = path.join(project, ".agents/skills/verify"); + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const verify = refOf( + (yield* catalog.list({ cwd: project })).skills, + "project", + "verify", + ); + yield* withClaudeOnVerify(manager, project, verify); + + const result = yield* manager.move({ + cwd: project, + skills: [verify], + to: "global", + }); + + expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + const moved = path.join(home, ".agents/skills/verify"); + expect(yield* fs.readFileString(path.join(moved, "run.sh"))).toBe("echo ok"); + expect(yield* fs.exists(from)).toBe(false); + expect(yield* fs.readDirectory(path.join(project, ".claude/skills"))).toEqual([]); + expect(yield* fs.readLink(path.join(home, ".claude/skills/verify"))).toBe(moved); + const left = yield* fs.readDirectory(path.join(home, ".agents/skills")); + expect(left.filter((name) => name.startsWith(".t3-moving"))).toEqual([]); + }), + ).pipe(Effect.provideService(FileSystem.FileSystem, onAnotherDisk(fs, from))); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "leaves the skill and its links as they were when the copy fails", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + const from = path.join(project, ".agents/skills/verify"); + const failing = FileSystem.FileSystem.of({ + ...onAnotherDisk(fs, from), + copyFile: (source) => + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "copyFile", + pathOrDescriptor: source, + cause: Object.assign(new Error("EIO"), { code: "EIO" }), + }), + ), + }); + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const verify = refOf( + (yield* catalog.list({ cwd: project })).skills, + "project", + "verify", + ); + yield* withClaudeOnVerify(manager, project, verify); + + const result = yield* manager.move({ + cwd: project, + skills: [verify], + to: "global", + }); + + expect(result.outcomes[0]).toMatchObject({ status: "skipped", reason: "failed" }); + expect(yield* fs.readFileString(path.join(from, "run.sh"))).toBe("echo ok"); + expect(yield* fs.readLink(path.join(project, ".claude/skills/verify"))).toBe( + "../../.agents/skills/verify", + ); + expect(yield* fs.exists(path.join(home, ".agents/skills/verify"))).toBe(false); + expect(yield* fs.readDirectory(path.join(home, ".agents/skills"))).toEqual([ + "alpha", + ]); + }), + ).pipe(Effect.provideService(FileSystem.FileSystem, failing)); + }), + ); + }); + }); + + describe("delete", () => { + it.effect.skipIf(!symlinksSupported)( + "deletes the skill's folder and every link to it, and nothing else", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + const verify = refOf(skills, "project", "verify"); + yield* manager.enable({ + cwd: project, + skills: [verify], + agents: [agent("claudeAgent")], + }); + + const result = yield* manager.delete({ cwd: project, skills: [verify] }); + + expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + expect(result.outcomes[0]?.affected).toContain(agent("claudeAgent")); + expect(result.outcomes[0]?.affected).toContain(agent("codex")); + yield* encodeResult(result); + expect(yield* fs.exists(path.join(project, ".agents/skills/verify"))).toBe(false); + expect(yield* fs.readDirectory(path.join(project, ".claude/skills"))).toEqual([]); + // The folders around it, and everything else, are as they were. + expect(yield* fs.exists(path.join(project, ".agents/skills"))).toBe(true); + expect(yield* fs.exists(path.join(home, ".claude/skills/solo/SKILL.md"))).toBe(true); + expect(yield* fs.exists(path.join(home, "library/skills/alpha/SKILL.md"))).toBe(true); + expect( + (yield* catalog.list({ cwd: project })).skills.some( + (skill) => skill.name === "verify", + ), + ).toBe(false); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "takes away the links that lead to a global skill from a project too", + () => + Effect.gen(function* () { + const { fs, path, home, project, link } = yield* makeMachine; + yield* link(".claude/skills/solo", "repos/app/.claude/skills/solo"); + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const solo = refOf((yield* catalog.list({ cwd: project })).skills, "global", "solo"); + + const result = yield* manager.delete({ cwd: project, skills: [solo] }); + + expect(result.outcomes[0]).toMatchObject({ status: "changed" }); + expect(yield* fs.exists(path.join(home, ".claude/skills/solo"))).toBe(false); + const left = yield* fs.readDirectory(path.join(project, ".claude/skills")); + expect(left).not.toContain("solo"); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "doesn't delete what a link leads to: a synced library's skill stays", + () => + Effect.gen(function* () { + const { fs, path, home } = yield* makeMachine; + yield* withManager(home, [], ({ manager, catalog }) => + Effect.gen(function* () { + const alpha = refOf((yield* catalog.list({})).skills, "global", "alpha"); + + const result = yield* manager.delete({ skills: [alpha] }); + + expect(result.outcomes[0]).toMatchObject({ status: "skipped", reason: "linked" }); + expect( + yield* fs.readFileString(path.join(home, "library/skills/alpha/notes.md")), + ).toBe("notes on alpha"); + expect(yield* fs.exists(path.join(home, ".agents/skills/alpha"))).toBe(true); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "tells what happened to each skill in a bulk delete, and refuses a stale one", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + const verify = refOf(skills, "project", "verify"); + const solo = refOf(skills, "global", "solo"); + const alpha = refOf(skills, "global", "alpha"); + const ghost: SkillRef = { scope: "global", name: "ghost", home: "~/ghost" }; + // `solo` was swapped for a link to another folder since the list was read. + yield* fs.remove(path.join(home, ".claude/skills/solo"), { recursive: true }); + yield* fs.symlink( + path.join(home, "library/skills/beta"), + path.join(home, ".claude/skills/solo"), + ); + + const result = yield* manager.delete({ + cwd: project, + skills: [verify, solo, alpha, ghost], + }); + + expect( + result.outcomes.map(({ skill, status, reason }) => [skill.name, status, reason]), + ).toEqual([ + ["verify", "changed", undefined], + ["solo", "skipped", "changed"], + ["alpha", "skipped", "linked"], + ["ghost", "skipped", "notFound"], + ]); + expect(yield* fs.exists(path.join(home, "library/skills/beta/SKILL.md"))).toBe(true); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "refuses a project folder the environment doesn't know", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* withManager(home, [], ({ manager }) => + Effect.gen(function* () { + // The list itself refuses a folder that isn't a project, so name the skill as a + // client holding an older list would. + const verify: SkillRef = { + scope: "project", + name: "verify", + home: ".agents/skills/verify", + }; + + const error = yield* manager + .delete({ cwd: project, skills: [verify] }) + .pipe(Effect.flip); + + expect(error).toEqual(new SkillRequestError({ reason: "projectNotRegistered" })); + expect(yield* fs.exists(path.join(project, ".agents/skills/verify/SKILL.md"))).toBe( + true, + ); + }), + ); + }), + ); + }); + + describe("the picker refresh", () => { + it.effect.skipIf(!symlinksSupported)( + "refreshes the agents whose skills changed, once, and nothing after a no-op or a refusal", + () => + Effect.gen(function* () { + const { home } = yield* makeMachine; + yield* withManager(home, [], ({ manager, catalog, refreshes }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + const alpha = refOf(skills, "global", "alpha"); + const ghost: SkillRef = { scope: "global", name: "ghost", home: "~/ghost" }; + // Codex reads the skill already, and the other one isn't there: nothing is written. + yield* manager.enable({ skills: [alpha, ghost], agents: [agent("codex")] }); + yield* manager.disable({ skills: [alpha], agents: [agent("claudeAgent")] }); + + yield* manager.enable({ skills: [alpha], agents: [agent("claudeAgent")] }); + + // Anything the two no-ops had asked for would come first. + expect(yield* Queue.take(refreshes)).toEqual({ + instanceId: agent("claudeAgent"), + cwd: undefined, + fresh: undefined, + }); + expect(yield* Queue.size(refreshes)).toBe(0); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "refreshes the open project's list for each agent that gained or lost the skill", + () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog, refreshes }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + const verify = refOf(skills, "project", "verify"); + + // A skill that is already on for the agent asked for changes nothing. + yield* manager.enable({ cwd: project, skills: [verify], agents: [agent("codex")] }); + yield* manager.enable({ + cwd: project, + skills: [verify], + agents: [agent("claudeAgent")], + }); + yield* manager.disable({ + cwd: project, + skills: [verify], + agents: [agent("claudeAgent")], + }); + + const asked = yield* Effect.forEach([1, 2], () => Queue.take(refreshes)); + expect(asked).toEqual([ + { instanceId: agent("claudeAgent"), cwd: project, fresh: true }, + { instanceId: agent("claudeAgent"), cwd: project, fresh: true }, + ]); + expect(yield* Queue.size(refreshes)).toBe(0); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "refreshes every agent a move or a delete touches, and none when the move is refused", + () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog, refreshes }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + const alpha = refOf(skills, "global", "alpha"); + const verify = refOf(skills, "project", "verify"); + const solo = refOf(skills, "global", "solo"); + const touched = (count: number) => + Effect.forEach(Array.from({ length: count }), () => Queue.take(refreshes)).pipe( + Effect.map((asked) => asked.map((item) => item.instanceId).toSorted()), + ); + + // A skill reached through a link can't move: nothing was written, so nothing refreshes. + yield* manager.move({ cwd: project, skills: [alpha], to: "project" }); + yield* manager.move({ cwd: project, skills: [verify], to: "global" }); + // Claude never used it. The other six did, or do now, or both. + expect(yield* touched(6)).toEqual( + ALL_AGENTS.filter((id) => id !== agent("claudeAgent")).toSorted(), + ); + expect(yield* Queue.size(refreshes)).toBe(0); + + yield* manager.delete({ cwd: project, skills: [solo] }); + // Claude, Cursor and OpenCode read the global `.claude/skills` folder. + expect(yield* touched(3)).toEqual( + [agent("claudeAgent"), agent("cursor"), agent("opencode")].toSorted(), + ); + }), + ); + }), + ); + }); + describe("requests", () => { it.effect.skipIf(!symlinksSupported)( "refuses to write when the skill is no longer where the list said, or gone", @@ -714,6 +1354,32 @@ describe("the request and result schemas", () => { true, ); }); + + it("accepts a request to move or delete skills, and needs a project and a scope to move", () => { + expect(decodes(SkillMoveInput, { cwd: "/repo", skills: [ref], to: "project" })).toBe(true); + expect(decodes(SkillDeleteInput, { skills: [ref] })).toBe(true); + expect(decodes(SkillMoveInput, { skills: [ref], to: "project" })).toBe(false); + expect(decodes(SkillMoveInput, { cwd: "/repo", skills: [ref] })).toBe(false); + expect(decodes(SkillMoveInput, { cwd: "/repo", skills: [ref], to: "elsewhere" })).toBe(false); + expect(decodes(SkillMoveInput, { cwd: "/repo", skills: [], to: "global" })).toBe(false); + expect(decodes(SkillDeleteInput, { skills: [] })).toBe(false); + }); + + it.effect("describes why a move or a delete left a skill, for each reason", () => + Effect.forEach(["linked", "destinationTaken", "inUse"] as const, (reason) => + encodeResult({ + outcomes: [ + { + skill: ref, + status: "skipped", + reason, + blocked: [{ instanceId: "codex", reason }], + affected: [], + }, + ], + }), + ), + ); }); describe("planEnable", () => { @@ -732,6 +1398,8 @@ describe("planEnable", () => { name: "verify", displayHome: ".agents/skills/verify", home: "/repo/.agents/skills/verify", + own: true, + standardFolders: { project: "/repo/.agents/skills", global: "/home/.agents/skills" }, entries, agents, }); diff --git a/apps/server/src/skills/SkillManager.ts b/apps/server/src/skills/SkillManager.ts index a6f2abb054eb..9e2863cb5566 100644 --- a/apps/server/src/skills/SkillManager.ts +++ b/apps/server/src/skills/SkillManager.ts @@ -3,13 +3,15 @@ * * A skill has one home, a real folder. An agent reads it either because the agent reads that * folder itself (`direct`) or because a link in a folder the agent reads points at it (`link`). - * Turning a skill on makes such a link in the agent's own folder; turning it off removes it. The - * only things written are links this service can show lead to the skill's home: a real folder is - * never replaced, moved or deleted here. + * Turning a skill on makes such a link in the agent's own folder; turning it off removes it. Those + * writes only touch links this service can show lead to the skill's home: a real folder is never + * replaced by them. Moving and deleting are the only writes that take a real folder, and only one + * that sits in an agent's skill folder itself (`own`), never a synced library behind a link. * * Every write starts from what the folders hold now, not from what a client last saw: a skill * whose home is not where the client said is refused, and each link is checked again right - * before it is made or removed (see `SkillLinks`). Writes run one request at a time. + * before it is made or removed (see `SkillLinks`). Writes run one request at a time, and an agent + * whose skills changed has its skill list for the composer refreshed afterwards. * * @module SkillManager */ @@ -17,12 +19,15 @@ import { SkillRequestError, type ProviderInstanceId, type SkillBatchResult, + type SkillDeleteInput, type SkillDisableInput, type SkillEnableInput, + type SkillMoveInput, type SkillOutcome, type SkillOutcomeReason, type SkillRef, type SkillRemoveInput, + type SkillScope, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Context from "effect/Context"; @@ -34,8 +39,10 @@ import * as Path from "effect/Path"; import * as Semaphore from "effect/Semaphore"; import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as SkillCatalog from "./SkillCatalog.ts"; import { createLink, removeLink, type RemoveLinkResult } from "./SkillLinks.ts"; +import { deleteFolder, moveFolder } from "./SkillMove.ts"; type Blocked = SkillOutcome["blocked"][number]; @@ -47,6 +54,10 @@ interface SkillChange { readonly blocked: readonly Blocked[]; /** Something about the skill as a whole kept the change from being complete. */ readonly reason?: SkillOutcomeReason | undefined; + /** Agents that gained or lost the skill without being asked, when the change works that out. */ + readonly affected?: readonly ProviderInstanceId[] | undefined; + /** Agents whose skill list changed, when the change works that out; their `$` picker is refreshed. */ + readonly touched?: readonly ProviderInstanceId[] | undefined; } /** @@ -136,6 +147,12 @@ export class SkillManager extends Context.Service< readonly remove: ( input: SkillRemoveInput, ) => Effect.Effect; + /** Move each skill's folder to the other scope; the agents that used it keep using it. */ + readonly move: (input: SkillMoveInput) => Effect.Effect; + /** Delete each skill's own folder and every link to it. */ + readonly delete: ( + input: SkillDeleteInput, + ) => Effect.Effect; } >()("t3/skills/SkillManager") {} @@ -145,6 +162,7 @@ const make = Effect.gen(function* () { const platform = yield* HostProcess.Platform; const catalog = yield* SkillCatalog.SkillCatalog; const projects = yield* ProjectService.ProjectService; + const providers = yield* ProviderRegistry.ProviderRegistry; const writeLock = yield* Semaphore.make(1); // The link primitives take the filesystem from their environment. const filesystemContext = yield* Effect.context(); @@ -224,11 +242,16 @@ const make = Effect.gen(function* () { return { wrote, blocked } satisfies SkillChange; }); - const removeOne = Effect.fnUntraced(function* (skill: SkillCatalog.ResolvedSkill) { - const links = skill.entries.flatMap((entry) => - entry.target === undefined ? [] : [{ path: entry.path, target: entry.target }], + /** The links among the skills' entries, with what each points at as written. */ + const linksTo = (skills: ReadonlyArray) => + skills.flatMap((skill) => + skill.entries.flatMap((entry) => + entry.target === undefined ? [] : [{ path: entry.path, target: entry.target }], + ), ); - const results = new Set((yield* removeAll(links)).values()); + + const removeOne = Effect.fnUntraced(function* (skill: SkillCatalog.ResolvedSkill) { + const results = new Set((yield* removeAll(linksTo([skill]))).values()); const reason: SkillOutcomeReason | undefined = results.has("failed") ? "failed" : results.has("changed") @@ -237,6 +260,150 @@ const make = Effect.gen(function* () { return { wrote: results.has("removed"), blocked: [], reason } satisfies SkillChange; }); + const skipped = (reason: SkillOutcomeReason): SkillChange => ({ + wrote: false, + blocked: [], + reason, + }); + + /** Every group that reaches this skill's folder, in either scope: the folder's whole audience. */ + const reaching = ( + skill: SkillCatalog.ResolvedSkill, + all: ReadonlyArray, + ) => all.filter((other) => other.name === skill.name && other.home === skill.home); + + const agentsWith = (skills: ReadonlyArray) => + new Set( + skills.flatMap((skill) => + skill.agents.filter((agent) => hasSkill(agent.state)).map((agent) => agent.instanceId), + ), + ); + + const moveOne = Effect.fnUntraced(function* ( + skill: SkillCatalog.ResolvedSkill, + to: SkillScope, + cwd: string, + projectRoot: string | undefined, + all: ReadonlyArray, + ) { + if (skill.scope === to) return { wrote: false, blocked: [] } satisfies SkillChange; + if (!skill.own) return skipped("linked"); + const folder = skill.standardFolders[to]; + if (folder === undefined) return skipped("failed"); + const destination = path.join(folder, skill.name); + // Whatever is under the name there, a skill or not, is never merged into or replaced. + if (all.some((other) => other.scope === to && other.name === skill.name)) { + return skipped("destinationTaken"); + } + + const audience = reaching(skill, all); + const had = agentsWith(audience); + const stale = linksTo(audience); + const moved = yield* moveFolder({ from: skill.home, to: destination, platform }).pipe( + Effect.provideContext(filesystemContext), + Effect.catchTags({ SkillMoveError: () => Effect.succeed("failed" as const) }), + ); + if (moved === "taken") return skipped("destinationTaken"); + if (moved === "inUse") return skipped("inUse"); + if (moved === "failed") return skipped("failed"); + + // The folder is in its new place; the links that led to the old one lead nowhere now. They go + // before new ones are made, because a new link may need the same path. + yield* removeAll(stale); + const real = yield* fileSystem + .realPath(destination) + .pipe(Effect.orElseSucceed(() => destination)); + const landed = (yield* catalog.resolve({ + cwd, + skills: [{ scope: to, name: skill.name }], + })).find((item) => item.scope === to && item.home === real); + const reason = moved === "movedWithLeftover" ? ("failed" as const) : undefined; + if (landed === undefined) { + return { + wrote: true, + blocked: [], + reason: "failed", + touched: [...had], + } satisfies SkillChange; + } + + // Every agent that used the skill keeps using it. One that reads the new scope's shared folder + // already does; any other gets a link in its own folder, by the same rules as turning it on. + const lacking = new Set( + landed.agents + .filter((agent) => had.has(agent.instanceId) && agent.state === "none") + .map((agent) => agent.instanceId), + ); + const relinked = + lacking.size === 0 + ? { wrote: false, blocked: [] as readonly Blocked[] } + : yield* enableOne(landed, lacking, projectRoot); + const settled = relinked.wrote + ? ((yield* catalog.resolve({ cwd, skills: [{ scope: to, name: skill.name }] })).find( + (item) => item.scope === to && item.home === real, + ) ?? landed) + : landed; + const has = agentsWith([settled]); + const unreached = new Set(relinked.blocked.map((item) => item.instanceId)); + const touched = [...new Set([...had, ...has])]; + return { + wrote: true, + blocked: relinked.blocked, + reason, + touched, + affected: touched.filter((id) => had.has(id) !== has.has(id) && !unreached.has(id)), + } satisfies SkillChange; + }); + + const deleteOne = Effect.fnUntraced(function* ( + skill: SkillCatalog.ResolvedSkill, + all: ReadonlyArray, + ) { + if (!skill.own) return skipped("linked"); + const audience = reaching(skill, all); + const had = [...agentsWith(audience)]; + const failed = yield* deleteFolder(skill.home).pipe( + Effect.provideContext(filesystemContext), + Effect.as(false), + Effect.catchTags({ SkillMoveError: () => Effect.succeed(true) }), + ); + // A delete that stopped before touching SKILL.md changed nothing an agent can see. + if ( + failed && + (yield* fileSystem + .exists(path.join(skill.home, "SKILL.md")) + .pipe(Effect.orElseSucceed(() => true))) + ) { + return skipped("failed"); + } + const results = new Set((yield* removeAll(linksTo(audience))).values()); + const reason: SkillOutcomeReason | undefined = + failed || results.has("failed") ? "failed" : results.has("changed") ? "changed" : undefined; + return { + wrote: true, + blocked: [], + reason, + affected: had, + touched: had, + } satisfies SkillChange; + }); + + /** + * Refreshes the skills the composer's `$` picker lists for agents whose skills changed: the + * project's own list when a project is open, else the agent's machine-wide one. A scan can take + * seconds, since some agents answer through their CLI, and the change is already on disk, so it + * runs in the background and a scan that fails changes nothing. + */ + const refreshPickers = (cwd: string | undefined, instances: Iterable) => + Effect.forEach( + instances, + (instanceId) => + cwd === undefined + ? providers.refreshInstance(instanceId) + : providers.refreshWorkspaceSnapshot({ instanceId, cwd, fresh: true }), + { discard: true }, + ).pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + /** * Looks every skill up as the folders hold it now, applies `change` to those that are still * where the client said, and tells what happened to each. An agent that gained or lost a skill @@ -246,15 +413,22 @@ const make = Effect.gen(function* () { readonly cwd: string | undefined; readonly skills: ReadonlyArray; readonly agents: ReadonlySet; + /** Skills to look up besides those asked for, such as the same names in the other scope. */ + readonly alsoLookUp?: ReadonlyArray<{ readonly scope: SkillScope; readonly name: string }>; readonly change: ( skill: SkillCatalog.ResolvedSkill, projectRoot: string | undefined, + /** Everything looked up, which includes the skills asked for. */ + all: ReadonlyArray, ) => Effect.Effect; }) => writeLock.withPermits(1)( Effect.gen(function* () { if (input.cwd !== undefined) yield* requireProject(input.cwd); - const before = yield* catalog.resolve({ cwd: input.cwd, skills: input.skills }); + const before = yield* catalog.resolve({ + cwd: input.cwd, + skills: [...input.skills, ...(input.alsoLookUp ?? [])], + }); const known = new Set((before[0]?.agents ?? []).map((agent) => agent.instanceId)); if (known.size > 0 && [...input.agents].some((id) => !known.has(id))) { return yield* new SkillRequestError({ reason: "unknownAgent" }); @@ -274,36 +448,38 @@ const make = Effect.gen(function* () { const reason = candidates.length > 0 ? "changed" : "notFound"; return { ref, found, change: { wrote: false, blocked: [], reason } as SkillChange }; } - return { ref, found, change: yield* input.change(found, projectRoot) }; + return { ref, found, change: yield* input.change(found, projectRoot, before) }; }), ); const after = changes.some((entry) => entry.change.wrote) ? yield* catalog.resolve({ cwd: input.cwd, skills: input.skills }) : before; - return { - outcomes: changes.map(({ ref, found, change }): SkillOutcome => { - const now = after.find( - (skill) => - skill.scope === ref.scope && - skill.name === ref.name && - skill.displayHome === ref.home, - ); - const affected = - found === undefined - ? [] - : found.agents - .filter( - (agent) => - !input.agents.has(agent.instanceId) && - hasSkill(agent.state) !== - hasSkill( - now?.agents.find((other) => other.instanceId === agent.instanceId) - ?.state ?? "none", - ), - ) - .map((agent) => agent.instanceId); - return { + const results = changes.map(({ ref, found, change }) => { + const now = after.find( + (skill) => + skill.scope === ref.scope && + skill.name === ref.name && + skill.displayHome === ref.home, + ); + // Agents whose use of the skill flipped, whether they were asked for or not. + const flipped = + found === undefined + ? [] + : found.agents + .filter( + (agent) => + hasSkill(agent.state) !== + hasSkill( + now?.agents.find((other) => other.instanceId === agent.instanceId)?.state ?? + "none", + ), + ) + .map((agent) => agent.instanceId); + return { + // Only what this request wrote counts; a change someone else made meanwhile doesn't. + touched: change.wrote ? (change.touched ?? flipped) : [], + outcome: { skill: ref, status: change.wrote ? "changed" @@ -315,10 +491,14 @@ const make = Effect.gen(function* () { (item, index, all) => all.findIndex((other) => other.instanceId === item.instanceId) === index, ), - affected, - }; - }), - } satisfies SkillBatchResult; + affected: change.affected ?? flipped.filter((id) => !input.agents.has(id)), + } satisfies SkillOutcome, + }; + }); + + const touched = new Set(results.flatMap((result) => result.touched)); + if (touched.size > 0) yield* refreshPickers(input.cwd, touched); + return { outcomes: results.map((result) => result.outcome) } satisfies SkillBatchResult; }), ); @@ -349,6 +529,28 @@ const make = Effect.gen(function* () { change: (skill) => removeOne(skill), }); }), + move: Effect.fn("SkillManager.move")(function* (input) { + return yield* run({ + cwd: input.cwd, + skills: input.skills, + agents: new Set(), + alsoLookUp: input.skills.map((ref) => ({ scope: input.to, name: ref.name })), + change: (skill, projectRoot, all) => moveOne(skill, input.to, input.cwd, projectRoot, all), + }); + }), + delete: Effect.fn("SkillManager.delete")(function* (input) { + return yield* run({ + cwd: input.cwd, + skills: input.skills, + agents: new Set(), + // Links from the other scope lead to the folder too, and would be left dangling. + alsoLookUp: input.skills.map((ref) => ({ + scope: ref.scope === "project" ? ("global" as const) : ("project" as const), + name: ref.name, + })), + change: (skill, _projectRoot, all) => deleteOne(skill, all), + }); + }), }); }); diff --git a/apps/server/src/skills/SkillMove.test.ts b/apps/server/src/skills/SkillMove.test.ts new file mode 100644 index 000000000000..52a612c3370f --- /dev/null +++ b/apps/server/src/skills/SkillMove.test.ts @@ -0,0 +1,275 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; + +import { deleteFolder, moveFolder, SkillMoveError } from "./SkillMove.ts"; + +/** A skill folder with nested files, an executable and a link that stays inside the skill. */ +const makeSkill = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "t3code-move-" })); + const from = path.join(root, "from/.agents/skills/verify"); + const to = path.join(root, "to/.agents/skills/verify"); + yield* fs.makeDirectory(path.join(from, "bin"), { recursive: true }); + yield* fs.makeDirectory(path.join(from, "refs/deep"), { recursive: true }); + yield* fs.writeFileString(path.join(from, "SKILL.md"), "---\nname: verify\n---\n"); + yield* fs.writeFileString(path.join(from, "bin/run"), "#!/bin/sh\necho ok\n"); + yield* fs.chmod(path.join(from, "bin/run"), 0o755); + yield* fs.writeFileString(path.join(from, "refs/deep/notes.md"), "notes"); + if (symlinksSupported) yield* fs.symlink("refs/deep/notes.md", path.join(from, "latest.md")); + return { fs, path, root, from, to }; +}); + +const entriesOf = (fs: FileSystem.FileSystem, folder: string) => + fs.readDirectory(folder, { recursive: true }).pipe(Effect.map((names) => names.toSorted())); + +/** A failure the way the Node file system reports it, so the code under test reads it as real. */ +const platformError = ( + tag: "Unknown" | "Busy", + method: string, + pathOrDescriptor: string, + code: string, +) => + PlatformError.systemError({ + _tag: tag, + module: "FileSystem", + method, + pathOrDescriptor, + cause: Object.assign(new Error(code), { code }), + }); + +/** Runs `effect` against a file system where some calls are replaced. */ +const withFileSystem = ( + effect: Effect.Effect, + replace: (real: FileSystem.FileSystem) => Partial, +) => + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + return yield* effect.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ ...real, ...replace(real) }), + ), + ); + }); + +const move = (input: { from: string; to: string }) => moveFolder({ ...input, platform: "linux" }); + +it.layer(NodeServices.layer, { excludeTestServices: true })("SkillMove", (it) => { + describe("on one filesystem", () => { + it.effect.skipIf(!symlinksSupported)("is a single rename that keeps everything", () => + Effect.gen(function* () { + const { fs, path, from, to } = yield* makeSkill; + const before = yield* entriesOf(fs, from); + + expect(yield* move({ from, to })).toBe("moved"); + + expect(yield* fs.exists(from)).toBe(false); + expect(yield* entriesOf(fs, to)).toEqual(before); + expect(yield* fs.readLink(path.join(to, "latest.md"))).toBe("refs/deep/notes.md"); + expect((yield* fs.stat(path.join(to, "bin/run"))).mode & 0o111).not.toBe(0); + }), + ); + + it.effect("never merges into or replaces what is at the destination", () => + Effect.gen(function* () { + const { fs, path, from, to } = yield* makeSkill; + yield* fs.makeDirectory(to, { recursive: true }); + const before = yield* entriesOf(fs, from); + + // An empty folder is taken, and so is one with a skill in it. + expect(yield* move({ from, to })).toBe("taken"); + yield* fs.writeFileString(path.join(to, "SKILL.md"), "theirs"); + expect(yield* move({ from, to })).toBe("taken"); + + expect(yield* entriesOf(fs, from)).toEqual(before); + expect(yield* fs.readFileString(path.join(to, "SKILL.md"))).toBe("theirs"); + }), + ); + + it.effect.skipIf(!symlinksSupported)("treats a link that leads nowhere as taken", () => + Effect.gen(function* () { + const { fs, path, from, to } = yield* makeSkill; + yield* fs.makeDirectory(path.dirname(to), { recursive: true }); + yield* fs.symlink(path.join(path.dirname(to), "missing"), to); + + expect(yield* move({ from, to })).toBe("taken"); + + expect(yield* fs.exists(path.join(from, "SKILL.md"))).toBe(true); + expect(yield* fs.readLink(to)).toBe(path.join(path.dirname(to), "missing")); + }), + ); + + it.effect("says so when the folder is in use, and changes nothing", () => + Effect.gen(function* () { + const { fs, from, to } = yield* makeSkill; + const before = yield* entriesOf(fs, from); + + const result = yield* withFileSystem(move({ from, to }), () => ({ + rename: (oldPath) => Effect.fail(platformError("Busy", "rename", oldPath, "EBUSY")), + })); + + expect(result).toBe("inUse"); + expect(yield* entriesOf(fs, from)).toEqual(before); + expect(yield* fs.exists(to)).toBe(false); + }), + ); + + it.effect("reads a destination that filled up after the check as taken", () => + Effect.gen(function* () { + const { fs, path, from, to } = yield* makeSkill; + + const result = yield* withFileSystem(move({ from, to }), () => ({ + rename: (oldPath) => + Effect.fail(platformError("Unknown", "rename", oldPath, "ENOTEMPTY")), + })); + + expect(result).toBe("taken"); + expect(yield* fs.exists(path.join(from, "SKILL.md"))).toBe(true); + }), + ); + }); + + describe("across filesystems", () => { + /** The first rename, of the skill's own folder, fails the way a different device does. */ + const crossDevice = (from: string) => (real: FileSystem.FileSystem) => ({ + rename: (oldPath: string, newPath: string) => + oldPath === from + ? Effect.fail(platformError("Unknown", "rename", oldPath, "EXDEV")) + : real.rename(oldPath, newPath), + }); + + it.effect.skipIf(!symlinksSupported)( + "copies, checks and renames the copy into place, then removes the original", + () => + Effect.gen(function* () { + const { fs, path, from, to } = yield* makeSkill; + const before = yield* entriesOf(fs, from); + + const result = yield* withFileSystem(move({ from, to }), crossDevice(from)); + + expect(result).toBe("moved"); + expect(yield* fs.exists(from)).toBe(false); + expect(yield* entriesOf(fs, to)).toEqual(before); + expect(yield* fs.readFileString(path.join(to, "refs/deep/notes.md"))).toBe("notes"); + // A link inside the skill keeps its target as written, so it still leads inside the copy. + expect(yield* fs.readLink(path.join(to, "latest.md"))).toBe("refs/deep/notes.md"); + expect((yield* fs.stat(path.join(to, "bin/run"))).mode & 0o111).not.toBe(0); + // No hidden folder is left beside the destination. + expect(yield* fs.readDirectory(path.dirname(to))).toEqual(["verify"]); + }), + ); + + it.effect("leaves no half-copied skill when a file can't be copied", () => + Effect.gen(function* () { + const { fs, path, from, to } = yield* makeSkill; + const before = yield* entriesOf(fs, from); + + const exit = yield* withFileSystem(move({ from, to }), (real) => ({ + ...crossDevice(from)(real), + copyFile: (source, target) => + source.endsWith("notes.md") + ? Effect.fail(platformError("Unknown", "copyFile", source, "EIO")) + : real.copyFile(source, target), + })).pipe(Effect.flip); + + expect(exit).toBeInstanceOf(SkillMoveError); + expect(exit.operation).toBe("copy"); + expect(yield* entriesOf(fs, from)).toEqual(before); + expect(yield* fs.exists(to)).toBe(false); + expect(yield* fs.readDirectory(path.dirname(to))).toEqual([]); + }), + ); + + it.effect("doesn't trust a copy that differs from the original", () => + Effect.gen(function* () { + const { fs, path, from, to } = yield* makeSkill; + const before = yield* entriesOf(fs, from); + + const error = yield* withFileSystem(move({ from, to }), (real) => ({ + ...crossDevice(from)(real), + // Writes an empty file where the original has text. + copyFile: (source, target) => + source.endsWith("notes.md") + ? fs.writeFileString(target, "") + : real.copyFile(source, target), + })).pipe(Effect.flip); + + expect(error.operation).toBe("verify"); + expect(yield* entriesOf(fs, from)).toEqual(before); + expect(yield* fs.exists(to)).toBe(false); + expect(yield* fs.readDirectory(path.dirname(to))).toEqual([]); + }), + ); + + it.effect("stops and removes its copy when something took the destination meanwhile", () => + Effect.gen(function* () { + const { fs, path, from, to } = yield* makeSkill; + + const result = yield* withFileSystem(move({ from, to }), () => ({ + rename: (oldPath) => + oldPath === from + ? Effect.fail(platformError("Unknown", "rename", oldPath, "EXDEV")) + : Effect.fail(platformError("Unknown", "rename", oldPath, "ENOTEMPTY")), + })); + + expect(result).toBe("taken"); + expect(yield* fs.exists(path.join(from, "SKILL.md"))).toBe(true); + expect(yield* fs.readDirectory(path.dirname(to))).toEqual([]); + }), + ); + + it.effect("keeps the new copy and says so when the original can't be removed", () => + Effect.gen(function* () { + const { fs, from, to } = yield* makeSkill; + const before = yield* entriesOf(fs, from); + + const result = yield* withFileSystem(move({ from, to }), (real) => ({ + ...crossDevice(from)(real), + remove: (target, options) => + target === from + ? Effect.fail(platformError("Unknown", "remove", target, "EACCES")) + : real.remove(target, options), + })); + + expect(result).toBe("movedWithLeftover"); + expect(yield* entriesOf(fs, to)).toEqual(before); + expect(yield* entriesOf(fs, from)).toEqual(before); + }), + ); + }); + + describe("deleting", () => { + it.effect("removes the folder and what is in it, and nothing beside it", () => + Effect.gen(function* () { + const { fs, path, from } = yield* makeSkill; + const sibling = path.join(path.dirname(from), "other"); + yield* fs.makeDirectory(sibling); + yield* fs.writeFileString(path.join(sibling, "SKILL.md"), "other"); + + yield* deleteFolder(from); + + expect(yield* fs.exists(from)).toBe(false); + expect(yield* fs.readFileString(path.join(sibling, "SKILL.md"))).toBe("other"); + }), + ); + + it.effect.skipIf(!symlinksSupported)("removes a link without following it", () => + Effect.gen(function* () { + const { fs, path, from, root } = yield* makeSkill; + const link = path.join(root, "link"); + yield* fs.symlink(from, link); + + yield* deleteFolder(link); + + expect(yield* fs.exists(link)).toBe(false); + expect(yield* fs.exists(path.join(from, "SKILL.md"))).toBe(true); + }), + ); + }); +}); diff --git a/apps/server/src/skills/SkillMove.ts b/apps/server/src/skills/SkillMove.ts new file mode 100644 index 000000000000..f6766d71a707 --- /dev/null +++ b/apps/server/src/skills/SkillMove.ts @@ -0,0 +1,234 @@ +/** + * SkillMove - the two filesystem writes that take a real skill folder somewhere else: moving it, + * and deleting it. + * + * A move is never allowed to leave a half-made skill or to replace one: + * - On one filesystem it is a single rename, which is all or nothing. Something already at the + * destination makes it stop; a rename can only replace an empty folder, which loses nothing. + * - Across filesystems a rename isn't possible, so the folder is copied next to the destination + * under a hidden name no agent reads, checked against the original, and only then renamed into + * place. Anything that goes wrong before that removes the copy and leaves the original alone. + * The original is removed last, so a crash leaves the skill in both places, never in neither. + * + * @module SkillMove + */ +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +export class SkillMoveError extends Schema.TaggedError()("SkillMoveError", { + operation: Schema.Literals(["makeDirectory", "inspect", "copy", "verify", "rename", "remove"]), + path: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `Skill folder operation '${this.operation}' failed.`; + } +} + +type MoveFolderResult = + /** The folder is at the destination and the original is gone. */ + | "moved" + /** The folder is at the destination, but the original couldn't be removed after a copy. */ + | "movedWithLeftover" + /** Something is at the destination. Nothing was changed. */ + | "taken" + /** Another program is using the folder. Nothing was changed. */ + | "inUse"; + +const errorCode = (error: PlatformError.PlatformError) => { + const cause: unknown = error.reason.cause; + return typeof cause === "object" && cause !== null && "code" in cause ? cause.code : undefined; +}; + +/** What a failed rename tells: another device, a busy folder, or something in the way. */ +type RenameFailure = "otherDevice" | "inUse" | "taken"; + +const renameFailure = ( + error: PlatformError.PlatformError, + platform: NodeJS.Platform, +): RenameFailure | undefined => { + const code = errorCode(error); + if (code === "EXDEV") return "otherDevice"; + if (error.reason._tag === "Busy" || (platform === "win32" && code === "EPERM")) return "inUse"; + if (error.reason._tag === "AlreadyExists" || code === "ENOTEMPTY" || code === "ENOTDIR") { + return "taken"; + } + return undefined; +}; + +/** One thing found in a folder, with what a copy has to keep the same. */ +interface Surveyed { + readonly relative: string; + readonly kind: "directory" | "file" | "link" | "other"; + readonly size: number; + readonly mode: number; + /** What a link points at, as written. */ + readonly target: string | undefined; +} + +const signature = (entry: Surveyed) => + `${entry.kind}\0${entry.relative}\0${entry.kind === "file" ? entry.size : (entry.target ?? "")}`; + +/** + * Moves the folder `from` to `to`, which must not exist. Its parent is made if it is missing. + * `from` is removed recursively only after a copy of it has been checked and put in place. + */ +export const moveFolder = Effect.fn("SkillMove.moveFolder")(function* (input: { + readonly from: string; + readonly to: string; + readonly platform: NodeJS.Platform; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = path.dirname(input.to); + const fail = (operation: SkillMoveError["operation"], target: string) => (cause: unknown) => + new SkillMoveError({ operation, path: target, cause }); + + /** Anything at the path, even a link that leads nowhere. Doubt counts as taken. */ + const occupied = fileSystem.readLink(input.to).pipe( + Effect.as(true), + Effect.catchTags({ + PlatformError: (error) => Effect.succeed(error.reason._tag !== "NotFound"), + }), + ); + if (yield* occupied) return "taken" as const satisfies MoveFolderResult; + yield* fileSystem + .makeDirectory(parent, { recursive: true }) + .pipe(Effect.mapError(fail("makeDirectory", parent))); + + /** Renames `from` to `to`, or says why it didn't. */ + const rename = (from: string) => + fileSystem.rename(from, input.to).pipe( + Effect.as(undefined), + Effect.catchTags({ + PlatformError: (error) => { + const failure = renameFailure(error, input.platform); + return failure === undefined + ? Effect.fail(new SkillMoveError({ operation: "rename", path: from, cause: error })) + : Effect.succeed(failure); + }, + }), + ); + + const renamed = yield* rename(input.from); + if (renamed === undefined) return "moved" as const satisfies MoveFolderResult; + if (renamed !== "otherDevice") return renamed satisfies MoveFolderResult; + + /** Everything under `root`, parents before the folders and files in them. */ + const survey = Effect.fnUntraced(function* (root: string) { + const found: Surveyed[] = []; + const pending = [""]; + for (let folder = pending.shift(); folder !== undefined; folder = pending.shift()) { + const names = yield* fileSystem + .readDirectory(path.join(root, folder)) + .pipe(Effect.mapError(fail("copy", path.join(root, folder)))); + for (const name of names.toSorted()) { + const relative = path.join(folder, name); + const absolute = path.join(root, relative); + const target = yield* fileSystem.readLink(absolute).pipe( + Effect.map((value): string | undefined => value), + Effect.orElseSucceed(() => undefined), + ); + if (target !== undefined) { + found.push({ relative, kind: "link", size: 0, mode: 0, target }); + continue; + } + const info = yield* fileSystem.stat(absolute).pipe(Effect.mapError(fail("copy", absolute))); + const kind = + info.type === "Directory" ? "directory" : info.type === "File" ? "file" : "other"; + found.push({ + relative, + kind, + size: Number(info.size), + mode: info.mode & 0o777, + target: undefined, + }); + if (kind === "directory") pending.push(relative); + } + } + return found; + }); + + const original = yield* survey(input.from); + const stage = yield* fileSystem + .makeTempDirectory({ directory: parent, prefix: ".t3-moving-" }) + .pipe(Effect.mapError(fail("makeDirectory", parent))); + const discardStage = fileSystem + .remove(stage, { recursive: true, force: true }) + .pipe(Effect.ignore); + + const staged = Effect.gen(function* () { + for (const entry of original) { + const source = path.join(input.from, entry.relative); + const copy = path.join(stage, entry.relative); + const done = + entry.kind === "directory" + ? fileSystem.makeDirectory(copy) + : entry.kind === "file" + ? fileSystem.copyFile(source, copy) + : entry.kind === "link" && entry.target !== undefined + ? fileSystem.symlink(entry.target, copy) + : // A socket or device can't be copied, and silently skipping it would lose it. + Effect.fail(new SkillMoveError({ operation: "copy", path: source })); + yield* done.pipe(Effect.mapError(fail("copy", source))); + } + // Folders last, so one without write permission doesn't stop its own contents. + for (const entry of original.toReversed()) { + if (entry.kind !== "directory") continue; + yield* fileSystem + .chmod(path.join(stage, entry.relative), entry.mode) + .pipe(Effect.mapError(fail("copy", entry.relative))); + } + const rootMode = yield* fileSystem + .stat(input.from) + .pipe(Effect.mapError(fail("copy", input.from))); + yield* fileSystem + .chmod(stage, rootMode.mode & 0o777) + .pipe(Effect.mapError(fail("copy", stage))); + + // The copy has to be the original, which must not have changed meanwhile: same entries, + // sizes and link targets. + const same = (left: readonly Surveyed[], right: readonly Surveyed[]) => + left.length === right.length && + left.every((entry, index) => signature(entry) === signature(right[index]!)); + const copied = yield* survey(stage); + const now = yield* survey(input.from); + if (!same(copied, original) || !same(now, original)) { + return yield* new SkillMoveError({ operation: "verify", path: stage }); + } + const placed = yield* rename(stage); + if (placed === "otherDevice") { + return yield* new SkillMoveError({ operation: "rename", path: stage }); + } + return placed; + }).pipe(Effect.onExit((exit) => (Exit.isFailure(exit) ? discardStage : Effect.void))); + + const placed = yield* staged; + if (placed !== undefined) { + // Put in place by someone else, or in use: the copy goes and the original stays. + yield* discardStage; + return placed satisfies MoveFolderResult; + } + return yield* fileSystem.remove(input.from, { recursive: true }).pipe( + Effect.as("moved" as MoveFolderResult), + // The skill is whole at its new place; what is left behind is reported, not undone. + Effect.catchTags({ + PlatformError: () => Effect.succeed("movedWithLeftover" as MoveFolderResult), + }), + ); +}); + +/** + * Deletes a skill's real folder and everything in it. The caller has to know the path is the + * skill's own folder and not a library's: a link at the path is removed, not followed. + */ +export const deleteFolder = Effect.fn("SkillMove.deleteFolder")(function* (path: string) { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem + .remove(path, { recursive: true }) + .pipe(Effect.mapError((cause) => new SkillMoveError({ operation: "remove", path, cause }))); +}); diff --git a/apps/server/src/skills/SkillTracking.test.ts b/apps/server/src/skills/SkillTracking.test.ts new file mode 100644 index 000000000000..2f865f45c521 --- /dev/null +++ b/apps/server/src/skills/SkillTracking.test.ts @@ -0,0 +1,235 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + ProjectId, + type Project, + type SkillRef, + type SkillScope, + type SkillSummary, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as Settings from "../serverSettings.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as SkillCatalog from "./SkillCatalog.ts"; +import * as SkillTracking from "./SkillTracking.ts"; + +const skillFile = (name: string) => `---\nname: ${name}\ndescription: The ${name} skill.\n---\n`; + +/** A project with three skills of its own and one that is only linked in, plus a global skill. */ +const makeMachine = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "t3code-skill-tracking-" }), + ); + const project = path.join(home, "repos/app"); + const write = (relative: string, contents: string) => + Effect.gen(function* () { + const target = path.join(home, relative); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, contents); + }); + yield* write("repos/app/.agents/skills/verify/SKILL.md", skillFile("verify")); + yield* write("repos/app/.claude/skills/own-copy/SKILL.md", skillFile("own-copy")); + yield* write("repos/app/.agents/skills/tdd/SKILL.md", skillFile("tdd")); + yield* write(".claude/skills/cloudflare/SKILL.md", skillFile("cloudflare")); + yield* write("library/relay/SKILL.md", skillFile("relay")); + yield* fs.symlink(path.join(home, "library/relay"), path.join(project, ".agents/skills/relay")); + return { home, project }; +}); + +const git = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const processRunner = yield* ProcessRunner.ProcessRunner; + return yield* processRunner.run({ + command: "git", + args: [ + "-C", + cwd, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + ...args, + ], + }); + }).pipe(Effect.provide(ProcessRunner.layer)); + +const makeProject = (workspaceRoot: string): Project => ({ + id: ProjectId.make("project-skill-tracking"), + title: "App", + workspaceRoot, + repositoryIdentity: null, + faviconPath: null, + projectIcon: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}); + +/** + * The skill list and the tracking check, on a machine whose home is `home`. Only the `registered` + * folders are projects; by default that is the machine's `repos/app`. + */ +const onMachine = ( + home: string, + use: (services: { + readonly catalog: SkillCatalog.SkillCatalog["Service"]; + readonly tracking: SkillTracking.SkillTracking["Service"]; + }) => Effect.Effect, + registered?: readonly string[], +) => + Effect.gen(function* () { + const path = yield* Path.Path; + const roots = registered ?? [path.join(home, "repos/app")]; + const projects = Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (root) => + Effect.succeed(roots.includes(root) ? Option.some(makeProject(root)) : Option.none()), + }); + return yield* Effect.gen(function* () { + return yield* use({ + catalog: yield* SkillCatalog.SkillCatalog, + tracking: yield* SkillTracking.SkillTracking, + }); + }).pipe( + Effect.provide( + SkillTracking.layer.pipe( + Layer.provideMerge(SkillCatalog.layer.pipe(Layer.provide(Settings.layerTest({})))), + Layer.provide(projects), + Layer.provide(VcsProcess.layer), + ), + ), + ); + }).pipe( + Effect.provideService(HostProcess.Environment, { HOME: home }), + Effect.provideService(HostProcess.HomeDirectory, home), + ); + +const refOf = (skills: readonly SkillSummary[], scope: SkillScope, name: string): SkillRef => { + const skill = skills.find((item) => item.scope === scope && item.name === name); + if (!skill) throw new Error(`No ${scope} skill ${name} in the list`); + return { scope, name, home: skill.home }; +}; + +it.layer(NodeServices.layer, { excludeTestServices: true })("SkillTracking", (it) => { + describe("tracked", () => { + it.effect("names the project skills that git tracks, and only those", () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* git(project, ["init"]); + yield* git(project, ["add", ".agents/skills/verify", ".claude/skills/own-copy"]); + yield* git(project, ["commit", "-m", "skills"]); + const result = yield* onMachine(home, ({ catalog, tracking }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + return yield* tracking.tracked({ + cwd: project, + skills: ["verify", "own-copy", "tdd"].map((name) => refOf(skills, "project", name)), + }); + }), + ); + + // `tdd` is in the repo but was never added. + expect([...result.tracked].toSorted()).toEqual(["own-copy", "verify"]); + }), + ); + + it.effect("never counts a global skill, even when it is asked about", () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* git(project, ["init"]); + yield* git(project, ["add", "."]); + yield* git(project, ["commit", "-m", "everything"]); + const result = yield* onMachine(home, ({ catalog, tracking }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + return yield* tracking.tracked({ + cwd: project, + skills: [refOf(skills, "global", "cloudflare"), refOf(skills, "project", "verify")], + }); + }), + ); + + expect(result.tracked).toEqual(["verify"]); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "doesn't count a skill that is only reached through a link", + () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* git(project, ["init"]); + yield* git(project, ["add", "."]); + yield* git(project, ["commit", "-m", "everything"]); + const result = yield* onMachine(home, ({ catalog, tracking }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + return yield* tracking.tracked({ + cwd: project, + skills: ["relay", "verify"].map((name) => refOf(skills, "project", name)), + }); + }), + ); + + expect(result.tracked).toEqual(["verify"]); + }), + ); + + it.effect("skips a skill that is no longer where the client said", () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* git(project, ["init"]); + yield* git(project, ["add", "."]); + yield* git(project, ["commit", "-m", "everything"]); + const result = yield* onMachine(home, ({ catalog, tracking }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + return yield* tracking.tracked({ + cwd: project, + skills: [ + { ...refOf(skills, "project", "verify"), home: "~/elsewhere/verify" }, + { scope: "project", name: "missing", home: ".agents/skills/missing" }, + refOf(skills, "project", "tdd"), + ], + }); + }), + ); + + expect(result.tracked).toEqual(["tdd"]); + }), + ); + + it.effect("tracks nothing outside a git repository", () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + const result = yield* onMachine(home, ({ catalog, tracking }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({ cwd: project }); + return yield* tracking.tracked({ + cwd: project, + skills: [refOf(skills, "project", "verify")], + }); + }), + ); + + expect(result.tracked).toEqual([]); + }), + ); + }); +}); diff --git a/apps/server/src/skills/SkillTracking.ts b/apps/server/src/skills/SkillTracking.ts new file mode 100644 index 000000000000..ab24746ff151 --- /dev/null +++ b/apps/server/src/skills/SkillTracking.ts @@ -0,0 +1,96 @@ +/** + * SkillTracking - tells which project skills git tracks, so a confirmation for moving or deleting + * them can say whether git can undo it. + * + * It is separate from `SkillCatalog` because the catalog's list spawns nothing and loads on every + * page open; this runs one `git ls-files` for all the skills asked about, and only when a person + * is about to confirm a change. Nothing is written. + * + * @module SkillTracking + */ +import type { SkillTrackedInput, SkillTrackedResult } 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 * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as SkillCatalog from "./SkillCatalog.ts"; + +const SKILL_FILE = "SKILL.md"; + +export class SkillTracking extends Context.Service< + SkillTracking, + { + /** + * The names of the project skills among `skills` whose SKILL.md git tracks. A skill that + * isn't where the client said, isn't a project skill, sits outside the repository, or whose + * folder is only reached through a link counts as not tracked, and so does every skill when + * git fails. + */ + readonly tracked: (input: SkillTrackedInput) => Effect.Effect; + } +>()("t3/skills/SkillTracking") {} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const catalog = yield* SkillCatalog.SkillCatalog; + const vcs = yield* VcsProcess.VcsProcess; + + const tracked: SkillTracking["Service"]["tracked"] = Effect.fn("SkillTracking.tracked")( + function* (input) { + const asked = input.skills.filter((ref) => ref.scope === "project"); + const resolved = yield* catalog.resolve({ cwd: input.cwd, skills: asked }); + const realCwd = yield* fileSystem + .realPath(input.cwd) + .pipe(Effect.orElseSucceed(() => input.cwd)); + + // The path git knows each skill's SKILL.md by, from the project's real folder. + const files = new Map(); + for (const ref of asked) { + const skill = resolved.find( + (item) => + item.scope === "project" && item.name === ref.name && item.displayHome === ref.home, + ); + if (skill === undefined || !skill.own) continue; + const inside = path.relative(realCwd, skill.home); + if (inside === "" || inside.startsWith("..") || path.isAbsolute(inside)) continue; + files.set(`${inside.replaceAll("\\", "/")}/${SKILL_FILE}`, ref.name); + } + if (files.size === 0) return { tracked: [] }; + + const result = yield* vcs + .run({ + operation: "SkillTracking.tracked", + command: "git", + args: [ + "--literal-pathspecs", + "-c", + "core.fsmonitor=false", + "ls-files", + "--cached", + "-z", + "--", + ...files.keys(), + ], + cwd: input.cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 256 * 1024, + }) + .pipe(Effect.orElseSucceed(() => undefined)); + if (result === undefined || result.exitCode !== 0) return { tracked: [] }; + + const listed = new Set(result.stdout.split("\0")); + return { + tracked: [...files].flatMap(([file, name]) => (listed.has(file) ? [name] : [])), + }; + }, + ); + + return SkillTracking.of({ tracked }); +}); + +export const layer = Layer.effect(SkillTracking, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ff5e769bd298..85ec14abc55d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -192,6 +192,7 @@ import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as SkillCatalog from "./skills/SkillCatalog.ts"; import * as SkillManager from "./skills/SkillManager.ts"; +import * as SkillTracking from "./skills/SkillTracking.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; @@ -1288,6 +1289,7 @@ const layerWsRpc = ( const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const skillCatalog = yield* SkillCatalog.SkillCatalog; const skillManager = yield* SkillManager.SkillManager; + const skillTracking = yield* SkillTracking.SkillTracking; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); @@ -2181,6 +2183,9 @@ const layerWsRpc = ( [WS_METHODS.serverEnableSkills]: (input) => skillManager.enable(input), [WS_METHODS.serverDisableSkills]: (input) => skillManager.disable(input), [WS_METHODS.serverRemoveSkills]: (input) => skillManager.remove(input), + [WS_METHODS.serverMoveSkills]: (input) => skillManager.move(input), + [WS_METHODS.serverDeleteSkills]: (input) => skillManager.delete(input), + [WS_METHODS.serverSkillsTracked]: (input) => skillTracking.tracked(input), [WS_METHODS.serverRefreshProviders]: (input) => Effect.gen(function* () { // Only explicit catalog refreshes bypass T3's caches. Workspace diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 9eda1d53ad6d..706fc29cc891 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -297,6 +297,8 @@ it.effect("needs the operate grant to change skills, but not to list or read the WS_METHODS.serverEnableSkills, WS_METHODS.serverDisableSkills, WS_METHODS.serverRemoveSkills, + WS_METHODS.serverMoveSkills, + WS_METHODS.serverDeleteSkills, ]) { const change = createCommandPermissions(runtime, method); registry.set(sessions(env), AsyncResult.success(grant(false))); @@ -308,7 +310,11 @@ it.effect("needs the operate grant to change skills, but not to list or read the expect(registry.get(change.permissionAtom(env))).toBe(true); yield* change.authorize(registry, env); } - for (const method of [WS_METHODS.serverListSkills, WS_METHODS.serverGetSkill]) { + for (const method of [ + WS_METHODS.serverListSkills, + WS_METHODS.serverGetSkill, + WS_METHODS.serverSkillsTracked, + ]) { expect(createCommandPermissions(runtime, method).requiredScopes()).toEqual([]); } }), diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index b4004761076b..f3581bf77a3f 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1190,6 +1190,18 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:remove-skills", tag: WS_METHODS.serverRemoveSkills, }), + moveSkills: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:move-skills", + tag: WS_METHODS.serverMoveSkills, + }), + deleteSkills: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:delete-skills", + tag: WS_METHODS.serverDeleteSkills, + }), + skillsTracked: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:skills-tracked", + tag: WS_METHODS.serverSkillsTracked, + }), refreshProviders: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-providers", tag: WS_METHODS.serverRefreshProviders, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 30254cdd24dc..485400e0e084 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -39,6 +39,8 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.serverEnableSkills]: AuthOrchestrationOperateScope, [WS_METHODS.serverDisableSkills]: AuthOrchestrationOperateScope, [WS_METHODS.serverRemoveSkills]: AuthOrchestrationOperateScope, + [WS_METHODS.serverMoveSkills]: AuthOrchestrationOperateScope, + [WS_METHODS.serverDeleteSkills]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 3be843ca2fe4..ea53adc8f544 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -325,14 +325,18 @@ import { } from "./settings.ts"; import { SkillBatchResult, + SkillDeleteInput, SkillDisableInput, SkillEnableInput, SkillGetInput, SkillGetResult, SkillListInput, SkillListResult, + SkillMoveInput, SkillRemoveInput, SkillRequestError, + SkillTrackedInput, + SkillTrackedResult, } from "./skills.ts"; import { ScheduledTaskDeleteInput, @@ -483,6 +487,9 @@ export const WS_METHODS = { serverEnableSkills: "server.enableSkills", serverDisableSkills: "server.disableSkills", serverRemoveSkills: "server.removeSkills", + serverMoveSkills: "server.moveSkills", + serverDeleteSkills: "server.deleteSkills", + serverSkillsTracked: "server.skillsTracked", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", @@ -644,6 +651,24 @@ const WsServerRemoveSkillsRpc = Rpc.make(WS_METHODS.serverRemoveSkills, { error: Schema.Union([SkillRequestError, EnvironmentAuthorizationError]), }); +const WsServerMoveSkillsRpc = Rpc.make(WS_METHODS.serverMoveSkills, { + payload: SkillMoveInput, + success: SkillBatchResult, + error: Schema.Union([SkillRequestError, EnvironmentAuthorizationError]), +}); + +const WsServerDeleteSkillsRpc = Rpc.make(WS_METHODS.serverDeleteSkills, { + payload: SkillDeleteInput, + success: SkillBatchResult, + error: Schema.Union([SkillRequestError, EnvironmentAuthorizationError]), +}); + +const WsServerSkillsTrackedRpc = Rpc.make(WS_METHODS.serverSkillsTracked, { + payload: SkillTrackedInput, + success: SkillTrackedResult, + error: EnvironmentAuthorizationError, +}); + const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** @@ -1882,6 +1907,9 @@ export const WsRpcGroup = RpcGroup.make( WsServerEnableSkillsRpc, WsServerDisableSkillsRpc, WsServerRemoveSkillsRpc, + WsServerMoveSkillsRpc, + WsServerDeleteSkillsRpc, + WsServerSkillsTrackedRpc, WsServerUpdateProviderRpc, WsProviderConsumeResetCreditRpc, WsProviderAuthStartRpc, diff --git a/packages/contracts/src/skills.ts b/packages/contracts/src/skills.ts index 1dee7aaa3e7e..c70e0882ae02 100644 --- a/packages/contracts/src/skills.ts +++ b/packages/contracts/src/skills.ts @@ -50,6 +50,11 @@ export const SkillSummary = Schema.Struct({ description: Schema.String, /** SKILL.md's header can't be read the way Claude Code reads it, so Claude skips the skill. */ invalidHeader: Schema.optional(Schema.Boolean), + /** + * The skill's folder sits in one of the agents' skill folders, so T3 Code can move or delete it. + * A skill reached only through links, such as a synced library, isn't. + */ + realFolder: Schema.optional(Schema.Boolean), /** The other skills with the same name, in either scope. */ copies: Schema.Array(SkillCopy), access: Schema.Array(SkillAgentAccess), @@ -137,6 +142,37 @@ export const SkillRemoveInput = Schema.Struct({ }); export type SkillRemoveInput = typeof SkillRemoveInput.Type; +/** + * Move each skill's folder to the other scope: from a project to the user's global folder, or the + * other way. Agents that used the skill keep using it. + */ +export const SkillMoveInput = Schema.Struct({ + /** A registered project's folder: the project the skills move from or into. */ + cwd: TrimmedNonEmptyString, + skills: SkillRefs, + /** Where the skills go. A skill that is there already is left as it is. */ + to: SkillScope, +}); +export type SkillMoveInput = typeof SkillMoveInput.Type; + +/** Which of these project skills git tracks, so a move or delete of them shows in git. */ +export const SkillTrackedInput = Schema.Struct({ + /** The project the skills are in. */ + cwd: TrimmedNonEmptyString, + skills: SkillRefs, +}); +export type SkillTrackedInput = typeof SkillTrackedInput.Type; + +export const SkillTrackedResult = Schema.Struct({ + /** Names of the project skills whose SKILL.md git tracks. Never includes a global skill. */ + tracked: Schema.Array(TrimmedNonEmptyString), +}); +export type SkillTrackedResult = typeof SkillTrackedResult.Type; + +/** Delete each skill's own folder and the links agents use to reach it. This can't be undone. */ +export const SkillDeleteInput = SkillRemoveInput; +export type SkillDeleteInput = typeof SkillDeleteInput.Type; + /** Why a skill or an agent was left as it was. A client words each one. */ export const SkillOutcomeReason = Schema.Literals([ /** The skill isn't in the agents' folders any more. */ @@ -153,6 +189,12 @@ export const SkillOutcomeReason = Schema.Literals([ "linkNotAllowed", /** The folder couldn't be written. */ "failed", + /** The skill's folder is reached through a link, so T3 Code leaves it where it is. */ + "linked", + /** The other scope already has something with this name, which a move never replaces. */ + "destinationTaken", + /** Another program is using the folder, so it couldn't be moved. */ + "inUse", ]); export type SkillOutcomeReason = typeof SkillOutcomeReason.Type; From 340d3e44b654a461da4366b04d896d0134528ec7 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:18:59 -0400 Subject: [PATCH 013/108] feat(web): move and delete skills in Settings Adds Move to Global, Move to this project and Delete to a skill's menu and to the bar over ticked skills. Each asks first and names what goes; Delete says it can't be undone, and Remove from agents says the original isn't deleted. A confirmation says "You can undo this with git" once the server has said git tracks the folder, and not when that check fails. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/settings/SkillBulkBar.tsx | 32 ++- .../src/components/settings/SkillDetail.tsx | 32 ++- .../settings/SkillsSettings.logic.test.ts | 226 +++++++++++++++++- .../settings/SkillsSettings.logic.ts | 192 ++++++++++++++- .../components/settings/SkillsSettings.tsx | 56 ++++- 5 files changed, 511 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/settings/SkillBulkBar.tsx b/apps/web/src/components/settings/SkillBulkBar.tsx index d24a2538f7d4..4d05d9ad1153 100644 --- a/apps/web/src/components/settings/SkillBulkBar.tsx +++ b/apps/web/src/components/settings/SkillBulkBar.tsx @@ -12,6 +12,8 @@ import { Button } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { SkillAgentIcon } from "./skillAgentIcon"; import { + planDelete, + planMove, planRemove, planTurnOff, planTurnOnAll, @@ -24,19 +26,25 @@ import { export function BulkBar({ selected, ctx, + hasProject, busy, onClear, onPlan, }: { selected: readonly Skill[]; ctx: SkillsContext; + /** A project is picked, so "Move to this project" means something. */ + hasProject: boolean; /** A change is being made, so nothing else can start. */ busy: boolean; onClear: () => void; onPlan: (plan: SkillPlan) => void; }) { const turnOn = planTurnOnAll(selected, ctx); + const toGlobal = planMove(selected, "global"); + const toProject = hasProject ? planMove(selected, "project") : null; const remove = planRemove(selected, ctx); + const del = planDelete(selected, ctx); return (
    + {toGlobal && ( + + )} + {toProject && ( + + )} {remove && ( + )} + {del && ( + )}
    diff --git a/apps/web/src/components/settings/SkillDetail.tsx b/apps/web/src/components/settings/SkillDetail.tsx index c0f2dda8cfd1..1527b79b7997 100644 --- a/apps/web/src/components/settings/SkillDetail.tsx +++ b/apps/web/src/components/settings/SkillDetail.tsx @@ -16,6 +16,8 @@ import { accessOf, agentSkillPath, attention, + planDelete, + planMove, planRemove, planToggle, planTurnOnAll, @@ -95,7 +97,7 @@ export function SkillDetail({ /** A change is being made, so nothing else can start. */ busy: boolean; onBack: () => void; - /** Turns an agent on or off, or removes the skill's links; a plan with a confirmation asks first. */ + /** Turns an agent on or off, moves, removes or deletes the skill; a plan with a confirmation asks first. */ onPlan: (plan: SkillPlan) => void; /** Opens this skill again, which reads its files again. */ onReload: () => void; @@ -136,6 +138,12 @@ export function SkillDetail({ const sameCopies = skill.copies.filter((copy) => copy.same); const turnOnAll = planTurnOnAll([skill], ctx); const remove = planRemove([skill], ctx); + const del = planDelete([skill], ctx); + // Moving into a project needs one picked above the page. + const move = + skill.scope === "global" && projectRoot === null + ? null + : planMove([skill], skill.scope === "global" ? "project" : "global"); const copyPath = (path: string) => { void writeTextToClipboard(path, "skill path").then( @@ -195,7 +203,7 @@ export function SkillDetail({ /> ))} - {(skillFolder || turnOnAll || remove) && ( + {(skillFolder || turnOnAll || move || remove || del) && ( } @@ -211,13 +219,21 @@ export function SkillDetail({ Turn on for all agents )} + {move && ( + onPlan(move)}> + {skill.scope === "global" ? "Move to this project" : "Move to Global"} + + )} + {(remove || del) && } {remove && ( - <> - - onPlan(remove)}> - Remove… - - + onPlan(remove)}> + Remove from agents… + + )} + {del && ( + onPlan(del)}> + Delete… + )} diff --git a/apps/web/src/components/settings/SkillsSettings.logic.test.ts b/apps/web/src/components/settings/SkillsSettings.logic.test.ts index 79d773d47d5a..7c28ae6da8d4 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.test.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.test.ts @@ -17,7 +17,9 @@ import { ingestSkills, installedAgents, matchesQuery, + planDelete, planFix, + planMove, planRemove, planToggle, planTurnOff, @@ -25,8 +27,10 @@ import { scriptFiles, skillBody, skillsEnvironment, + skillsToCheckWithGit, switchBlocker, unreadableNote, + withGitNote, type Skill, type SkillAgent, } from "./SkillsSettings.logic"; @@ -570,7 +574,7 @@ describe("removing skills from the agents", () => { expect(plan?.change).toEqual({ kind: "remove", skills: [ref("tdd", "~/.agents/skills/tdd")] }); expect(plan?.confirmation).toEqual({ title: "Remove tdd from your agents?", - body: "Claude will stop using it; the original stays.", + body: "Claude will stop using it; the original in ~/.agents/skills/tdd isn't deleted.", notes: ["Codex still uses it from its own folder."], confirm: "Remove", destructive: true, @@ -590,7 +594,7 @@ describe("removing skills from the agents", () => { expect(plan?.change).toMatchObject({ skills: [ref("a"), ref("b")] }); expect(plan?.confirmation).toMatchObject({ title: "Remove 2 skills from your agents?", - body: "Agents will stop using them; the originals stay.", + body: "Agents will stop using them; the originals aren't deleted.", notes: ["1 skill is only in its own folder, so nothing changes there."], destructive: true, }); @@ -598,6 +602,161 @@ describe("removing skills from the agents", () => { }); }); +/** A global skill kept in an agent's folder itself, so it can move or be deleted. */ +const owned = (name: string, extra: Partial = {}): Skill => ({ + ...reached( + name, + { + claudeAgent: { state: "link", folder: "~/.claude/skills" }, + codex: { state: "direct", folder: "~/.agents/skills" }, + cursor: { state: "none", folder: "~/.cursor/skills" }, + }, + `~/.agents/skills/${name}`, + ), + realFolder: true, + ...extra, +}); + +const ownedInProject = (name: string) => + owned(name, { scope: "project", home: `.agents/skills/${name}` }); + +describe("moving skills between this project and Global", () => { + /** A skill kept in the project's shared folder, which Claude reaches through a link. */ + const inProject = (name: string, extra: Partial = {}) => + owned(name, { scope: "project", home: `.agents/skills/${name}`, ...extra }); + + it("always asks first, and says where the skill goes and who sees it", () => { + const toGlobal = planMove([inProject("verify")], "global"); + expect(toGlobal?.change).toEqual({ + kind: "move", + skills: [{ scope: "project", name: "verify", home: ".agents/skills/verify" }], + to: "global", + }); + expect(toGlobal?.confirmation).toEqual({ + title: "Move “verify” to Global?", + body: "Moves to your Global skills, for all your projects.", + notes: ["Agents that use it keep using it."], + confirm: "Move", + destructive: false, + }); + + const global = inProject("tdd", { scope: "global", home: "~/.agents/skills/tdd" }); + const toProject = planMove([global, inProject("grill", { scope: "global" })], "project"); + expect(toProject?.confirmation).toMatchObject({ + title: "Move 2 skills to this project?", + body: "Moves into this project, so anyone who clones it gets it.", + notes: ["Agents that use them keep using them."], + }); + }); + + it("asks git only about project skills a move takes out of a project", () => { + const out = planMove([inProject("a"), inProject("b")], "global")!; + expect(skillsToCheckWithGit(out)).toEqual( + out.change.kind === "move" ? out.change.skills : null, + ); + // Moving into a project makes new files, so there is nothing in git to undo. + const global = inProject("g", { scope: "global" }); + expect(skillsToCheckWithGit(planMove([global], "project")!)).toBeNull(); + expect(planMove([inProject("a")], "global")?.confirmation?.notes.join(" ")).not.toContain( + "git", + ); + }); + + it("leaves out skills that are in the place already or only reached through a link", () => { + const linked = inProject("synced", { realFolder: undefined }); + const plan = planMove( + [inProject("verify"), linked, inProject("home", { scope: "global" })], + "global", + ); + expect(plan?.change).toMatchObject({ skills: [{ name: "verify" }] }); + expect(plan?.affected).toBe(1); + expect(plan?.confirmation?.notes).toContain("1 skill is reached through a link, so it stays."); + expect(planMove([linked], "global")).toBeNull(); + expect(planMove([inProject("home", { scope: "global" })], "global")).toBeNull(); + }); +}); + +describe("deleting skills", () => { + it("names the folder that goes and who stops using the skill, apart from Remove", () => { + const plan = planDelete([owned("tdd")], ctx); + expect(plan?.change).toEqual({ + kind: "delete", + skills: [ref("tdd", "~/.agents/skills/tdd")], + }); + expect(plan?.confirmation).toEqual({ + title: "Delete tdd?", + body: "This deletes ~/.agents/skills/tdd and any links to it. It can't be undone.", + notes: ["Claude and Codex will stop using it."], + confirm: "Delete", + destructive: true, + }); + expect(planRemove([owned("tdd")], ctx)?.confirmation?.body).toContain("isn't deleted"); + }); + + it("counts the folders in a bulk delete and names some of the skills", () => { + const plan = planDelete( + ["a", "b", "c", "d", "e", "f"].map((name) => owned(name)), + ctx, + ); + expect(plan?.affected).toBe(6); + expect(plan?.confirmation).toMatchObject({ + title: "Delete 6 skills?", + body: "This deletes 6 folders and any links to them. It can't be undone.", + notes: ["“a”, “b”, “c”, “d” and 2 more."], + destructive: true, + }); + }); + + it("never offers to delete a skill that is only linked, and says Remove is for those", () => { + const linked = owned("synced", { realFolder: undefined }); + const plan = planDelete([owned("tdd"), linked], ctx); + expect(plan?.change).toMatchObject({ skills: [{ name: "tdd" }] }); + expect(plan?.confirmation?.notes).toContain( + "1 skill is reached through a link, so it stays. Remove takes it away from your agents.", + ); + expect(planDelete([linked], ctx)).toBeNull(); + }); + + it("asks git about the project skills only, and not for a global one", () => { + const plan = planDelete([ownedInProject("a"), owned("g")], ctx)!; + expect(skillsToCheckWithGit(plan)?.map((skill) => skill.name)).toEqual(["a"]); + expect(skillsToCheckWithGit(planDelete([owned("g")], ctx)!)).toBeNull(); + }); +}); + +describe("promising an undo with git", () => { + const delete3 = () => + planDelete([ownedInProject("a"), ownedInProject("b"), ownedInProject("c")], ctx)!; + + it("says so once the server has named the skills git tracks", () => { + expect( + withGitNote(planDelete([ownedInProject("a")], ctx)!, ["a"]).confirmation?.notes, + ).toContain("You can undo this with git."); + expect(withGitNote(delete3(), ["a", "b", "c"]).confirmation?.notes).toContain( + "You can undo this with git.", + ); + const some = withGitNote(delete3(), ["a"]).confirmation?.notes; + expect(some).toContain("1 of these is tracked by git, so you can undo that one with git."); + expect(withGitNote(delete3(), ["a", "b"]).confirmation?.notes).toContain( + "2 of these are tracked by git, so you can undo those with git.", + ); + }); + + it("adds nothing when git tracks none of them, and works for a move out of a project", () => { + const plan = delete3(); + expect(withGitNote(plan, [])).toBe(plan); + expect(withGitNote(plan, ["other"])).toBe(plan); + expect( + withGitNote(planMove([ownedInProject("a")], "global")!, ["a"]).confirmation?.notes, + ).toContain("You can undo this with git."); + }); + + it("leaves the plan's change alone", () => { + const plan = delete3(); + expect(withGitNote(plan, ["a"]).change).toBe(plan.change); + }); +}); + describe("telling what a change did", () => { it("says who got a skill, and who else did because they share a folder", () => { expect( @@ -619,6 +778,63 @@ describe("telling what a change did", () => { ).toBe("Removed 1 skill from your agents."); }); + it("says where skills went and who else got them, and what a delete took", () => { + expect( + describeResult( + { kind: "move", skills: [ref("a"), ref("b")], to: "global" }, + [outcome({ name: "a", affected: [codex.instanceId] }), outcome({ name: "b" })], + ctx, + ), + ).toBe("Moved 2 skills to Global. Codex gets them too."); + expect( + describeResult( + { kind: "move", skills: [ref("a")], to: "project" }, + [outcome({ name: "a" })], + ctx, + ), + ).toBe("Moved 1 skill to this project."); + expect( + describeResult({ kind: "delete", skills: [ref("a")] }, [outcome({ name: "a" })], ctx), + ).toBe("Deleted 1 skill."); + }); + + it("says why a move or a delete left a skill alone, or didn't finish", () => { + expect( + describeResult( + { kind: "move", skills: [], to: "global" }, + [ + outcome({ name: "a", status: "skipped", reason: "destinationTaken" }), + outcome({ name: "b", status: "skipped", reason: "linked" }), + outcome({ name: "c", status: "skipped", reason: "inUse" }), + ], + ctx, + ), + ).toBe( + "Global already has a “a”, so it stays. “b” is reached through a link, so it stays where it is. “c” is in use by another program, so it wasn't moved.", + ); + expect( + describeResult( + { kind: "move", skills: [], to: "project" }, + [outcome({ name: "a", status: "skipped", reason: "destinationTaken" })], + ctx, + ), + ).toBe("This project already has a “a”, so it stays."); + expect( + describeResult( + { kind: "move", skills: [ref("a")], to: "global" }, + [outcome({ name: "a", reason: "failed" })], + ctx, + ), + ).toBe("Moved 1 skill to Global. “a” moved, but its old folder couldn't be removed."); + expect( + describeResult( + { kind: "delete", skills: [ref("a")] }, + [outcome({ name: "a", reason: "failed" })], + ctx, + ), + ).toBe("Deleted 1 skill. “a” was only partly deleted."); + }); + it("says why a skill or an agent was skipped, in the person's words", () => { expect( describeResult( @@ -674,5 +890,11 @@ describe("telling what a change did", () => { expect(describeResult({ kind: "remove", skills: [] }, unchanged, ctx)).toBe( "Nothing to remove.", ); + expect(describeResult({ kind: "move", skills: [], to: "global" }, unchanged, ctx)).toBe( + "Already in Global.", + ); + expect(describeResult({ kind: "delete", skills: [] }, unchanged, ctx)).toBe( + "Nothing to delete.", + ); }); }); diff --git a/apps/web/src/components/settings/SkillsSettings.logic.ts b/apps/web/src/components/settings/SkillsSettings.logic.ts index 21c34cde1427..c10d66c445b5 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.ts @@ -193,7 +193,9 @@ export type SkillChange = readonly skills: readonly SkillRef[]; readonly agents: readonly ProviderInstanceId[]; } - | { readonly kind: "remove"; readonly skills: readonly SkillRef[] }; + | { readonly kind: "remove"; readonly skills: readonly SkillRef[] } + | { readonly kind: "move"; readonly skills: readonly SkillRef[]; readonly to: SkillScope } + | { readonly kind: "delete"; readonly skills: readonly SkillRef[] }; export type SkillPlan = { readonly change: SkillChange; @@ -340,7 +342,7 @@ export function planRemove(selected: readonly Skill[], ctx: SkillsContext): Skil affected: 1, confirmation: { title: `Remove ${skill.name} from your agents?`, - body: `${joinNames(losing.map((agent) => agent.displayName)) || "No agent"} will stop using it; the original stays.`, + body: `${joinNames(losing.map((agent) => agent.displayName)) || "No agent"} will stop using it; the original in ${skill.home} isn't deleted.`, notes, confirm: "Remove", destructive: true, @@ -352,7 +354,7 @@ export function planRemove(selected: readonly Skill[], ctx: SkillsContext): Skil affected: targets.length, confirmation: { title: `Remove ${targets.length} skills from your agents?`, - body: "Agents will stop using them; the originals stay.", + body: "Agents will stop using them; the originals aren't deleted.", notes, confirm: "Remove", destructive: true, @@ -360,6 +362,132 @@ export function planRemove(selected: readonly Skill[], ctx: SkillsContext): Skil }; } +// -- Moving and deleting ---------------------------------------------------------------------- + +/** Whether the skill's own folder is in an agent's skill folder, which is what can move or go. */ +const hasOwnFolder = (skill: Skill) => skill.realFolder === true; + +const destinationName = (to: SkillScope) => (to === "global" ? "Global" : "this project"); + +const quoted = (skills: readonly Skill[]) => skills.map((skill) => `“${skill.name}”`); + +/** The skill names a note lists, cut short so a long selection stays one line. */ +const someNames = (skills: readonly Skill[], shown = 4) => + skills.length <= shown + ? joinNames(quoted(skills)) + : `${quoted(skills.slice(0, shown)).join(", ")} and ${skills.length - shown} more`; + +/** Skills that stay because they are reached through a link, not kept in an agent's folder. */ +const linkedNote = (kept: readonly Skill[], afterwards = "") => + kept.length === 0 + ? [] + : [ + `${plural(kept.length, "skill")} ${kept.length === 1 ? "is" : "are"} reached through a link, so ${kept.length === 1 ? "it stays" : "they stay"}.${afterwards}`, + ]; + +/** + * Moving skills between This project and Global. It always asks first, since it changes who + * sees the skills. The agents that used a skill keep using it; the server links them again. + */ +export function planMove(selected: readonly Skill[], to: SkillScope): SkillPlan | null { + const coming = selected.filter((skill) => skill.scope !== to); + const targets = coming.filter(hasOwnFolder); + if (targets.length === 0) return null; + const them = targets.length === 1 ? "it" : "them"; + const notes = [ + `Agents that use ${them} keep using ${them}.`, + ...linkedNote(coming.filter((skill) => !hasOwnFolder(skill))), + ]; + return { + change: { kind: "move", skills: targets.map(skillRef), to }, + affected: targets.length, + confirmation: { + title: `Move ${targets.length === 1 ? `“${targets[0]!.name}”` : plural(targets.length, "skill")} to ${destinationName(to)}?`, + body: + to === "global" + ? "Moves to your Global skills, for all your projects." + : "Moves into this project, so anyone who clones it gets it.", + notes, + confirm: "Move", + destructive: false, + }, + }; +} + +/** + * Deleting the skills' own folders and the links that lead to them. This is not Remove: Remove + * only takes the links away and leaves the original, and a skill that is only linked here, such + * as one from a synced library, can't be deleted from this page at all. + */ +export function planDelete(selected: readonly Skill[], ctx: SkillsContext): SkillPlan | null { + const targets = selected.filter(hasOwnFolder); + if (targets.length === 0) return null; + const kept = selected.filter((skill) => !hasOwnFolder(skill)); + const notes: string[] = []; + if (targets.length === 1) { + const losing = ctx.installed.filter((agent) => hasAccess(targets[0]!, agent)); + if (losing.length > 0) { + notes.push(`${joinNames(losing.map((agent) => agent.displayName))} will stop using it.`); + } + } else { + notes.push(`${someNames(targets)}.`); + } + notes.push( + ...linkedNote( + kept, + ` Remove takes ${kept.length === 1 ? "it" : "them"} away from your agents.`, + ), + ); + return { + change: { kind: "delete", skills: targets.map(skillRef) }, + affected: targets.length, + confirmation: { + title: + targets.length === 1 ? `Delete ${targets[0]!.name}?` : `Delete ${targets.length} skills?`, + body: + targets.length === 1 + ? `This deletes ${targets[0]!.home} and any links to it. It can't be undone.` + : `This deletes ${plural(targets.length, "folder")} and any links to them. It can't be undone.`, + notes, + confirm: "Delete", + destructive: true, + }, + }; +} + +/** + * The project skills a confirmation should ask git about: those a delete removes or a move out of + * a project takes. A move into a project makes new files, so there is nothing in git to undo. + * Null when the plan has nothing to ask about. + */ +export function skillsToCheckWithGit(plan: SkillPlan): readonly SkillRef[] | null { + const { change } = plan; + if (plan.confirmation === undefined) return null; + if (change.kind !== "delete" && !(change.kind === "move" && change.to === "global")) return null; + const skills = change.skills.filter((skill) => skill.scope === "project"); + return skills.length === 0 ? null : skills; +} + +/** + * The plan with a line saying git can undo it, once the server has said which project skills it + * tracks. A plan nothing is tracked for is returned as it was. + */ +export function withGitNote(plan: SkillPlan, tracked: readonly string[]): SkillPlan { + if (plan.confirmation === undefined) return plan; + const { skills } = plan.change; + const names = new Set(tracked); + const count = skills.filter((skill) => skill.scope === "project" && names.has(skill.name)).length; + if (count === 0) return plan; + const note = + count === skills.length + ? "You can undo this with git." + : `${count} of these ${count === 1 ? "is" : "are"} tracked by git, so you can undo ${count === 1 ? "that one" : "those"} with git.`; + return { + ...plan, + confirmation: { ...plan.confirmation, notes: [...plan.confirmation.notes, note] }, + }; +} + /** A one-click fix for a skill that installed agents can't use yet. */ export function planFix(skill: Skill, ctx: SkillsContext) { const missing = missingAgents(skill, ctx); @@ -371,7 +499,13 @@ export function planFix(skill: Skill, ctx: SkillsContext) { }; } -const problemText = (reason: SkillOutcomeReason, name: string, who: string | undefined) => { +const problemText = ( + reason: SkillOutcomeReason, + name: string, + who: string | undefined, + /** Where a move was going, to say who is in the way. */ + to?: SkillScope, +) => { switch (reason) { case "notFound": return `“${name}” isn't there any more.`; @@ -385,6 +519,12 @@ const problemText = (reason: SkillOutcomeReason, name: string, who: string | und return `${who ?? "An agent"} loads another “${name}” first.`; case "linkNotAllowed": return "Your system doesn't let T3 Code make links there. On Windows, turn on Developer Mode."; + case "linked": + return `“${name}” is reached through a link, so it stays where it is.`; + case "destinationTaken": + return `${to === undefined ? "The other side" : capitalize(destinationName(to))} already has a “${name}”, so it stays.`; + case "inUse": + return `“${name}” is in use by another program, so it wasn't moved.`; case "failed": return who === undefined ? `Couldn't change “${name}”.` @@ -392,6 +532,14 @@ const problemText = (reason: SkillOutcomeReason, name: string, who: string | und } }; +const capitalize = (text: string) => `${text.slice(0, 1).toUpperCase()}${text.slice(1)}`; + +/** A skill that was changed, but not all the way: its old folder stayed, or only some of it went. */ +const partialText = (kind: SkillChange["kind"], name: string) => + kind === "move" + ? `“${name}” moved, but its old folder couldn't be removed.` + : `“${name}” was only partly deleted.`; + const MAX_PROBLEMS = 3; /** One status line on what a change did, from what the server says happened to each skill. */ @@ -416,12 +564,29 @@ export function describeResult( return `Turned off ${count} for ${joinNames(change.agents.map(nameOf))}.${also.length > 0 ? ` ${alsoNames} ${also.length === 1 ? "loses" : "lose"} ${them} too.` : ""}`; case "remove": return `Removed ${count} from your agents.`; + case "move": + return `Moved ${count} to ${destinationName(change.to)}.${also.length > 0 ? ` ${alsoNames} ${also.length === 1 ? "gets" : "get"} ${them} too.` : ""}`; + case "delete": + return `Deleted ${count}.`; } })(); const problems = [ ...new Set( outcomes.flatMap((outcome) => [ - ...(outcome.reason ? [problemText(outcome.reason, outcome.skill.name, undefined)] : []), + ...(outcome.reason + ? [ + outcome.status === "changed" && + outcome.reason === "failed" && + (change.kind === "move" || change.kind === "delete") + ? partialText(change.kind, outcome.skill.name) + : problemText( + outcome.reason, + outcome.skill.name, + undefined, + change.kind === "move" ? change.to : undefined, + ), + ] + : []), ...outcome.blocked.map((blocked) => problemText(blocked.reason, outcome.skill.name, nameOf(blocked.instanceId)), ), @@ -429,11 +594,18 @@ export function describeResult( ), ]; if (lead === "" && problems.length === 0) { - return change.kind === "enable" - ? "Already on." - : change.kind === "disable" - ? "Already off." - : "Nothing to remove."; + switch (change.kind) { + case "enable": + return "Already on."; + case "disable": + return "Already off."; + case "remove": + return "Nothing to remove."; + case "move": + return `Already in ${destinationName(change.to)}.`; + case "delete": + return "Nothing to delete."; + } } const shown = problems.slice(0, MAX_PROBLEMS); if (problems.length > shown.length) { diff --git a/apps/web/src/components/settings/SkillsSettings.tsx b/apps/web/src/components/settings/SkillsSettings.tsx index 1544765d41f5..4cfe55d8bb6a 100644 --- a/apps/web/src/components/settings/SkillsSettings.tsx +++ b/apps/web/src/components/settings/SkillsSettings.tsx @@ -26,7 +26,9 @@ import { matchesQuery, planFix, skillsEnvironment, + skillsToCheckWithGit, unreadableNote, + withGitNote, type Skill, type SkillPlan, type SkillsContext, @@ -112,6 +114,9 @@ function EnvironmentSkills({ const enableSkills = useAtomCommand(serverEnvironment.enableSkills, { reportFailure: false }); const disableSkills = useAtomCommand(serverEnvironment.disableSkills, { reportFailure: false }); const removeSkills = useAtomCommand(serverEnvironment.removeSkills, { reportFailure: false }); + const moveSkills = useAtomCommand(serverEnvironment.moveSkills, { reportFailure: false }); + const deleteSkills = useAtomCommand(serverEnvironment.deleteSkills, { reportFailure: false }); + const skillsTracked = useAtomCommand(serverEnvironment.skillsTracked, { reportFailure: false }); // Reading the list needs no grant; each change needs its command's. const canEnable = useAtomValue( serverEnvironment.enableSkills.permissionAtom(environment.environmentId), @@ -122,6 +127,12 @@ function EnvironmentSkills({ const canRemove = useAtomValue( serverEnvironment.removeSkills.permissionAtom(environment.environmentId), ); + const canMove = useAtomValue( + serverEnvironment.moveSkills.permissionAtom(environment.environmentId), + ); + const canDelete = useAtomValue( + serverEnvironment.deleteSkills.permissionAtom(environment.environmentId), + ); const connected = environment.connection.phase === "connected"; const providers = environment.serverConfig?.providers ?? NO_PROVIDERS; const cwd = project?.cwd ?? null; @@ -139,7 +150,7 @@ function EnvironmentSkills({ /** A change is being made and the list read again; nothing else can start meanwhile. */ const [busy, setBusy] = useState(false); /** The controls that change skills are off while a change runs or the grant is missing. */ - const locked = busy || !(canEnable && canDisable && canRemove); + const locked = busy || !(canEnable && canDisable && canRemove && canMove && canDelete); const [notice, setNotice] = useState(null); const rootRef = useRef(null); const mounted = useRef(true); @@ -263,9 +274,19 @@ function EnvironmentSkills({ ...base, input: { ...scoped, skills: change.skills, agents: change.agents }, }) - : await removeSkills({ ...base, input: { ...scoped, skills: change.skills } }); + : change.kind === "remove" + ? await removeSkills({ ...base, input: { ...scoped, skills: change.skills } }) + : change.kind === "move" + ? // A move is between a project and Global, so it needs the project picked above. + cwd + ? await moveSkills({ + ...base, + input: { cwd, skills: change.skills, to: change.to }, + }) + : null + : await deleteSkills({ ...base, input: { ...scoped, skills: change.skills } }); setNotice( - result._tag === "Success" + result?._tag === "Success" ? describeResult(change, result.value.outcomes, ctx) : CHANGE_ERROR, ); @@ -286,10 +307,32 @@ function EnvironmentSkills({ setSelected(new Set()); setBusy(false); }; - /** A plan that needs confirming waits for the dialog; any other goes ahead. */ + /** + * A plan that needs confirming waits for the dialog; any other goes ahead. For a move or delete + * the dialog opens at once and git is asked meanwhile: the "undo with git" line appears when the + * answer is in, and never when the check fails. + */ const runPlan = (plan: SkillPlan) => { - if (plan.confirmation) setConfirming(plan); - else void apply(plan); + if (!plan.confirmation) { + void apply(plan); + return; + } + setConfirming(plan); + const skills = cwd ? skillsToCheckWithGit(plan) : null; + if (!cwd || !skills) return; + void (async () => { + try { + const result = await skillsTracked({ + environmentId: environment.environmentId, + input: { cwd, skills }, + }); + if (result._tag !== "Success") return; + const tracked = result.value.tracked; + setConfirming((current) => (current === plan ? withGitNote(plan, tracked) : current)); + } catch { + // No answer, no promise: the dialog stays as it was. + } + })(); }; const chosen = useMemo( () => (skills ?? []).filter((skill) => selected.has(skill.id)), @@ -451,6 +494,7 @@ function EnvironmentSkills({ setSelected(new Set())} onPlan={runPlan} From a934f9fbae54fe3e4e65c850257a10c2e9cae49d Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:19:01 -0400 Subject: [PATCH 014/108] docs: describe moving and deleting skills Co-Authored-By: Claude Sonnet 5.5 --- docs/user/skills.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/user/skills.md b/docs/user/skills.md index c829b0209c0b..2944d9db6f56 100644 --- a/docs/user/skills.md +++ b/docs/user/skills.md @@ -41,8 +41,18 @@ removes that link and nothing else. administrator rights. Tick the boxes beside skills to act on several at once: turn them on for all agents, turn them off -for one agent, or remove them. **Remove** takes away every link to the skills so agents stop using -them. The original skill folders are never deleted. +for one agent, or remove them. **Remove from agents** takes away every link to the skills so agents +stop using them. It never deletes the original folders. + +## Moving and deleting + +**Move to Global** and **Move to this project** move a skill's folder between the project's +`.agents/skills` and `~/.agents/skills`, and the agents that used it keep using it. A skill is never +merged into or replaced by one with the same name on the other side; T3 Code leaves both and says +so. **Delete** removes the skill's folder and the links to it, and can't be undone. When git tracks +a project skill, both show up in `git status` and the confirmation says you can undo them with git. +Only a skill kept in an agent's own skill folder can be moved or deleted. One that is only linked +there, such as a skill from a synced folder, stays where it is. ## Needs attention From 473bc7a5d08067c1fccce2ca5f468eeb616f9b0e Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:01:47 -0400 Subject: [PATCH 015/108] feat(server): turn a skill on for all agents, or name an agent by its driver kind Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/skills/SkillManager.ts | 48 +++++++++++++++++--------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/apps/server/src/skills/SkillManager.ts b/apps/server/src/skills/SkillManager.ts index 9e2863cb5566..b698b94d605a 100644 --- a/apps/server/src/skills/SkillManager.ts +++ b/apps/server/src/skills/SkillManager.ts @@ -135,11 +135,17 @@ const hasSkill = (state: "direct" | "link" | "none") => state !== "none"; export class SkillManager extends Context.Service< SkillManager, { - /** Make a link in each agent's own folder so it can use each skill. */ + /** + * Make a link in each agent's own folder so it can use each skill. `"all"` means every + * enabled agent. An agent is named by its instance id, or by its driver kind to mean every + * instance of that driver when no instance has that id. + */ readonly enable: ( - input: SkillEnableInput, + input: Omit & { + readonly agents: SkillEnableInput["agents"] | "all"; + }, ) => Effect.Effect; - /** Remove each agent's link to each skill. */ + /** Remove each agent's link to each skill. Agents are named as for `enable`. */ readonly disable: ( input: SkillDisableInput, ) => Effect.Effect; @@ -412,11 +418,12 @@ const make = Effect.gen(function* () { const run = (input: { readonly cwd: string | undefined; readonly skills: ReadonlyArray; - readonly agents: ReadonlySet; + readonly agents: "all" | ReadonlySet; /** Skills to look up besides those asked for, such as the same names in the other scope. */ readonly alsoLookUp?: ReadonlyArray<{ readonly scope: SkillScope; readonly name: string }>; readonly change: ( skill: SkillCatalog.ResolvedSkill, + agents: ReadonlySet, projectRoot: string | undefined, /** Everything looked up, which includes the skills asked for. */ all: ReadonlyArray, @@ -429,10 +436,18 @@ const make = Effect.gen(function* () { cwd: input.cwd, skills: [...input.skills, ...(input.alsoLookUp ?? [])], }); - const known = new Set((before[0]?.agents ?? []).map((agent) => agent.instanceId)); - if (known.size > 0 && [...input.agents].some((id) => !known.has(id))) { - return yield* new SkillRequestError({ reason: "unknownAgent" }); + const instances = before[0]?.agents ?? []; + const agents = new Set(); + for (const name of input.agents === "all" ? [] : input.agents) { + const byId = instances.filter((agent) => agent.instanceId === name); + const matches = + byId.length > 0 ? byId : instances.filter((agent) => agent.driver === name); + if (matches.length === 0 && instances.length > 0) { + return yield* new SkillRequestError({ reason: "unknownAgent" }); + } + for (const agent of matches) agents.add(agent.instanceId); } + if (input.agents === "all") for (const agent of instances) agents.add(agent.instanceId); const projectRoot = input.cwd === undefined ? undefined @@ -448,7 +463,7 @@ const make = Effect.gen(function* () { const reason = candidates.length > 0 ? "changed" : "notFound"; return { ref, found, change: { wrote: false, blocked: [], reason } as SkillChange }; } - return { ref, found, change: yield* input.change(found, projectRoot, before) }; + return { ref, found, change: yield* input.change(found, agents, projectRoot, before) }; }), ); @@ -491,7 +506,7 @@ const make = Effect.gen(function* () { (item, index, all) => all.findIndex((other) => other.instanceId === item.instanceId) === index, ), - affected: change.affected ?? flipped.filter((id) => !input.agents.has(id)), + affected: change.affected ?? flipped.filter((id) => !agents.has(id)), } satisfies SkillOutcome, }; }); @@ -504,21 +519,19 @@ const make = Effect.gen(function* () { return SkillManager.of({ enable: Effect.fn("SkillManager.enable")(function* (input) { - const agents = new Set(input.agents); return yield* run({ cwd: input.cwd, skills: input.skills, - agents, - change: (skill, projectRoot) => enableOne(skill, agents, projectRoot), + agents: input.agents === "all" ? "all" : new Set(input.agents), + change: enableOne, }); }), disable: Effect.fn("SkillManager.disable")(function* (input) { - const agents = new Set(input.agents); return yield* run({ cwd: input.cwd, skills: input.skills, - agents, - change: (skill) => disableOne(skill, agents), + agents: new Set(input.agents), + change: disableOne, }); }), remove: Effect.fn("SkillManager.remove")(function* (input) { @@ -535,7 +548,8 @@ const make = Effect.gen(function* () { skills: input.skills, agents: new Set(), alsoLookUp: input.skills.map((ref) => ({ scope: input.to, name: ref.name })), - change: (skill, projectRoot, all) => moveOne(skill, input.to, input.cwd, projectRoot, all), + change: (skill, _agents, projectRoot, all) => + moveOne(skill, input.to, input.cwd, projectRoot, all), }); }), delete: Effect.fn("SkillManager.delete")(function* (input) { @@ -548,7 +562,7 @@ const make = Effect.gen(function* () { scope: ref.scope === "project" ? ("global" as const) : ("project" as const), name: ref.name, })), - change: (skill, _projectRoot, all) => deleteOne(skill, all), + change: (skill, _agents, _projectRoot, all) => deleteOne(skill, all), }); }), }); From 0ce722475544cc8ac8cd302c273e1d4994a219fd Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:01:50 -0400 Subject: [PATCH 016/108] feat(server): let agents list and turn skills on or off through MCP tools Adds t3_skill_list, t3_skill_get, t3_skill_enable and t3_skill_disable. Reads need any orchestration credential; changes need a full-access caller, like project changes. Removing, deleting and moving skills stay out of reach of agents. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/mcp/McpHttpServer.ts | 5 + apps/server/src/mcp/toolkits/core.test.ts | 2 + .../src/mcp/toolkits/skills/handlers.test.ts | 501 ++++++++++++++++++ .../src/mcp/toolkits/skills/handlers.ts | 87 +++ apps/server/src/mcp/toolkits/skills/tools.ts | 94 ++++ .../toolkits/worktree/registration.test.ts | 4 + .../Adapters/ClaudeAdapterV2.test.ts | 2 + .../Adapters/ClaudeAdapterV2.ts | 2 + docs/user/skills.md | 3 + packages/client-runtime/src/t3ToolSummary.ts | 12 + .../src/work-log/presentation.ts | 2 + packages/shared/src/t3McpToolPresentation.ts | 11 + 12 files changed, 725 insertions(+) create mode 100644 apps/server/src/mcp/toolkits/skills/handlers.test.ts create mode 100644 apps/server/src/mcp/toolkits/skills/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/skills/tools.ts diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 0e2b52696712..58b8a573343d 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -30,6 +30,8 @@ import { EnvironmentToolkit } from "./toolkits/environment/tools.ts"; import * as EnvironmentHandlers from "./toolkits/environment/handlers.ts"; import { ProjectToolkit } from "./toolkits/project/tools.ts"; import * as ProjectHandlers from "./toolkits/project/handlers.ts"; +import { SkillsToolkit } from "./toolkits/skills/tools.ts"; +import * as SkillsHandlers from "./toolkits/skills/handlers.ts"; import { AttachmentToolkit } from "./toolkits/attachment/tools.ts"; import * as AttachmentHandlers from "./toolkits/attachment/handlers.ts"; import { ThreadToolkit } from "./toolkits/thread/tools.ts"; @@ -833,6 +835,8 @@ export const layerEnvironmentToolkit = toolkitRegistration( const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); +export const layerSkillsToolkit = toolkitRegistration(SkillsToolkit, SkillsHandlers.layer); + export const layerAttachmentToolkit = toolkitRegistration( AttachmentToolkit, AttachmentHandlers.layer, @@ -872,6 +876,7 @@ export const layer = Layer.mergeAll( layerThreadToolkit, layerAttachmentToolkit, layerProjectRegistration, + layerSkillsToolkit, layerEnvironmentToolkit, layerPreviewControlsRegistration, layerWorktreeToolkitRegistration, diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index a938ba7fd999..c0383cc17d01 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -47,6 +47,7 @@ import { PreviewControlsToolkit } from "./previewControls/tools.ts"; import { EnvironmentToolkit } from "./environment/tools.ts"; import * as EnvironmentHandlers from "./environment/handlers.ts"; import { ProjectToolkit } from "./project/tools.ts"; +import { SkillsToolkit } from "./skills/tools.ts"; import { AttachmentToolkit } from "./attachment/tools.ts"; import * as AttachmentHandlers from "./attachment/handlers.ts"; import { ThreadToolkit } from "./thread/tools.ts"; @@ -85,6 +86,7 @@ it("publishes unique tool names with reference-free object-root inputs", () => { ThreadToolkit, AttachmentToolkit, ProjectToolkit, + SkillsToolkit, EnvironmentToolkit, PreviewControlsToolkit, DeviceToolkit, diff --git a/apps/server/src/mcp/toolkits/skills/handlers.test.ts b/apps/server/src/mcp/toolkits/skills/handlers.test.ts new file mode 100644 index 000000000000..0322c29dd2c2 --- /dev/null +++ b/apps/server/src/mcp/toolkits/skills/handlers.test.ts @@ -0,0 +1,501 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + SkillListResult, + ThreadId, + type OrchestrationV2ThreadShell, + type Project, + type RuntimeMode, + type SkillSummary, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import { McpSchema, McpServer } from "effect/ai"; + +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../../../project/ProjectService.ts"; +import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; +import * as Settings from "../../../serverSettings.ts"; +import * as SkillCatalog from "../../../skills/SkillCatalog.ts"; +import * as SkillManager from "../../../skills/SkillManager.ts"; +import * as McpHttpServer from "../../McpHttpServer.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const threadId = ThreadId.make("skills-mcp-thread"); +const projectId = ProjectId.make("skills-mcp-project"); +const claudeDriver = ProviderDriverKind.make("claudeAgent"); +const callingInstance = ProviderInstanceId.make("codex"); + +const client = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "skills-mcp", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "skills-mcp", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +const scope: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("skills-mcp-environment"), + requestNamespace: "skills-mcp-session", + thread: { + threadId, + providerSessionId: "skills-mcp-session", + providerInstanceId: callingInstance, + }, + client: undefined, + issuedAt: 0, + capabilities: new Set(["orchestration"]), +}; + +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +// Effect returns a declared tool failure as `isError` with its encoded payload as JSON text. +const declaredFailure = (result: McpSchema.CallToolResult) => { + const text = result.content[0]; + return result.isError === true && text?.type === "text" ? decodeJson(text.text) : undefined; +}; + +const skillFile = (name: string) => `---\nname: ${name}\ndescription: The ${name} skill.\n---\n`; + +/** A made-up machine: `alpha` linked into the shared folder, `beta` in Claude's own folder, and one project. */ +const makeMachine = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "t3code-skills-mcp-" }), + ); + const write = (relative: string, contents: string) => + Effect.gen(function* () { + const target = path.join(home, relative); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, contents); + }); + yield* write("library/skills/alpha/SKILL.md", skillFile("alpha")); + // Only Claude (and Cursor) read this folder. + yield* write(".claude/skills/beta/SKILL.md", skillFile("beta")); + yield* fs.makeDirectory(path.join(home, ".agents/skills"), { recursive: true }); + yield* fs.symlink( + path.join(home, "library/skills/alpha"), + path.join(home, ".agents/skills/alpha"), + ); + yield* write("repos/app/.agents/skills/verify/SKILL.md", skillFile("verify")); + return { fs, path, home, project: path.join(home, "repos/app") }; +}); + +const projectAt = (workspaceRoot: string): Project => ({ + id: projectId, + title: "App", + workspaceRoot, + repositoryIdentity: null, + faviconPath: null, + projectIcon: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}); + +type Refresh = { + readonly instanceId: ProviderInstanceId; + readonly cwd: string | undefined; + readonly fresh: boolean | undefined; +}; + +/** + * The production skills registration over the real catalog and manager, on the machine at `home`. + * The provider registry is a stand-in that queues each picker refresh it is asked for. + */ +const layerFor = ( + home: string, + project: string, + options: { + readonly refreshes?: Queue.Queue; + readonly runtimeMode?: RuntimeMode; + readonly providerInstances?: NonNullable< + Parameters[0] + >["providerInstances"]; + } = {}, +) => { + const noteRefresh = (refresh: Refresh) => + (options.refreshes === undefined ? Effect.void : Queue.offer(options.refreshes, refresh)).pipe( + Effect.as([]), + ); + return McpHttpServer.layerSkillsToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide( + SkillManager.layer.pipe( + Layer.provideMerge( + SkillCatalog.layer.pipe( + Layer.provide( + Settings.layerTest({ + providerInstances: { + ...Object.fromEntries( + ["cursor", "grok"].map((driver) => [ + ProviderInstanceId.make(driver), + { driver: ProviderDriverKind.make(driver), enabled: true }, + ]), + ), + ...options.providerInstances, + }, + }), + ), + ), + ), + ), + ), + Layer.provide( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + refreshInstance: (instanceId) => + noteRefresh({ instanceId, cwd: undefined, fresh: undefined }), + refreshWorkspaceSnapshot: ({ instanceId, cwd, fresh }) => + noteRefresh({ instanceId, cwd, fresh }), + }), + ), + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: (id) => + Effect.succeed(id === projectId ? Option.some(projectAt(project)) : Option.none()), + getByWorkspaceRoot: (root) => + Effect.succeed(root === project ? Option.some(projectAt(project)) : Option.none()), + }), + ), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ + id: threadId, + projectId, + providerInstanceId: callingInstance, + runtimeMode: options.runtimeMode ?? "full-access", + interactionMode: "default", + activeRunId: RunId.make("skills-mcp-run"), + archivedAt: null, + deletedAt: null, + } as OrchestrationV2ThreadShell), + }), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, { HOME: home })), + Layer.provide(Layer.succeed(HostProcess.HomeDirectory, home)), + ); +}; + +const call = (name: string, args: Record) => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + return yield* server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + }); + +const decodeList = Schema.decodeUnknownSync(SkillListResult); +const listSkills = (args: Record = {}) => + call("t3_skill_list", args).pipe( + Effect.map((result) => decodeList(result.structuredContent).skills), + ); + +const refOf = (skills: ReadonlyArray, scope: "project" | "global", name: string) => { + const skill = skills.find((item) => item.scope === scope && item.name === name); + if (!skill) throw new Error(`No ${scope} skill ${name} in the list`); + return { scope, name, home: skill.home }; +}; + +const stateOf = (skills: ReadonlyArray, scope: "project" | "global", name: string) => + Object.fromEntries( + (skills.find((item) => item.scope === scope && item.name === name)?.access ?? []).map( + (entry) => [entry.instanceId, entry.state], + ), + ); + +describe("skills MCP tools", () => { + it.layer(NodeServices.layer, { excludeTestServices: true })("over a real skill layout", (it) => { + it.effect("lists the calling thread's project skills and the global ones", () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* Effect.gen(function* () { + const skills = yield* listSkills(); + + expect(skills.map((skill) => `${skill.scope}:${skill.name}`)).toEqual([ + "global:alpha", + "global:beta", + "project:verify", + ]); + expect(stateOf(skills, "global", "alpha")).toMatchObject({ + claudeAgent: "none", + codex: "direct", + }); + expect(stateOf(skills, "global", "beta")).toMatchObject({ + claudeAgent: "direct", + codex: "none", + }); + // The home an agent passes back to enable is the one the list shows. + expect(refOf(skills, "project", "verify").home).toBe(".agents/skills/verify"); + }).pipe(Effect.provide(layerFor(home, project))); + }), + ); + + it.effect("reads one skill's text and files", () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* Effect.gen(function* () { + const skills = yield* listSkills(); + const result = yield* call("t3_skill_get", refOf(skills, "global", "beta")); + + expect(result.structuredContent).toMatchObject({ + contents: skillFile("beta"), + files: [{ path: "SKILL.md" }], + }); + }).pipe(Effect.provide(layerFor(home, project))); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "turns a skill on for a named agent, and the list then shows it", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* Effect.gen(function* () { + const skills = yield* listSkills(); + + const result = yield* call("t3_skill_enable", { + skills: [refOf(skills, "global", "alpha")], + agents: ["claudeAgent"], + }); + + expect(result.structuredContent).toEqual({ + outcomes: [ + { + skill: refOf(skills, "global", "alpha"), + status: "changed", + blocked: [], + affected: [], + }, + ], + }); + expect(yield* fs.readLink(path.join(home, ".claude/skills/alpha"))).toBe( + path.join(home, "library/skills/alpha"), + ); + expect(stateOf(yield* listSkills(), "global", "alpha").claudeAgent).toBe("link"); + }).pipe(Effect.provide(layerFor(home, project))); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "turns a project skill on for every agent, using the thread's project", + () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* Effect.gen(function* () { + const skills = yield* listSkills(); + + const result = yield* call("t3_skill_enable", { + skills: [refOf(skills, "project", "verify")], + agents: "all", + }); + + expect(result.structuredContent).toMatchObject({ + outcomes: [{ status: "changed", blocked: [] }], + }); + const states = stateOf(yield* listSkills(), "project", "verify"); + expect(Object.values(states).every((state) => state !== "none")).toBe(true); + }).pipe(Effect.provide(layerFor(home, project))); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "refreshes the thread's project picker for the agent a skill was turned on for", + () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + const refreshes = yield* Queue.unbounded(); + yield* Effect.gen(function* () { + const skills = yield* listSkills(); + + yield* call("t3_skill_enable", { + skills: [refOf(skills, "project", "verify")], + agents: ["claudeAgent"], + }); + + expect(yield* Queue.take(refreshes)).toEqual({ + instanceId: "claudeAgent", + cwd: project, + fresh: true, + }); + expect(yield* Queue.size(refreshes)).toBe(0); + }).pipe(Effect.provide(layerFor(home, project, { refreshes }))); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "shows an agent why a skill could not be turned off for it", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* Effect.gen(function* () { + const skills = yield* listSkills(); + + const result = yield* call("t3_skill_disable", { + skills: [refOf(skills, "global", "alpha")], + agents: ["codex"], + }); + + // Codex reads the shared folder itself, so there is no link of its own to remove. + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ + outcomes: [ + { status: "skipped", blocked: [{ instanceId: "codex", reason: "alwaysOn" }] }, + ], + }); + expect(yield* fs.exists(path.join(home, ".agents/skills/alpha"))).toBe(true); + }).pipe(Effect.provide(layerFor(home, project))); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "turns a skill on for every instance of a driver named by its driver kind", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* Effect.gen(function* () { + const skills = yield* listSkills(); + // Only the two named instances are agents here; no instance is called "claudeAgent". + expect(stateOf(skills, "global", "alpha")).toMatchObject({ + claude_home: "none", + claude_work: "none", + }); + + const result = yield* call("t3_skill_enable", { + skills: [refOf(skills, "global", "alpha")], + agents: ["claudeAgent"], + }); + + expect(result.structuredContent).toMatchObject({ outcomes: [{ status: "changed" }] }); + expect(yield* fs.readLink(path.join(home, ".claude/skills/alpha"))).toBe( + path.join(home, "library/skills/alpha"), + ); + expect(yield* fs.readLink(path.join(home, "work-claude/skills/alpha"))).toBe( + path.join(home, "library/skills/alpha"), + ); + }).pipe( + Effect.provide( + layerFor(home, project, { + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: claudeDriver, + enabled: false, + }, + [ProviderInstanceId.make("claude_home")]: { driver: claudeDriver }, + [ProviderInstanceId.make("claude_work")]: { + driver: claudeDriver, + config: { homePath: `${home}/work-claude` }, + }, + }, + }), + ), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)("turns a skill back off", () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* Effect.gen(function* () { + const skill = refOf(yield* listSkills(), "global", "alpha"); + yield* call("t3_skill_enable", { skills: [skill], agents: ["claudeAgent"] }); + + const result = yield* call("t3_skill_disable", { + skills: [skill], + agents: ["claudeAgent"], + }); + + expect(result.structuredContent).toMatchObject({ outcomes: [{ status: "changed" }] }); + expect(yield* fs.exists(path.join(home, ".claude/skills/alpha"))).toBe(false); + // The skill's own folder is untouched. + expect(yield* fs.exists(path.join(home, "library/skills/alpha/SKILL.md"))).toBe(true); + }).pipe(Effect.provide(layerFor(home, project))); + }), + ); + + it.effect("tells the agent when an agent name is not one it has", () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* Effect.gen(function* () { + const skills = yield* listSkills(); + + const result = yield* call("t3_skill_enable", { + skills: [refOf(skills, "global", "beta")], + agents: ["no-such-agent"], + }); + + expect(declaredFailure(result)).toMatchObject({ + code: "invalid_request", + message: "That agent isn't enabled in this environment.", + }); + }).pipe(Effect.provide(layerFor(home, project))); + }), + ); + + it.effect("lets a supervised thread read skills but not change them", () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* Effect.gen(function* () { + const skills = yield* listSkills(); + expect(skills.length).toBeGreaterThan(0); + + const result = yield* call("t3_skill_enable", { + skills: [refOf(skills, "global", "beta")], + agents: ["codex"], + }); + + expect(declaredFailure(result)).toMatchObject({ code: "capability_denied" }); + expect(yield* fs.exists(path.join(home, ".agents/skills/beta"))).toBe(false); + }).pipe(Effect.provide(layerFor(home, project, { runtimeMode: "approval-required" }))); + }), + ); + + it.effect("rejects inputs the tools do not accept before touching any service", () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* Effect.gen(function* () { + const skill = { scope: "global", name: "beta", home: "~/.claude/skills/beta" }; + for (const [name, args] of [ + ["t3_skill_enable", { skills: [skill], agents: [] }], + ["t3_skill_enable", { skills: [], agents: "all" }], + ["t3_skill_enable", { skills: [skill], agents: ["not a slug"] }], + // Only enabling takes "all". + ["t3_skill_disable", { skills: [skill], agents: "all" }], + [ + "t3_skill_enable", + { skills: [{ scope: "everywhere", name: "beta", home: "x" }], agents: "all" }, + ], + ] as const) { + const error = yield* call(name, args).pipe(Effect.flip); + expect(error._tag, `${name} ${Object.keys(args).join()}`).toBe("InvalidParams"); + } + }).pipe(Effect.provide(layerFor(home, project))); + }), + ); + }); +}); diff --git a/apps/server/src/mcp/toolkits/skills/handlers.ts b/apps/server/src/mcp/toolkits/skills/handlers.ts new file mode 100644 index 000000000000..af52536743d3 --- /dev/null +++ b/apps/server/src/mcp/toolkits/skills/handlers.ts @@ -0,0 +1,87 @@ +import { + OrchestratorMcpFailure, + type ProjectId, + type SkillRef, + type SkillRequestError, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as ProjectService from "../../../project/ProjectService.ts"; +import * as SkillCatalog from "../../../skills/SkillCatalog.ts"; +import * as SkillManager from "../../../skills/SkillManager.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { readCaller, resolveProjectId, unavailable, type Caller } from "../../threadAccess.ts"; +import { SkillsToolkit } from "./tools.ts"; + +const skillFailure = (error: SkillRequestError) => + new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }); + +/** + * The folder of the project the call is about: the one passed, else the calling thread's. + * Without either, project skills can't be reached: `required` says whether that is a failure + * (a change to a project skill) or just means the global skills alone (a read). + */ +const projectFolder = Effect.fnUntraced(function* ( + context: Caller, + projectId: ProjectId | undefined, + required: boolean, +) { + if (projectId === undefined && context.caller === undefined && !required) return undefined; + const id = yield* resolveProjectId(context, projectId); + const projects = yield* ProjectService.ProjectService; + const project = yield* projects.getById(id).pipe(Effect.mapError(unavailable)); + if (Option.isNone(project) || project.value.deletedAt !== null) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "The project was not found.", + }); + return project.value.workspaceRoot; +}); + +/** + * Changing skills rewrites the folders agents run from, so it needs full access; + * `McpToolAccess.writesEnvironment` checks that. Only a change to a project skill needs a project. + */ +const changeFolder = ( + context: Caller, + projectId: ProjectId | undefined, + skills: ReadonlyArray, +) => + projectFolder( + context, + projectId, + skills.some((skill) => skill.scope === "project"), + ); + +export const layer = McpToolAccess.toLayer(SkillsToolkit, { + t3_skill_list: McpToolAccess.reads((input) => + Effect.gen(function* () { + const context = yield* readCaller(); + const cwd = yield* projectFolder(context, input.projectId, false); + const catalog = yield* SkillCatalog.SkillCatalog; + return yield* catalog.list({ cwd }).pipe(Effect.mapError(skillFailure)); + }), + ), + t3_skill_get: McpToolAccess.reads(({ projectId, ...skill }) => + Effect.gen(function* () { + const context = yield* readCaller(); + const cwd = yield* projectFolder(context, projectId, skill.scope === "project"); + const catalog = yield* SkillCatalog.SkillCatalog; + return yield* catalog.get({ cwd, ...skill }).pipe(Effect.mapError(skillFailure)); + }), + ), + t3_skill_enable: McpToolAccess.writesEnvironment(({ projectId, ...input }, check) => + Effect.gen(function* () { + const cwd = yield* changeFolder(yield* check, projectId, input.skills); + const manager = yield* SkillManager.SkillManager; + return yield* manager.enable({ cwd, ...input }).pipe(Effect.mapError(skillFailure)); + }), + ), + t3_skill_disable: McpToolAccess.writesEnvironment(({ projectId, ...input }, check) => + Effect.gen(function* () { + const cwd = yield* changeFolder(yield* check, projectId, input.skills); + const manager = yield* SkillManager.SkillManager; + return yield* manager.disable({ cwd, ...input }).pipe(Effect.mapError(skillFailure)); + }), + ), +}); diff --git a/apps/server/src/mcp/toolkits/skills/tools.ts b/apps/server/src/mcp/toolkits/skills/tools.ts new file mode 100644 index 000000000000..71aa30611a86 --- /dev/null +++ b/apps/server/src/mcp/toolkits/skills/tools.ts @@ -0,0 +1,94 @@ +import { + OrchestratorMcpFailure, + ProjectId, + ProviderInstanceId, + SkillBatchResult, + SkillGetResult, + SkillListResult, + SkillRef, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/ai"; +import * as ProjectService from "../../../project/ProjectService.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as SkillCatalog from "../../../skills/SkillCatalog.ts"; +import * as SkillManager from "../../../skills/SkillManager.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const shared = { + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ProjectService.ProjectService, + ], +}; + +const projectId = Schema.optional(ProjectId).annotate({ + description: + "The project whose skills to use. Defaults to the calling thread's project; a client outside a T3 thread passes it for project skills.", +}); +const skills = Schema.Array(SkillRef) + .check(Schema.isMinLength(1), Schema.isMaxLength(200)) + .annotate({ + description: + "The skills to change, each exactly as t3_skill_list returned it: scope, name and home.", + }); +const agentNames = Schema.Array(ProviderInstanceId).check( + Schema.isMinLength(1), + Schema.isMaxLength(64), +); +const agentsDescription = + "agents are named by provider instance id or driver kind, as in the access entries t3_skill_list returns."; +const resultNotes = + 'Each outcome says changed, unchanged or skipped. blocked lists agents the change did not reach: "alwaysOn" means the agent reads the skill\'s own folder, so there is no link to remove; "shadowed" means it loads another skill with that name first; "entryTaken" means something else is where the link would go. affected lists agents that gained or lost the skill without being asked, because they read the same folder.'; + +const SkillListTool = Tool.make("t3_skill_list", { + ...shared, + description: + "List the agent skills T3 Code can see, in a project and in the user's home folder, and which agents can use each (access: direct = reads the skill's folder, link = reached through a link, none = cannot use it). A skill is named by scope, name and home. Use t3_skill_enable and t3_skill_disable to change who uses it. Removing, deleting and moving skills is not available to agents.", + parameters: Schema.Struct({ projectId }), + success: SkillListResult, + dependencies: [...shared.dependencies, SkillCatalog.SkillCatalog], +}) + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); + +const SkillGetTool = Tool.make("t3_skill_get", { + ...shared, + description: + "Read one skill's whole description, its SKILL.md text and its file list. Name the skill by scope, name and home as t3_skill_list returned them.", + parameters: Schema.Struct({ projectId, ...SkillRef.fields }), + success: SkillGetResult, + dependencies: [...shared.dependencies, SkillCatalog.SkillCatalog], +}) + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); + +const SkillEnableTool = Tool.make("t3_skill_enable", { + ...shared, + description: `Let agents use skills by linking each skill into the agent's own skill folder. Nothing is copied or deleted. agents is "all" for every enabled agent, or a list; ${agentsDescription} ${resultNotes} Requires a live full-access/default calling thread or a full-access client.`, + parameters: Schema.Struct({ + projectId, + skills, + agents: Schema.Union([Schema.Literal("all"), agentNames]), + }), + success: SkillBatchResult, + dependencies: [...shared.dependencies, SkillManager.SkillManager], +}).annotate(Tool.Destructive, false); + +const SkillDisableTool = Tool.make("t3_skill_disable", { + ...shared, + description: `Stop agents using skills by removing the agent's link to each skill. The skill's own folder is never deleted, and an agent that reads that folder itself stays on (blocked: alwaysOn). Turn a skill back on with t3_skill_enable. ${agentsDescription} ${resultNotes} Requires a live full-access/default calling thread or a full-access client.`, + parameters: Schema.Struct({ projectId, skills, agents: agentNames }), + success: SkillBatchResult, + dependencies: [...shared.dependencies, SkillManager.SkillManager], +}).annotate(Tool.Destructive, false); + +export const SkillsToolkit = Toolkit.make( + SkillListTool, + SkillGetTool, + SkillEnableTool, + SkillDisableTool, +); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 91257c2fdb80..870730f279e5 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -21,6 +21,8 @@ import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../../../secrets/SecretRequests.ts"; import * as ServerSettings from "../../../serverSettings.ts"; +import * as SkillCatalog from "../../../skills/SkillCatalog.ts"; +import * as SkillManager from "../../../skills/SkillManager.ts"; import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts"; import * as ServerSecretStore from "../../../auth/ServerSecretStore.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; @@ -55,6 +57,8 @@ const layerStubServices = Layer.mergeAll( Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), Layer.mock(ThreadSearch.ThreadSearch)({}), + Layer.mock(SkillCatalog.SkillCatalog)({}), + Layer.mock(SkillManager.SkillManager)({}), ); const ToolsListPayload = Schema.fromJsonString( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 6777f0a2902a..48749aafb40a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -58,6 +58,7 @@ import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts"; import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts"; import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; +import { SkillsToolkit } from "../../mcp/toolkits/skills/tools.ts"; import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; @@ -642,6 +643,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { ...Object.values(ThreadToolkit.tools), ...Object.values(WorktreeToolkit.tools), ...Object.values(ProjectToolkit.tools), + ...Object.values(SkillsToolkit.tools), ...Object.values(EnvironmentToolkit.tools), ...Object.values(PreviewControlsToolkit.tools), ...Object.values(HtmlToolkit.tools), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 79d7bdd7e115..e63e7c301fc5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -947,6 +947,8 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_thread_search", "mcp__t3-code__t3_preview_list", "mcp__t3-code__t3_environment_read", + "mcp__t3-code__t3_skill_list", + "mcp__t3-code__t3_skill_get", "mcp__t3-code__t3_queue_list", "mcp__t3-code__t3_queue_read", "mcp__t3-code__html_preview", diff --git a/docs/user/skills.md b/docs/user/skills.md index 2944d9db6f56..51e045e5a8ee 100644 --- a/docs/user/skills.md +++ b/docs/user/skills.md @@ -54,6 +54,9 @@ a project skill, both show up in `git status` and the confirmation says you can Only a skill kept in an agent's own skill folder can be moved or deleted. One that is only linked there, such as a skill from a synced folder, stays where it is. +Agents running in T3 Code can list skills and turn them on or off for agents too; they can't +remove or delete them. + ## Needs attention **Needs attention** filters the list to skills that need a look. A skill is on it when: diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index b9a46fa65287..59f1e751542d 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -323,6 +323,18 @@ export function summarizeT3ToolCalls( quantity(countEntities(entityIds("cwd")), "repository", "repositories"), ); break; + case "skill-list": + label = phrase("Listed", "list", `skills ${times}`); + break; + case "skill-read": + label = phrase("Read", "read", quantity(selected.length, "skill")); + break; + case "skill-enable": + label = phrase("Enabled", "enable", `skills for agents ${times}`); + break; + case "skill-disable": + label = phrase("Disabled", "disable", `skills for agents ${times}`); + break; case "environment-read": label = phrase("Checked", "check", `environment preferences ${times}`); break; diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 8ef6f58be595..d802c35d0e3e 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -629,6 +629,8 @@ function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction) case "project-delete": case "project-clone": case "environment-update": + case "skill-enable": + case "skill-disable": case "attachment-prepare": case "attachment-discard": case "attachment-send": diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 11abf77f704b..1b5a2dee599f 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -48,6 +48,10 @@ export type T3McpToolSummaryAction = | "project-update" | "project-delete" | "project-clone" + | "skill-list" + | "skill-read" + | "skill-enable" + | "skill-disable" | "environment-read" | "environment-update" | "attachment-prepare" @@ -304,6 +308,13 @@ const T3_MCP_TOOLS: Readonly> = { t3_project_update: tool(["Update", "Updating", "Updated", "a project"], "project-update"), t3_project_delete: tool(["Delete", "Deleting", "Deleted", "a project"], "project-delete"), t3_project_clone: tool(["Clone", "Cloning", "Cloned", "a repository"], "project-clone"), + t3_skill_list: tool(["List", "Listing", "Listed", "skills"], "skill-list"), + t3_skill_get: tool(["Read", "Reading", "Read", "a skill"], "skill-read"), + t3_skill_enable: tool(["Enable", "Enabling", "Enabled", "skills for agents"], "skill-enable"), + t3_skill_disable: tool( + ["Disable", "Disabling", "Disabled", "skills for agents"], + "skill-disable", + ), t3_attachment_prepare_upload: tool( ["Prepare", "Preparing", "Prepared", "an attachment upload"], "attachment-prepare", From 47539b19b166b814a72c7a7d857c32b9b559e049 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:39:21 -0400 Subject: [PATCH 017/108] feat(contracts): describe agent skill settings, groups and placing skills in projects The skill contracts gain what the next steps build on: - An agent can be `off` for a skill: it can see the skill, but its own settings switch it off. An agent T3 Code can't switch for a skill is `fixed`, so a client can disable that switch. - A skill can carry `source` (`owner/repo` from the installer's record, for grouping) and `projects` (the projects a Global skill is used in, when that isn't all of them). - `server.placeSkills` replaces `server.moveSkills`. It puts skills in one project, in Global, or in Global but used only in some projects. Every project named must be registered. - `server.removeSkills` is gone: turning a skill off for every agent covers it, so the page no longer offers "Remove from agents". - A skill can be left as it is with `setElsewhere`: a project or organization setting decides it, so the agent's switch can't. Enable, disable, place and delete are guarded client mutations, and the permission tests cover them and the reads that stay open. The server still moves skills between a project and Global as before; placing in only some projects is refused until it is built. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/auth/RpcAuthorization.test.ts | 3 +- apps/server/src/mcp/toolkits/skills/tools.ts | 2 +- .../src/observability/RpcInstrumentation.ts | 3 +- apps/server/src/skills/SkillManager.test.ts | 256 ++++++++++++------ apps/server/src/skills/SkillManager.ts | 61 ++--- apps/server/src/ws.ts | 3 +- .../src/components/settings/SkillBulkBar.tsx | 13 - .../src/components/settings/SkillDetail.tsx | 13 +- .../settings/SkillsSettings.logic.test.ts | 75 ++--- .../settings/SkillsSettings.logic.ts | 78 +----- .../components/settings/SkillsSettings.tsx | 36 ++- docs/user/skills.md | 7 +- .../src/state/commandPermissions.test.ts | 3 +- packages/client-runtime/src/state/server.ts | 10 +- .../contracts/src/clientRpcPermissions.ts | 3 +- packages/contracts/src/rpc.ts | 19 +- packages/contracts/src/skills.ts | 71 +++-- 17 files changed, 319 insertions(+), 337 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 4e1c1ff84645..62117df8736d 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -73,8 +73,7 @@ describe("RPC authorization scopes", () => { for (const method of [ WS_METHODS.serverEnableSkills, WS_METHODS.serverDisableSkills, - WS_METHODS.serverRemoveSkills, - WS_METHODS.serverMoveSkills, + WS_METHODS.serverPlaceSkills, WS_METHODS.serverDeleteSkills, ]) { expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationOperateScope); diff --git a/apps/server/src/mcp/toolkits/skills/tools.ts b/apps/server/src/mcp/toolkits/skills/tools.ts index 71aa30611a86..330175f51cec 100644 --- a/apps/server/src/mcp/toolkits/skills/tools.ts +++ b/apps/server/src/mcp/toolkits/skills/tools.ts @@ -47,7 +47,7 @@ const resultNotes = const SkillListTool = Tool.make("t3_skill_list", { ...shared, description: - "List the agent skills T3 Code can see, in a project and in the user's home folder, and which agents can use each (access: direct = reads the skill's folder, link = reached through a link, none = cannot use it). A skill is named by scope, name and home. Use t3_skill_enable and t3_skill_disable to change who uses it. Removing, deleting and moving skills is not available to agents.", + "List the agent skills T3 Code can see, in a project and in the user's home folder, and which agents can use each (access: direct = reads the skill's folder, link = reached through a link, none = cannot use it). A skill is named by scope, name and home. Use t3_skill_enable and t3_skill_disable to change who uses it. Deleting and moving skills is not available to agents.", parameters: Schema.Struct({ projectId }), success: SkillListResult, dependencies: [...shared.dependencies, SkillCatalog.SkillCatalog], diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 1164e18f3a75..67d471fe2646 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -37,8 +37,7 @@ const RPC_AGGREGATES = { [WS_METHODS.serverGetSkill]: "server", [WS_METHODS.serverEnableSkills]: "server", [WS_METHODS.serverDisableSkills]: "server", - [WS_METHODS.serverRemoveSkills]: "server", - [WS_METHODS.serverMoveSkills]: "server", + [WS_METHODS.serverPlaceSkills]: "server", [WS_METHODS.serverDeleteSkills]: "server", [WS_METHODS.serverSkillsTracked]: "server", [WS_METHODS.serverUpdateProvider]: "server", diff --git a/apps/server/src/skills/SkillManager.test.ts b/apps/server/src/skills/SkillManager.test.ts index 3aa73c20c66d..be0b47214bb7 100644 --- a/apps/server/src/skills/SkillManager.test.ts +++ b/apps/server/src/skills/SkillManager.test.ts @@ -8,8 +8,8 @@ import { SkillDeleteInput, SkillDisableInput, SkillEnableInput, - SkillMoveInput, - SkillRemoveInput, + SkillListResult, + SkillPlaceInput, SkillRequestError, type Project, type SkillRef, @@ -560,55 +560,7 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) ); }); - describe("remove", () => { - it.effect.skipIf(!symlinksSupported)( - "removes every link to the skill and never the original folder", - () => - Effect.gen(function* () { - const { fs, path, home } = yield* makeMachine; - yield* withManager(home, [], ({ manager, catalog }) => - Effect.gen(function* () { - const { skills } = yield* catalog.list({}); - const alpha = refOf(skills, "global", "alpha"); - yield* manager.enable({ skills: [alpha], agents: [agent("claudeAgent")] }); - - const result = yield* manager.remove({ skills: [alpha] }); - - expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); - expect(result.outcomes[0]?.affected).toContain(agent("claudeAgent")); - expect(result.outcomes[0]?.affected).toContain(agent("codex")); - yield* encodeResult(result); - expect(yield* fs.exists(path.join(home, ".agents/skills/alpha"))).toBe(false); - expect(yield* fs.exists(path.join(home, ".claude/skills/alpha"))).toBe(false); - expect( - yield* fs.readFileString(path.join(home, "library/skills/alpha/SKILL.md")), - ).toBe(skillFile("alpha")); - expect((yield* catalog.list({})).skills.some((skill) => skill.name === "alpha")).toBe( - false, - ); - }), - ); - }), - ); - - it.effect.skipIf(!symlinksSupported)("does nothing to a skill that is a real folder", () => - Effect.gen(function* () { - const { fs, path, home } = yield* makeMachine; - yield* withManager(home, [], ({ manager, catalog }) => - Effect.gen(function* () { - const { skills } = yield* catalog.list({}); - - const result = yield* manager.remove({ skills: [refOf(skills, "global", "solo")] }); - - expect(result.outcomes[0]).toMatchObject({ status: "unchanged", blocked: [] }); - expect(yield* fs.exists(path.join(home, ".claude/skills/solo/SKILL.md"))).toBe(true); - }), - ); - }), - ); - }); - - describe("move", () => { + describe("place", () => { /** Claude reads a project skill through a link made by turning it on. */ const withClaudeOnVerify = ( manager: SkillManager.SkillManager["Service"], @@ -633,7 +585,11 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) "../../.agents/skills/verify", ); - const result = yield* manager.move({ cwd: project, skills: [verify], to: "global" }); + const result = yield* manager.place({ + cwd: project, + skills: [verify], + to: { kind: "global" }, + }); // Grok reads the shared global folder but not the project's, so it gets the skill. expect(result.outcomes).toEqual([ @@ -667,6 +623,71 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) }), ); + it.effect.skipIf(!symlinksSupported)( + "refuses a project that isn't registered, whether it is the list's or a destination", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const verify = refOf( + (yield* catalog.list({ cwd: project })).skills, + "project", + "verify", + ); + const stranger = path.join(home, "repos/stranger"); + + for (const input of [ + { cwd: stranger, skills: [verify], to: { kind: "global" } }, + { cwd: project, skills: [verify], to: { kind: "project", cwd: stranger } }, + { + cwd: project, + skills: [verify], + to: { kind: "projects", cwds: [project, stranger] }, + }, + ] as const) { + const error = yield* manager.place(input).pipe(Effect.flip); + expect(error).toEqual(new SkillRequestError({ reason: "projectNotRegistered" })); + } + expect(yield* fs.exists(path.join(project, ".agents/skills/verify/SKILL.md"))).toBe( + true, + ); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "leaves a skill where it is when asked to use it in only some projects", + () => + Effect.gen(function* () { + const { fs, path, home, project } = yield* makeMachine; + yield* withManager(home, [project], ({ manager, catalog }) => + Effect.gen(function* () { + const verify = refOf( + (yield* catalog.list({ cwd: project })).skills, + "project", + "verify", + ); + + const result = yield* manager.place({ + cwd: project, + skills: [verify], + to: { kind: "projects", cwds: [project] }, + }); + + expect(result.outcomes).toEqual([ + { skill: verify, status: "skipped", reason: "failed", blocked: [], affected: [] }, + ]); + yield* encodeResult(result); + expect(yield* fs.exists(path.join(project, ".agents/skills/verify/SKILL.md"))).toBe( + true, + ); + }), + ); + }), + ); + it.effect.skipIf(!symlinksSupported)( "moves a skill in an agent's own folder to this project, and the agent keeps it", () => @@ -676,7 +697,11 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) Effect.gen(function* () { const solo = refOf((yield* catalog.list({ cwd: project })).skills, "global", "solo"); - const result = yield* manager.move({ cwd: project, skills: [solo], to: "project" }); + const result = yield* manager.place({ + cwd: project, + skills: [solo], + to: { kind: "project", cwd: project }, + }); expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); // Codex, Antigravity and Pi read the project's shared folder; they hadn't the skill. @@ -716,9 +741,17 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) recursive: true, }); - const folder = yield* manager.move({ cwd: project, skills: [verify], to: "global" }); + const folder = yield* manager.place({ + cwd: project, + skills: [verify], + to: { kind: "global" }, + }); yield* write(".agents/skills/verify/SKILL.md", skillFile("theirs")); - const skill = yield* manager.move({ cwd: project, skills: [verify], to: "global" }); + const skill = yield* manager.place({ + cwd: project, + skills: [verify], + to: { kind: "global" }, + }); for (const result of [folder, skill]) { expect(result.outcomes[0]).toMatchObject({ @@ -749,7 +782,11 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) const { skills } = yield* catalog.list({ cwd: project }); const alpha = refOf(skills, "global", "alpha"); - const result = yield* manager.move({ cwd: project, skills: [alpha], to: "project" }); + const result = yield* manager.place({ + cwd: project, + skills: [alpha], + to: { kind: "project", cwd: project }, + }); expect(result.outcomes[0]).toMatchObject({ status: "skipped", reason: "linked" }); expect(yield* fs.readLink(path.join(home, ".agents/skills/alpha"))).toBe( @@ -777,14 +814,14 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) home: ".agents/skills/ghost", }; - const result = yield* manager.move({ + const result = yield* manager.place({ cwd: project, skills: [ refOf(skills, "project", "synced"), ghost, refOf(skills, "project", "verify"), ], - to: "global", + to: { kind: "global" }, }); expect( @@ -823,10 +860,10 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) path.join(home, ".claude/skills/solo"), ); - const result = yield* manager.move({ + const result = yield* manager.place({ cwd: project, skills: [solo, verify], - to: "project", + to: { kind: "project", cwd: project }, }); expect(result.outcomes.map(({ status, reason }) => ({ status, reason }))).toEqual([ @@ -859,7 +896,7 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) }; const error = yield* manager - .move({ cwd: project, skills: [verify], to: "global" }) + .place({ cwd: project, skills: [verify], to: { kind: "global" } }) .pipe(Effect.flip); expect(error).toEqual(new SkillRequestError({ reason: "projectNotRegistered" })); @@ -906,10 +943,10 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) ); yield* withClaudeOnVerify(manager, project, verify); - const result = yield* manager.move({ + const result = yield* manager.place({ cwd: project, skills: [verify], - to: "global", + to: { kind: "global" }, }); expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); @@ -953,10 +990,10 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) ); yield* withClaudeOnVerify(manager, project, verify); - const result = yield* manager.move({ + const result = yield* manager.place({ cwd: project, skills: [verify], - to: "global", + to: { kind: "global" }, }); expect(result.outcomes[0]).toMatchObject({ status: "skipped", reason: "failed" }); @@ -1202,8 +1239,12 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillManager", (it) ); // A skill reached through a link can't move: nothing was written, so nothing refreshes. - yield* manager.move({ cwd: project, skills: [alpha], to: "project" }); - yield* manager.move({ cwd: project, skills: [verify], to: "global" }); + yield* manager.place({ + cwd: project, + skills: [alpha], + to: { kind: "project", cwd: project }, + }); + yield* manager.place({ cwd: project, skills: [verify], to: { kind: "global" } }); // Claude never used it. The other six did, or do now, or both. expect(yield* touched(6)).toEqual( ALL_AGENTS.filter((id) => id !== agent("claudeAgent")).toSorted(), @@ -1341,32 +1382,91 @@ describe("the request and result schemas", () => { expect(decodes(SkillDisableInput, { cwd: "/repo", skills: [ref], agents: ["codex"] })).toBe( true, ); - expect(decodes(SkillRemoveInput, { skills: [ref] })).toBe(true); }); it("rejects a request for no skills, no agents or too many skills", () => { expect(decodes(SkillEnableInput, { skills: [], agents: ["codex"] })).toBe(false); expect(decodes(SkillEnableInput, { skills: [ref], agents: [] })).toBe(false); - expect(decodes(SkillRemoveInput, { skills: Array.from({ length: 201 }, () => ref) })).toBe( + expect(decodes(SkillDeleteInput, { skills: Array.from({ length: 201 }, () => ref) })).toBe( false, ); - expect(decodes(SkillRemoveInput, { skills: Array.from({ length: 200 }, () => ref) })).toBe( + expect(decodes(SkillDeleteInput, { skills: Array.from({ length: 200 }, () => ref) })).toBe( true, ); }); - it("accepts a request to move or delete skills, and needs a project and a scope to move", () => { - expect(decodes(SkillMoveInput, { cwd: "/repo", skills: [ref], to: "project" })).toBe(true); + it("accepts a request to place skills in a project, in Global or in some projects", () => { + const to = [ + { kind: "project", cwd: "/repo" }, + { kind: "global" }, + { kind: "projects", cwds: ["/repo", "/other"] }, + ]; + for (const placement of to) { + expect(decodes(SkillPlaceInput, { skills: [ref], to: placement })).toBe(true); + expect(decodes(SkillPlaceInput, { cwd: "/repo", skills: [ref], to: placement })).toBe(true); + } expect(decodes(SkillDeleteInput, { skills: [ref] })).toBe(true); - expect(decodes(SkillMoveInput, { skills: [ref], to: "project" })).toBe(false); - expect(decodes(SkillMoveInput, { cwd: "/repo", skills: [ref] })).toBe(false); - expect(decodes(SkillMoveInput, { cwd: "/repo", skills: [ref], to: "elsewhere" })).toBe(false); - expect(decodes(SkillMoveInput, { cwd: "/repo", skills: [], to: "global" })).toBe(false); + }); + + it("rejects a placement with no skills, no destination, or a destination it can't use", () => { + expect(decodes(SkillPlaceInput, { skills: [], to: { kind: "global" } })).toBe(false); + expect(decodes(SkillPlaceInput, { skills: [ref] })).toBe(false); + expect(decodes(SkillPlaceInput, { skills: [ref], to: { kind: "project" } })).toBe(false); + expect(decodes(SkillPlaceInput, { skills: [ref], to: { kind: "elsewhere" } })).toBe(false); + expect(decodes(SkillPlaceInput, { skills: [ref], to: "global" })).toBe(false); + expect(decodes(SkillPlaceInput, { skills: [ref], to: { kind: "projects", cwds: [] } })).toBe( + false, + ); + const cwds = (count: number) => Array.from({ length: count }, (_, index) => `/repo-${index}`); + expect( + decodes(SkillPlaceInput, { skills: [ref], to: { kind: "projects", cwds: cwds(65) } }), + ).toBe(false); + expect( + decodes(SkillPlaceInput, { skills: [ref], to: { kind: "projects", cwds: cwds(64) } }), + ).toBe(true); expect(decodes(SkillDeleteInput, { skills: [] })).toBe(false); }); - it.effect("describes why a move or a delete left a skill, for each reason", () => - Effect.forEach(["linked", "destinationTaken", "inUse"] as const, (reason) => + it("describes a skill by how an agent reaches it, who it is used in and where it came from", () => { + const summary = { + name: "alpha", + scope: "global", + home: "~/.agents/skill-library/alpha", + description: "The alpha skill.", + copies: [], + access: [ + { + instanceId: "codex", + driver: "codex", + state: "off", + folder: "~/.agents/skills", + fixed: false, + }, + { + instanceId: "cursor", + driver: "cursor", + state: "direct", + folder: "~/.cursor", + fixed: true, + }, + ], + source: "acme/skills", + projects: ["/home/user/acme-web"], + }; + expect(decodes(SkillListResult, { skills: [summary], unreadable: [] })).toBe(true); + expect( + decodes(SkillListResult, { + skills: [{ ...summary, access: [{ ...summary.access[0], state: "maybe" }] }], + unreadable: [], + }), + ).toBe(false); + expect( + decodes(SkillListResult, { skills: [{ ...summary, projects: [""] }], unreadable: [] }), + ).toBe(false); + }); + + it.effect("describes why a skill or an agent was left as it was, for each reason", () => + Effect.forEach(["linked", "destinationTaken", "inUse", "setElsewhere"] as const, (reason) => encodeResult({ outcomes: [ { diff --git a/apps/server/src/skills/SkillManager.ts b/apps/server/src/skills/SkillManager.ts index b698b94d605a..ab8d988f3bc4 100644 --- a/apps/server/src/skills/SkillManager.ts +++ b/apps/server/src/skills/SkillManager.ts @@ -5,7 +5,7 @@ * folder itself (`direct`) or because a link in a folder the agent reads points at it (`link`). * Turning a skill on makes such a link in the agent's own folder; turning it off removes it. Those * writes only touch links this service can show lead to the skill's home: a real folder is never - * replaced by them. Moving and deleting are the only writes that take a real folder, and only one + * replaced by them. Placing and deleting are the only writes that take a real folder, and only one * that sits in an agent's skill folder itself (`own`), never a synced library behind a link. * * Every write starts from what the folders hold now, not from what a client last saw: a skill @@ -22,11 +22,11 @@ import { type SkillDeleteInput, type SkillDisableInput, type SkillEnableInput, - type SkillMoveInput, + type SkillAgentState, type SkillOutcome, type SkillOutcomeReason, + type SkillPlaceInput, type SkillRef, - type SkillRemoveInput, type SkillScope, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -130,7 +130,7 @@ const planDisable = ( return { unlinks: [...unlinks.values()], blocked }; }; -const hasSkill = (state: "direct" | "link" | "none") => state !== "none"; +const hasSkill = (state: SkillAgentState) => state === "direct" || state === "link"; export class SkillManager extends Context.Service< SkillManager, @@ -149,12 +149,8 @@ export class SkillManager extends Context.Service< readonly disable: ( input: SkillDisableInput, ) => Effect.Effect; - /** Remove every link to each skill. The skills' own folders are never touched. */ - readonly remove: ( - input: SkillRemoveInput, - ) => Effect.Effect; - /** Move each skill's folder to the other scope; the agents that used it keep using it. */ - readonly move: (input: SkillMoveInput) => Effect.Effect; + /** Put each skill where `to` says, moving its folder; the agents that used it keep using it. */ + readonly place: (input: SkillPlaceInput) => Effect.Effect; /** Delete each skill's own folder and every link to it. */ readonly delete: ( input: SkillDeleteInput, @@ -256,16 +252,6 @@ const make = Effect.gen(function* () { ), ); - const removeOne = Effect.fnUntraced(function* (skill: SkillCatalog.ResolvedSkill) { - const results = new Set((yield* removeAll(linksTo([skill]))).values()); - const reason: SkillOutcomeReason | undefined = results.has("failed") - ? "failed" - : results.has("changed") - ? "changed" - : undefined; - return { wrote: results.has("removed"), blocked: [], reason } satisfies SkillChange; - }); - const skipped = (reason: SkillOutcomeReason): SkillChange => ({ wrote: false, blocked: [], @@ -288,7 +274,7 @@ const make = Effect.gen(function* () { const moveOne = Effect.fnUntraced(function* ( skill: SkillCatalog.ResolvedSkill, to: SkillScope, - cwd: string, + cwd: string | undefined, projectRoot: string | undefined, all: ReadonlyArray, ) { @@ -534,22 +520,31 @@ const make = Effect.gen(function* () { change: disableOne, }); }), - remove: Effect.fn("SkillManager.remove")(function* (input) { - return yield* run({ - cwd: input.cwd, - skills: input.skills, - agents: new Set(), - change: (skill) => removeOne(skill), - }); - }), - move: Effect.fn("SkillManager.move")(function* (input) { + place: Effect.fn("SkillManager.place")(function* (input) { + const { to } = input; + if (input.cwd !== undefined) yield* requireProject(input.cwd); + if (to.kind === "projects") { + // Stage B implements this: one copy in the library, linked into each of these projects. + for (const cwd of to.cwds) yield* requireProject(cwd); + return { + outcomes: input.skills.map((skill) => ({ + skill, + status: "skipped" as const, + reason: "failed" as const, + blocked: [], + affected: [], + })), + } satisfies SkillBatchResult; + } + // Stage B: a project skill placed into a different project than the list was read for. + const cwd = to.kind === "project" ? to.cwd : input.cwd; return yield* run({ - cwd: input.cwd, + cwd, skills: input.skills, agents: new Set(), - alsoLookUp: input.skills.map((ref) => ({ scope: input.to, name: ref.name })), + alsoLookUp: input.skills.map((ref) => ({ scope: to.kind, name: ref.name })), change: (skill, _agents, projectRoot, all) => - moveOne(skill, input.to, input.cwd, projectRoot, all), + moveOne(skill, to.kind, cwd, projectRoot, all), }); }), delete: Effect.fn("SkillManager.delete")(function* (input) { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 85ec14abc55d..0b0d3fb553ac 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2182,8 +2182,7 @@ const layerWsRpc = ( [WS_METHODS.serverGetSkill]: (input) => skillCatalog.get(input), [WS_METHODS.serverEnableSkills]: (input) => skillManager.enable(input), [WS_METHODS.serverDisableSkills]: (input) => skillManager.disable(input), - [WS_METHODS.serverRemoveSkills]: (input) => skillManager.remove(input), - [WS_METHODS.serverMoveSkills]: (input) => skillManager.move(input), + [WS_METHODS.serverPlaceSkills]: (input) => skillManager.place(input), [WS_METHODS.serverDeleteSkills]: (input) => skillManager.delete(input), [WS_METHODS.serverSkillsTracked]: (input) => skillTracking.tracked(input), [WS_METHODS.serverRefreshProviders]: (input) => diff --git a/apps/web/src/components/settings/SkillBulkBar.tsx b/apps/web/src/components/settings/SkillBulkBar.tsx index 4d05d9ad1153..56181a50a50a 100644 --- a/apps/web/src/components/settings/SkillBulkBar.tsx +++ b/apps/web/src/components/settings/SkillBulkBar.tsx @@ -14,7 +14,6 @@ import { SkillAgentIcon } from "./skillAgentIcon"; import { planDelete, planMove, - planRemove, planTurnOff, planTurnOnAll, type Skill, @@ -43,7 +42,6 @@ export function BulkBar({ const turnOn = planTurnOnAll(selected, ctx); const toGlobal = planMove(selected, "global"); const toProject = hasProject ? planMove(selected, "project") : null; - const remove = planRemove(selected, ctx); const del = planDelete(selected, ctx); return (
    )} - {remove && ( - - )} {del && ( - -
    - - - }> - Turn off for… - - - {ctx.installed.map((agent) => { - const plan = planTurnOff(selected, agent, ctx); - return ( - plan && onPlan(plan)} - > - - {agent.displayName} - {plan && plan.affected > 0 && ( - · {plan.affected} - )} - - ); - })} - - - {toGlobal && ( - - )} - {toProject && ( - - )} - {del && ( - - )} -
    -
    -
    - ); -} +import type { SkillPlan } from "./SkillsSettings.logic"; /** Asks before a plan changes anything, with the same plain words for one skill or many. */ export function ConfirmPlan({ @@ -139,7 +35,7 @@ export function ConfirmPlan({ {shown?.title} - {shown?.body} + {shown?.body && {shown.body}} {shown && shown.notes.length > 0 && (
      diff --git a/apps/web/src/components/settings/SkillDetail.tsx b/apps/web/src/components/settings/SkillDetail.tsx index 5c83e10425c7..f32442fce56d 100644 --- a/apps/web/src/components/settings/SkillDetail.tsx +++ b/apps/web/src/components/settings/SkillDetail.tsx @@ -1,5 +1,5 @@ import type { EnvironmentId, SkillGetResult } from "@t3tools/contracts"; -import { AlertTriangleIcon, ArrowLeftIcon, LockIcon, MoreHorizontalIcon } from "lucide-react"; +import { AlertTriangleIcon, ArrowLeftIcon, MoreHorizontalIcon } from "lucide-react"; import { lazy, Suspense, useEffect, useEffectEvent, useMemo, useState } from "react"; import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; @@ -11,19 +11,14 @@ import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/men import { Skeleton } from "../ui/skeleton"; import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { SkillAgentIcon } from "./skillAgentIcon"; +import { AgentSwitchChip } from "./SkillAgentSwitch"; import { - accessOf, - agentSkillPath, attention, planDelete, - planMove, planToggle, planTurnOnAll, scriptFiles, - switchBlocker, type Skill, - type SkillAgent, type SkillPlan, type SkillsContext, } from "./SkillsSettings.logic"; @@ -96,7 +91,7 @@ export function SkillDetail({ /** A change is being made, so nothing else can start. */ busy: boolean; onBack: () => void; - /** Turns an agent on or off, moves or deletes the skill; a plan with a confirmation asks first. */ + /** Turns an agent on or off, places or deletes the skill; a plan with a confirmation asks first. */ onPlan: (plan: SkillPlan) => void; /** Opens this skill again, which reads its files again. */ onReload: () => void; @@ -137,11 +132,6 @@ export function SkillDetail({ const sameCopies = skill.copies.filter((copy) => copy.same); const turnOnAll = planTurnOnAll([skill], ctx); const del = planDelete([skill], ctx); - // Moving into a project needs one picked above the page. - const move = - skill.scope === "global" && projectRoot === null - ? null - : planMove([skill], skill.scope === "global" ? "project" : "global"); const copyPath = (path: string) => { void writeTextToClipboard(path, "skill path").then( @@ -188,7 +178,7 @@ export function SkillDetail({ No agents are installed. )} {ctx.installed.map((agent) => ( - ))} - {(skillFolder || turnOnAll || move || del) && ( + {(skillFolder || turnOnAll || del) && ( } @@ -217,11 +207,6 @@ export function SkillDetail({ Turn on for all agents )} - {move && ( - onPlan(move)}> - {skill.scope === "global" ? "Move to this project" : "Move to Global"} - - )} {del && } {del && ( onPlan(del)}> @@ -307,55 +292,3 @@ export function SkillDetail({
    ); } - -/** One agent: click to switch it on or off, unless it reads the skill's folder directly. */ -function AgentChip({ - skill, - agent, - ctx, - busy, - onToggle, -}: { - skill: Skill; - agent: SkillAgent; - ctx: SkillsContext; - busy: boolean; - onToggle: () => void; -}) { - const access = accessOf(skill, agent); - const on = access?.state === "direct" || access?.state === "link"; - const blocker = switchBlocker(skill, agent); - return ( - - { - if (blocker === null && !busy) onToggle(); - }} - /> - } - > - - {agent.displayName} - {blocker && } - - - {blocker && {blocker}} - {!blocker && access?.state === "link" && ( - {agent.displayName} reads a link to this skill. - )} - {!on && ( - - {agent.displayName} doesn't use this skill. It reads skills from: - - )} - {agentSkillPath(skill, agent)} - - - ); -} diff --git a/apps/web/src/components/settings/SkillList.tsx b/apps/web/src/components/settings/SkillList.tsx index d3ed89ead12b..c1072cc197d0 100644 --- a/apps/web/src/components/settings/SkillList.tsx +++ b/apps/web/src/components/settings/SkillList.tsx @@ -1,111 +1,149 @@ -import { InfoIcon } from "lucide-react"; +import { ChevronRightIcon, InfoIcon } from "lucide-react"; +import { memo, useMemo, useState, type MouseEvent } from "react"; import { cn } from "../../lib/utils"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; -import { Checkbox } from "../ui/checkbox"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Switch } from "../ui/switch"; import { SettingsGroup } from "./SettingsGroup"; +import { AgentSwitchChip } from "./SkillAgentSwitch"; import { SkillAgents } from "./skillAgentIcon"; -import { attention, type Skill, type SkillsContext } from "./SkillsSettings.logic"; +import { + attention, + availability, + listSwitchOn, + planFix, + planListSwitch, + planRowSwitch, + planToggle, + projectsBadge, + rowSwitchOn, + type Skill, + type SkillPlan, + type SkillsContext, +} from "./SkillsSettings.logic"; -/** A checkbox that shows on hover or focus, always on touch, and stays once something is ticked. */ -function SelectBox({ - label, - checked, - indeterminate = false, - visible, - onChange, -}: { - label: string; - checked: boolean; - indeterminate?: boolean; - /** Something is selected somewhere, so every box shows. */ - visible: boolean; - onChange: (checked: boolean) => void; -}) { - return ( - - onChange(value)} - /> - - ); -} +/** Keeps a click on a control inside a clickable row from also opening or closing the row. */ +const stopRowClick = (event: MouseEvent) => event.stopPropagation(); -/** A one-click change for a row, such as turning the skill on for the agent that lacks it. */ -export type RowFix = { readonly label: string; readonly run: () => void }; - -function SkillRow({ +const SkillRow = memo(function SkillRow({ skill, ctx, - selected, - anySelected, - fix, + showFix, busy, - onToggle, + onPlan, onOpen, }: { skill: Skill; ctx: SkillsContext; - selected: boolean; - anySelected: boolean; - fix: RowFix | null; + /** Offer the one-click fix for a skill some agent lacks; only the Needs attention list does. */ + showFix: boolean; + /** A change is being made, so nothing else can start. */ busy: boolean; - onToggle: (checked: boolean) => void; - onOpen: () => void; + /** Turns agents on or off for skills; a plan with a confirmation asks first. */ + onPlan: (plan: SkillPlan) => void; + /** Opens the skill itself, with its files. */ + onOpen: (id: string) => void; }) { - const warning = attention(skill, ctx); + const [open, setOpen] = useState(false); + const derived = useMemo(() => { + const warning = attention(skill, ctx); + return { + conflict: warning?.kind === "conflict" ? warning.detail : null, + fix: showFix && warning?.kind === "missing" ? planFix(skill, ctx) : null, + availability: availability(skill, ctx), + on: rowSwitchOn(skill, ctx), + projects: projectsBadge(skill), + }; + }, [skill, ctx, showFix]); + const { fix } = derived; + const panelId = `skill-panel-${skill.id}`; return ( -
  • - - + + {derived.conflict && ( + Conflict )} - + {derived.projects && ( + + {derived.projects} + + )} + + + {fix && ( + + )} + { + const plan = planRowSwitch(skill, ctx); + if (plan) onPlan(plan); + }} + /> + + - - {fix && ( - + + {open && ( +
    + {ctx.installed.length === 0 ? ( +

    No agents are installed.

    + ) : ( +
    + {ctx.installed.map((agent) => ( + { + const plan = planToggle(skill, agent, ctx); + if (plan) onPlan(plan); + }} + /> + ))} +
    + )} +
    + +
    +
    )}
  • ); -} +}); /** Small info button beside the page heading: where project and global skills live. */ export function StandardInfo() { @@ -128,70 +166,41 @@ export function StandardInfo() { export function SkillSection({ title, - hint, - detail, - folder, - all, visible, ctx, emptyText, - selected, + showFix, busy, - rowFix, - onSelectedChange, + onPlan, onOpen, }: { title: string; - /** A short muted phrase beside the name, in plain words. */ - hint: string; - /** What the tooltip on the name adds, before the folder. */ - detail?: string; - /** The section's folder, shown in a tooltip on its name. */ - folder: string; - /** Every skill in the section, before search narrows it. */ - all: readonly Skill[]; /** The skills that match the search and filters. */ visible: readonly Skill[]; ctx: SkillsContext; emptyText: string; - /** The ids of the ticked rows, across both sections. */ - selected: ReadonlySet; + showFix: boolean; /** A change is being made, so nothing else can start. */ busy: boolean; - /** The one-click change a row offers, if any. */ - rowFix: (skill: Skill) => RowFix | null; - onSelectedChange: (ids: readonly string[], checked: boolean) => void; + onPlan: (plan: SkillPlan) => void; onOpen: (id: string) => void; }) { - const anySelected = selected.size > 0; - const selectedCount = visible.filter((skill) => selected.has(skill.id)).length; + const on = useMemo(() => listSwitchOn(visible, ctx), [visible, ctx]); return (
    -
    - 0 && selectedCount === visible.length} - indeterminate={selectedCount > 0 && selectedCount < visible.length} - visible={anySelected} - onChange={(checked) => - onSelectedChange( - visible.map((skill) => skill.id), - checked, - ) - } +
    +

    {title}

    + { + const plan = planListSwitch(visible, ctx); + if (plan) onPlan(plan); + }} /> -

    - - }> - {title} · {all.length} - - - {detail && {detail}} - {folder} - - - {hint} -

    + {/* The width of a row's chevron, so this switch sits over the rows' switches. */} +
    {visible.length === 0 ? ( @@ -203,12 +212,10 @@ export function SkillSection({ key={skill.id} skill={skill} ctx={ctx} - selected={selected.has(skill.id)} - anySelected={anySelected} - fix={rowFix(skill)} + showFix={showFix} busy={busy} - onToggle={(checked) => onSelectedChange([skill.id], checked)} - onOpen={() => onOpen(skill.id)} + onPlan={onPlan} + onOpen={onOpen} /> ))} diff --git a/apps/web/src/components/settings/SkillsSettings.logic.test.ts b/apps/web/src/components/settings/SkillsSettings.logic.test.ts index 40e2a1fdbd06..64e034f49ab7 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.test.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.test.ts @@ -8,28 +8,40 @@ import type { } from "@t3tools/contracts"; import { - agentSkillPath, attention, availability, availabilityNote, + checkState, compareSkillFiles, describeResult, + groupAvailability, + groupBySource, ingestSkills, installedAgents, + listSwitchOn, matchesQuery, + placeTarget, planDelete, planFix, - planMove, + planListSwitch, + planPlace, + planRowSwitch, planToggle, planTurnOff, + planTurnOffAll, planTurnOnAll, + projectsBadge, + rowSwitchOn, scriptFiles, skillBody, skillsEnvironment, skillsToCheckWithGit, + startingPlacement, switchBlocker, unreadableNote, withGitNote, + type PlaceTarget, + type ProjectOption, type Skill, type SkillAgent, } from "./SkillsSettings.logic"; @@ -217,19 +229,6 @@ describe("who can use a skill", () => { const value = availability(skill("a", { codex: "direct" }), { installed: [] }); expect(value).toMatchObject({ everyone: false, agents: [], missing: [] }); }); - - it("tells where an agent reads the skill, or where it looks when it can't see it", () => { - const linked = skill("a", { claudeAgent: "link" }); - const withFolder = { - ...linked, - access: linked.access.map((item) => - item.instanceId === "claudeAgent" ? { ...item, folder: ".claude/skills" } : item, - ), - }; - expect(agentSkillPath(withFolder, claude)).toBe(".claude/skills/a"); - expect(agentSkillPath(withFolder, codex)).toBe(".agents/skills"); - expect(agentSkillPath(withFolder, agent("unknown", "pi", "Pi"))).toBeNull(); - }); }); describe("attention", () => { @@ -380,16 +379,17 @@ describe("a skill's files", () => { /** A skill whose agents each read it from their own folder, the way the server reports links. */ function reached( name: string, - access: Record, + access: Record, home = `~/library/skills/${name}`, ): Skill { return { ...skill(name, {}, { scope: "global", home }), - access: Object.entries(access).map(([instanceId, { state, folder }]) => ({ + access: Object.entries(access).map(([instanceId, { state, folder, fixed }]) => ({ instanceId: ProviderInstanceId.make(instanceId), driver: ProviderDriverKind.make(instanceId), state, folder, + ...(fixed ? { fixed } : {}), })), }; } @@ -408,15 +408,21 @@ const outcome = (over: Partial & { name: string }): SkillOutcome = }); describe("an agent's switch", () => { - it("is locked only when the agent reads the skill's folder itself", () => { + it("is locked only when T3 Code can't switch the agent, whatever way it reaches the skill", () => { const tdd = reached("tdd", { claudeAgent: { state: "direct", folder: "~/.claude/skills" }, codex: { state: "link", folder: "~/.codex/skills" }, - cursor: { state: "none", folder: "~/.cursor/skills" }, + cursor: { state: "direct", folder: "~/.agents/skills", fixed: true }, }); - expect(switchBlocker(tdd, claude)).toBe("Always on. It reads this folder directly."); + expect(switchBlocker(tdd, claude)).toBeNull(); expect(switchBlocker(tdd, codex)).toBeNull(); - expect(switchBlocker(tdd, ALL[2]!)).toBeNull(); + expect(switchBlocker(tdd, ALL[2]!)).toBe("Always on. Cursor reads this folder directly."); + expect( + switchBlocker( + reached("x", { cursor: { state: "none", folder: "~/.cursor/skills", fixed: true } }), + ALL[2]!, + ), + ).toBe("Cursor can't be switched for this skill."); }); it("turns on for the agent that was clicked, and off for one that has it", () => { @@ -500,6 +506,183 @@ describe("turning on for all agents", () => { }); }); +describe("one switch for every agent", () => { + const cursor = ALL[2]!; + const on = (name: string, extra: Record = {}) => + reached(name, { + claudeAgent: { state: "link", folder: "~/.claude/skills" }, + codex: { state: "direct", folder: "~/.agents/skills" }, + cursor: { + state: "direct", + folder: "~/.agents/skills", + ...(extra.fixed ? { fixed: true } : {}), + }, + }); + const off = (name: string) => + reached(name, { + claudeAgent: { state: "none", folder: "~/.claude/skills" }, + codex: { state: "off", folder: "~/.agents/skills" }, + cursor: { state: "none", folder: "~/.cursor/skills" }, + }); + const some = (name: string) => + reached(name, { + claudeAgent: { state: "link", folder: "~/.claude/skills" }, + codex: { state: "off", folder: "~/.agents/skills" }, + cursor: { state: "none", folder: "~/.cursor/skills" }, + }); + + it("is on when any agent uses the skill, and an agent switched off in its settings doesn't", () => { + expect(rowSwitchOn(on("a"), ctx)).toBe(true); + expect(rowSwitchOn(some("a"), ctx)).toBe(true); + expect(rowSwitchOn(off("a"), ctx)).toBe(false); + expect(rowSwitchOn(on("a"), { installed: [] })).toBe(false); + }); + + it("turns a skill on for every agent that lacks it, and off for every agent that has it", () => { + expect(planRowSwitch(off("a"), ctx)).toEqual({ + change: { + kind: "enable", + skills: [ref("a")], + agents: ["claudeAgent", "codex", "cursor"], + }, + affected: 1, + }); + // A skill that is on for some agents has its switch on, so flipping it turns it off. + const turnOff = planRowSwitch(some("a"), ctx); + expect(turnOff?.change).toEqual({ + kind: "disable", + skills: [ref("a")], + agents: ["claudeAgent"], + }); + expect(turnOff?.confirmation).toBeUndefined(); + }); + + it("asks an agent T3 Code can't switch too when turning off, so the result can say why it stays", () => { + const plan = planRowSwitch(on("a", { fixed: true }), ctx); + expect(plan?.change).toMatchObject({ + kind: "disable", + agents: ["claudeAgent", "codex", "cursor"], + }); + expect(plan?.confirmation).toBeUndefined(); + // And never asks to turn on an agent that can't be switched. + const stuck = reached("b", { + claudeAgent: { state: "link", folder: "~/.claude/skills" }, + codex: { state: "link", folder: "~/.codex/skills" }, + cursor: { state: "none", folder: "~/.cursor/skills", fixed: true }, + }); + expect(planTurnOnAll([stuck], ctx)).toBeNull(); + }); + + it("is a section's switch only when every skill is on for every agent that can be switched", () => { + expect(listSwitchOn([on("a"), on("b")], ctx)).toBe(true); + expect(listSwitchOn([on("a"), some("b")], ctx)).toBe(false); + expect(listSwitchOn([on("a"), off("b")], ctx)).toBe(false); + expect(listSwitchOn([], ctx)).toBe(false); + // The agent T3 Code can't switch is left out of the question. + const withoutCursor = reached("c", { + claudeAgent: { state: "link", folder: "~/.claude/skills" }, + codex: { state: "direct", folder: "~/.agents/skills" }, + cursor: { state: "none", folder: "~/.cursor/skills", fixed: true }, + }); + expect(listSwitchOn([withoutCursor], ctx)).toBe(true); + }); + + it("turns a whole section on for all agents without asking", () => { + const plan = planListSwitch([some("a"), off("b"), on("c")], ctx); + expect(plan?.change).toEqual({ + kind: "enable", + skills: [ref("a"), ref("b")], + agents: ["claudeAgent", "codex", "cursor"], + }); + expect(plan?.confirmation).toBeUndefined(); + }); + + it("asks before turning a whole section off, and says what stays on", () => { + const plan = planListSwitch([on("a"), on("b", { fixed: true })], ctx); + expect(plan?.change).toEqual({ + kind: "disable", + skills: [ref("a"), ref("b")], + agents: ["claudeAgent", "codex", "cursor"], + }); + expect(plan?.affected).toBe(2); + expect(plan?.confirmation).toEqual({ + title: "Turn off 2 skills for every agent?", + body: "", + notes: ["1 skill stays on because Cursor reads its folder."], + confirm: "Turn off", + destructive: false, + }); + expect(planListSwitch([on("only")], ctx)?.confirmation?.title).toBe( + "Turn off “only” for every agent?", + ); + }); + + it("turns the selected skills off for every agent, asking only when asked to", () => { + expect(planTurnOffAll([off("a")], ctx, { ask: true })).toBeNull(); + expect(planTurnOffAll([on("a"), on("b")], ctx, { ask: false })?.confirmation).toBeUndefined(); + expect(planTurnOffAll([on("a"), on("b")], ctx, { ask: true })?.confirmation?.title).toBe( + "Turn off 2 skills for every agent?", + ); + expect( + planTurnOffAll([on("a")], { installed: [cursor] }, { ask: false })?.change, + ).toMatchObject({ + agents: ["cursor"], + }); + }); +}); + +describe("selecting rows", () => { + it("ticks a group's box when all its skills are ticked, and half-ticks it when some are", () => { + const ids = ["a", "b", "c"]; + expect(checkState(ids, new Set())).toEqual({ checked: false, indeterminate: false }); + expect(checkState(ids, new Set(["b"]))).toEqual({ checked: false, indeterminate: true }); + expect(checkState(ids, new Set(["a", "b", "c", "other"]))).toEqual({ + checked: true, + indeterminate: false, + }); + expect(checkState([], new Set(["a"]))).toEqual({ checked: false, indeterminate: false }); + }); +}); + +describe("grouping by where skills came from", () => { + const from = (name: string, source?: string) => skill(name, {}, source ? { source } : {}); + + it("groups skills that share a source when there are two or more, by name", () => { + const { groups, loose } = groupBySource([ + from("tdd", "mattpocock/skills"), + from("solo", "acme/one-off"), + from("mine"), + from("grill", "mattpocock/skills"), + from("db", "acme/tools"), + from("api", "acme/tools"), + ]); + expect(groups.map((group) => [group.source, group.skills.map((item) => item.name)])).toEqual([ + ["acme/tools", ["db", "api"]], + ["mattpocock/skills", ["tdd", "grill"]], + ]); + // One skill from a source isn't a group, and neither is one with no source. + expect(loose.map((item) => item.name)).toEqual(["solo", "mine"]); + }); + + it("has no groups when nothing shares a source", () => { + const { groups, loose } = groupBySource([from("a", "x/y"), from("b")]); + expect(groups).toEqual([]); + expect(loose).toHaveLength(2); + }); + + it("shows the agents that have every skill in the group on", () => { + const both = { installed: [claude, codex] }; + const a = skill("a", { claudeAgent: "link", codex: "direct" }); + const b = skill("b", { claudeAgent: "link" }); + expect(groupAvailability([a, b], both)).toMatchObject({ + everyone: false, + agents: [claude], + missing: [codex], + }); + expect(groupAvailability([a, a], both).everyone).toBe(true); + }); +}); + describe("turning off for one agent", () => { const workCodex = agent("codex_work", "codex", "Codex Work"); const both = { installed: [codex, workCodex, claude] }; @@ -541,7 +724,9 @@ describe("turning off for one agent", () => { }); it("says which skills stay on because the agent reads their folder", () => { - const stays = reached("stays", { codex: { state: "direct", folder: "~/.agents/skills" } }); + const stays = reached("stays", { + codex: { state: "direct", folder: "~/.agents/skills", fixed: true }, + }); const plan = planTurnOff([linked("tdd"), stays], codex, { installed: [codex] }); expect(plan?.change).toMatchObject({ skills: [ref("tdd")] }); expect(plan?.confirmation?.notes).toEqual(["1 skill stays on because Codex reads its folder."]); @@ -550,7 +735,7 @@ describe("turning off for one agent", () => { it("offers nothing for an agent that uses none of the skills", () => { expect(planTurnOff([linked("tdd")], claude, both)).toBeNull(); const onlyStuck = planTurnOff( - [reached("stays", { codex: { state: "direct", folder: "~/.agents/skills" } })], + [reached("stays", { codex: { state: "direct", folder: "~/.agents/skills", fixed: true } })], codex, both, ); @@ -576,59 +761,146 @@ const owned = (name: string, extra: Partial = {}): Skill => ({ const ownedInProject = (name: string) => owned(name, { scope: "project", home: `.agents/skills/${name}` }); -describe("moving skills between this project and Global", () => { +describe("using skills in a project, everywhere or in some projects", () => { + const web: ProjectOption = { cwd: "/home/user/acme-web", label: "acme-web" }; + const api: ProjectOption = { cwd: "/home/user/acme-api", label: "acme-api" }; + const site: ProjectOption = { cwd: "/home/user/marketing-site", label: "marketing-site" }; /** A skill kept in the project's shared folder, which Claude reaches through a link. */ const inProject = (name: string, extra: Partial = {}) => owned(name, { scope: "project", home: `.agents/skills/${name}`, ...extra }); + const global = (name: string, extra: Partial = {}) => owned(name, extra); + const usedIn = (name: string, ...projects: ProjectOption[]) => + owned(name, { projects: projects.map((project) => project.cwd) }); + const inBoth: PlaceTarget = { kind: "projects", projects: [web, api] }; - it("always asks first, and says where the skill goes and who sees it", () => { - const toGlobal = planMove([inProject("verify")], "global"); - expect(toGlobal?.change).toEqual({ - kind: "move", - skills: [{ scope: "project", name: "verify", home: ".agents/skills/verify" }], - to: "global", - }); - expect(toGlobal?.confirmation).toEqual({ - title: "Move “verify” to Global?", - body: "Moves to your Global skills, for all your projects.", - notes: ["Agents that use it keep using it."], - confirm: "Move", + it("asks first, and says who will have a skill that becomes Global in some projects", () => { + const plan = planPlace([inProject("db-migrations")], inBoth); + expect(plan?.change).toEqual({ + kind: "place", + skills: [{ scope: "project", name: "db-migrations", home: ".agents/skills/db-migrations" }], + to: { kind: "projects", cwds: [web.cwd, api.cwd] }, + projectNames: ["acme-web", "acme-api"], + }); + expect(plan?.confirmation).toEqual({ + title: "Make db-migrations Global?", + body: "It will be on in acme-web and acme-api. There's one copy, so an edit shows up in both.", + notes: [], + confirm: "Make Global", destructive: false, }); + }); - const global = inProject("tdd", { scope: "global", home: "~/.agents/skills/tdd" }); - const toProject = planMove([global, inProject("grill", { scope: "global" })], "project"); - expect(toProject?.confirmation).toMatchObject({ - title: "Move 2 skills to this project?", - body: "Moves into this project, so anyone who clones it gets it.", - notes: ["Agents that use them keep using them."], + it("words one project, three projects and several skills", () => { + expect( + planPlace([inProject("a")], { kind: "projects", projects: [web] })?.confirmation?.body, + ).toBe("It will be on in acme-web only."); + expect( + planPlace([inProject("a")], { kind: "projects", projects: [web, api, site] })?.confirmation, + ).toMatchObject({ + body: "It will be on in acme-web, acme-api and marketing-site. There's one copy, so an edit shows up in all of them.", + }); + expect( + planPlace([inProject("a"), inProject("b"), inProject("c")], inBoth)?.confirmation, + ).toMatchObject({ + title: "Make 3 skills Global?", }); }); - it("asks git only about project skills a move takes out of a project", () => { - const out = planMove([inProject("a"), inProject("b")], "global")!; - expect(skillsToCheckWithGit(out)).toEqual( - out.change.kind === "move" ? out.change.skills : null, + it("makes a project skill Global for every project, and a Global one a project's own", () => { + expect(planPlace([inProject("verify")], { kind: "global" })).toMatchObject({ + change: { kind: "place", to: { kind: "global" }, projectNames: [] }, + confirmation: { + title: "Make verify Global?", + body: "It will be on in every project.", + confirm: "Make Global", + }, + }); + expect( + planPlace([global("tdd"), global("grill")], { kind: "project", project: web }), + ).toMatchObject({ + change: { to: { kind: "project", cwd: web.cwd }, projectNames: ["acme-web"] }, + confirmation: { + title: "Use 2 skills only in acme-web?", + body: "It moves into acme-web, so anyone who clones it gets it.", + confirm: "Move", + }, + }); + }); + + it("doesn't say Global is new for a skill that already is", () => { + expect(planPlace([global("tdd")], inBoth)?.confirmation).toMatchObject({ + title: "Use tdd only in acme-web and acme-api?", + confirm: "Apply", + }); + expect(planPlace([usedIn("tdd", web)], { kind: "global" })?.confirmation).toMatchObject({ + title: "Use tdd in every project?", + }); + }); + + it("leaves out skills that are placed that way already, in any order of projects", () => { + const plan = planPlace( + [inProject("new"), usedIn("same", api, web), usedIn("fewer", web), global("everywhere")], + inBoth, ); + expect(plan?.change).toMatchObject({ + skills: [{ name: "new" }, { name: "fewer" }, { name: "everywhere" }], + }); + expect(plan?.affected).toBe(3); + expect(planPlace([usedIn("same", api, web)], inBoth)).toBeNull(); + expect(planPlace([global("everywhere")], { kind: "global" })).toBeNull(); + expect(planPlace([inProject("here")], { kind: "project", project: web })).toBeNull(); + }); + + it("asks git only about project skills that leave their project", () => { + const out = planPlace([inProject("a"), global("g"), inProject("b")], inBoth)!; + expect(skillsToCheckWithGit(out)?.map((item) => item.name)).toEqual(["a", "b"]); + expect(skillsToCheckWithGit(planPlace([inProject("a")], { kind: "global" })!)).toHaveLength(1); // Moving into a project makes new files, so there is nothing in git to undo. - const global = inProject("g", { scope: "global" }); - expect(skillsToCheckWithGit(planMove([global], "project")!)).toBeNull(); - expect(planMove([inProject("a")], "global")?.confirmation?.notes.join(" ")).not.toContain( - "git", - ); + expect( + skillsToCheckWithGit(planPlace([global("g")], { kind: "project", project: web })!), + ).toBeNull(); + expect(skillsToCheckWithGit(planPlace([global("g")], inBoth)!)).toBeNull(); + expect(withGitNote(planPlace([inProject("a")], inBoth)!, ["a"]).confirmation?.notes).toEqual([ + "You can undo this with git.", + ]); }); - it("leaves out skills that are in the place already or only reached through a link", () => { - const linked = inProject("synced", { realFolder: undefined }); - const plan = planMove( - [inProject("verify"), linked, inProject("home", { scope: "global" })], - "global", - ); - expect(plan?.change).toMatchObject({ skills: [{ name: "verify" }] }); - expect(plan?.affected).toBe(1); - expect(plan?.confirmation?.notes).toContain("1 skill is reached through a link, so it stays."); - expect(planMove([linked], "global")).toBeNull(); - expect(planMove([inProject("home", { scope: "global" })], "global")).toBeNull(); + it("starts on where the skills are now, with their projects or the picked one ticked", () => { + expect(startingPlacement([inProject("a")], web)).toEqual({ + choice: "project", + ticked: [web.cwd], + }); + expect(startingPlacement([global("g")], web)).toEqual({ choice: "global", ticked: [web.cwd] }); + expect(startingPlacement([usedIn("u", api, site)], web)).toEqual({ + choice: "projects", + ticked: [api.cwd, site.cwd], + }); + expect(startingPlacement([global("g")], null)).toEqual({ choice: "global", ticked: [] }); + // Skills in different places start on no choice at all. + expect(startingPlacement([inProject("a"), global("g")], web).choice).toBeNull(); + }); + + it("turns a choice into a placement once it is complete", () => { + const all = [web, api, site]; + const ticked = new Set([api.cwd, "/home/user/gone"]); + expect(placeTarget("project", web, all, ticked)).toEqual({ kind: "project", project: web }); + expect(placeTarget("project", null, all, ticked)).toBeNull(); + expect(placeTarget("global", null, all, ticked)).toEqual({ kind: "global" }); + // Only projects that are registered count, in the list's order. + expect(placeTarget("projects", web, all, ticked)).toEqual({ + kind: "projects", + projects: [api], + }); + expect(placeTarget("projects", web, all, new Set())).toBeNull(); + expect(placeTarget("projects", web, all, new Set(["/home/user/gone"]))).toBeNull(); + expect(placeTarget(null, web, all, ticked)).toBeNull(); + }); + + it("badges a Global skill that is used in some projects only", () => { + expect(projectsBadge(usedIn("u", web, api))).toBe("2 projects"); + expect(projectsBadge(usedIn("u", web))).toBe("1 project"); + expect(projectsBadge(global("g"))).toBeNull(); + expect(projectsBadge(inProject("p"))).toBeNull(); }); }); @@ -695,12 +967,12 @@ describe("promising an undo with git", () => { ); }); - it("adds nothing when git tracks none of them, and works for a move out of a project", () => { + it("adds nothing when git tracks none of them, and works for a skill leaving a project", () => { const plan = delete3(); expect(withGitNote(plan, [])).toBe(plan); expect(withGitNote(plan, ["other"])).toBe(plan); expect( - withGitNote(planMove([ownedInProject("a")], "global")!, ["a"]).confirmation?.notes, + withGitNote(planPlace([ownedInProject("a")], { kind: "global" })!, ["a"]).confirmation?.notes, ).toContain("You can undo this with git."); }); @@ -731,27 +1003,44 @@ describe("telling what a change did", () => { it("says where skills went and who else got them, and what a delete took", () => { expect( describeResult( - { kind: "move", skills: [ref("a"), ref("b")], to: "global" }, + { kind: "place", skills: [ref("a"), ref("b")], to: { kind: "global" }, projectNames: [] }, [outcome({ name: "a", affected: [codex.instanceId] }), outcome({ name: "b" })], ctx, ), - ).toBe("Moved 2 skills to Global. Codex gets them too."); + ).toBe("Made 2 skills Global. Codex gets them too."); expect( describeResult( - { kind: "move", skills: [ref("a")], to: "project" }, + { + kind: "place", + skills: [ref("a")], + to: { kind: "project", cwd: "/p" }, + projectNames: ["acme-web"], + }, [outcome({ name: "a" })], ctx, ), - ).toBe("Moved 1 skill to this project."); + ).toBe("Moved 1 skill to acme-web."); + expect( + describeResult( + { + kind: "place", + skills: [ref("a"), ref("b")], + to: { kind: "projects", cwds: ["/p", "/q"] }, + projectNames: ["acme-web", "acme-api"], + }, + [outcome({ name: "a" }), outcome({ name: "b" })], + ctx, + ), + ).toBe("2 skills now used in acme-web and acme-api."); expect( describeResult({ kind: "delete", skills: [ref("a")] }, [outcome({ name: "a" })], ctx), ).toBe("Deleted 1 skill."); }); - it("says why a move or a delete left a skill alone, or didn't finish", () => { + it("says why a placement or a delete left a skill alone, or didn't finish", () => { expect( describeResult( - { kind: "move", skills: [], to: "global" }, + { kind: "place", skills: [], to: { kind: "global" }, projectNames: [] }, [ outcome({ name: "a", status: "skipped", reason: "destinationTaken" }), outcome({ name: "b", status: "skipped", reason: "linked" }), @@ -764,18 +1053,35 @@ describe("telling what a change did", () => { ); expect( describeResult( - { kind: "move", skills: [], to: "project" }, + { + kind: "place", + skills: [], + to: { kind: "project", cwd: "/p" }, + projectNames: ["acme-web"], + }, [outcome({ name: "a", status: "skipped", reason: "destinationTaken" })], ctx, ), - ).toBe("This project already has a “a”, so it stays."); + ).toBe("acme-web already has a “a”, so it stays."); expect( describeResult( - { kind: "move", skills: [ref("a")], to: "global" }, + { + kind: "place", + skills: [], + to: { kind: "projects", cwds: ["/p"] }, + projectNames: ["acme-web"], + }, + [outcome({ name: "a", status: "skipped", reason: "destinationTaken" })], + ctx, + ), + ).toBe("A project already has a “a”, so it stays."); + expect( + describeResult( + { kind: "place", skills: [ref("a")], to: { kind: "global" }, projectNames: [] }, [outcome({ name: "a", reason: "failed" })], ctx, ), - ).toBe("Moved 1 skill to Global. “a” moved, but its old folder couldn't be removed."); + ).toBe("Made 1 skill Global. “a” moved, but its old folder couldn't be removed."); expect( describeResult( { kind: "delete", skills: [ref("a")] }, @@ -821,6 +1127,31 @@ describe("telling what a change did", () => { ).toBe("Claude's settings decide “a”, so it stays as it is."); }); + it("counts the skills an agent held back for the same reason, instead of listing each", () => { + const held = (name: string, reason: SkillOutcome["blocked"][number]["reason"]) => + outcome({ + name, + status: "skipped", + blocked: [{ instanceId: ALL[2]!.instanceId, reason }], + }); + expect( + describeResult( + { kind: "disable", skills: [], agents: [] }, + [held("a", "alwaysOn"), held("b", "alwaysOn"), held("c", "alwaysOn")], + ctx, + ), + ).toBe("Cursor reads 3 skills directly, so they stay on."); + expect( + describeResult( + { kind: "disable", skills: [], agents: [] }, + [held("a", "setElsewhere"), held("b", "setElsewhere"), held("c", "failed")], + ctx, + ), + ).toBe( + "Cursor's settings decide 2 skills, so they stay as they are. Couldn't change Cursor's folder for “c”.", + ); + }); + it("names an agent the page doesn't list by its id, and cuts a long list short", () => { const blocked = (name: string, reason: SkillOutcome["blocked"][number]["reason"]) => outcome({ @@ -853,9 +1184,13 @@ describe("telling what a change did", () => { expect(describeResult({ kind: "disable", skills: [], agents: [] }, unchanged, ctx)).toBe( "Already off.", ); - expect(describeResult({ kind: "move", skills: [], to: "global" }, unchanged, ctx)).toBe( - "Already in Global.", - ); + expect( + describeResult( + { kind: "place", skills: [], to: { kind: "global" }, projectNames: [] }, + unchanged, + ctx, + ), + ).toBe("Already there."); expect(describeResult({ kind: "delete", skills: [] }, unchanged, ctx)).toBe( "Nothing to delete.", ); diff --git a/apps/web/src/components/settings/SkillsSettings.logic.ts b/apps/web/src/components/settings/SkillsSettings.logic.ts index 6a395a8e6d92..9bd34aa454aa 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.ts @@ -6,6 +6,7 @@ import type { SkillListResult, SkillOutcome, SkillOutcomeReason, + SkillPlacement, SkillRef, SkillScope, SkillSummary, @@ -90,13 +91,14 @@ export function installedAgents( // -- Access ----------------------------------------------------------------------------------- -export const accessOf = ( +const accessOf = ( skill: Skill, agent: Pick, ): SkillAgentAccess | undefined => skill.access.find((access) => access.instanceId === agent.instanceId); -const hasAccess = (skill: Skill, agent: SkillAgent) => { +/** The agent loads the skill: through a link, or by reading its folder. */ +export const hasAccess = (skill: Skill, agent: SkillAgent) => { const state = accessOf(skill, agent)?.state; return state === "direct" || state === "link"; }; @@ -151,7 +153,7 @@ export function attention(skill: Skill, ctx: SkillsContext): Attention | null { } /** Who can use a skill, among the installed agents. */ -type Availability = { +export type Availability = { /** Every installed agent can use it. */ everyone: boolean; agents: SkillAgent[]; @@ -193,7 +195,13 @@ export type SkillChange = readonly skills: readonly SkillRef[]; readonly agents: readonly ProviderInstanceId[]; } - | { readonly kind: "move"; readonly skills: readonly SkillRef[]; readonly to: SkillScope } + | { + readonly kind: "place"; + readonly skills: readonly SkillRef[]; + readonly to: SkillPlacement; + /** The projects `to` names, for telling the person where the skills went. */ + readonly projectNames: readonly string[]; + } | { readonly kind: "delete"; readonly skills: readonly SkillRef[] }; export type SkillPlan = { @@ -203,6 +211,7 @@ export type SkillPlan = { /** Present when the change should be confirmed first, in plain words. */ readonly confirmation?: { readonly title: string; + /** May be empty when the title says it all. */ readonly body: string; /** Lines under the body, such as what stays on and why. */ readonly notes: readonly string[]; @@ -226,26 +235,107 @@ const enablePlan = (skills: readonly Skill[], agents: readonly SkillAgent[]): Sk affected: skills.length, }); +/** T3 Code can't switch this agent for this skill: it reads the folder and has no setting for it. */ +const isFixed = (skill: Skill, agent: Pick) => + accessOf(skill, agent)?.fixed === true; + +/** Installed agents T3 Code can switch for this skill. */ +const switchableAgents = (skill: Skill, ctx: SkillsContext) => + ctx.installed.filter((agent) => !isFixed(skill, agent)); + /** Why an agent's switch can't be flipped, or null when it can. */ export function switchBlocker(skill: Skill, agent: SkillAgent) { - return accessOf(skill, agent)?.state === "direct" - ? "Always on. It reads this folder directly." - : null; + if (!isFixed(skill, agent)) return null; + return hasAccess(skill, agent) + ? `Always on. ${agent.displayName} reads this folder directly.` + : `${agent.displayName} can't be switched for this skill.`; } +/** One skill's switch is on when any agent uses it; the icons show who. */ +export const rowSwitchOn = (skill: Skill, ctx: SkillsContext) => + ctx.installed.some((agent) => hasAccess(skill, agent)); + +/** + * A section's or group's switch is on when every skill in it is on for every agent that can be + * switched. Agents T3 Code can't switch are left out, since the switch could never reach them. + */ +export const listSwitchOn = (skills: readonly Skill[], ctx: SkillsContext) => + skills.length > 0 && + skills.every((skill) => switchableAgents(skill, ctx).every((agent) => hasAccess(skill, agent))); + /** Turning one agent on for one skill, or off. Off asks first when other agents lose it too. */ export function planToggle(skill: Skill, agent: SkillAgent, ctx: SkillsContext) { return hasAccess(skill, agent) ? planTurnOff([skill], agent, ctx) : enablePlan([skill], [agent]); } -/** Every installed agent that lacks one of the skills gets a link; nothing asks first. */ +/** Every agent that lacks one of the skills, and can be switched, gets it; nothing asks first. */ export function planTurnOnAll(selected: readonly Skill[], ctx: SkillsContext): SkillPlan | null { - const targets = selected.filter((skill) => missingAgents(skill, ctx).length > 0); + const wanted = selected + .map((skill) => ({ + skill, + missing: switchableAgents(skill, ctx).filter((agent) => !hasAccess(skill, agent)), + })) + .filter((entry) => entry.missing.length > 0); + if (wanted.length === 0) return null; + const ids = new Set(wanted.flatMap((entry) => entry.missing.map((agent) => agent.instanceId))); + return enablePlan( + wanted.map((entry) => entry.skill), + ctx.installed.filter((agent) => ids.has(agent.instanceId)), + ); +} + +const staysOnNote = (count: number, agent: SkillAgent) => + `${plural(count, "skill")} ${count === 1 ? "stays" : "stay"} on because ${agent.displayName} reads ${count === 1 ? "its" : "their"} folder.`; + +/** + * Every agent that uses one of the skills is switched off. Agents T3 Code can't switch are asked + * anyway, so the result can say why a skill stays on. With `ask`, the change waits for a yes. + */ +export function planTurnOffAll( + selected: readonly Skill[], + ctx: SkillsContext, + { ask }: { ask: boolean }, +): SkillPlan | null { + const targets = selected.filter((skill) => rowSwitchOn(skill, ctx)); if (targets.length === 0) return null; - const agents = ctx.installed.filter((agent) => targets.some((skill) => !hasAccess(skill, agent))); - return enablePlan(targets, agents); + const agents = ctx.installed.filter((agent) => targets.some((skill) => hasAccess(skill, agent))); + const notes = agents.flatMap((agent) => { + const stays = targets.filter((skill) => isFixed(skill, agent) && hasAccess(skill, agent)); + return stays.length > 0 ? [staysOnNote(stays.length, agent)] : []; + }); + return { + change: { + kind: "disable", + skills: targets.map(skillRef), + agents: agents.map((agent) => agent.instanceId), + }, + affected: targets.length, + ...(ask + ? { + confirmation: { + title: `Turn off ${targets.length === 1 ? `“${targets[0]!.name}”` : plural(targets.length, "skill")} for every agent?`, + body: "", + notes, + confirm: "Turn off", + destructive: false, + }, + } + : {}), + }; } +/** What a row's switch does: turn the skill on for every agent, or off for every agent. */ +export const planRowSwitch = (skill: Skill, ctx: SkillsContext) => + rowSwitchOn(skill, ctx) + ? planTurnOffAll([skill], ctx, { ask: false }) + : planTurnOnAll([skill], ctx); + +/** What a section's or group's switch does. Turning off many skills asks first. */ +export const planListSwitch = (skills: readonly Skill[], ctx: SkillsContext) => + listSwitchOn(skills, ctx) + ? planTurnOffAll(skills, ctx, { ask: true }) + : planTurnOnAll(skills, ctx); + /** Other installed agents that lose the skill when this agent's link goes: same folder, same link. */ function alsoLosesOnTurnOff(skill: Skill, agent: SkillAgent, ctx: SkillsContext) { const target = accessOf(skill, agent); @@ -260,15 +350,16 @@ function alsoLosesOnTurnOff(skill: Skill, agent: SkillAgent, ctx: SkillsContext) }); } -/** Turning one agent off for the skills it uses through a link. Others stay on. */ +/** Turning one agent off for the skills it uses. Others stay on. */ export function planTurnOff( selected: readonly Skill[], agent: SkillAgent, ctx: SkillsContext, ): SkillPlan | null { - const targets = selected.filter((skill) => accessOf(skill, agent)?.state === "link"); - const stuck = selected.filter((skill) => accessOf(skill, agent)?.state === "direct"); - if (targets.length === 0 && stuck.length === 0) return null; + const using = selected.filter((skill) => hasAccess(skill, agent)); + const targets = using.filter((skill) => !isFixed(skill, agent)); + const stuck = using.filter((skill) => isFixed(skill, agent)); + if (using.length === 0) return null; const alsoLose = new Map( targets .flatMap((skill) => alsoLosesOnTurnOff(skill, agent, ctx)) @@ -280,11 +371,7 @@ export function planTurnOff( `${joinNames([...alsoLose.values()].map((other) => other.displayName))} ${alsoLose.size === 1 ? "loses" : "lose"} ${targets.length === 1 ? "it" : "these"} too.`, ); } - if (stuck.length > 0) { - notes.push( - `${plural(stuck.length, "skill")} ${stuck.length === 1 ? "stays" : "stay"} on because ${agent.displayName} reads ${stuck.length === 1 ? "its" : "their"} folder.`, - ); - } + if (stuck.length > 0) notes.push(staysOnNote(stuck.length, agent)); return { change: { kind: "disable", @@ -306,12 +393,192 @@ export function planTurnOff( }; } -// -- Moving and deleting ---------------------------------------------------------------------- +// -- Selecting and grouping ------------------------------------------------------------------- -/** Whether the skill's own folder is in an agent's skill folder, which is what can move or go. */ -const hasOwnFolder = (skill: Skill) => skill.realFolder === true; +/** A checkbox over several rows: ticked when all are, indeterminate when only some are. */ +export function checkState(ids: readonly string[], selected: ReadonlySet) { + const count = ids.filter((id) => selected.has(id)).length; + return { + checked: ids.length > 0 && count === ids.length, + indeterminate: count > 0 && count < ids.length, + }; +} + +/** A group shows this many skills before a "more" row. */ +export const GROUP_PREVIEW = 3; + +export type SkillGroup = { readonly source: string; readonly skills: readonly Skill[] }; + +/** + * Skills the installer says came from the same place form a group when there are two or more. + * Groups come first, by name; everything else keeps its order. + */ +export function groupBySource(skills: readonly Skill[]): { + groups: SkillGroup[]; + loose: Skill[]; +} { + const bySource = new Map(); + for (const skill of skills) { + if (!skill.source) continue; + const list = bySource.get(skill.source); + if (list) list.push(skill); + else bySource.set(skill.source, [skill]); + } + const groups = [...bySource] + .filter(([, list]) => list.length >= 2) + .map(([source, list]): SkillGroup => ({ source, skills: list })) + .toSorted((a, b) => a.source.localeCompare(b.source)); + const grouped = new Set(groups.map((group) => group.source)); + return { groups, loose: skills.filter((skill) => !skill.source || !grouped.has(skill.source)) }; +} + +/** Who has all of a group's skills on, in the same terms as one skill's icons. */ +export function groupAvailability(skills: readonly Skill[], ctx: SkillsContext): Availability { + const agents = ctx.installed.filter((agent) => skills.every((skill) => hasAccess(skill, agent))); + const missing = ctx.installed.filter((agent) => !agents.includes(agent)); + return { everyone: ctx.installed.length > 0 && missing.length === 0, agents, missing }; +} + +/** The badge on a Global skill that is used in some projects only. */ +export const projectsBadge = (skill: Skill) => + skill.projects && skill.projects.length > 0 ? plural(skill.projects.length, "project") : null; + +// -- Placing and deleting --------------------------------------------------------------------- + +/** A registered project of this environment, as the Use in… list shows it. */ +export type ProjectOption = { readonly cwd: string; readonly label: string }; + +/** Where "Use in…" can put skills. */ +export type PlaceChoice = "project" | "global" | "projects"; + +export type PlaceTarget = + | { readonly kind: "project"; readonly project: ProjectOption } + | { readonly kind: "global" } + | { readonly kind: "projects"; readonly projects: readonly ProjectOption[] }; + +/** The server takes at most this many projects in one placement. */ +const MAX_PLACE_PROJECTS = 64; + +const placementOf = (skill: Skill): PlaceChoice => + skill.scope === "project" ? "project" : skill.projects?.length ? "projects" : "global"; + +/** + * What the Use in… list starts on: where the skills are now, when they all share one place, and + * the projects ticked: the ones a skill is used in, else the project picked above the page. + */ +export function startingPlacement(selected: readonly Skill[], picked: ProjectOption | null) { + const places = new Set(selected.map(placementOf)); + const choice = places.size === 1 ? [...places][0]! : null; + const used = [...new Set(selected.flatMap((skill) => skill.projects ?? []))]; + const ticked = choice === "projects" ? used : picked ? [picked.cwd] : []; + return { choice, ticked }; +} + +/** The placement a choice stands for, or null while it is unfinished or can't be asked for. */ +export function placeTarget( + choice: PlaceChoice | null, + picked: ProjectOption | null, + projects: readonly ProjectOption[], + ticked: ReadonlySet, +): PlaceTarget | null { + switch (choice) { + case "project": + return picked ? { kind: "project", project: picked } : null; + case "global": + return { kind: "global" }; + case "projects": { + const chosen = projects.filter((project) => ticked.has(project.cwd)); + return chosen.length > 0 && chosen.length <= MAX_PLACE_PROJECTS + ? { kind: "projects", projects: chosen } + : null; + } + default: + return null; + } +} + +/** Whether the skill is placed that way already, so there is nothing to do for it. */ +function placedAlready(skill: Skill, target: PlaceTarget) { + switch (target.kind) { + case "project": + return skill.scope === "project"; + case "global": + return skill.scope === "global" && !skill.projects?.length; + case "projects": { + const used = new Set(skill.projects ?? []); + return ( + skill.scope === "global" && + used.size === target.projects.length && + target.projects.every((project) => used.has(project.cwd)) + ); + } + } +} -const destinationName = (to: SkillScope) => (to === "global" ? "Global" : "this project"); +const placement = (target: PlaceTarget): SkillPlacement => + target.kind === "project" + ? { kind: "project", cwd: target.project.cwd } + : target.kind === "global" + ? { kind: "global" } + : { kind: "projects", cwds: target.projects.map((project) => project.cwd) }; + +const projectNamesOf = (target: PlaceTarget) => + target.kind === "project" + ? [target.project.label] + : target.kind === "projects" + ? target.projects.map((project) => project.label) + : []; + +/** + * Putting skills in one project, in every project, or in some. It always asks first, since it + * changes who sees the skills. Skills that are placed that way already are left out. + */ +export function planPlace(selected: readonly Skill[], target: PlaceTarget): SkillPlan | null { + const coming = selected.filter((skill) => !placedAlready(skill, target)); + if (coming.length === 0) return null; + const what = coming.length === 1 ? coming[0]!.name : plural(coming.length, "skill"); + const alreadyGlobal = coming.every((skill) => skill.scope === "global"); + const names = projectNamesOf(target); + const where = joinNames(names); + const confirmation = (() => { + switch (target.kind) { + case "project": + return { + title: `Use ${what} only in ${where}?`, + body: `It moves into ${where}, so anyone who clones it gets it.`, + confirm: "Move", + }; + case "global": + return { + title: alreadyGlobal ? `Use ${what} in every project?` : `Make ${what} Global?`, + body: "It will be on in every project.", + confirm: alreadyGlobal ? "Apply" : "Make Global", + }; + case "projects": + return { + title: alreadyGlobal ? `Use ${what} only in ${where}?` : `Make ${what} Global?`, + body: + names.length === 1 + ? `It will be on in ${where} only.` + : `It will be on in ${where}. There's one copy, so an edit shows up in ${names.length === 2 ? "both" : "all of them"}.`, + confirm: alreadyGlobal ? "Apply" : "Make Global", + }; + } + })(); + return { + change: { + kind: "place", + skills: coming.map(skillRef), + to: placement(target), + projectNames: names, + }, + affected: coming.length, + confirmation: { ...confirmation, notes: [], destructive: false }, + }; +} + +/** Whether the skill's own folder is in an agent's skill folder, which is what can be deleted. */ +const hasOwnFolder = (skill: Skill) => skill.realFolder === true; const quoted = (skills: readonly Skill[]) => skills.map((skill) => `“${skill.name}”`); @@ -329,35 +596,6 @@ const linkedNote = (kept: readonly Skill[]) => `${plural(kept.length, "skill")} ${kept.length === 1 ? "is" : "are"} reached through a link, so ${kept.length === 1 ? "it stays" : "they stay"}.`, ]; -/** - * Moving skills between This project and Global. It always asks first, since it changes who - * sees the skills. The agents that used a skill keep using it; the server links them again. - */ -export function planMove(selected: readonly Skill[], to: SkillScope): SkillPlan | null { - const coming = selected.filter((skill) => skill.scope !== to); - const targets = coming.filter(hasOwnFolder); - if (targets.length === 0) return null; - const them = targets.length === 1 ? "it" : "them"; - const notes = [ - `Agents that use ${them} keep using ${them}.`, - ...linkedNote(coming.filter((skill) => !hasOwnFolder(skill))), - ]; - return { - change: { kind: "move", skills: targets.map(skillRef), to }, - affected: targets.length, - confirmation: { - title: `Move ${targets.length === 1 ? `“${targets[0]!.name}”` : plural(targets.length, "skill")} to ${destinationName(to)}?`, - body: - to === "global" - ? "Moves to your Global skills, for all your projects." - : "Moves into this project, so anyone who clones it gets it.", - notes, - confirm: "Move", - destructive: false, - }, - }; -} - /** * Deleting the skills' own folders and the links that lead to them. A skill that is only linked * here, such as one from a synced library, can't be deleted from this page at all. @@ -394,14 +632,15 @@ export function planDelete(selected: readonly Skill[], ctx: SkillsContext): Skil } /** - * The project skills a confirmation should ask git about: those a delete removes or a move out of - * a project takes. A move into a project makes new files, so there is nothing in git to undo. - * Null when the plan has nothing to ask about. + * The project skills a confirmation should ask git about: those a delete removes or a placement + * takes out of their project. A move into a project makes new files, so there is nothing in git + * to undo. Null when the plan has nothing to ask about. */ export function skillsToCheckWithGit(plan: SkillPlan): readonly SkillRef[] | null { const { change } = plan; if (plan.confirmation === undefined) return null; - if (change.kind !== "delete" && !(change.kind === "move" && change.to === "global")) return null; + if (change.kind === "enable" || change.kind === "disable") return null; + if (change.kind === "place" && change.to.kind === "project") return null; const skills = change.skills.filter((skill) => skill.scope === "project"); return skills.length === 0 ? null : skills; } @@ -428,7 +667,7 @@ export function withGitNote(plan: SkillPlan, tracked: readonly string[]): SkillP /** A one-click fix for a skill that installed agents can't use yet. */ export function planFix(skill: Skill, ctx: SkillsContext) { - const missing = missingAgents(skill, ctx); + const missing = missingAgents(skill, ctx).filter((agent) => !isFixed(skill, agent)); if (missing.length === 0) return null; return { label: @@ -441,8 +680,8 @@ const problemText = ( reason: SkillOutcomeReason, name: string, who: string | undefined, - /** Where a move was going, to say who is in the way. */ - to?: SkillScope, + /** Where a placement was going, to say who is in the way. */ + where?: string, ) => { switch (reason) { case "notFound": @@ -460,7 +699,7 @@ const problemText = ( case "linked": return `“${name}” is reached through a link, so it stays where it is.`; case "destinationTaken": - return `${to === undefined ? "The other side" : capitalize(destinationName(to))} already has a “${name}”, so it stays.`; + return `${where ?? "The other side"} already has a “${name}”, so it stays.`; case "inUse": return `“${name}” is in use by another program, so it wasn't moved.`; case "setElsewhere": @@ -472,11 +711,25 @@ const problemText = ( } }; -const capitalize = (text: string) => `${text.slice(0, 1).toUpperCase()}${text.slice(1)}`; +/** Many skills held back for the same agent and reason are one sentence, not one each. */ +const manyProblemText = (reason: SkillOutcomeReason, who: string, count: number) => + reason === "alwaysOn" + ? `${who} reads ${count} skills directly, so they stay on.` + : `${who}'s settings decide ${count} skills, so they stay as they are.`; + +const AGGREGATED_REASONS = new Set(["alwaysOn", "setElsewhere"]); + +/** Where a placement went, as the start of a sentence naming who is in the way. */ +const placeWhere = (change: Extract) => + change.to.kind === "global" + ? "Global" + : change.to.kind === "project" + ? (change.projectNames[0] ?? "This project") + : "A project"; /** A skill that was changed, but not all the way: its old folder stayed, or only some of it went. */ const partialText = (kind: SkillChange["kind"], name: string) => - kind === "move" + kind === "place" ? `“${name}” moved, but its old folder couldn't be removed.` : `“${name}” was only partly deleted.`; @@ -494,20 +747,37 @@ export function describeResult( const also = [...new Set(changed.flatMap((outcome) => outcome.affected.map(nameOf)))]; const alsoNames = joinNames(also); const them = changed.length === 1 ? "it" : "them"; + const alsoText = (verb: string) => + also.length > 0 ? ` ${alsoNames} ${also.length === 1 ? `${verb}s` : verb} ${them} too.` : ""; const lead = (() => { if (changed.length === 0) return ""; const count = plural(changed.length, "skill"); switch (change.kind) { case "enable": - return `Turned on ${count} for ${joinNames(change.agents.map(nameOf))}.${also.length > 0 ? ` ${alsoNames} ${also.length === 1 ? "gets" : "get"} ${them} too.` : ""}`; + return `Turned on ${count} for ${joinNames(change.agents.map(nameOf))}.${alsoText("get")}`; case "disable": return `Turned off ${count} for ${joinNames(change.agents.map(nameOf))}.${also.length > 0 ? ` ${alsoNames} ${also.length === 1 ? "loses" : "lose"} ${them} too.` : ""}`; - case "move": - return `Moved ${count} to ${destinationName(change.to)}.${also.length > 0 ? ` ${alsoNames} ${also.length === 1 ? "gets" : "get"} ${them} too.` : ""}`; + case "place": + return `${ + change.to.kind === "global" + ? `Made ${count} Global.` + : change.to.kind === "project" + ? `Moved ${count} to ${change.projectNames[0] ?? "this project"}.` + : `${count} now used in ${joinNames(change.projectNames)}.` + }${alsoText("get")}`; case "delete": return `Deleted ${count}.`; } })(); + // Skills held back for the same agent and reason are counted once, so a bulk change stays short. + const held = new Map(); + for (const outcome of outcomes) { + for (const blocked of outcome.blocked) { + if (!AGGREGATED_REASONS.has(blocked.reason)) continue; + const key = `${blocked.reason}\0${blocked.instanceId}`; + held.set(key, (held.get(key) ?? 0) + 1); + } + } const problems = [ ...new Set( outcomes.flatMap((outcome) => [ @@ -515,19 +785,22 @@ export function describeResult( ? [ outcome.status === "changed" && outcome.reason === "failed" && - (change.kind === "move" || change.kind === "delete") + (change.kind === "place" || change.kind === "delete") ? partialText(change.kind, outcome.skill.name) : problemText( outcome.reason, outcome.skill.name, undefined, - change.kind === "move" ? change.to : undefined, + change.kind === "place" ? placeWhere(change) : undefined, ), ] : []), - ...outcome.blocked.map((blocked) => - problemText(blocked.reason, outcome.skill.name, nameOf(blocked.instanceId)), - ), + ...outcome.blocked.map((blocked) => { + const count = held.get(`${blocked.reason}\0${blocked.instanceId}`) ?? 0; + return count > 1 + ? manyProblemText(blocked.reason, nameOf(blocked.instanceId), count) + : problemText(blocked.reason, outcome.skill.name, nameOf(blocked.instanceId)); + }), ]), ), ]; @@ -537,8 +810,8 @@ export function describeResult( return "Already on."; case "disable": return "Already off."; - case "move": - return `Already in ${destinationName(change.to)}.`; + case "place": + return "Already there."; case "delete": return "Nothing to delete."; } diff --git a/apps/web/src/components/settings/SkillsSettings.tsx b/apps/web/src/components/settings/SkillsSettings.tsx index 018ea061b3f0..7a0783b33a75 100644 --- a/apps/web/src/components/settings/SkillsSettings.tsx +++ b/apps/web/src/components/settings/SkillsSettings.tsx @@ -12,9 +12,9 @@ import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { RefreshIcon } from "../ui/refresh-icon"; import { Skeleton } from "../ui/skeleton"; -import { BulkBar, ConfirmPlan } from "./SkillBulkBar"; +import { ConfirmPlan } from "./SkillBulkBar"; import { SkillDetail } from "./SkillDetail"; -import { SkillSection, StandardInfo, type RowFix } from "./SkillList"; +import { SkillSection, StandardInfo } from "./SkillList"; import { SettingsGroup } from "./SettingsGroup"; import { SettingsPageContainer } from "./settingsLayout"; import { useSettingsScope } from "./SettingsScopeContext"; @@ -24,7 +24,6 @@ import { ingestSkills, installedAgents, matchesQuery, - planFix, skillsEnvironment, skillsToCheckWithGit, unreadableNote, @@ -140,7 +139,6 @@ function EnvironmentSkills({ const [query, setQuery] = useState(""); const [onlyAttention, setOnlyAttention] = useState(false); const [detailReload, setDetailReload] = useState(0); - const [selected, setSelected] = useState>(new Set()); /** A change that is waiting for the person to confirm it. */ const [confirming, setConfirming] = useState(null); /** A change is being made and the list read again; nothing else can start meanwhile. */ @@ -157,10 +155,11 @@ function EnvironmentSkills({ }; }, []); // A new view starts at the top of the page. - const show = (next: View) => { + const show = useCallback((next: View) => { setView(next); rootRef.current?.closest("[data-settings-page-scroll]")?.scrollTo({ top: 0 }); - }; + }, []); + const openSkill = useCallback((id: string) => show({ kind: "skill", id }), [show]); // The server reads a fixed list of folders each time; no agent is asked to rescan. const load = useCallback(async () => { @@ -270,18 +269,11 @@ function EnvironmentSkills({ ...base, input: { ...scoped, skills: change.skills, agents: change.agents }, }) - : change.kind === "move" - ? // A move is between a project and Global, so it needs the project picked above. - cwd - ? await placeSkills({ - ...base, - input: { - cwd, - skills: change.skills, - to: change.to === "global" ? { kind: "global" } : { kind: "project", cwd }, - }, - }) - : null + : change.kind === "place" + ? await placeSkills({ + ...base, + input: { ...scoped, skills: change.skills, to: change.to }, + }) : await deleteSkills({ ...base, input: { ...scoped, skills: change.skills } }); setNotice( result?._tag === "Success" @@ -302,13 +294,12 @@ function EnvironmentSkills({ } catch { setLoadError(LOAD_ERROR); } - setSelected(new Set()); setBusy(false); }; /** - * A plan that needs confirming waits for the dialog; any other goes ahead. For a move or delete - * the dialog opens at once and git is asked meanwhile: the "undo with git" line appears when the - * answer is in, and never when the check fails. + * A plan that needs confirming waits for the dialog; any other goes ahead. For a placement or + * delete the dialog opens at once and git is asked meanwhile: the "undo with git" line appears + * when the answer is in, and never when the check fails. */ const runPlan = (plan: SkillPlan) => { if (!plan.confirmation) { @@ -332,25 +323,12 @@ function EnvironmentSkills({ } })(); }; - const chosen = useMemo( - () => (skills ?? []).filter((skill) => selected.has(skill.id)), - [skills, selected], - ); - const setSelection = (ids: readonly string[], checked: boolean) => - setSelected((current) => { - const next = new Set(current); - for (const id of ids) { - if (checked) next.add(id); - else next.delete(id); - } - return next; - }); - /** Only the Needs attention list offers it, where the missing link is the point of the row. */ - const rowFix = (skill: Skill): RowFix | null => { - if (!onlyAttention || attention(skill, ctx)?.kind !== "missing") return null; - const fix = planFix(skill, ctx); - return fix ? { label: fix.label, run: () => runPlan(fix.plan) } : null; - }; + // Rows are memoized, so they get one function that always calls the latest runPlan. + const runPlanRef = useRef(runPlan); + useEffect(() => { + runPlanRef.current = runPlan; + }); + const onPlan = useCallback((plan: SkillPlan) => runPlanRef.current(plan), []); const offline = !connected; const empty = skills !== null && skills.length === 0; const emptyText = (total: number, none: string) => @@ -459,45 +437,26 @@ function EnvironmentSkills({ {project && ( show({ kind: "skill", id })} + onPlan={onPlan} + onOpen={openSkill} /> )} show({ kind: "skill", id })} + onPlan={onPlan} + onOpen={openSkill} /> {empty &&

    No skills yet.

    } - {chosen.length > 0 && ( - setSelected(new Set())} - onPlan={runPlan} - /> - )} )} diff --git a/apps/web/src/components/settings/skillAgentIcon.tsx b/apps/web/src/components/settings/skillAgentIcon.tsx index 810e08179b98..28a6539c1b04 100644 --- a/apps/web/src/components/settings/skillAgentIcon.tsx +++ b/apps/web/src/components/settings/skillAgentIcon.tsx @@ -5,9 +5,8 @@ import { shouldShowInstanceBadge } from "../../providerInstances"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { - availability, availabilityNote, - type Skill, + type Availability, type SkillAgent, type SkillsContext, } from "./SkillsSettings.logic"; @@ -53,11 +52,11 @@ function IconRow({ label, children }: { label: string; children: ReactNode }) { } /** - * Who can use a skill: one mark when every installed agent can, otherwise just the agents that - * can. Agents that aren't installed and enabled never show. + * Who has a skill, or a group of them, on: one mark when every installed agent does, otherwise just + * the agents that do, and nothing when none does. Agents that aren't installed and enabled never + * show. */ -export function SkillAgents({ skill, ctx }: { skill: Skill; ctx: SkillsContext }) { - const value = availability(skill, ctx); +export function SkillAgents({ value, ctx }: { value: Availability; ctx: SkillsContext }) { const label = availabilityNote(value); if (value.everyone) return ( From 416d5543dce6bda46c95c37308fe463a59c1e578 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:10:40 -0400 Subject: [PATCH 026/108] feat(web): group skills by where they came from, and select several to act on together Skills from the same source sit under one row with its own switch and a count, showing the first three and a row for the rest. Select in the page header turns on checkboxes on rows and groups, and a bar at the bottom turns the ticked skills on or off or deletes them. The checkboxes that only showed on hover are gone. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/settings/SkillBulkBar.tsx | 104 ++++++- .../web/src/components/settings/SkillList.tsx | 278 +++++++++++++++--- .../components/settings/SkillsSettings.tsx | 54 +++- 3 files changed, 391 insertions(+), 45 deletions(-) diff --git a/apps/web/src/components/settings/SkillBulkBar.tsx b/apps/web/src/components/settings/SkillBulkBar.tsx index 555425d0b1a2..2311e55ea515 100644 --- a/apps/web/src/components/settings/SkillBulkBar.tsx +++ b/apps/web/src/components/settings/SkillBulkBar.tsx @@ -1,3 +1,4 @@ +import { MoreHorizontalIcon } from "lucide-react"; import { useState } from "react"; import { @@ -9,7 +10,108 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; -import type { SkillPlan } from "./SkillsSettings.logic"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; +import { + planDelete, + planTurnOffAll, + planTurnOnAll, + type Skill, + type SkillPlan, + type SkillsContext, +} from "./SkillsSettings.logic"; + +/** + * Acts on every ticked row. It sticks to the bottom of the page, so it is in reach on a phone, + * where Turn on, Turn off and Delete fold into one menu. + */ +export function BulkBar({ + selected, + ctx, + busy, + onPlan, +}: { + selected: readonly Skill[]; + ctx: SkillsContext; + /** A change is being made, so nothing else can start. */ + busy: boolean; + onPlan: (plan: SkillPlan) => void; +}) { + const turnOn = planTurnOnAll(selected, ctx); + const turnOff = planTurnOffAll(selected, ctx, { ask: selected.length > 1 }); + const del = planDelete(selected, ctx); + return ( +
    +
    + {selected.length} selected + + + + {del && ( + + )} + + + + + } + > + + + + turnOn && onPlan(turnOn)}> + Turn on + + turnOff && onPlan(turnOff)}> + Turn off + + {del && } + {del && ( + onPlan(del)}> + Delete… + + )} + + + +
    +
    + ); +} /** Asks before a plan changes anything, with the same plain words for one skill or many. */ export function ConfirmPlan({ diff --git a/apps/web/src/components/settings/SkillList.tsx b/apps/web/src/components/settings/SkillList.tsx index c1072cc197d0..96dbd75e4d75 100644 --- a/apps/web/src/components/settings/SkillList.tsx +++ b/apps/web/src/components/settings/SkillList.tsx @@ -4,14 +4,20 @@ import { memo, useMemo, useState, type MouseEvent } from "react"; import { cn } from "../../lib/utils"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Switch } from "../ui/switch"; +import { GitHubIcon } from "../Icons"; import { SettingsGroup } from "./SettingsGroup"; import { AgentSwitchChip } from "./SkillAgentSwitch"; import { SkillAgents } from "./skillAgentIcon"; import { + GROUP_PREVIEW, attention, availability, + checkState, + groupAvailability, + groupBySource, listSwitchOn, planFix, planListSwitch, @@ -20,6 +26,7 @@ import { projectsBadge, rowSwitchOn, type Skill, + type SkillGroup, type SkillPlan, type SkillsContext, } from "./SkillsSettings.logic"; @@ -30,17 +37,27 @@ const stopRowClick = (event: MouseEvent) => event.stopPropagation(); const SkillRow = memo(function SkillRow({ skill, ctx, + nested = false, + selecting, + selected, showFix, busy, + onSelect, onPlan, onOpen, }: { skill: Skill; ctx: SkillsContext; + /** The row sits under a group's row, so it is indented. */ + nested?: boolean; + /** Rows have a checkbox instead of a switch, and a click ticks them. */ + selecting: boolean; + selected: boolean; /** Offer the one-click fix for a skill some agent lacks; only the Needs attention list does. */ showFix: boolean; /** A change is being made, so nothing else can start. */ busy: boolean; + onSelect: (ids: readonly string[], checked: boolean) => void; /** Turns agents on or off for skills; a plan with a confirmation asks first. */ onPlan: (plan: SkillPlan) => void; /** Opens the skill itself, with its files. */ @@ -60,15 +77,27 @@ const SkillRow = memo(function SkillRow({ const { fix } = derived; const panelId = `skill-panel-${skill.id}`; return ( -
  • +
  • setOpen((value) => !value)} - className="flex cursor-pointer flex-wrap items-center gap-x-2 gap-y-1 px-3 py-2 hover:bg-muted/40 sm:px-4" + onClick={() => (selecting ? onSelect([skill.id], !selected) : setOpen((value) => !value))} + className={cn( + "flex cursor-pointer flex-wrap items-center gap-x-2 gap-y-1 py-2 pr-3 hover:bg-muted/40 sm:pr-4", + nested ? "pl-9 sm:pl-10" : "pl-3 sm:pl-4", + )} > + {selecting && ( + + onSelect([skill.id], checked)} + /> + + )} - )} - { - const plan = planRowSwitch(skill, ctx); - if (plan) onPlan(plan); - }} - /> - - + {!selecting && ( + <> + + {fix && ( + + )} + { + const plan = planRowSwitch(skill, ctx); + if (plan) onPlan(plan); + }} + /> + + + + )}
    - {open && ( -
    + {open && !selecting && ( +
    {ctx.installed.length === 0 ? (

    No agents are installed.

    ) : ( @@ -145,6 +186,124 @@ const SkillRow = memo(function SkillRow({ ); }); +/** A group's row and its skills: the first few, and a row that reveals the rest. */ +const SkillGroupRows = memo(function SkillGroupRows({ + group, + ctx, + selecting, + selected, + showFix, + busy, + onSelect, + onPlan, + onOpen, +}: { + group: SkillGroup; + ctx: SkillsContext; + selecting: boolean; + /** The ids of the ticked rows, across the page. */ + selected: ReadonlySet; + showFix: boolean; + busy: boolean; + onSelect: (ids: readonly string[], checked: boolean) => void; + onPlan: (plan: SkillPlan) => void; + onOpen: (id: string) => void; +}) { + const [open, setOpen] = useState(true); + const [showAll, setShowAll] = useState(false); + const derived = useMemo( + () => ({ + on: listSwitchOn(group.skills, ctx), + availability: groupAvailability(group.skills, ctx), + }), + [group.skills, ctx], + ); + const ids = useMemo(() => group.skills.map((skill) => skill.id), [group.skills]); + const ticks = selecting ? checkState(ids, selected) : null; + const shown = showAll ? group.skills : group.skills.slice(0, GROUP_PREVIEW); + const hidden = group.skills.length - shown.length; + return ( + <> +
  • +
    setOpen((value) => !value)} + className="flex cursor-pointer items-center gap-2 px-3 py-2 hover:bg-muted/40 sm:px-4" + > + {ticks && ( + + onSelect(ids, checked)} + /> + + )} + + + + {!selecting && ( + <> + + { + const plan = planListSwitch(group.skills, ctx); + if (plan) onPlan(plan); + }} + /> + + {/* The width of a row's chevron, so this switch sits over the rows' switches. */} + + + )} +
    +
  • + {open && + shown.map((skill) => ( + + ))} + {open && group.skills.length > GROUP_PREVIEW && ( +
  • + +
  • + )} + + ); +}); + /** Small info button beside the page heading: where project and global skills live. */ export function StandardInfo() { return ( @@ -169,8 +328,12 @@ export function SkillSection({ visible, ctx, emptyText, + flat, + selecting, + selected, showFix, busy, + onSelect, onPlan, onOpen, }: { @@ -179,41 +342,72 @@ export function SkillSection({ visible: readonly Skill[]; ctx: SkillsContext; emptyText: string; + /** List the skills without groups, as a search does. */ + flat: boolean; + selecting: boolean; + /** The ids of the ticked rows, across both sections. */ + selected: ReadonlySet; showFix: boolean; /** A change is being made, so nothing else can start. */ busy: boolean; + onSelect: (ids: readonly string[], checked: boolean) => void; onPlan: (plan: SkillPlan) => void; onOpen: (id: string) => void; }) { const on = useMemo(() => listSwitchOn(visible, ctx), [visible, ctx]); + const { groups, loose } = useMemo( + () => (flat ? { groups: [], loose: visible } : groupBySource(visible)), + [visible, flat], + ); return (

    {title}

    - { - const plan = planListSwitch(visible, ctx); - if (plan) onPlan(plan); - }} - /> - {/* The width of a row's chevron, so this switch sits over the rows' switches. */} - + {!selecting && ( + <> + { + const plan = planListSwitch(visible, ctx); + if (plan) onPlan(plan); + }} + /> + {/* The width of a row's chevron, so this switch sits over the rows' switches. */} + + + )}
    {visible.length === 0 ? (

    {emptyText}

    ) : (
      - {visible.map((skill) => ( + {groups.map((group) => ( + + ))} + {loose.map((skill) => ( diff --git a/apps/web/src/components/settings/SkillsSettings.tsx b/apps/web/src/components/settings/SkillsSettings.tsx index 7a0783b33a75..9c328e04a133 100644 --- a/apps/web/src/components/settings/SkillsSettings.tsx +++ b/apps/web/src/components/settings/SkillsSettings.tsx @@ -12,7 +12,7 @@ import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { RefreshIcon } from "../ui/refresh-icon"; import { Skeleton } from "../ui/skeleton"; -import { ConfirmPlan } from "./SkillBulkBar"; +import { BulkBar, ConfirmPlan } from "./SkillBulkBar"; import { SkillDetail } from "./SkillDetail"; import { SkillSection, StandardInfo } from "./SkillList"; import { SettingsGroup } from "./SettingsGroup"; @@ -69,6 +69,9 @@ export function SkillsSettings() { const missingProject = (scope.kind === "project" || scope.kind === "checkout") && !project; // On a phone, an open skill gets the whole screen under its Back row. const [subpage, setSubpage] = useState(false); + /** Rows have checkboxes, and a bar at the bottom acts on the ticked ones. */ + const [selecting, setSelecting] = useState(false); + const canSelect = environment !== undefined && !missingProject && !subpage; return (
      @@ -76,6 +79,16 @@ export function SkillsSettings() {

      Skills

      + + {canSelect && ( + + )}
    {!environment ? ( @@ -91,6 +104,7 @@ export function SkillsSettings() { key={`${environment.environmentId}:${picked?.id ?? "global"}`} environment={environment} project={picked} + selecting={selecting} onSubpageChange={setSubpage} /> )} @@ -101,11 +115,14 @@ export function SkillsSettings() { function EnvironmentSkills({ environment, project, + selecting, onSubpageChange, }: { environment: ReturnType["environments"][number]; - /** The project picked above the page, or null for "All projects". */ + /** The project picked above the page, or null when none is. */ project: PickedProject | null; + /** Rows have checkboxes, and a bar at the bottom acts on the ticked ones. */ + selecting: boolean; /** True while a skill is open instead of the list. */ onSubpageChange: (open: boolean) => void; }) { @@ -139,6 +156,7 @@ function EnvironmentSkills({ const [query, setQuery] = useState(""); const [onlyAttention, setOnlyAttention] = useState(false); const [detailReload, setDetailReload] = useState(0); + const [selected, setSelected] = useState>(new Set()); /** A change that is waiting for the person to confirm it. */ const [confirming, setConfirming] = useState(null); /** A change is being made and the list read again; nothing else can start meanwhile. */ @@ -294,6 +312,7 @@ function EnvironmentSkills({ } catch { setLoadError(LOAD_ERROR); } + setSelected(new Set()); setBusy(false); }; /** @@ -329,6 +348,26 @@ function EnvironmentSkills({ runPlanRef.current = runPlan; }); const onPlan = useCallback((plan: SkillPlan) => runPlanRef.current(plan), []); + const chosen = useMemo( + () => (skills ?? []).filter((skill) => selected.has(skill.id)), + [skills, selected], + ); + const setSelection = useCallback((ids: readonly string[], checked: boolean) => { + setSelected((current) => { + const next = new Set(current); + for (const id of ids) { + if (checked) next.add(id); + else next.delete(id); + } + return next; + }); + }, []); + // Leaving Select mode leaves nothing ticked. + const [wasSelecting, setWasSelecting] = useState(selecting); + if (wasSelecting !== selecting) { + setWasSelecting(selecting); + if (!selecting) setSelected(new Set()); + } const offline = !connected; const empty = skills !== null && skills.length === 0; const emptyText = (total: number, none: string) => @@ -440,8 +479,12 @@ function EnvironmentSkills({ visible={visible(projectSkills)} ctx={ctx} emptyText={emptyText(projectSkills.length, "No skills in this project.")} + flat={needle !== ""} + selecting={selecting} + selected={selected} showFix={onlyAttention} busy={locked} + onSelect={setSelection} onPlan={onPlan} onOpen={openSkill} /> @@ -451,12 +494,19 @@ function EnvironmentSkills({ visible={visible(globalSkills)} ctx={ctx} emptyText={emptyText(globalSkills.length, "No Global skills yet.")} + flat={needle !== ""} + selecting={selecting} + selected={selected} showFix={onlyAttention} busy={locked} + onSelect={setSelection} onPlan={onPlan} onOpen={openSkill} /> {empty &&

    No skills yet.

    } + {selecting && chosen.length > 0 && ( + + )} )} From 34f2096aa59511e7709af30171ab23270220bedc Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:20:04 -0400 Subject: [PATCH 027/108] =?UTF-8?q?feat(web):=20choose=20where=20a=20skill?= =?UTF-8?q?=20is=20used=20with=20Use=20in=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use in… on an opened skill, on the skill view, and on the bar for selected skills puts them in this project only, in every project, or in just the projects you tick. Applying asks first, and says you can undo it with git when a project skill leaves its project. A Global skill used in only some projects shows a badge such as 2 projects. Skills held back for the same agent now get one line in the result instead of one each. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/settings/SkillBulkBar.tsx | 4 + .../src/components/settings/SkillDetail.tsx | 5 + .../web/src/components/settings/SkillList.tsx | 18 ++- .../src/components/settings/SkillUseIn.tsx | 149 ++++++++++++++++++ .../settings/SkillsSettings.logic.ts | 7 - .../components/settings/SkillsSettings.tsx | 47 +++++- 6 files changed, 213 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/settings/SkillUseIn.tsx diff --git a/apps/web/src/components/settings/SkillBulkBar.tsx b/apps/web/src/components/settings/SkillBulkBar.tsx index 2311e55ea515..6211a8a0556b 100644 --- a/apps/web/src/components/settings/SkillBulkBar.tsx +++ b/apps/web/src/components/settings/SkillBulkBar.tsx @@ -11,6 +11,7 @@ import { } from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; +import { UseInPopover, type PlaceOptions } from "./SkillUseIn"; import { planDelete, planTurnOffAll, @@ -27,11 +28,13 @@ import { export function BulkBar({ selected, ctx, + places, busy, onPlan, }: { selected: readonly Skill[]; ctx: SkillsContext; + places: PlaceOptions; /** A change is being made, so nothing else can start. */ busy: boolean; onPlan: (plan: SkillPlan) => void; @@ -47,6 +50,7 @@ export function BulkBar({ >
    {selected.length} selected + @@ -190,6 +195,7 @@ const SkillRow = memo(function SkillRow({ const SkillGroupRows = memo(function SkillGroupRows({ group, ctx, + places, selecting, selected, showFix, @@ -200,6 +206,7 @@ const SkillGroupRows = memo(function SkillGroupRows({ }: { group: SkillGroup; ctx: SkillsContext; + places: PlaceOptions; selecting: boolean; /** The ids of the ticked rows, across the page. */ selected: ReadonlySet; @@ -283,6 +290,7 @@ const SkillGroupRows = memo(function SkillGroupRows({ key={skill.id} skill={skill} ctx={ctx} + places={places} nested selecting={selecting} selected={selected.has(skill.id)} @@ -316,7 +324,7 @@ export function StandardInfo() {

    Project skills live in the repo, so anyone who clones it gets them. Global skills are - yours and work in all your projects. + yours, and work in every project or just the ones you choose.

    @@ -327,6 +335,7 @@ export function SkillSection({ title, visible, ctx, + places, emptyText, flat, selecting, @@ -341,6 +350,7 @@ export function SkillSection({ /** The skills that match the search and filters. */ visible: readonly Skill[]; ctx: SkillsContext; + places: PlaceOptions; emptyText: string; /** List the skills without groups, as a search does. */ flat: boolean; @@ -389,6 +399,7 @@ export function SkillSection({ key={group.source} group={group} ctx={ctx} + places={places} selecting={selecting} selected={selected} showFix={showFix} @@ -403,6 +414,7 @@ export function SkillSection({ key={skill.id} skill={skill} ctx={ctx} + places={places} selecting={selecting} selected={selected.has(skill.id)} showFix={showFix} diff --git a/apps/web/src/components/settings/SkillUseIn.tsx b/apps/web/src/components/settings/SkillUseIn.tsx new file mode 100644 index 000000000000..fba19e4e6304 --- /dev/null +++ b/apps/web/src/components/settings/SkillUseIn.tsx @@ -0,0 +1,149 @@ +import { ChevronDownIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover"; +import { Radio, RadioGroup } from "../ui/radio-group"; +import { + placeTarget, + planPlace, + startingPlacement, + type PlaceChoice, + type ProjectOption, + type Skill, + type SkillPlan, +} from "./SkillsSettings.logic"; + +/** What the Use in… list needs to know about projects. */ +export type PlaceOptions = { + /** The project picked above the page; "This project only" needs one. */ + readonly picked: ProjectOption | null; + /** This environment's registered projects. */ + readonly projects: readonly ProjectOption[]; +}; + +const isPlaceChoice = (value: unknown): value is PlaceChoice => + value === "project" || value === "global" || value === "projects"; + +/** + * Where skills are used: in the project picked above the page only, in every project, or in a few + * projects. Applying hands over a plan that asks before it changes anything. + */ +export function UseInPopover({ + skills, + places, + busy, + side = "bottom", + onPlan, +}: { + skills: readonly Skill[]; + places: PlaceOptions; + /** A change is being made, so nothing else can start. */ + busy: boolean; + side?: "top" | "bottom"; + onPlan: (plan: SkillPlan) => void; +}) { + const [open, setOpen] = useState(false); + return ( + + }> + Use in… + + + + {/* The form is only mounted while open, so it starts from the skills' place each time. */} + setOpen(false)} + onApply={(plan) => { + setOpen(false); + onPlan(plan); + }} + /> + + + ); +} + +function UseInForm({ + skills, + picked, + projects, + onCancel, + onApply, +}: { + skills: readonly Skill[]; + picked: ProjectOption | null; + projects: readonly ProjectOption[]; + onCancel: () => void; + onApply: (plan: SkillPlan) => void; +}) { + const start = useMemo(() => startingPlacement(skills, picked), [skills, picked]); + const [choice, setChoice] = useState(start.choice); + const [ticked, setTicked] = useState>(() => new Set(start.ticked)); + const target = placeTarget(choice, picked, projects, ticked); + // Nothing to apply while the skills are placed that way already. + const plan = target ? planPlace(skills, target) : null; + return ( +
    + + {skills.length === 1 ? `Use ${skills[0]!.name}` : `Use ${skills.length} skills`} + + { + if (isPlaceChoice(value)) setChoice(value); + }} + > + {picked && ( + + )} + + + + {choice === "projects" && ( +
      + {projects.map((project) => ( +
    • + +
    • + ))} +
    + )} +
    + + +
    +
    + ); +} diff --git a/apps/web/src/components/settings/SkillsSettings.logic.ts b/apps/web/src/components/settings/SkillsSettings.logic.ts index 9bd34aa454aa..ac64f1d65f44 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.ts @@ -103,13 +103,6 @@ export const hasAccess = (skill: Skill, agent: SkillAgent) => { return state === "direct" || state === "link"; }; -/** Where the agent reads the skill from, or the folder it looks in when it can't see it. */ -export const agentSkillPath = (skill: Skill, agent: SkillAgent) => { - const access = accessOf(skill, agent); - if (!access) return null; - return hasAccess(skill, agent) ? `${access.folder}/${skill.name}` : access.folder; -}; - /** Installed agents that don't load this copy of the skill. */ const missingAgents = (skill: Skill, ctx: SkillsContext) => ctx.installed.filter((agent) => !hasAccess(skill, agent)); diff --git a/apps/web/src/components/settings/SkillsSettings.tsx b/apps/web/src/components/settings/SkillsSettings.tsx index 9c328e04a133..ed5f3b1c9126 100644 --- a/apps/web/src/components/settings/SkillsSettings.tsx +++ b/apps/web/src/components/settings/SkillsSettings.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAfterDelay } from "../../hooks/useAfterDelay"; import { cn } from "../../lib/utils"; import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { useProjects } from "../../state/entities"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; @@ -15,6 +16,7 @@ import { Skeleton } from "../ui/skeleton"; import { BulkBar, ConfirmPlan } from "./SkillBulkBar"; import { SkillDetail } from "./SkillDetail"; import { SkillSection, StandardInfo } from "./SkillList"; +import type { PlaceOptions } from "./SkillUseIn"; import { SettingsGroup } from "./SettingsGroup"; import { SettingsPageContainer } from "./settingsLayout"; import { useSettingsScope } from "./SettingsScopeContext"; @@ -28,6 +30,7 @@ import { skillsToCheckWithGit, unreadableNote, withGitNote, + type ProjectOption, type Skill, type SkillPlan, type SkillsContext, @@ -145,6 +148,7 @@ function EnvironmentSkills({ const canDelete = useAtomValue( serverEnvironment.deleteSkills.permissionAtom(environment.environmentId), ); + const allProjects = useProjects(); const connected = environment.connection.phase === "connected"; const providers = environment.serverConfig?.providers ?? NO_PROVIDERS; const cwd = project?.cwd ?? null; @@ -232,6 +236,20 @@ function EnvironmentSkills({ [data, providers], ); const ctx = useMemo(() => ({ installed }), [installed]); + // The projects "Use in…" can name: the ones registered in this environment. + const places = useMemo(() => { + const projects = allProjects + .filter((entry) => entry.environmentId === environment.environmentId) + .map((entry): ProjectOption => ({ cwd: entry.workspaceRoot, label: entry.title })) + .toSorted((a, b) => a.label.localeCompare(b.label)); + const picked = project + ? (projects.find((entry) => entry.cwd === project.cwd) ?? { + cwd: project.cwd, + label: project.label, + }) + : null; + return { picked, projects }; + }, [allProjects, environment.environmentId, project]); const loading = connected && skills === null && loadError === null; const showSkeleton = useAfterDelay(loading, SKELETON_DELAY_MS); @@ -249,10 +267,16 @@ function EnvironmentSkills({ [skills, ctx], ); const needle = query.trim().toLowerCase(); - const visible = (list: readonly Skill[]) => - list.filter( - (skill) => (!onlyAttention || attentionIds.has(skill.id)) && matchesQuery(skill, needle), - ); + // Memoized, so a row or group is only drawn again when what it shows changed. + const narrow = useCallback( + (list: readonly Skill[]) => + list.filter( + (skill) => (!onlyAttention || attentionIds.has(skill.id)) && matchesQuery(skill, needle), + ), + [onlyAttention, attentionIds, needle], + ); + const visibleProject = useMemo(() => narrow(projectSkills), [narrow, projectSkills]); + const visibleGlobal = useMemo(() => narrow(globalSkills), [narrow, globalSkills]); const current = view.kind === "skill" ? skills?.find((skill) => skill.id === view.id) : undefined; // A view whose skill is gone (a refresh dropped it) falls back to the list. @@ -420,6 +444,7 @@ function EnvironmentSkills({ ctx={ctx} environmentId={environment.environmentId} projectRoot={cwd} + places={places} busy={locked} onBack={toList} onPlan={runPlan} @@ -476,8 +501,9 @@ function EnvironmentSkills({ {project && ( {empty &&

    No skills yet.

    } {selecting && chosen.length > 0 && ( - + )} )} From 2e36032db18ede445bfeca741d2ce5323cc8c587 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:20:07 -0400 Subject: [PATCH 028/108] =?UTF-8?q?docs:=20describe=20the=20skill=20switch?= =?UTF-8?q?es,=20groups,=20selecting=20and=20Use=20in=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/user/skills.md | 56 +++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/docs/user/skills.md b/docs/user/skills.md index c4069e7498bb..18e84aa72cd8 100644 --- a/docs/user/skills.md +++ b/docs/user/skills.md @@ -2,8 +2,9 @@ Open **Settings → Skills** on web and desktop to see which skills your agents can use. The page reads the environment and project chosen at the top of Settings, so with a remote environment you -see that machine's skills. You can turn each skill on or off for each agent here. To change what a -skill says, edit it in your editor or ask an agent. +see that machine's skills. You can turn each skill on or off for every agent, or for one agent at a +time, and choose which projects use it. To change what a skill says, open it with **Edit skill**, +edit it in your editor or ask an agent. The agents are your enabled provider instances. Two Claude instances show as two agents, each with its own config folder. @@ -23,19 +24,21 @@ Each instance's config folder follows its settings: a Claude instance's config d agents reads isn't listed. If a folder exists but can't be read, the page says so above the list instead of showing it as empty. -## Turning a skill on or off for an agent +## Turning skills on or off -Open a skill and click an agent under **Used by**. Turning a skill on makes a link in that agent's -own folder that points at the skill's real folder, so the files stay in one place. Turning it off +Every skill has a switch. It turns the skill on for every agent, or off for every agent. The icons +beside it show who has the skill on: a sparkle when every agent does, otherwise the agents that +do. Click a row to open it, switch single agents and use **Use in…** or **Edit skill**. The switch +on **This project**, **Global** or a group turns all of its skills on or off at once, and asks +before turning many off. + +Turning a skill on for an agent that reads a different folder makes a link in that agent's own +folder that points at the skill's real folder, so the files stay in one place. Turning it off removes that link and nothing else. -- An agent that reads the skill's own folder directly has no link to remove. For Claude Code, - Codex, OpenCode and Pi, T3 Code switches the skill off in that agent's own settings instead, and - takes that setting away to turn it back on; the rest of the file stays as it was. A project's - skill is switched in Claude Code's local project settings, and Pi can't switch a project's - skills. Other agents can't be switched: their switch is disabled, and to stop one using the - skill you move the skill out of that folder yourself. When a project or organization setting - decides it, T3 Code leaves it as it is and says so. +- An agent that reads the skill's own folder directly, with no setting T3 Code can change, has its + switch disabled and stays on. To stop it using the skill, move the skill out of that folder + yourself. - Agents that read the same folder share one link, so turning a skill on or off for one can change it for the others. T3 Code says who else is affected. - If something is already in the agent's folder under that name, such as a real folder, a file or @@ -45,18 +48,27 @@ removes that link and nothing else. the skill. On Windows, global links are junctions, and project links need Developer Mode or administrator rights. -Tick the boxes beside skills to act on several at once: turn them on for all agents, or turn them off -for one agent. +Skills the installer recorded as coming from the same place, such as a GitHub repo, sit together +under **From owner/repo** when there are two or more. A search lists skills without groups. + +## Acting on several skills + +**Select** turns on checkboxes. A group's box ticks all of its skills. A bar at the bottom turns the +ticked skills on or off for every agent, deletes them, or puts them in a project with **Use in…**. +**Done** goes back to the switches. + +## Using a skill in projects -## Moving and deleting +**Use in…** chooses where a skill is used: **This project only**, **Globally**, or **Only these +projects**, which lists the projects of this environment. A skill used in only some projects is +still Global, with one copy, so an edit shows up in all of them. It has a badge such as **2 +projects**. Each change asks first, and never merges into or replaces a skill with the same name; +T3 Code leaves both and says so. When git tracks a project skill that leaves its project, the +confirmation says you can undo it with git. -**Move to Global** and **Move to this project** move a skill's folder between the project's -`.agents/skills` and `~/.agents/skills`, and the agents that used it keep using it. A skill is never -merged into or replaced by one with the same name on the other side; T3 Code leaves both and says -so. **Delete** removes the skill's folder and the links to it, and can't be undone. When git tracks -a project skill, both show up in `git status` and the confirmation says you can undo them with git. -Only a skill kept in an agent's own skill folder can be moved or deleted. One that is only linked -there, such as a skill from a synced folder, stays where it is. +**Delete** removes the skill's folder and the links to it, and can't be undone. Only a skill kept +in an agent's own skill folder can be deleted. One that is only linked there, such as a skill from +a synced folder, stays where it is. Agents running in T3 Code can list skills and turn them on or off for agents too; they can't move or delete them. From 92d616c8f17989680c73ab75cf69ffe48e5d26b6 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:21:13 -0400 Subject: [PATCH 029/108] fix(server): switch the agents of a skill used in only some projects through its project links A Global skill used in only some projects lives in the library and is linked into each of those projects. Its agents are now the skill's own, whichever project is open: an agent that reads `.agents/skills` has it in every project that uses it, and one with a folder of its own (Claude, Grok) has it where it has a link. - Turning such an agent on adds its link, and the exclude lines that keep it out of git, in each project that uses the skill. It no longer links into the global folders, which made the skill Global for that agent everywhere. Turning it off takes those links away again. - An agent that reads the shared folder is switched through its own setting: Codex keys it by the library's SKILL.md, OpenCode by name. Pi's list only covers the user-level folders, so Pi stays fixed for these skills. - The row's turn-on and turn-off for every agent work the same way. - A library skill's links in the projects' folders are read once per project folder, however many library skills there are, and the same read gives the projects badge. - When a library skill's links go (placing it out of a project, deleting it), the same links in that project's git worktrees go with them. Only a link that leads to that library skill is removed. - The manager's test harnesses now provide what the merged services need. Co-Authored-By: Claude Sonnet 5.5 --- .../src/mcp/toolkits/skills/handlers.test.ts | 3 + apps/server/src/skills/AgentSkillSettings.ts | 42 +- apps/server/src/skills/SkillCatalog.ts | 135 +++-- apps/server/src/skills/SkillGitExclude.ts | 29 +- apps/server/src/skills/SkillLibrary.test.ts | 31 +- apps/server/src/skills/SkillLibrary.ts | 98 ++-- apps/server/src/skills/SkillManager.ts | 126 ++++- apps/server/src/skills/SkillPlacement.test.ts | 460 +++++++++++++++++- apps/server/src/skills/SkillPlacement.ts | 234 +++++++-- apps/server/src/skills/SkillSwitches.test.ts | 55 +-- apps/server/src/skills/testing/CodexDouble.ts | 54 ++ .../src/server/AgentSkillFolders.ts | 10 + 12 files changed, 1054 insertions(+), 223 deletions(-) create mode 100644 apps/server/src/skills/testing/CodexDouble.ts diff --git a/apps/server/src/mcp/toolkits/skills/handlers.test.ts b/apps/server/src/mcp/toolkits/skills/handlers.test.ts index 3a0480de3ca4..1ef2f1eb86f9 100644 --- a/apps/server/src/mcp/toolkits/skills/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/skills/handlers.test.ts @@ -29,6 +29,7 @@ import * as ProjectService from "../../../project/ProjectService.ts"; import * as ProviderInstanceRegistry from "../../../provider/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as Settings from "../../../serverSettings.ts"; +import * as VcsProcess from "../../../vcs/VcsProcess.ts"; import * as SkillCatalog from "../../../skills/SkillCatalog.ts"; import * as SkillManager from "../../../skills/SkillManager.ts"; import * as McpHttpServer from "../../McpHttpServer.ts"; @@ -201,6 +202,8 @@ const layerFor = ( } as OrchestrationV2ThreadShell), }), ), + // The manager keeps the links it makes out of git, so it runs git. + Layer.provide(VcsProcess.layer), Layer.provide(Layer.succeed(HostProcess.Environment, { HOME: home })), Layer.provide(Layer.succeed(HostProcess.HomeDirectory, home)), ); diff --git a/apps/server/src/skills/AgentSkillSettings.ts b/apps/server/src/skills/AgentSkillSettings.ts index 472aa3448708..a3b46b75e7e5 100644 --- a/apps/server/src/skills/AgentSkillSettings.ts +++ b/apps/server/src/skills/AgentSkillSettings.ts @@ -25,7 +25,8 @@ * https://github.com/earendil-works/pi/blob/43d3763991/packages/coding-agent/src/core/package-manager.ts, * which applies the user's array to the skills found in `~/.agents/skills`. Only for Global * skills: a project's skills are filtered by the project's own `.pi/settings.json`, which is - * usually committed, so a project skill is `fixed`. Not run against Pi (not installed here). + * usually committed, so a project skill is `fixed`, and so is a Global skill used in only some + * projects, which Pi finds in the projects' folders. Not run against Pi (not installed here). * - Cursor: `fixed`. Its skills page documents no setting to switch one skill off, only the * `disable-model-invocation` field in the skill's own file. https://cursor.com/docs/context/skills * - Grok: `fixed`. The docs list `[skills] paths` for extra folders and a TUI `/skills` modal, @@ -49,20 +50,41 @@ import { piSwitches, setPiSwitch } from "./PiSkillSettings.ts"; type SwitchKind = "claude" | "codex" | "opencode" | "pi"; -/** The adapters that have a per-skill setting, and for which skills. */ +/** + * The adapters that have a per-skill setting, and for which skills. `inProjects`: the setting also + * reaches a Global skill the agent finds only through links in projects' folders (a skill used in + * some projects, see `SkillLibrary`). Claude and OpenCode name the skill and Codex records its real + * folder, so wherever the agent finds it they apply; Pi applies its list to the user-level folders + * only. + */ const SWITCHES: Readonly< - Record + Record< + string, + { + readonly kind: SwitchKind; + readonly scopes: readonly SkillScope[]; + readonly inProjects: boolean; + } + > > = { - claudeAgent: { kind: "claude", scopes: ["global", "project"] }, - codex: { kind: "codex", scopes: ["global", "project"] }, - opencode: { kind: "opencode", scopes: ["global", "project"] }, - pi: { kind: "pi", scopes: ["global"] }, + claudeAgent: { kind: "claude", scopes: ["global", "project"], inProjects: true }, + codex: { kind: "codex", scopes: ["global", "project"], inProjects: true }, + opencode: { kind: "opencode", scopes: ["global", "project"], inProjects: true }, + pi: { kind: "pi", scopes: ["global"], inProjects: false }, }; -/** Which settings switch a skill of this scope for the agent, if T3 Code knows any. */ -export const skillSwitchKind = (driver: ProviderDriverKind, scope: SkillScope) => { +/** + * Which settings switch a skill of this scope for the agent, if T3 Code knows any. `reach` is + * `projects` for a Global skill that the agent finds only through links in projects' folders. + */ +export const skillSwitchKind = ( + driver: ProviderDriverKind, + scope: SkillScope, + reach: "folder" | "projects" = "folder", +) => { const entry = SWITCHES[driver]; - return entry?.scopes.includes(scope) ? entry.kind : undefined; + if (entry === undefined || !entry.scopes.includes(scope)) return undefined; + return reach === "projects" && !entry.inProjects ? undefined : entry.kind; }; /** What T3 Code needs to know about an agent instance to read and write its settings. */ diff --git a/apps/server/src/skills/SkillCatalog.ts b/apps/server/src/skills/SkillCatalog.ts index 64d2fd5c00ef..592fe14a56e3 100644 --- a/apps/server/src/skills/SkillCatalog.ts +++ b/apps/server/src/skills/SkillCatalog.ts @@ -47,7 +47,9 @@ import * as Stream from "effect/Stream"; import { AGENT_SKILL_FOLDERS, STANDARD_SKILL_FOLDER, + ownProjectFolderFor, skillCollisionFor, + skillFoldersFor, skillRootsFor, type AgentSkillFolderList, type SkillCollision, @@ -69,7 +71,13 @@ import { type SkillSwitchView, type SwitchedSkill, } from "./AgentSkillSettings.ts"; -import { LIBRARY_FOLDER, RegisteredProjects, linkLeadsTo, projectsUsing } from "./SkillLibrary.ts"; +import { + LIBRARY_FOLDER, + RegisteredProjects, + libraryLinksIn, + linkLeadsTo, + type LibraryLink, +} from "./SkillLibrary.ts"; import { readSources } from "./SkillLockFiles.ts"; const SKILL_FILE = "SKILL.md"; @@ -178,13 +186,15 @@ export interface ResolvedSkill { /** The shared folder of each scope, where a moved skill lands; a project's needs `cwd`. */ readonly standardFolders: Readonly>; /** - * Set when the skill is kept in the library (`SkillLibrary`): its entry there, and what that - * links to when it is a link to a synced folder. The projects that link to it are not looked - * for here; `SkillLibrary.libraryLinksOf` finds them in the registered projects. + * Set when the skill is kept in the library (`SkillLibrary`): its entry there, what that links + * to when it is a link to a synced folder, and the links to it in the registered projects' + * folders. Such a skill reaches an agent through those links, whichever project the list is for, + * so its `agents` say what the links give each agent across all of them. */ readonly library?: { readonly entry: string; readonly target: string | undefined; + readonly links: ReadonlyArray; }; /** Every entry in the agents' folders that reaches the skill: a real folder, or a link. */ readonly entries: ReadonlyArray<{ @@ -697,8 +707,65 @@ const make = Effect.gen(function* () { return entry && owner && !skipped ? { entry, owner } : undefined; }; + // The registered projects' links to each library skill, read only when there are any. + const libraryEntries = new Map( + groups.flatMap((group) => { + const entry = group.entries.find((item) => item.root.library === true); + return entry === undefined + ? [] + : [[group.name, path.join(entry.root.directory, entry.name)] as const]; + }), + ); + const libraryLinks = + libraryEntries.size === 0 + ? new Map() + : yield* libraryLinksIn({ + roots: yield* registeredProjects, + entries: libraryEntries, + }).pipe(Effect.provideContext(filesystemContext)); + const isLibrary = (group: SkillGroup) => + group.entries.some((entry) => entry.root.library === true); + + /** + * How an instance reaches a library skill: through its links in the projects' folders, across + * all of them, whichever project the list is for. An agent that reads the shared folder has + * the link every project using the skill has; one that doesn't needs a link in its own folder. + */ + const libraryAccessFor = (group: SkillGroup, instance: AgentInstance) => { + const folders = skillFoldersFor(instance.driver, "project"); + const seen = (libraryLinks.get(group.name) ?? []).filter((link) => + folders.includes(link.folder), + ); + const settings = + skillSwitchKind(instance.driver, group.scope, "projects") === undefined + ? undefined + : instance.switches; + const switchedOff = + settings !== undefined && + views.get(instance.instanceId)?.off(switchedSkillOf(group)) === true; + const shared = seen.some((link) => link.folder === STANDARD_SKILL_FOLDER); + const folder = + (shared ? STANDARD_SKILL_FOLDER : seen[0]?.folder) ?? + ownProjectFolderFor(instance.driver) ?? + STANDARD_SKILL_FOLDER; + const fixed = seen.length > 0 && shared && !switchedOff && settings === undefined; + return { + loadedEntries: [] as FolderEntry[], + switchedOff, + settings, + access: { + instanceId: instance.instanceId, + driver: instance.driver, + state: seen.length === 0 ? "none" : switchedOff ? "off" : shared ? "direct" : "link", + folder, + ...(fixed ? { fixed } : {}), + } satisfies SkillAgentAccess, + }; + }; + /** How one instance reaches a skill: through the folders it loads it from, else `none`. */ const accessFor = (group: SkillGroup, instance: AgentInstance) => { + if (isLibrary(group)) return libraryAccessFor(group, instance); const found = instance.reads.flatMap((root) => { const loadable = loadableAt(group, instance, root); return loadable ? [loadable] : []; @@ -751,12 +818,23 @@ const make = Effect.gen(function* () { }; }; - return { displayRoots, instances, scanned, groups, accessFor, loadableAt, isOwn, roots }; + return { + displayRoots, + instances, + scanned, + groups, + accessFor, + loadableAt, + isOwn, + roots, + libraryLinks, + }; }); const list: SkillCatalog["Service"]["list"] = Effect.fn("SkillCatalog.list")(function* (input) { const cwd = yield* requireProject(input.cwd); - const { displayRoots, instances, scanned, groups, accessFor, isOwn } = yield* scanSkills(cwd); + const { displayRoots, instances, scanned, groups, accessFor, isOwn, libraryLinks } = + yield* scanSkills(cwd); const copies = yield* compareCopies(groups, displayRoots); const sources = yield* readSources({ environment, @@ -764,27 +842,18 @@ const make = Effect.gen(function* () { projectRoot: cwd, }).pipe(Effect.provideContext(filesystemContext)); - // The registered projects that link to each library skill, read only when there are any. - const libraryEntries = new Map( - groups.flatMap((group) => { - const entry = group.entries.find((item) => item.root.library === true); - return entry === undefined - ? [] - : [[group.name, path.join(entry.root.directory, entry.name)] as const]; - }), - ); - const usedIn = - libraryEntries.size === 0 - ? new Map() - : yield* projectsUsing({ roots: yield* registeredProjects, entries: libraryEntries }).pipe( - Effect.provideContext(filesystemContext), - ); - const skills = groups.map((group): SkillSummary => { const source = (group.scope === "project" ? sources.project : sources.global).get(group.name); + // The projects a library skill is used in: where the shared folder has its link. const using = group.entries.some((item) => item.root.library === true) - ? usedIn.get(group.name) - : undefined; + ? [ + ...new Set( + (libraryLinks.get(group.name) ?? []) + .filter((link) => link.folder === STANDARD_SKILL_FOLDER) + .map((link) => link.project), + ), + ] + : []; return { name: group.name, scope: group.scope, @@ -795,7 +864,7 @@ const make = Effect.gen(function* () { copies: copies.get(group) ?? [], access: instances.map((instance) => accessFor(group, instance).access), ...(source === undefined ? {} : { source }), - ...(using === undefined || using.length === 0 ? {} : { projects: using }), + ...(using.length === 0 ? {} : { projects: using }), }; }); @@ -815,12 +884,18 @@ const make = Effect.gen(function* () { }; }); - /** Where a group is kept in the library, when it is. */ - const libraryOf = (group: SkillGroup) => { + /** Where a group is kept in the library, when it is, and the projects' links to it. */ + const libraryOf = (group: SkillGroup, links: ReadonlyMap) => { const entry = group.entries.find((item) => item.root.library === true); return entry === undefined ? {} - : { library: { entry: path.join(entry.root.directory, entry.name), target: entry.target } }; + : { + library: { + entry: path.join(entry.root.directory, entry.name), + target: entry.target, + links: links.get(group.name) ?? [], + }, + }; }; const resolve: SkillCatalog["Service"]["resolve"] = Effect.fn("SkillCatalog.resolve")( @@ -830,7 +905,7 @@ const make = Effect.gen(function* () { (skill) => isSkillFolderName(skill.name) && (skill.scope === "global" || cwd !== undefined), ); if (wanted.length === 0) return []; - const { displayRoots, instances, groups, accessFor, loadableAt, isOwn, roots } = + const { displayRoots, instances, groups, accessFor, loadableAt, isOwn, roots, libraryLinks } = yield* scanSkills(cwd, new Set(wanted.map((skill) => skill.name))); const standardFolders = { project: roots.find((root) => root.scope === "project" && root.standard)?.directory, @@ -847,7 +922,7 @@ const make = Effect.gen(function* () { home: group.home, own: isOwn(group), standardFolders, - ...libraryOf(group), + ...libraryOf(group, libraryLinks), entries: group.entries.map((entry) => ({ path: path.join(entry.root.directory, entry.name), directory: entry.root.directory, diff --git a/apps/server/src/skills/SkillGitExclude.ts b/apps/server/src/skills/SkillGitExclude.ts index 6a16ec9f7ba8..140d11a09501 100644 --- a/apps/server/src/skills/SkillGitExclude.ts +++ b/apps/server/src/skills/SkillGitExclude.ts @@ -5,7 +5,8 @@ * repository's `info/exclude` (in the common git dir, so every worktree of the repository shares * it) instead of a `.gitignore` that gets committed. T3 Code owns one marked block there and * leaves every other line alone; the block goes when its last line does. A project that isn't in a - * git repository has no exclude file, so its links need nothing. + * git repository has no exclude file, so its links need nothing. The same repository's other + * worktrees (`worktreesOf`) hold the links the worktree hook made in them. * * @module SkillGitExclude */ @@ -93,3 +94,29 @@ export const updateExclude = Effect.fn("SkillGitExclude.updateExclude")(function if (next === text || (text === "" && next === "")) return; yield* writeFileStringAtomically({ filePath: file, contents: next }); }); + +/** + * The checkouts of the repository a project is in, the project's own among them: the paths + * `git worktree list --porcelain` names. Empty outside a git repository. + */ +export const worktreesOf = Effect.fn("SkillGitExclude.worktreesOf")(function* ( + projectRoot: string, +) { + const vcs = yield* VcsProcess.VcsProcess; + const result = yield* vcs + .run({ + operation: "SkillGitExclude.worktreesOf", + command: "git", + args: ["worktree", "list", "--porcelain"], + cwd: projectRoot, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 256 * 1024, + }) + .pipe(Effect.orElseSucceed(() => undefined)); + if (result === undefined || result.exitCode !== 0) return []; + return result.stdout + .split("\n") + .filter((line) => line.startsWith("worktree ") && line.length > "worktree ".length) + .map((line) => line.slice("worktree ".length)); +}); diff --git a/apps/server/src/skills/SkillLibrary.test.ts b/apps/server/src/skills/SkillLibrary.test.ts index fdca062c7791..32d4ee3c6574 100644 --- a/apps/server/src/skills/SkillLibrary.test.ts +++ b/apps/server/src/skills/SkillLibrary.test.ts @@ -115,6 +115,11 @@ const onMachine = ( const rowOf = (skills: readonly SkillSummary[], scope: SkillSummary["scope"], name: string) => skills.find((skill) => skill.scope === scope && skill.name === name); +const statesOf = (row: SkillSummary | undefined) => + row === undefined + ? undefined + : Object.fromEntries(row.access.map((access) => [access.instanceId, access.state])); + it.layer(NodeServices.layer, { excludeTestServices: true })("SkillLibrary", (it) => { describe("the list", () => { it.effect.skipIf(!symlinksSupported)( @@ -140,11 +145,14 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillLibrary", (it) home: "~/Knowledge/skills/alpha", }); expect(rowOf(result.skills, "global", "alpha")?.realFolder).toBeUndefined(); - // No agent reads the library. - for (const row of result.skills) { - expect(new Set(row.access.map((access) => access.state))).toEqual( - new Set(["none"]), - ); + // No agent reads the library; each has what the projects' links give it, across all + // of them. Codex reads the shared folder the links are in. Claude reads its own, + // where nothing is linked yet. + for (const name of ["db-migrations", "alpha"]) { + expect(statesOf(rowOf(result.skills, "global", name))).toEqual({ + claudeAgent: "none", + codex: "direct", + }); } }), ); @@ -173,16 +181,13 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillLibrary", (it) // Its own skill, linked the same way, is still its own. expect(rowOf(inWeb, "project", "solo")).toBeDefined(); - // A project that doesn't link to it sees the same Global skill, used nowhere here. + // A project that doesn't link to it sees the same Global skill, with the same + // agents: they are the skill's, not the project's. const inMarketing = (yield* catalog.list({ cwd: marketing })).skills; expect(rowOf(inMarketing, "global", "db-migrations")?.projects).toEqual([web, api]); - expect( - new Set( - rowOf(inMarketing, "global", "db-migrations")?.access.map( - (access) => access.state, - ), - ), - ).toEqual(new Set(["none"])); + expect(statesOf(rowOf(inMarketing, "global", "db-migrations"))).toEqual( + statesOf(rowOf(inWeb, "global", "db-migrations")), + ); }), ); }), diff --git a/apps/server/src/skills/SkillLibrary.ts b/apps/server/src/skills/SkillLibrary.ts index 937b015a3310..b0c39be165b3 100644 --- a/apps/server/src/skills/SkillLibrary.ts +++ b/apps/server/src/skills/SkillLibrary.ts @@ -63,83 +63,73 @@ export const linkLeadsTo = ( entry: string, ) => path.resolve(path.dirname(link.path), link.target) === entry; +/** A link in one project's skill folder that leads to a library entry. */ +export interface LibraryLink { + readonly project: string; + readonly path: string; + /** What the link points at, as written. */ + readonly target: string; + /** The folder it is in, relative to the project. */ + readonly folder: string; +} + /** - * The projects, among `roots`, whose shared skill folder has a link to each library entry. One - * folder is read per project, and only when there are entries. `entries` maps a skill's name to - * its library entry's path. + * Every link, in any agent's project folder in these projects, that leads to a library entry, + * for each skill in `entries` (its name, then its library entry's path). A folder is read once + * per project whatever the number of skills, and only a name that is a library skill is looked at + * further. The links of a skill come in the order of the projects, then of the folders. */ -export const projectsUsing = Effect.fn("SkillLibrary.projectsUsing")(function* (input: { +export const libraryLinksIn = Effect.fn("SkillLibrary.libraryLinksIn")(function* (input: { readonly roots: ReadonlyArray; readonly entries: ReadonlyMap; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const used = new Map(); - if (input.entries.size === 0) return used; - const found = yield* Effect.forEach( + const found = new Map(); + if (input.entries.size === 0) return found; + const perProject = yield* Effect.forEach( input.roots, - (root) => + (project) => Effect.gen(function* () { - const folder = path.join(root, STANDARD_SKILL_FOLDER); - const names = yield* fileSystem - .readDirectory(folder) - .pipe(Effect.orElseSucceed((): string[] => [])); - const linked: string[] = []; - for (const name of names) { - const entry = input.entries.get(name); - if (entry === undefined) continue; - const linkPath = path.join(folder, name); - const target = yield* fileSystem.readLink(linkPath).pipe( - Effect.map((value): string | undefined => value), - Effect.orElseSucceed(() => undefined), - ); - if (target !== undefined && linkLeadsTo(path, { path: linkPath, target }, entry)) { - linked.push(name); + const links: Array = []; + for (const folder of PROJECT_SKILL_FOLDERS) { + const names = yield* fileSystem + .readDirectory(path.join(project, folder)) + .pipe(Effect.orElseSucceed((): string[] => [])); + for (const name of names) { + const entry = input.entries.get(name); + if (entry === undefined) continue; + const linkPath = path.join(project, folder, name); + const target = yield* fileSystem.readLink(linkPath).pipe( + Effect.map((value): string | undefined => value), + Effect.orElseSucceed(() => undefined), + ); + if (target !== undefined && linkLeadsTo(path, { path: linkPath, target }, entry)) { + links.push([name, { project, path: linkPath, target, folder }]); + } } } - return { root, linked }; + return links; }), { concurrency: 8 }, ); - // In the order the projects were given, whichever was read first. - for (const { root, linked } of found) { - for (const name of linked) used.set(name, [...(used.get(name) ?? []), root]); + for (const links of perProject) { + for (const [name, link] of links) found.set(name, [...(found.get(name) ?? []), link]); } - return used; + return found; }); -/** A link in one project's skill folder that leads to a library entry. */ -export interface LibraryLink { - readonly project: string; - readonly path: string; - /** What the link points at, as written. */ - readonly target: string; - /** The folder it is in, relative to the project. */ - readonly folder: string; -} - /** Every link, in any agent's project folder in these projects, that leads to `entry`. */ export const libraryLinksOf = Effect.fn("SkillLibrary.libraryLinksOf")(function* (input: { readonly roots: ReadonlyArray; readonly name: string; readonly entry: string; }) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const found: LibraryLink[] = []; - for (const project of input.roots) { - for (const folder of PROJECT_SKILL_FOLDERS) { - const linkPath = path.join(project, folder, input.name); - const target = yield* fileSystem.readLink(linkPath).pipe( - Effect.map((value): string | undefined => value), - Effect.orElseSucceed(() => undefined), - ); - if (target !== undefined && linkLeadsTo(path, { path: linkPath, target }, input.entry)) { - found.push({ project, path: linkPath, target, folder }); - } - } - } - return found; + const found = yield* libraryLinksIn({ + roots: input.roots, + entries: new Map([[input.name, input.entry]]), + }); + return found.get(input.name) ?? []; }); /** diff --git a/apps/server/src/skills/SkillManager.ts b/apps/server/src/skills/SkillManager.ts index 3f5d78d52b08..5221e33fc631 100644 --- a/apps/server/src/skills/SkillManager.ts +++ b/apps/server/src/skills/SkillManager.ts @@ -44,6 +44,7 @@ import * as Path from "effect/Path"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import type { SkillSettingsWriter } from "@t3tools/provider-core/server/driver"; +import { ownProjectFolderFor } from "@t3tools/provider-core/server/AgentSkillFolders"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; @@ -58,7 +59,7 @@ import { import * as SkillCatalog from "./SkillCatalog.ts"; import { createLink, removeLink, type RemoveLinkResult } from "./SkillLinks.ts"; import { deleteFolder } from "./SkillMove.ts"; -import { makeSkillPlacement } from "./SkillPlacement.ts"; +import { makeSkillPlacement, projectsOfLibrarySkill, type LibrarySkill } from "./SkillPlacement.ts"; type Blocked = SkillOutcome["blocked"][number]; @@ -163,6 +164,70 @@ const planDisable = ( return { unlinks: [...unlinks.values()], blocked, switchOffs }; }; +/** + * What turning agents on takes for a skill used in only some projects. Every project that uses it + * has a link in the shared folder, which the agents that read that folder already have. An agent + * that doesn't gets a link in its own folder in each of those projects (`links`), and one whose + * own settings switch the skill off gets that taken away (`clears`). + */ +const planEnableLibrary = (skill: LibrarySkill, requested: ReadonlySet) => { + const folders = new Map(); + const blocked: Blocked[] = []; + const clears: ProviderInstanceId[] = []; + const projects = projectsOfLibrarySkill(skill); + for (const agent of skill.agents) { + if (!requested.has(agent.instanceId)) continue; + if (agent.state === "off") { + clears.push(agent.instanceId); + continue; + } + if (agent.state !== "none") continue; + const folder = ownProjectFolderFor(agent.driver); + // With no project using the skill there is nowhere to link it for the agent. + if (folder === undefined || projects.length === 0) { + blocked.push({ instanceId: agent.instanceId, reason: "failed" }); + continue; + } + if (agent.switchedOff) clears.push(agent.instanceId); + folders.set(folder, [...(folders.get(folder) ?? []), agent.instanceId]); + } + return { + links: [...folders].map(([folder, agents]) => ({ folder, agents })), + blocked, + clears, + }; +}; + +/** + * What turning agents off takes for a skill used in only some projects: the links in an agent's + * own folder go (`unlinks`), in every project. An agent that reads the shared folder, which every + * project that uses the skill links into, can't be switched by a link: it has its own setting + * written (`switchOffs`) or stays on. + */ +const planDisableLibrary = (skill: LibrarySkill, requested: ReadonlySet) => { + const folders = new Map(); + const blocked: Blocked[] = []; + const switchOffs: ProviderInstanceId[] = []; + for (const agent of skill.agents) { + if (!requested.has(agent.instanceId) || agent.state === "none" || agent.state === "off") { + continue; + } + const folder = ownProjectFolderFor(agent.driver); + if (folder !== undefined) { + folders.set(folder, [...(folders.get(folder) ?? []), agent.instanceId]); + } else if (agent.settings === undefined) { + blocked.push({ instanceId: agent.instanceId, reason: "alwaysOn" }); + } else { + switchOffs.push(agent.instanceId); + } + } + return { + unlinks: [...folders].map(([folder, agents]) => ({ folder, agents })), + blocked, + switchOffs, + }; +}; + const hasSkill = (state: SkillAgentState) => state === "direct" || state === "link"; /** @@ -387,12 +452,56 @@ const make = Effect.gen(function* () { return { wrote, blocked } satisfies SkillChange; }); + const placement = yield* makeSkillPlacement({ + catalog, + platform, + environment, + home: homeDirectory, + registeredRoots: projects.listShells().pipe( + Effect.map((shells) => shells.map((shell) => shell.workspaceRoot)), + Effect.orElseSucceed((): string[] => []), + ), + enable: (skill, agents, projectRoot) => enableOne(skill, agents, projectRoot), + }); + + /** A skill used in only some projects: links in the projects' folders, and the agents' settings. */ + const enableLibraryAgents = Effect.fnUntraced(function* ( + skill: LibrarySkill, + requested: ReadonlySet, + writers: SettingsWriters, + ) { + const plan = planEnableLibrary(skill, requested); + const linked = yield* placement.addLibraryLinks(skill, plan.links); + const cleared = yield* switchAgents(skill, plan.clears, false, writers); + return { + wrote: linked.wrote || cleared.wrote, + blocked: [...plan.blocked, ...linked.blocked, ...cleared.blocked], + } satisfies SkillChange; + }); + + const disableLibraryAgents = Effect.fnUntraced(function* ( + skill: LibrarySkill, + requested: ReadonlySet, + writers: SettingsWriters, + ) { + const plan = planDisableLibrary(skill, requested); + const unlinked = yield* placement.removeLibraryLinks(skill, plan.unlinks); + const switched = yield* switchAgents(skill, plan.switchOffs, true, writers); + return { + wrote: unlinked.wrote || switched.wrote, + blocked: [...plan.blocked, ...unlinked.blocked, ...switched.blocked], + } satisfies SkillChange; + }); + const enableAgents = Effect.fnUntraced(function* ( skill: SkillCatalog.ResolvedSkill, requested: ReadonlySet, projectRoot: string | undefined, writers: SettingsWriters, ) { + if (skill.library !== undefined) { + return yield* enableLibraryAgents({ ...skill, library: skill.library }, requested, writers); + } const linked = yield* enableOne(skill, requested, projectRoot); const cleared = yield* switchAgents(skill, planEnable(skill, requested).clears, false, writers); return combine(linked, cleared); @@ -403,6 +512,9 @@ const make = Effect.gen(function* () { requested: ReadonlySet, writers: SettingsWriters, ) { + if (skill.library !== undefined) { + return yield* disableLibraryAgents({ ...skill, library: skill.library }, requested, writers); + } const unlinked = yield* disableOne(skill, requested); const switched = yield* switchAgents( skill, @@ -440,18 +552,6 @@ const make = Effect.gen(function* () { ), ); - const placement = yield* makeSkillPlacement({ - catalog, - platform, - environment, - home: homeDirectory, - registeredRoots: projects.listShells().pipe( - Effect.map((shells) => shells.map((shell) => shell.workspaceRoot)), - Effect.orElseSucceed((): string[] => []), - ), - enable: (skill, agents, projectRoot) => enableOne(skill, agents, projectRoot), - }); - const deleteOne = Effect.fnUntraced(function* ( skill: SkillCatalog.ResolvedSkill, all: ReadonlyArray, diff --git a/apps/server/src/skills/SkillPlacement.test.ts b/apps/server/src/skills/SkillPlacement.test.ts index 35d7fab924cc..ab33b473e788 100644 --- a/apps/server/src/skills/SkillPlacement.test.ts +++ b/apps/server/src/skills/SkillPlacement.test.ts @@ -22,16 +22,21 @@ import * as Schema from "effect/Schema"; import * as ProcessRunner from "../processRunner.ts"; import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as Settings from "../serverSettings.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import { EXCLUDE_BLOCK_START } from "./SkillGitExclude.ts"; import * as SkillCatalog from "./SkillCatalog.ts"; -import { RegisteredProjects } from "./SkillLibrary.ts"; +import { RegisteredProjects, restoreLibraryLinks } from "./SkillLibrary.ts"; import * as SkillManager from "./SkillManager.ts"; +import { makeCodexDouble, type CodexDouble } from "./testing/CodexDouble.ts"; const encodeResult = Schema.encodeUnknownEffect(SkillBatchResult); const agent = ProviderInstanceId.make; +const ALL_AGENTS = ["claudeAgent", "codex", "cursor", "grok", "opencode", "antigravity", "pi"].map( + (id) => agent(id), +); const skillFile = (name: string) => `---\nname: ${name}\ndescription: The ${name} skill.\n---\n`; @@ -140,6 +145,7 @@ const withManager = ( readonly catalog: SkillCatalog.SkillCatalog["Service"]; }) => Effect.Effect, environment: NodeJS.ProcessEnv = {}, + codex?: CodexDouble, ) => Effect.gen(function* () { const registry = Layer.mock(ProviderRegistry.ProviderRegistry)({ @@ -152,6 +158,21 @@ const withManager = ( listShells: () => Effect.succeed(registered.map((workspaceRoot) => ({ workspaceRoot }) as never)), }); + // Only Codex has a settings writer, and only when a test gives it a double. + const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({ + getInstance: (instanceId) => + Effect.succeed( + instanceId === "codex" && codex !== undefined + ? ({ + enabled: true, + openSkillSettingsWriter: Effect.sync(() => { + codex.state.opened += 1; + return codex.write; + }), + } as never) + : undefined, + ), + }); const catalog = SkillCatalog.layer.pipe( Layer.provide( Settings.layerTest({ @@ -175,12 +196,19 @@ const withManager = ( Layer.provideMerge(catalog), Layer.provide(projects), Layer.provide(registry), + Layer.provide(instances), Layer.provide(VcsProcess.layer), ), ), ); }).pipe( - Effect.provideService(HostProcess.Environment, { HOME: home, ...environment }), + Effect.provideService(HostProcess.Environment, { + HOME: home, + // Keep the managed folders of the agents that read one off the real machine. + OPENCODE_TEST_MANAGED_CONFIG_DIR: `${home}/no-managed-opencode`, + ...environment, + }), + Effect.provideService(HostProcess.HomeDirectory, home), Effect.provideService(RegisteredProjects, Effect.succeed(registered)), ); @@ -302,19 +330,20 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillPlacement", (i expect(rows[0]?.realFolder).toBe(true); } - // Used in web: the agents that read its folders have it. Not used in marketing. - const inWeb = stateOf( - (yield* catalog.list({ cwd: web })).skills, - "global", - "db-migrations", - ); - expect(inWeb).toMatchObject({ claudeAgent: "link", codex: "direct", pi: "direct" }); - const elsewhere = stateOf( - (yield* catalog.list({ cwd: marketing })).skills, - "global", - "db-migrations", - ); - expect(new Set(Object.values(elsewhere))).toEqual(new Set(["none"])); + // The agents are the skill's, whichever project is open: the ones that read the + // folders it is linked into have it. + for (const cwd of [undefined, web, marketing]) { + const states = stateOf( + (yield* catalog.list(cwd === undefined ? {} : { cwd })).skills, + "global", + "db-migrations", + ); + expect(states).toMatchObject({ + claudeAgent: "link", + codex: "direct", + pi: "direct", + }); + } }), ); }), @@ -366,9 +395,10 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillPlacement", (i }); expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); - // Claude, Cursor and OpenCode read Claude's folder; they lose it everywhere else. + // Claude, Cursor and OpenCode read Claude's folder, and keep it in api. The agents + // that read the shared folder only get it there too. expect(result.outcomes[0]?.affected.toSorted()).toEqual( - [agent("claudeAgent"), agent("cursor"), agent("opencode")].toSorted(), + [agent("antigravity"), agent("codex"), agent("pi")].toSorted(), ); expect(yield* fs.exists(path.join(home, ".claude/skills/solo"))).toBe(false); expect(yield* fs.exists(path.join(library, "solo/SKILL.md"))).toBe(true); @@ -383,8 +413,9 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillPlacement", (i const inApi = stateOf((yield* catalog.list({ cwd: api })).skills, "global", "solo"); expect(inApi).toMatchObject({ claudeAgent: "link", codex: "direct" }); + // The skill's agents are the same whichever project is open. const inWeb = stateOf((yield* catalog.list({ cwd: web })).skills, "global", "solo"); - expect(new Set(Object.values(inWeb))).toEqual(new Set(["none"])); + expect(inWeb).toEqual(inApi); expect( summaryOf((yield* catalog.list({})).skills, "global", "solo")?.projects, ).toEqual([api]); @@ -1247,4 +1278,397 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillPlacement", (i }), ); }); + + describe("the agents of a skill used in only some projects", () => { + /** web's project skill `db-migrations`, made Global and used in web and api; its Global row. */ + const useInWebAndApi = ( + manager: SkillManager.SkillManager["Service"], + catalog: SkillCatalog.SkillCatalog["Service"], + web: string, + api: string, + ) => + Effect.gen(function* () { + const verify = refOf( + (yield* catalog.list({ cwd: web })).skills, + "project", + "db-migrations", + ); + yield* manager.place({ + cwd: web, + skills: [verify], + to: { kind: "projects", cwds: [web, api] }, + }); + return refOf((yield* catalog.list({})).skills, "global", "db-migrations"); + }); + + it.effect.skipIf(!symlinksSupported)( + "turns Claude on in the projects that use the skill, never Global, and off again", + () => + Effect.gen(function* () { + const { fs, path, home, web, api, marketing, library } = yield* makeMachine; + yield* withManager(home, [web, api, marketing], ({ manager, catalog }) => + Effect.gen(function* () { + const skill = yield* useInWebAndApi(manager, catalog, web, api); + expect( + stateOf((yield* catalog.list({})).skills, "global", "db-migrations").claudeAgent, + ).toBe("none"); + + // From a project that doesn't use the skill: the Global row acts on Global. + const on = yield* manager.enable({ + cwd: marketing, + skills: [skill], + agents: [agent("claudeAgent")], + }); + + expect(on.outcomes).toEqual([ + { skill, status: "changed", blocked: [], affected: [] }, + ]); + yield* encodeResult(on); + const entry = path.join(library, "db-migrations"); + for (const project of [web, api]) { + expect(yield* fs.readLink(path.join(project, ".claude/skills/db-migrations"))).toBe( + entry, + ); + expect(blockLines(yield* exclude(project))).toEqual([ + "/.agents/skills/db-migrations", + "/.claude/skills/db-migrations", + ]); + expect(yield* status(project)).toBe(""); + } + expect(yield* fs.exists(path.join(home, ".claude/skills/db-migrations"))).toBe(false); + expect(yield* fs.exists(path.join(marketing, ".claude"))).toBe(false); + // Claude has the skill, whichever project is open, and it is one Global skill. + for (const cwd of [undefined, web, api, marketing]) { + const { skills } = yield* catalog.list(cwd === undefined ? {} : { cwd }); + expect( + skills.filter((item) => item.name === "db-migrations").map((item) => item.scope), + ).toEqual(["global"]); + expect(stateOf(skills, "global", "db-migrations").claudeAgent).toBe("link"); + } + + const off = yield* manager.disable({ + skills: [skill], + agents: [agent("claudeAgent")], + }); + + expect(off.outcomes).toEqual([ + { skill, status: "changed", blocked: [], affected: [] }, + ]); + for (const project of [web, api]) { + expect(yield* fs.exists(path.join(project, ".claude/skills/db-migrations"))).toBe( + false, + ); + expect(blockLines(yield* exclude(project))).toEqual([ + "/.agents/skills/db-migrations", + ]); + // The shared link stays: the agents that read it keep the skill. + expect(yield* fs.readLink(path.join(project, ".agents/skills/db-migrations"))).toBe( + entry, + ); + } + expect( + stateOf((yield* catalog.list({})).skills, "global", "db-migrations"), + ).toMatchObject({ claudeAgent: "none", codex: "direct" }); + yield* encodeResult(off); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "switches every agent from the row: a link where an agent needs one, its setting where it reads the shared folder", + () => + Effect.gen(function* () { + const { fs, path, home, web, api, library } = yield* makeMachine; + const codex = yield* makeCodexDouble(path.join(home, ".codex")); + yield* withManager( + home, + [web, api], + ({ manager, catalog }) => + Effect.gen(function* () { + const skill = yield* useInWebAndApi(manager, catalog, web, api); + const row = Effect.map(catalog.list({}), ({ skills }) => + summaryOf(skills, "global", "db-migrations"), + ); + const states = Effect.map(row, (item) => + Object.fromEntries((item?.access ?? []).map((a) => [a.instanceId, a.state])), + ); + const entry = path.join(library, "db-migrations"); + + expect(yield* states).toEqual({ + claudeAgent: "none", + codex: "direct", + cursor: "direct", + grok: "none", + opencode: "direct", + antigravity: "direct", + pi: "direct", + }); + // Cursor, Antigravity and Pi read the shared folder and have no setting to switch. + expect( + (yield* row)?.access + .filter((item) => item.fixed === true) + .map((item) => item.instanceId) + .toSorted(), + ).toEqual([agent("antigravity"), agent("cursor"), agent("pi")]); + + // Turning on what is off: Claude and Grok read folders of their own. + const on = yield* manager.enable({ + skills: [skill], + agents: [agent("claudeAgent"), agent("grok")], + }); + expect(on.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + for (const project of [web, api]) { + for (const folder of [".claude/skills", ".grok/skills"]) { + expect(yield* fs.readLink(path.join(project, folder, "db-migrations"))).toBe( + entry, + ); + } + expect(yield* status(project)).toBe(""); + } + expect(yield* states).toMatchObject({ claudeAgent: "link", grok: "link" }); + + // Turning every agent off. + const off = yield* manager.disable({ + skills: [skill], + agents: ALL_AGENTS, + }); + yield* encodeResult(off); + expect(off.outcomes[0]?.status).toBe("changed"); + expect( + off.outcomes[0]?.blocked.toSorted((a, b) => + a.instanceId.localeCompare(b.instanceId), + ), + ).toEqual([ + { instanceId: agent("antigravity"), reason: "alwaysOn" }, + { instanceId: agent("cursor"), reason: "alwaysOn" }, + { instanceId: agent("pi"), reason: "alwaysOn" }, + ]); + for (const project of [web, api]) { + expect(yield* fs.exists(path.join(project, ".claude/skills/db-migrations"))).toBe( + false, + ); + expect(yield* fs.exists(path.join(project, ".grok/skills/db-migrations"))).toBe( + false, + ); + } + // Codex records the real SKILL.md, which is the library's; OpenCode names the skill. + expect(codex.calls).toEqual([ + { path: path.join(entry, "SKILL.md"), enabled: false }, + ]); + expect( + JSON.parse( + yield* fs.readFileString(path.join(home, ".config/opencode/opencode.json")), + ), + ).toEqual({ permission: { skill: { "db-migrations": "deny" } } }); + expect(yield* states).toEqual({ + claudeAgent: "none", + codex: "off", + cursor: "direct", + grok: "none", + opencode: "off", + antigravity: "direct", + pi: "direct", + }); + + // And on again. + const back = yield* manager.enable({ + skills: [skill], + agents: [agent("claudeAgent"), agent("grok"), agent("codex"), agent("opencode")], + }); + expect(back.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + expect(yield* states).toMatchObject({ + claudeAgent: "link", + codex: "direct", + grok: "link", + opencode: "direct", + }); + expect(yield* fs.readFileString(codex.file)).toBe(""); + expect( + yield* fs.readFileString(path.join(home, ".config/opencode/opencode.json")), + ).not.toContain("deny"); + }), + {}, + codex, + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "links what it can and says which project had something else in the way", + () => + Effect.gen(function* () { + const { fs, path, home, web, api, write } = yield* makeMachine; + yield* write("repos/acme-api/.claude/skills/db-migrations/SKILL.md", skillFile("mine")); + yield* withManager(home, [web, api], ({ manager, catalog }) => + Effect.gen(function* () { + const skill = yield* useInWebAndApi(manager, catalog, web, api); + + const on = yield* manager.enable({ skills: [skill], agents: [agent("claudeAgent")] }); + + expect(on.outcomes[0]).toMatchObject({ + status: "changed", + blocked: [{ instanceId: agent("claudeAgent"), reason: "entryTaken" }], + }); + expect(yield* fs.readLink(path.join(web, ".claude/skills/db-migrations"))).toBe( + path.join(home, ".agents/skill-library/db-migrations"), + ); + // The other project's own folder is never replaced. + expect( + yield* fs.readFileString(path.join(api, ".claude/skills/db-migrations/SKILL.md")), + ).toBe(skillFile("mine")); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)("has nowhere to link a skill that no project uses", () => + Effect.gen(function* () { + const { fs, path, home, web, write } = yield* makeMachine; + yield* write(".agents/skill-library/lonely/SKILL.md", skillFile("lonely")); + yield* withManager(home, [web], ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + expect(new Set(Object.values(stateOf(skills, "global", "lonely")))).toEqual( + new Set(["none"]), + ); + + const on = yield* manager.enable({ + skills: [refOf(skills, "global", "lonely")], + agents: [agent("claudeAgent")], + }); + + expect(on.outcomes[0]).toMatchObject({ + status: "skipped", + blocked: [{ instanceId: agent("claudeAgent"), reason: "failed" }], + }); + expect(yield* fs.exists(path.join(home, ".claude/skills/lonely"))).toBe(false); + expect(yield* fs.exists(path.join(web, ".claude"))).toBe(false); + }), + ); + }), + ); + }); + + describe("the git worktrees of a project that uses a library skill", () => { + /** A worktree of `project` with the links the worktree hook makes in it. */ + const addWorktree = (project: string, name: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktree = path.join(path.dirname(project), `${path.basename(project)}-${name}`); + yield* git(project, ["worktree", "add", "-q", "-b", name, worktree]); + yield* restoreLibraryLinks({ project, worktree }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + return worktree; + }); + + it.effect.skipIf(!symlinksSupported)( + "lose the links when Claude is turned off and when the project stops using the skill", + () => + Effect.gen(function* () { + const { fs, path, home, web, api, library } = yield* makeMachine; + yield* withManager(home, [web, api], ({ manager, catalog }) => + Effect.gen(function* () { + const verify = refOf( + (yield* catalog.list({ cwd: web })).skills, + "project", + "db-migrations", + ); + yield* manager.place({ + cwd: web, + skills: [verify], + to: { kind: "projects", cwds: [web, api] }, + }); + const skill = refOf((yield* catalog.list({})).skills, "global", "db-migrations"); + yield* manager.enable({ skills: [skill], agents: [agent("claudeAgent")] }); + const entry = path.join(library, "db-migrations"); + const worktree = yield* addWorktree(web, "feature"); + const other = yield* addWorktree(api, "feature"); + // Something of the worktree's own is never touched. + yield* fs.makeDirectory(path.join(worktree, ".claude/skills/own"), { + recursive: true, + }); + yield* fs.symlink( + path.join(home, "somewhere-else"), + path.join(worktree, ".agents/skills/different"), + ); + expect(yield* fs.readLink(path.join(worktree, ".claude/skills/db-migrations"))).toBe( + entry, + ); + + // Turning Claude off takes its link out of the worktrees as well. + yield* manager.disable({ skills: [skill], agents: [agent("claudeAgent")] }); + expect(yield* fs.exists(path.join(web, ".claude/skills/db-migrations"))).toBe(false); + expect(yield* fs.exists(path.join(worktree, ".claude/skills/db-migrations"))).toBe( + false, + ); + expect(yield* fs.exists(path.join(other, ".claude/skills/db-migrations"))).toBe( + false, + ); + expect(yield* fs.readLink(path.join(worktree, ".agents/skills/db-migrations"))).toBe( + entry, + ); + + // A project that stops using the skill: its worktrees' links go, api's stay. + const moved = yield* manager.place({ + skills: [skill], + to: { kind: "projects", cwds: [api] }, + }); + expect(moved.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + expect(yield* fs.exists(path.join(web, ".agents/skills/db-migrations"))).toBe(false); + expect(yield* fs.exists(path.join(worktree, ".agents/skills/db-migrations"))).toBe( + false, + ); + expect(yield* fs.readLink(path.join(other, ".agents/skills/db-migrations"))).toBe( + entry, + ); + // What the worktree had of its own stays. + expect(yield* fs.exists(path.join(worktree, ".claude/skills/own"))).toBe(true); + expect(yield* fs.readLink(path.join(worktree, ".agents/skills/different"))).toBe( + path.join(home, "somewhere-else"), + ); + // Only the worktree's own link shows in git; the exclude lines covered the rest. + expect(yield* status(worktree)).toBe("?? .agents/skills/different\n"); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "lose the links when the skill is deleted, and the project's own checkout isn't a worktree", + () => + Effect.gen(function* () { + const { fs, path, home, web, api } = yield* makeMachine; + yield* withManager(home, [web, api], ({ manager, catalog }) => + Effect.gen(function* () { + const verify = refOf( + (yield* catalog.list({ cwd: web })).skills, + "project", + "db-migrations", + ); + yield* manager.place({ + cwd: web, + skills: [verify], + to: { kind: "projects", cwds: [web, api] }, + }); + const worktree = yield* addWorktree(web, "feature"); + const skill = refOf((yield* catalog.list({})).skills, "global", "db-migrations"); + + const result = yield* manager.delete({ skills: [skill] }); + + expect(result.outcomes[0]).toMatchObject({ status: "changed" }); + for (const root of [web, api, worktree]) { + expect(yield* fs.exists(path.join(root, ".agents/skills/db-migrations"))).toBe( + false, + ); + } + expect(blockLines(yield* exclude(web))).toEqual([]); + }), + ); + }), + ); + }); }); diff --git a/apps/server/src/skills/SkillPlacement.ts b/apps/server/src/skills/SkillPlacement.ts index 95ee32dea4a7..381e21613de1 100644 --- a/apps/server/src/skills/SkillPlacement.ts +++ b/apps/server/src/skills/SkillPlacement.ts @@ -18,6 +18,10 @@ * with the skill. The skill's source record in the `skills` CLI's lock (`SkillLockFiles`) moves * with it between a project and Global. * + * The agents of a skill used in only some projects are switched through its project links: a link + * in each project's folder for an agent that doesn't read the shared one (`addLibraryLinks`, + * `removeLibraryLinks`). A link that goes is taken out of the project's git worktrees too. + * * @module SkillPlacement */ import { @@ -36,13 +40,14 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { STANDARD_SKILL_FOLDER, + ownProjectFolderFor, skillFoldersFor, } from "@t3tools/provider-core/server/AgentSkillFolders"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import type * as SkillCatalog from "./SkillCatalog.ts"; -import { updateExclude } from "./SkillGitExclude.ts"; -import { LIBRARY_FOLDER, libraryLinksOf, type LibraryLink } from "./SkillLibrary.ts"; +import { updateExclude, worktreesOf } from "./SkillGitExclude.ts"; +import { LIBRARY_FOLDER, libraryLinksOf, linkLeadsTo, type LibraryLink } from "./SkillLibrary.ts"; import { createLink, removeLink, type RemoveLinkResult } from "./SkillLinks.ts"; import { moveRecord, type LockScope } from "./SkillLockFiles.ts"; import { moveFolder } from "./SkillMove.ts"; @@ -89,6 +94,20 @@ export interface PlacementView { readonly all: ReadonlyArray; } +/** A skill kept in the library, whose registered projects' links the catalog found. */ +export type LibrarySkill = SkillCatalog.ResolvedSkill & { + readonly library: NonNullable; +}; + +/** The projects a library skill is used in: where the shared folder has its link. */ +export const projectsOfLibrarySkill = (skill: LibrarySkill) => [ + ...new Set( + skill.library.links + .filter((link) => link.folder === STANDARD_SKILL_FOLDER) + .map((link) => link.project), + ), +]; + export interface PlacementDeps { readonly catalog: SkillCatalog.SkillCatalog["Service"]; readonly platform: NodeJS.Platform; @@ -166,12 +185,6 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe ), ].map(([linkPath, target]) => ({ path: linkPath, target })); - /** The folder, besides the shared one, an agent needs a link in to use a skill in a project. */ - const ownProjectFolder = (driver: SkillCatalog.ResolvedSkill["agents"][number]["driver"]) => { - const folders = skillFoldersFor(driver, "project"); - return folders.includes(STANDARD_SKILL_FOLDER) ? undefined : folders[0]; - }; - /** Removes links that are still what was inspected, and remembers how to put each back. */ const unlink = Effect.fnUntraced(function* ( journal: Journal, @@ -258,25 +271,68 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe return blocked; }); - /** Removes a library skill's links from these projects, and their lines from the exclude files. */ + /** + * Removes the library skill's links that the project's other git worktrees got when they were + * made, which only the links that lead to `entry` count as. A link that is a folder there, or + * leads somewhere else, stays. + */ + const unlinkInWorktrees = Effect.fnUntraced(function* ( + journal: Journal, + links: ReadonlyArray, + entry: string, + ) { + for (const project of new Set(links.map((link) => link.project))) { + const own = yield* realPath(project); + const worktrees = yield* inContext(worktreesOf(project)); + for (const worktree of worktrees) { + // The checkout the project is in (or is inside) keeps what it has. + const real = yield* realPath(worktree); + if (own === real || own.startsWith(`${real}${path.sep}`)) continue; + const found: Array<{ path: string; target: string }> = []; + for (const link of links.filter((item) => item.project === project)) { + const created = path.join(worktree, link.folder, path.basename(link.path)); + const target = yield* fileSystem.readLink(created).pipe( + Effect.map((value): string | undefined => value), + Effect.orElseSucceed(() => undefined), + ); + if (target !== undefined && linkLeadsTo(path, { path: created, target }, entry)) { + found.push({ path: created, target }); + } + } + yield* unlink(journal, found); + } + } + }); + + /** + * Removes a library skill's links from these projects (and from their git worktrees), and their + * lines from the exclude files. What happened to each link is told; a link that is gone counts + * as removed. + */ const unlinkProjects = Effect.fnUntraced(function* ( journal: Journal, links: ReadonlyArray, + entry: string, ) { const results = yield* unlink(journal, links); const gone = links.filter((link) => { const result = results.get(link.path); return result === "removed" || result === "gone"; }); + yield* unlinkInWorktrees(journal, gone, entry); for (const project of new Set(gone.map((link) => link.project))) { const paths = gone.filter((link) => link.project === project).map((link) => link.path); yield* inContext(updateExclude({ projectRoot: project, links: paths, action: "remove" })); journal.add(inContext(updateExclude({ projectRoot: project, links: paths, action: "add" }))); } - return links.some( + return results; + }); + + /** Whether any of the links was left in place or couldn't be removed. */ + const anyStuck = (links: ReadonlyArray, results: ReadonlyMap) => + links.some( (link) => results.get(link.path) === "changed" || results.get(link.path) === "failed", ); - }); /** The skill's source record goes along; a failure is logged and never undoes the placement. */ const moveSourceRecord = (input: { @@ -410,7 +466,14 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe lacking, dest.scope === "project" ? yield* realPath(dest.cwd) : undefined, ); - return yield* settle({ view, skill, had, home: real, blocked: relinked.blocked, reason }); + return yield* settle({ + view, + skill, + had, + home: real, + blocked: relinked.blocked, + reason, + }); }); /** One skill into the library, linked into `projects`. */ @@ -440,7 +503,7 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe const folders = [ ...new Set( instances.flatMap((agent) => { - const own = had.has(agent.instanceId) ? ownProjectFolder(agent.driver) : undefined; + const own = had.has(agent.instanceId) ? ownProjectFolderFor(agent.driver) : undefined; return own === undefined ? [] : [own]; }), ), @@ -478,7 +541,7 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe folders, agentsOf: (folder) => instances - .filter((agent) => ownProjectFolder(agent.driver) === folder) + .filter((agent) => ownProjectFolderFor(agent.driver) === folder) .map((agent) => agent.instanceId), }); if (skill.scope === "project" && view.cwd !== undefined) { @@ -489,15 +552,20 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe folder: home, }); } - return yield* settle({ view, skill, had, home, blocked, reason }); + return yield* settle({ + view, + skill, + had, + home, + blocked, + reason, + }); }); /** A library skill used in a different set of projects: links are added and taken away. */ const retarget = Effect.fnUntraced(function* ( journal: Journal, - skill: SkillCatalog.ResolvedSkill & { - readonly library: NonNullable; - }, + skill: LibrarySkill, projects: ReadonlyArray, view: PlacementView, ) { @@ -531,29 +599,25 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe folders, agentsOf: (folder) => skill.agents - .filter((agent) => ownProjectFolder(agent.driver) === folder) + .filter((agent) => ownProjectFolderFor(agent.driver) === folder) .map((agent) => agent.instanceId), }); - const leftover = yield* unlinkProjects( - journal, - links.filter((link) => remove.includes(link.project)), - ); + const leaving = links.filter((link) => remove.includes(link.project)); + const results = yield* unlinkProjects(journal, leaving, skill.library.entry); return yield* settle({ view, skill, had, home: skill.home, blocked, - reason: leftover ? "changed" : undefined, + reason: anyStuck(leaving, results) ? "changed" : undefined, }); }); /** A library skill into Global or one project: its links go, and the folder takes the place. */ const outOfLibrary = Effect.fnUntraced(function* ( journal: Journal, - skill: SkillCatalog.ResolvedSkill & { - readonly library: NonNullable; - }, + skill: LibrarySkill, dest: { readonly scope: "global" } | { readonly scope: "project"; readonly cwd: string }, view: PlacementView, ) { @@ -605,8 +669,8 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe ]); // The links come out first: one of them may be in the way of the folder. - const stuck = yield* unlinkProjects(journal, links); - let reason: SkillOutcomeReason | undefined = stuck ? "changed" : undefined; + const unlinked = yield* unlinkProjects(journal, links, skill.library.entry); + let reason: SkillOutcomeReason | undefined = anyStuck(links, unlinked) ? "changed" : undefined; if (skill.own) { const moved = yield* inContext( moveFolder({ from: skill.library.entry, to: destination, platform: deps.platform }), @@ -659,7 +723,14 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe lacking, dest.scope === "project" ? yield* realPath(dest.cwd) : undefined, ); - return yield* settle({ view, skill, had, home: real, blocked: relinked.blocked, reason }); + return yield* settle({ + view, + skill, + had, + home: real, + blocked: relinked.blocked, + reason, + }); }); /** @@ -672,7 +743,8 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe view: PlacementView, ): Effect.Effect => { const journal = makeJournal(); - const library = skill.library === undefined ? undefined : { ...skill, library: skill.library }; + const library: LibrarySkill | undefined = + skill.library === undefined ? undefined : { ...skill, library: skill.library }; const attempt = Effect.gen(function* () { if (to.kind === "projects") { const projects = [...new Set(to.cwds)]; @@ -717,8 +789,104 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe entry: skill.library.entry, }), ); - yield* unlinkProjects(makeJournal(), links); + yield* unlinkProjects(makeJournal(), links, skill.library.entry); }).pipe(Effect.ignoreCause); - return { place, unlinkLibrarySkill }; + /** + * Gives agents that don't read the shared folder a link in their own folder, in every project + * that uses the library skill. `plan` says which folder serves which agents. A link that is + * already there is left; something else in the way, or a system that refuses, blocks the agents + * of that folder. + */ + const addLibraryLinks = ( + skill: LibrarySkill, + plan: ReadonlyArray<{ + readonly folder: string; + readonly agents: readonly ProviderInstanceId[]; + }>, + ) => + Effect.gen(function* () { + const blocked: Blocked[] = []; + let wrote = false; + const journal = makeJournal(); + for (const project of projectsOfLibrarySkill(skill)) { + const made: string[] = []; + for (const { folder, agents } of plan) { + const link = path.join(project, folder, skill.name); + const result = yield* linkTo( + journal, + { link, target: skill.library.entry, home: skill.home }, + "project", + ).pipe(Effect.catchTags({ SkillLinkError: () => Effect.succeed("failed" as const) })); + if (result === "created") { + made.push(link); + wrote = true; + } else if (result !== "unchanged") { + const reason: SkillOutcomeReason = + result === "taken" + ? "entryTaken" + : result === "notAllowed" + ? "linkNotAllowed" + : "failed"; + for (const instanceId of agents) blocked.push({ instanceId, reason }); + } + } + // The link works without its exclude line; it only shows up in git status. + yield* inContext(updateExclude({ projectRoot: project, links: made, action: "add" })).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning("could not keep a skill link out of git", { + project, + cause: Cause.pretty(cause), + }), + ), + ); + } + return { wrote, blocked } satisfies { wrote: boolean; blocked: readonly Blocked[] }; + }); + + /** + * Takes those agents' links out of every project that uses the library skill, and out of the + * projects' git worktrees. A link that is no longer the one that was inspected is left, and + * blocks the agents of that folder. + */ + const removeLibraryLinks = ( + skill: LibrarySkill, + plan: ReadonlyArray<{ + readonly folder: string; + readonly agents: readonly ProviderInstanceId[]; + }>, + ) => + Effect.gen(function* () { + const blocked: Blocked[] = []; + let wrote = false; + for (const { folder, agents } of plan) { + const links = skill.library.links.filter((link) => link.folder === folder); + // A step that fails part way leaves unknown links behind: all of them are reported. + const results = yield* unlinkProjects(makeJournal(), links, skill.library.entry).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.as( + Effect.logWarning("could not remove a skill's links", { + name: skill.name, + cause: Cause.pretty(cause), + }), + new Map(links.map((link) => [link.path, "failed" as const])), + ), + ), + ); + for (const link of links) { + const result = results.get(link.path); + if (result === "removed" || result === "failed") wrote = true; + if (result === "changed" || result === "failed") { + for (const instanceId of agents) blocked.push({ instanceId, reason: result }); + } + } + } + return { wrote, blocked } satisfies { wrote: boolean; blocked: readonly Blocked[] }; + }); + + return { place, unlinkLibrarySkill, addLibraryLinks, removeLibraryLinks }; }); diff --git a/apps/server/src/skills/SkillSwitches.test.ts b/apps/server/src/skills/SkillSwitches.test.ts index 2856d850f586..df5b98d0f003 100644 --- a/apps/server/src/skills/SkillSwitches.test.ts +++ b/apps/server/src/skills/SkillSwitches.test.ts @@ -19,18 +19,16 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; -import type { - SkillSettingsChange, - SkillSettingsWriter, -} from "@t3tools/provider-core/server/driver"; +import { parse as parseToml } from "smol-toml"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as Settings from "../serverSettings.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as SkillCatalog from "./SkillCatalog.ts"; import * as SkillManager from "./SkillManager.ts"; +import { makeCodexDouble, type CodexDouble } from "./testing/CodexDouble.ts"; const encodeResult = Schema.encodeUnknownEffect(SkillBatchResult); const encodeList = Schema.encodeUnknownEffect(SkillListResult); @@ -86,52 +84,6 @@ const makeProject = (workspaceRoot: string): Project => ({ deletedAt: null, }); -/** - * Stands in for the `codex app-server` process and nothing else: it edits the real `config.toml` - * the way Codex does (checked against codex 0.160.1): a path is recorded by the real path of its - * SKILL.md, `enabled: true` removes the entry for the selector, and the answer is the selector's - * state. - */ -const makeCodexDouble = (codexHome: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const file = path.join(codexHome, "config.toml"); - const canonical = (value: string) => fs.realPath(value).pipe(Effect.orElseSucceed(() => value)); - const calls: SkillSettingsChange[] = []; - const state = { opened: 0, effective: undefined as boolean | undefined }; - const write: SkillSettingsWriter = (change) => - Effect.gen(function* () { - calls.push(change); - const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => "")); - const document = parseToml(text) as { skills?: { config?: Record[] } }; - const rules = document.skills?.config ?? []; - const selected = "path" in change ? yield* canonical(change.path) : change.name; - const kept: Record[] = []; - for (const rule of rules) { - const named = - "path" in change && typeof rule.path === "string" - ? (yield* canonical(rule.path)) === selected - : "name" in change && rule.name === selected; - if (!named) kept.push(rule); - } - if (!change.enabled) { - kept.push( - "path" in change - ? { path: selected, enabled: false } - : { name: selected, enabled: false }, - ); - } - const next = kept.length > 0 ? { skills: { config: kept } } : {}; - yield* fs.makeDirectory(codexHome, { recursive: true }); - yield* fs.writeFileString(file, kept.length > 0 ? stringifyToml(next) : ""); - return { effectiveEnabled: state.effective ?? change.enabled }; - }).pipe(Effect.orDie); - return { file, calls, state, write }; - }); - -type CodexDouble = Effect.Success>; - /** The manager and catalog on a machine whose home is `home`; only `registered` are projects. */ const withManager = ( home: string, @@ -193,6 +145,7 @@ const withManager = ( Layer.provide(projects), Layer.provide(registry), Layer.provide(instances), + Layer.provide(VcsProcess.layer), ), ), ); diff --git a/apps/server/src/skills/testing/CodexDouble.ts b/apps/server/src/skills/testing/CodexDouble.ts new file mode 100644 index 000000000000..cc2be15044ea --- /dev/null +++ b/apps/server/src/skills/testing/CodexDouble.ts @@ -0,0 +1,54 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; +import type { + SkillSettingsChange, + SkillSettingsWriter, +} from "@t3tools/provider-core/server/driver"; + +/** + * Stands in for the `codex app-server` process and nothing else: it edits the real `config.toml` + * the way Codex does (checked against codex 0.160.1): a path is recorded by the real path of its + * SKILL.md, `enabled: true` removes the entry for the selector, and the answer is the selector's + * state. + */ +export const makeCodexDouble = (codexHome: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const file = path.join(codexHome, "config.toml"); + const canonical = (value: string) => fs.realPath(value).pipe(Effect.orElseSucceed(() => value)); + const calls: SkillSettingsChange[] = []; + const state = { opened: 0, effective: undefined as boolean | undefined }; + const write: SkillSettingsWriter = (change) => + Effect.gen(function* () { + calls.push(change); + const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => "")); + const document = parseToml(text) as { skills?: { config?: Record[] } }; + const rules = document.skills?.config ?? []; + const selected = "path" in change ? yield* canonical(change.path) : change.name; + const kept: Record[] = []; + for (const rule of rules) { + const named = + "path" in change && typeof rule.path === "string" + ? (yield* canonical(rule.path)) === selected + : "name" in change && rule.name === selected; + if (!named) kept.push(rule); + } + if (!change.enabled) { + kept.push( + "path" in change + ? { path: selected, enabled: false } + : { name: selected, enabled: false }, + ); + } + const next = kept.length > 0 ? { skills: { config: kept } } : {}; + yield* fs.makeDirectory(codexHome, { recursive: true }); + yield* fs.writeFileString(file, kept.length > 0 ? stringifyToml(next) : ""); + return { effectiveEnabled: state.effective ?? change.enabled }; + }).pipe(Effect.orDie); + return { file, calls, state, write }; + }); + +export type CodexDouble = Effect.Success>; diff --git a/packages/provider-core/src/server/AgentSkillFolders.ts b/packages/provider-core/src/server/AgentSkillFolders.ts index 0d543e89e8f8..f1b563d1dcdf 100644 --- a/packages/provider-core/src/server/AgentSkillFolders.ts +++ b/packages/provider-core/src/server/AgentSkillFolders.ts @@ -167,3 +167,13 @@ export const skillFoldersFor = (agent: ProviderDriverKind, scope: SkillScope): r skillRootsFor(agent) .filter((root) => root.scope === scope) .map((root) => root.folder); + +/** + * The project folder, besides the shared one, an agent needs a link in to use a skill: its own + * (`.claude/skills` for Claude). Undefined for an agent that reads the shared folder, which a + * skill used in a project is linked into anyway. + */ +export const ownProjectFolderFor = (agent: ProviderDriverKind) => { + const folders = skillFoldersFor(agent, "project"); + return folders.includes(STANDARD_SKILL_FOLDER) ? undefined : folders[0]; +}; From a1671f665e3a7bb5881c564cb9882883790410c4 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:23:59 -0400 Subject: [PATCH 030/108] fix(server): keep a moved skill's Codex setting, and say when its source record is lost Two things travel with a skill when its real folder moves, and neither did all the way. - Codex names a skill it switches off by the real path of its SKILL.md, so a move left that entry pointing at a folder that was gone and the skill on again. Every placement that moves the folder (project, Global, only some projects) now asks Codex, through the same writer as a switch, to switch the new path off and to clear the old entry. An entry that names the skill by name needs no change. An instance Codex can't be asked for is reported as blocked, and the skill still moves. - When a skill's record in the `skills` CLI's lock can't go along (Global to project, when the folder isn't exactly what was recorded), the skill ends up with no source. The outcome now says so with `sourceDropped`, so a client can tell. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/skills/SkillManager.ts | 54 +++++- apps/server/src/skills/SkillPlacement.test.ts | 181 +++++++++++++++++- apps/server/src/skills/SkillPlacement.ts | 85 +++++--- packages/contracts/src/skills.ts | 5 + 4 files changed, 292 insertions(+), 33 deletions(-) diff --git a/apps/server/src/skills/SkillManager.ts b/apps/server/src/skills/SkillManager.ts index 5221e33fc631..a9c76b930930 100644 --- a/apps/server/src/skills/SkillManager.ts +++ b/apps/server/src/skills/SkillManager.ts @@ -75,6 +75,8 @@ interface SkillChange { readonly affected?: readonly ProviderInstanceId[] | undefined; /** Agents whose skill list changed, when the change works that out; their `$` picker is refreshed. */ readonly touched?: readonly ProviderInstanceId[] | undefined; + /** The skill's source record couldn't go along with a placement, so it has none now. */ + readonly sourceDropped?: boolean | undefined; } /** @@ -525,6 +527,47 @@ const make = Effect.gen(function* () { return combine(unlinked, switched); }); + /** + * Codex names a skill it switches off by the real path of its SKILL.md, so a moved folder leaves + * that entry behind and the skill on. The setting goes to the new path and the old entry is + * cleared, through Codex like any other write. A rule that names the skill by its name needs no + * change. The agents it couldn't carry over are returned. + */ + const followCodexMove = Effect.fnUntraced(function* ( + skill: SkillCatalog.ResolvedSkill, + home: string, + writers: SettingsWriters, + ) { + const blocked: Blocked[] = []; + const old = switchedSkillOf(skill); + const from = codexSkillFile(path, old); + const moved: SwitchedSkill = { ...old, home, entryPaths: [] }; + for (const agent of skill.agents) { + if (agent.driver !== "codex" || agent.settings === undefined) continue; + const rules = yield* readCodexSkillRules(agent.settings).pipe( + Effect.provideContext(filesystemContext), + ); + const keyed = rules.findLast( + (rule) => "path" in rule.selector && rule.selector.path === from, + ); + if (keyed === undefined || keyed.enabled) continue; + const wrote = yield* switchCodex(agent, moved, true, writers); + if (wrote === "failed" || wrote === "setElsewhere") { + blocked.push({ instanceId: agent.instanceId, reason: wrote }); + continue; + } + const write = yield* writers(agent.instanceId); + const cleared = + write === undefined + ? undefined + : yield* write({ path: from, enabled: true }).pipe(Effect.option); + if (cleared === undefined || Option.isNone(cleared)) { + blocked.push({ instanceId: agent.instanceId, reason: "failed" }); + } + } + return blocked; + }); + /** The links among the skills' entries, with what each points at as written. */ const linksTo = (skills: ReadonlyArray) => skills.flatMap((skill) => @@ -695,6 +738,7 @@ const make = Effect.gen(function* () { ? "skipped" : "unchanged", ...(change.reason === undefined ? {} : { reason: change.reason }), + ...(change.sourceDropped === true ? { sourceDropped: true } : {}), blocked: change.blocked.filter( (item, index, all) => all.findIndex((other) => other.instanceId === item.instanceId) === index, @@ -731,6 +775,8 @@ const make = Effect.gen(function* () { }); }, Effect.scoped), place: Effect.fn("SkillManager.place")(function* (input) { + // Codex, if its setting has to follow a moved folder, stays open for the whole request. + const writers = makeWriters(yield* Scope.Scope); const { to } = input; if (input.cwd !== undefined) yield* requireProject(input.cwd); if (to.kind === "project") yield* requireProject(to.cwd); @@ -745,9 +791,13 @@ const make = Effect.gen(function* () { { scope: "project" as const, name: ref.name }, ]), change: (skill, _agents, _projectRoot, all) => - placement.place(skill, to, { cwd: input.cwd, all }), + placement.place(skill, to, { + cwd: input.cwd, + all, + followMove: (home) => followCodexMove(skill, home, writers), + }), }); - }), + }, Effect.scoped), delete: Effect.fn("SkillManager.delete")(function* (input) { return yield* run({ cwd: input.cwd, diff --git a/apps/server/src/skills/SkillPlacement.test.ts b/apps/server/src/skills/SkillPlacement.test.ts index ab33b473e788..e8fde24d5d0e 100644 --- a/apps/server/src/skills/SkillPlacement.test.ts +++ b/apps/server/src/skills/SkillPlacement.test.ts @@ -19,6 +19,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import { parse as parseToml } from "smol-toml"; import * as ProcessRunner from "../processRunner.ts"; import * as ProjectService from "../project/ProjectService.ts"; @@ -1073,12 +1074,14 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillPlacement", (i const before = (yield* catalog.list({ cwd: web })).skills; expect(summaryOf(before, "project", "db-migrations")?.source).toBe("acme/skills"); - yield* manager.place({ + const result = yield* manager.place({ cwd: web, skills: [refOf(before, "project", "db-migrations")], to: { kind: "global" }, }); + // Nothing was lost on the way, so there is nothing to say about it. + expect(result.outcomes[0]?.sourceDropped).toBeUndefined(); const lock = JSON.parse( yield* fs.readFileString(path.join(home, "state/skills/.skill-lock.json")), ); @@ -1232,7 +1235,8 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillPlacement", (i to: { kind: "project", cwd: web }, }); - expect(result.outcomes[0]).toMatchObject({ status: "changed" }); + expect(result.outcomes[0]).toMatchObject({ status: "changed", sourceDropped: true }); + yield* encodeResult(result); expect(yield* fs.exists(path.join(web, "skills-lock.json"))).toBe(false); expect( summaryOf((yield* catalog.list({ cwd: web })).skills, "project", "exact")?.source, @@ -1265,7 +1269,9 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillPlacement", (i to: { kind: "global" }, }); + // A lock that wasn't touched still holds the record: nothing was dropped. expect(result.outcomes[0]).toMatchObject({ status: "changed" }); + expect(result.outcomes[0]?.sourceDropped).toBeUndefined(); expect( yield* fs.exists(path.join(home, ".agents/skills/db-migrations/SKILL.md")), ).toBe(true); @@ -1550,6 +1556,177 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillPlacement", (i ); }); + describe("Codex's setting for a skill whose folder moves", () => { + const rulesOf = (text: string) => + (parseToml(text) as { skills?: { config?: Array> } }).skills + ?.config ?? []; + + it.effect.skipIf(!symlinksSupported)( + "follows the real SKILL.md from a project to Global, the library and another project", + () => + Effect.gen(function* () { + const { fs, path, home, web, api, library } = yield* makeMachine; + const codex = yield* makeCodexDouble(path.join(home, ".codex")); + yield* withManager( + home, + [web, api], + ({ manager, catalog }) => + Effect.gen(function* () { + const verify = refOf( + (yield* catalog.list({ cwd: web })).skills, + "project", + "db-migrations", + ); + yield* manager.disable({ cwd: web, skills: [verify], agents: [agent("codex")] }); + const rules = Effect.map(fs.readFileString(codex.file), rulesOf); + const codexState = (scope: SkillScope, cwd?: string) => + Effect.map( + catalog.list(cwd === undefined ? {} : { cwd }), + ({ skills }) => stateOf(skills, scope, "db-migrations").codex, + ); + expect(yield* rules).toEqual([ + { path: path.join(web, ".agents/skills/db-migrations/SKILL.md"), enabled: false }, + ]); + expect(yield* codexState("project", web)).toBe("off"); + + // Project -> Global. + const toGlobal = yield* manager.place({ + cwd: web, + skills: [verify], + to: { kind: "global" }, + }); + expect(toGlobal.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + expect(yield* rules).toEqual([ + { + path: path.join(home, ".agents/skills/db-migrations/SKILL.md"), + enabled: false, + }, + ]); + expect(yield* codexState("global")).toBe("off"); + + // Global -> only some projects: the library's folder. + const global = refOf((yield* catalog.list({})).skills, "global", "db-migrations"); + const toLibrary = yield* manager.place({ + skills: [global], + to: { kind: "projects", cwds: [web, api] }, + }); + expect(toLibrary.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + expect(yield* rules).toEqual([ + { path: path.join(library, "db-migrations/SKILL.md"), enabled: false }, + ]); + expect(yield* codexState("global", web)).toBe("off"); + + // Some projects -> one project. + const inLibrary = refOf( + (yield* catalog.list({})).skills, + "global", + "db-migrations", + ); + const toProject = yield* manager.place({ + skills: [inLibrary], + to: { kind: "project", cwd: api }, + }); + expect(toProject.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + expect(yield* rules).toEqual([ + { path: path.join(api, ".agents/skills/db-migrations/SKILL.md"), enabled: false }, + ]); + expect(yield* codexState("project", api)).toBe("off"); + // Written through Codex each time, one new path then the old one cleared. + expect(codex.calls.map((call) => call.enabled)).toEqual([ + false, + false, + true, + false, + true, + false, + true, + ]); + }), + {}, + codex, + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "leaves a setting that names the skill alone, and a skill Codex had on", + () => + Effect.gen(function* () { + const { fs, path, home, web } = yield* makeMachine; + const codex = yield* makeCodexDouble(path.join(home, ".codex")); + yield* fs.makeDirectory(path.join(home, ".codex"), { recursive: true }); + yield* fs.writeFileString( + codex.file, + '[[skills.config]]\nname = "db-migrations"\nenabled = false\n', + ); + yield* withManager( + home, + [web], + ({ manager, catalog }) => + Effect.gen(function* () { + const verify = refOf( + (yield* catalog.list({ cwd: web })).skills, + "project", + "db-migrations", + ); + + const result = yield* manager.place({ + cwd: web, + skills: [verify], + to: { kind: "global" }, + }); + + expect(result.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + expect(codex.calls).toEqual([]); + expect(rulesOf(yield* fs.readFileString(codex.file))).toEqual([ + { name: "db-migrations", enabled: false }, + ]); + }), + {}, + codex, + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "says Codex wasn't carried over when it can't be asked, and the skill still moves", + () => + Effect.gen(function* () { + const { fs, path, home, web } = yield* makeMachine; + yield* fs.makeDirectory(path.join(home, ".codex"), { recursive: true }); + const old = path.join(web, ".agents/skills/db-migrations/SKILL.md"); + yield* fs.writeFileString( + path.join(home, ".codex/config.toml"), + `[[skills.config]]\npath = "${old}"\nenabled = false\n`, + ); + // No double: the registry has no Codex instance to open a writer on. + yield* withManager(home, [web], ({ manager, catalog }) => + Effect.gen(function* () { + const verify = refOf( + (yield* catalog.list({ cwd: web })).skills, + "project", + "db-migrations", + ); + + const result = yield* manager.place({ + cwd: web, + skills: [verify], + to: { kind: "global" }, + }); + + expect(result.outcomes[0]).toMatchObject({ + status: "changed", + blocked: [{ instanceId: agent("codex"), reason: "failed" }], + }); + expect( + yield* fs.exists(path.join(home, ".agents/skills/db-migrations/SKILL.md")), + ).toBe(true); + }), + ); + }), + ); + }); + describe("the git worktrees of a project that uses a library skill", () => { /** A worktree of `project` with the links the worktree hook makes in it. */ const addWorktree = (project: string, name: string) => diff --git a/apps/server/src/skills/SkillPlacement.ts b/apps/server/src/skills/SkillPlacement.ts index 381e21613de1..0e5c5c3dbe74 100644 --- a/apps/server/src/skills/SkillPlacement.ts +++ b/apps/server/src/skills/SkillPlacement.ts @@ -15,8 +15,10 @@ * Every transition re-reads the folders it works on, replaces nothing that is in the way * (`destinationTaken`), and undoes the steps it has taken when a later one fails, so a failure * leaves the skill where it was. What an agent used it through (its own link, its settings) goes - * with the skill. The skill's source record in the `skills` CLI's lock (`SkillLockFiles`) moves - * with it between a project and Global. + * with the skill: Codex's switch-off is keyed by the real SKILL.md, so it follows a moved folder + * (`PlacementView.followMove`). The skill's source record in the `skills` CLI's lock + * (`SkillLockFiles`) moves with it between a project and Global, or is dropped when the lock + * can't take it, which the result says (`sourceDropped`). * * The agents of a skill used in only some projects are switched through its project links: a link * in each project's folder for an agent that doesn't read the shared one (`addLibraryLinks`, @@ -49,7 +51,7 @@ import type * as SkillCatalog from "./SkillCatalog.ts"; import { updateExclude, worktreesOf } from "./SkillGitExclude.ts"; import { LIBRARY_FOLDER, libraryLinksOf, linkLeadsTo, type LibraryLink } from "./SkillLibrary.ts"; import { createLink, removeLink, type RemoveLinkResult } from "./SkillLinks.ts"; -import { moveRecord, type LockScope } from "./SkillLockFiles.ts"; +import { moveRecord, type LockScope, type MoveRecordResult } from "./SkillLockFiles.ts"; import { moveFolder } from "./SkillMove.ts"; type Blocked = SkillOutcome["blocked"][number]; @@ -66,6 +68,8 @@ export interface PlacementChange { readonly affected?: readonly ProviderInstanceId[] | undefined; /** Agents whose skill list changed; their `$` picker is refreshed. */ readonly touched?: readonly ProviderInstanceId[] | undefined; + /** The skill's source record couldn't go along with it, so the skill no longer has one. */ + readonly sourceDropped?: boolean | undefined; } /** A step found the placement can't be done; what was done before it is undone. */ @@ -92,6 +96,11 @@ export interface PlacementView { /** The project the list was read for. */ readonly cwd: string | undefined; readonly all: ReadonlyArray; + /** + * Called once the skill's real folder has moved to `home`, so the agents' own settings that name + * the old place can name the new one. Agents it couldn't carry over are returned. + */ + readonly followMove?: (home: string) => Effect.Effect; } /** A skill kept in the library, whose registered projects' links the catalog found. */ @@ -334,7 +343,10 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe (link) => results.get(link.path) === "changed" || results.get(link.path) === "failed", ); - /** The skill's source record goes along; a failure is logged and never undoes the placement. */ + /** + * The skill's source record goes along; a failure is logged and never undoes the placement. + * Whether the record had to be dropped is told, since the skill then has no source any more. + */ const moveSourceRecord = (input: { readonly name: string; readonly from: LockScope; @@ -342,13 +354,17 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe readonly folder: string; }) => inContext(moveRecord({ ...input, environment: deps.environment, home: deps.home })).pipe( + Effect.map((result: MoveRecordResult) => result === "dropped"), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.interrupt - : Effect.logWarning("could not move a skill's source record", { - name: input.name, - cause: Cause.pretty(cause), - }), + : Effect.as( + Effect.logWarning("could not move a skill's source record", { + name: input.name, + cause: Cause.pretty(cause), + }), + false, + ), ), ); @@ -360,6 +376,7 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe readonly home: string; readonly blocked: readonly Blocked[]; readonly reason?: SkillOutcomeReason | undefined; + readonly sourceDropped?: boolean | undefined; }) { const after = yield* deps.catalog.resolve({ cwd: input.view.cwd, @@ -377,6 +394,7 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe reason: input.reason, touched, affected: touched.filter((id) => input.had.has(id) !== has.has(id) && !unreached.has(id)), + ...(input.sourceDropped === true ? { sourceDropped: true } : {}), } satisfies PlacementChange; }); @@ -440,15 +458,17 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe : { kind: "global" }; const to: LockScope = dest.scope === "global" ? { kind: "global" } : { kind: "project", root: dest.cwd }; - yield* moveSourceRecord({ name: skill.name, from, to, folder: real }); + const sourceDropped = yield* moveSourceRecord({ name: skill.name, from, to, folder: real }); + const followed = view.followMove === undefined ? [] : yield* view.followMove(real); const landed = yield* resolveLanded(); if (landed === undefined) { return { wrote: true, - blocked: [], + blocked: followed, reason: "failed", touched: [...had], + ...(sourceDropped ? { sourceDropped } : {}), } satisfies PlacementChange; } // Every agent that used the skill keeps using it. One that reads the new scope's shared folder @@ -471,8 +491,9 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe skill, had, home: real, - blocked: relinked.blocked, + blocked: [...relinked.blocked, ...followed], reason, + sourceDropped, }); }); @@ -533,6 +554,7 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe // same path, as when the skill is already in one of the projects. yield* unlink(journal, stale); const home = yield* realPath(entry); + const followed = skill.own && view.followMove !== undefined ? yield* view.followMove(home) : []; const blocked = yield* linkProjects(journal, { projects, name: skill.name, @@ -544,21 +566,23 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe .filter((agent) => ownProjectFolderFor(agent.driver) === folder) .map((agent) => agent.instanceId), }); - if (skill.scope === "project" && view.cwd !== undefined) { - yield* moveSourceRecord({ - name: skill.name, - from: { kind: "project", root: view.cwd }, - to: { kind: "global" }, - folder: home, - }); - } + const sourceDropped = + skill.scope === "project" && view.cwd !== undefined + ? yield* moveSourceRecord({ + name: skill.name, + from: { kind: "project", root: view.cwd }, + to: { kind: "global" }, + folder: home, + }) + : false; return yield* settle({ view, skill, had, home, - blocked, + blocked: [...blocked, ...followed], reason, + sourceDropped, }); }); @@ -697,14 +721,16 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe } const real = yield* realPath(destination); - if (dest.scope === "project") { - yield* moveSourceRecord({ - name: skill.name, - from: { kind: "global" }, - to: { kind: "project", root: dest.cwd }, - folder: real, - }); - } + const followed = skill.own && view.followMove !== undefined ? yield* view.followMove(real) : []; + const sourceDropped = + dest.scope === "project" + ? yield* moveSourceRecord({ + name: skill.name, + from: { kind: "global" }, + to: { kind: "project", root: dest.cwd }, + folder: real, + }) + : false; const landedIn = dest.scope === "global" ? view.cwd : dest.cwd; const landed = (yield* deps.catalog.resolve({ cwd: landedIn, @@ -728,8 +754,9 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe skill, had, home: real, - blocked: relinked.blocked, + blocked: [...relinked.blocked, ...followed], reason, + sourceDropped, }); }); diff --git a/packages/contracts/src/skills.ts b/packages/contracts/src/skills.ts index 29fe88e066eb..050d292432db 100644 --- a/packages/contracts/src/skills.ts +++ b/packages/contracts/src/skills.ts @@ -242,6 +242,11 @@ export const SkillOutcome = Schema.Struct({ ), /** Agents that weren't asked for but gained or lost the skill, because they read the same folder. */ affected: Schema.Array(ProviderInstanceId), + /** + * Set when a placement moved the skill but the installer's record of where it came from (`source` + * in the list) couldn't go along, so the skill no longer has one and won't be updated from there. + */ + sourceDropped: Schema.optional(Schema.Boolean), }); export type SkillOutcome = typeof SkillOutcome.Type; From 7f459275226efe14452aa4698d9db88e82104351 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:25:04 -0400 Subject: [PATCH 031/108] fix(server): keep Claude's local settings file out of git when T3 Code creates it Switching a project skill off for Claude writes `.claude/settings.local.json`. Claude Code keeps that file out of git when it creates it (https://code.claude.com/docs/en/settings, "Settings files"), by adding it to the global git excludes the first time it writes the file in a repository that doesn't already ignore it. T3 Code now does the same when its own write creates the file, in the repository's `info/exclude` since it doesn't edit the user's global git configuration, in a block of its own. A file that was there already, one the repository ignores or tracks, and a project that isn't in a git repository are left alone. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/skills/AgentSkillSettings.ts | 8 +- apps/server/src/skills/ClaudeSkillSettings.ts | 23 +++- .../server/src/skills/SkillGitExclude.test.ts | 102 ++++++++++++++---- apps/server/src/skills/SkillGitExclude.ts | 69 +++++++++++- apps/server/src/skills/SkillManager.ts | 5 +- apps/server/src/skills/SkillPlacement.test.ts | 53 +++++++++ 6 files changed, 234 insertions(+), 26 deletions(-) diff --git a/apps/server/src/skills/AgentSkillSettings.ts b/apps/server/src/skills/AgentSkillSettings.ts index a3b46b75e7e5..4bf1c9faa323 100644 --- a/apps/server/src/skills/AgentSkillSettings.ts +++ b/apps/server/src/skills/AgentSkillSettings.ts @@ -43,6 +43,8 @@ import * as Effect from "effect/Effect"; import type * as FileSystem from "effect/FileSystem"; import type * as Path from "effect/Path"; +import type * as VcsProcess from "../vcs/VcsProcess.ts"; + import { claudeSwitches, setClaudeSwitch } from "./ClaudeSkillSettings.ts"; import { codexSwitches } from "./CodexSkillSettings.ts"; import { openCodeSwitches, setOpenCodeSwitch } from "./OpenCodeSkillSettings.ts"; @@ -153,7 +155,11 @@ export const setSkillSwitch = ( context: SkillSwitchContext, skill: SwitchedSkill, off: boolean, -): Effect.Effect => { +): Effect.Effect< + SkillSwitchWrite, + never, + FileSystem.FileSystem | Path.Path | VcsProcess.VcsProcess +> => { switch (SWITCHES[context.driver]?.kind) { case "claude": return setClaudeSwitch(context, skill, off); diff --git a/apps/server/src/skills/ClaudeSkillSettings.ts b/apps/server/src/skills/ClaudeSkillSettings.ts index 8ea3c3fe3fd4..5983148a22df 100644 --- a/apps/server/src/skills/ClaudeSkillSettings.ts +++ b/apps/server/src/skills/ClaudeSkillSettings.ts @@ -10,11 +10,15 @@ * edits a file a team shares). What the result would be is worked out first, over every layer, so * a layer above the one written (a project's local file over the user's, or the managed policy) * that keeps the skill the way it is makes this `setElsewhere` and writes nothing. Turning a skill - * on removes the key; `"on"` is written only when a layer below the target still says off. + * on removes the key; `"on"` is written only when a layer below the target still says off. When + * the project's local file is created here, it is kept out of git as Claude Code does when it + * creates the file (`excludeNewFile`). * * @module ClaudeSkillSettings */ +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -25,6 +29,7 @@ import { } from "../provider/Drivers/ClaudeSkills.ts"; import type { SkillSwitchContext, SkillSwitchView, SwitchedSkill } from "./AgentSkillSettings.ts"; import { editJsoncFile } from "./JsoncSettings.ts"; +import { excludeNewFile } from "./SkillGitExclude.ts"; type OverrideValue = typeof SkillOverrideValue.Type; @@ -66,6 +71,7 @@ export const setClaudeSwitch = Effect.fn("setClaudeSwitch")(function* ( off: boolean, ) { const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; const targetPath = skill.scope === "global" ? path.join(context.configHome, "settings.json") @@ -81,12 +87,27 @@ export const setClaudeSwitch = Effect.fn("setClaudeSwitch")(function* ( if (targetPath === undefined || index < 0) return "failed" as const; const current = layers[index]?.overrides?.get(skill.name); + // Only a project's local file is kept out of git, and only when this write is what makes it. + const existed = yield* fileSystem.exists(targetPath).pipe(Effect.orElseSucceed(() => true)); const edit = Effect.fnUntraced(function* (value: OverrideValue | undefined) { const result = yield* editJsoncFile({ file: targetPath, changes: [{ path: ["skillOverrides", skill.name], value }], accept: isValidSettings, }); + if (result === "written" && !existed && skill.scope === "project" && context.cwd) { + // The file works either way; it would only show up in git status. + yield* excludeNewFile({ projectRoot: context.cwd, file: targetPath }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning("could not keep Claude's local settings out of git", { + file: targetPath, + cause: Cause.pretty(cause), + }), + ), + ); + } return result === "invalid" ? ("failed" as const) : result; }); diff --git a/apps/server/src/skills/SkillGitExclude.test.ts b/apps/server/src/skills/SkillGitExclude.test.ts index e78db2a49d9c..39ae16359df1 100644 --- a/apps/server/src/skills/SkillGitExclude.test.ts +++ b/apps/server/src/skills/SkillGitExclude.test.ts @@ -10,6 +10,7 @@ import { EXCLUDE_BLOCK_END, EXCLUDE_BLOCK_START, editExcludeBlock, + excludeNewFile, updateExclude, } from "./SkillGitExclude.ts"; @@ -24,6 +25,24 @@ const git = (cwd: string, args: ReadonlyArray) => }); }).pipe(Effect.provide(ProcessRunner.layer)); +const run = (effect: Effect.Effect) => + effect.pipe(Effect.provide(VcsProcess.layer)); + +const makeRepo = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "t3code-exclude-" })); + const repo = path.join(root, "acme-web"); + yield* fs.makeDirectory(repo, { recursive: true }); + yield* git(repo, ["init", "-q", "-b", "main"]); + // Not the machine's own global ignore file, which may already name what a test creates. + yield* git(repo, ["config", "core.excludesFile", path.join(root, "global-ignore")]); + yield* fs.writeFileString(path.join(repo, "README.md"), "# acme-web\n"); + yield* git(repo, ["add", "-A"]); + yield* git(repo, ["commit", "-q", "-m", "init"]); + return { fs, path, root, repo }; +}); + it.layer(NodeServices.layer, { excludeTestServices: true })("SkillGitExclude", (it) => { describe("editExcludeBlock", () => { it("starts a block at the end and leaves the user's lines alone", () => { @@ -68,24 +87,6 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillGitExclude", ( }); describe("updateExclude", () => { - const run = (effect: Effect.Effect) => - effect.pipe(Effect.provide(VcsProcess.layer)); - - const makeRepo = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.realPath( - yield* fs.makeTempDirectoryScoped({ prefix: "t3code-exclude-" }), - ); - const repo = path.join(root, "acme-web"); - yield* fs.makeDirectory(repo, { recursive: true }); - yield* git(repo, ["init", "-q", "-b", "main"]); - yield* fs.writeFileString(path.join(repo, "README.md"), "# acme-web\n"); - yield* git(repo, ["add", "-A"]); - yield* git(repo, ["commit", "-q", "-m", "init"]); - return { fs, path, root, repo }; - }); - it.effect("keeps links out of git status, and takes the lines out again", () => Effect.gen(function* () { const { fs, path, repo } = yield* makeRepo; @@ -195,4 +196,69 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillGitExclude", ( }), ); }); + + describe("excludeNewFile", () => { + const LOCAL_BLOCK = + "# T3 Code: local settings\n/.claude/settings.local.json\n# End T3 Code: local settings\n"; + + it.effect("keeps a file that was just created out of git, in a block of its own", () => + Effect.gen(function* () { + const { fs, path, repo } = yield* makeRepo; + const file = path.join(repo, ".claude/settings.local.json"); + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, "{}\n"); + expect((yield* git(repo, ["status", "--porcelain"])).stdout).toBe("?? .claude/\n"); + + yield* run(excludeNewFile({ projectRoot: repo, file })); + + expect(yield* fs.readFileString(path.join(repo, ".git/info/exclude"))).toContain( + LOCAL_BLOCK, + ); + expect((yield* git(repo, ["status", "--porcelain", "-uall"])).stdout).toBe(""); + // Doing it again changes nothing. + const before = yield* fs.readFileString(path.join(repo, ".git/info/exclude")); + yield* run(excludeNewFile({ projectRoot: repo, file })); + expect(yield* fs.readFileString(path.join(repo, ".git/info/exclude"))).toBe(before); + }), + ); + + it.effect("leaves a file the repository ignores already, or tracks, alone", () => + Effect.gen(function* () { + const { fs, path, repo } = yield* makeRepo; + const exclude = path.join(repo, ".git/info/exclude"); + const before = yield* fs.readFileString(exclude); + const ignored = path.join(repo, ".claude/settings.local.json"); + yield* fs.makeDirectory(path.dirname(ignored), { recursive: true }); + yield* fs.writeFileString(ignored, "{}\n"); + yield* fs.writeFileString( + path.join(repo, ".gitignore"), + "**/.claude/settings.local.json\n", + ); + + yield* run(excludeNewFile({ projectRoot: repo, file: ignored })); + expect(yield* fs.readFileString(exclude)).toBe(before); + + // Tracked, whatever ignores it. + yield* fs.remove(path.join(repo, ".gitignore")); + yield* git(repo, ["add", "-f", ".claude/settings.local.json"]); + yield* git(repo, ["commit", "-q", "-m", "track it"]); + yield* run(excludeNewFile({ projectRoot: repo, file: ignored })); + expect(yield* fs.readFileString(exclude)).toBe(before); + }), + ); + + it.effect("does nothing for a project that isn't in a git repository", () => + Effect.gen(function* () { + const { fs, path, root } = yield* makeRepo; + const loose = path.join(root, "marketing-site"); + const file = path.join(loose, ".claude/settings.local.json"); + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, "{}\n"); + + yield* run(excludeNewFile({ projectRoot: loose, file })); + + expect(yield* fs.exists(path.join(loose, ".git"))).toBe(false); + }), + ); + }); }); diff --git a/apps/server/src/skills/SkillGitExclude.ts b/apps/server/src/skills/SkillGitExclude.ts index 140d11a09501..eb63ead65caa 100644 --- a/apps/server/src/skills/SkillGitExclude.ts +++ b/apps/server/src/skills/SkillGitExclude.ts @@ -8,6 +8,10 @@ * git repository has no exclude file, so its links need nothing. The same repository's other * worktrees (`worktreesOf`) hold the links the worktree hook made in them. * + * A file T3 Code creates in a project that isn't meant to be committed, Claude's + * `.claude/settings.local.json`, is kept out of git the same way (`excludeNewFile`), in a block of + * its own. + * * @module SkillGitExclude */ import * as Effect from "effect/Effect"; @@ -20,6 +24,18 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; export const EXCLUDE_BLOCK_START = "# T3 Code: skills used from Global"; export const EXCLUDE_BLOCK_END = "# End T3 Code: skills used from Global"; +/** The lines T3 Code owns in the exclude file are between a start and an end marker. */ +export interface ExcludeBlock { + readonly start: string; + readonly end: string; +} + +const LIBRARY_BLOCK: ExcludeBlock = { start: EXCLUDE_BLOCK_START, end: EXCLUDE_BLOCK_END }; +const LOCAL_SETTINGS_BLOCK: ExcludeBlock = { + start: "# T3 Code: local settings", + end: "# End T3 Code: local settings", +}; + /** A path as one exclude line: anchored at the repository root, with its glob characters quoted. */ const excludeLine = (relative: string) => `/${relative.replace(/[\\*?[\]]/g, "\\$&").replace(/ +$/, (spaces) => "\\ ".repeat(spaces.length))}`; @@ -31,21 +47,22 @@ const excludeLine = (relative: string) => export const editExcludeBlock = ( text: string, change: { readonly add: readonly string[]; readonly remove: readonly string[] }, + block: ExcludeBlock = LIBRARY_BLOCK, ) => { const lines = text === "" ? [] : text.split("\n"); if (lines.at(-1) === "") lines.pop(); - const start = lines.indexOf(EXCLUDE_BLOCK_START); - const end = start < 0 ? -1 : lines.indexOf(EXCLUDE_BLOCK_END, start + 1); + const start = lines.indexOf(block.start); + const end = start < 0 ? -1 : lines.indexOf(block.end, start + 1); const kept = start >= 0 && end > start ? lines.slice(start + 1, end) : []; const removed = new Set(change.remove); const inBlock = [...kept.filter((line) => !removed.has(line)), ...change.add].filter( (line, index, all) => all.indexOf(line) === index, ); - const block = inBlock.length === 0 ? [] : [EXCLUDE_BLOCK_START, ...inBlock, EXCLUDE_BLOCK_END]; + const marked = inBlock.length === 0 ? [] : [block.start, ...inBlock, block.end]; const next = start >= 0 && end > start - ? [...lines.slice(0, start), ...block, ...lines.slice(end + 1)] - : [...lines, ...block]; + ? [...lines.slice(0, start), ...marked, ...lines.slice(end + 1)] + : [...lines, ...marked]; return next.length === 0 ? "" : `${next.join("\n")}\n`; }; @@ -58,6 +75,8 @@ export const updateExclude = Effect.fn("SkillGitExclude.updateExclude")(function readonly projectRoot: string; readonly links: ReadonlyArray; readonly action: "add" | "remove"; + /** The block the lines are kept in; the one for skill links by default. */ + readonly block?: ExcludeBlock; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -90,6 +109,7 @@ export const updateExclude = Effect.fn("SkillGitExclude.updateExclude")(function const next = editExcludeBlock( text, input.action === "add" ? { add: lines, remove: [] } : { add: [], remove: lines }, + input.block, ); if (next === text || (text === "" && next === "")) return; yield* writeFileStringAtomically({ filePath: file, contents: next }); @@ -120,3 +140,42 @@ export const worktreesOf = Effect.fn("SkillGitExclude.worktreesOf")(function* ( .filter((line) => line.startsWith("worktree ") && line.length > "worktree ".length) .map((line) => line.slice("worktree ".length)); }); + +/** + * Keeps a file T3 Code has just created in a project out of git: Claude Code's own + * `.claude/settings.local.json`, which is the user's and not the repository's. Claude Code does + * this itself when it creates the file: "Claude Code keeps it out of git when it creates the file" + * (https://code.claude.com/docs/en/settings, "Settings files"), by adding it to the global git + * excludes the first time it writes the file in a repository that doesn't already ignore it. T3 + * Code follows that rule, but in the repository's own `info/exclude`, since it doesn't edit the + * user's global git configuration. A file the repository already ignores or tracks is left alone, + * and so is a project that isn't in a git repository. + */ +export const excludeNewFile = Effect.fn("SkillGitExclude.excludeNewFile")(function* (input: { + readonly projectRoot: string; + readonly file: string; +}) { + const vcs = yield* VcsProcess.VcsProcess; + const asked = (args: ReadonlyArray) => + vcs + .run({ + operation: "SkillGitExclude.excludeNewFile", + command: "git", + args, + cwd: input.projectRoot, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }) + .pipe(Effect.orElseSucceed(() => undefined)); + // `check-ignore` is 0 for an ignored file; `ls-files` is 0 for a tracked one. + const ignored = yield* asked(["check-ignore", "-q", "--", input.file]); + const tracked = yield* asked(["ls-files", "--error-unmatch", "--", input.file]); + if (ignored?.exitCode === 0 || tracked?.exitCode === 0) return; + yield* updateExclude({ + projectRoot: input.projectRoot, + links: [input.file], + action: "add", + block: LOCAL_SETTINGS_BLOCK, + }); +}); diff --git a/apps/server/src/skills/SkillManager.ts b/apps/server/src/skills/SkillManager.ts index a9c76b930930..cf7d2fdef58b 100644 --- a/apps/server/src/skills/SkillManager.ts +++ b/apps/server/src/skills/SkillManager.ts @@ -49,6 +49,7 @@ import { ownProjectFolderFor } from "@t3tools/provider-core/server/AgentSkillFol import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import { setSkillSwitch, type SkillSwitchWrite, type SwitchedSkill } from "./AgentSkillSettings.ts"; import { codexRulesSwitchOff, @@ -285,7 +286,9 @@ const make = Effect.gen(function* () { const homeDirectory = yield* HostProcess.HomeDirectory; const writeLock = yield* Semaphore.make(1); // The link primitives take the filesystem from their environment. - const filesystemContext = yield* Effect.context(); + const filesystemContext = yield* Effect.context< + FileSystem.FileSystem | Path.Path | VcsProcess.VcsProcess + >(); /** Links are only written under a folder the environment knows as a project. */ const requireProject = (cwd: string) => diff --git a/apps/server/src/skills/SkillPlacement.test.ts b/apps/server/src/skills/SkillPlacement.test.ts index e8fde24d5d0e..40bf48b3328a 100644 --- a/apps/server/src/skills/SkillPlacement.test.ts +++ b/apps/server/src/skills/SkillPlacement.test.ts @@ -93,6 +93,8 @@ const makeMachine = Effect.gen(function* () { for (const repo of [web, api, marketing]) { yield* fs.makeDirectory(repo, { recursive: true }); yield* git(repo, ["init", "-q", "-b", "main"]); + // Not the machine's own global ignore file, which may already name what a test creates. + yield* git(repo, ["config", "core.excludesFile", path.join(home, "global-ignore")]); yield* fs.writeFileString(path.join(repo, "README.md"), `# ${path.basename(repo)}\n`); yield* git(repo, ["add", "-A"]); yield* git(repo, ["commit", "-q", "-m", "init"]); @@ -1848,4 +1850,55 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillPlacement", (i }), ); }); + + describe("Claude's local settings file", () => { + const LOCAL_BLOCK = + "# T3 Code: local settings\n/.claude/settings.local.json\n# End T3 Code: local settings"; + + it.effect.skipIf(!symlinksSupported)( + "stays out of git when T3 Code creates it, and is left alone when it was there", + () => + Effect.gen(function* () { + const { fs, path, home, web, api, write } = yield* makeMachine; + yield* write("repos/acme-web/.claude/skills/own/SKILL.md", skillFile("own")); + yield* write("repos/acme-web/.claude/skills/other/SKILL.md", skillFile("other")); + yield* write("repos/acme-api/.claude/skills/own/SKILL.md", skillFile("own")); + yield* write("repos/acme-api/.claude/settings.local.json", '{"theme":"dark"}\n'); + yield* withManager(home, [web, api], ({ manager, catalog }) => + Effect.gen(function* () { + const off = (cwd: string, name: string) => + Effect.gen(function* () { + const skill = refOf((yield* catalog.list({ cwd })).skills, "project", name); + return yield* manager.disable({ + cwd, + skills: [skill], + agents: [agent("claudeAgent")], + }); + }); + + // A new file in a git repository: ignored by git from then on. + const created = yield* off(web, "own"); + expect(created.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + expect( + JSON.parse(yield* fs.readFileString(path.join(web, ".claude/settings.local.json"))), + ).toEqual({ skillOverrides: { own: "off" } }); + expect(yield* exclude(web)).toContain(LOCAL_BLOCK); + expect(yield* status(web)).not.toContain("settings.local.json"); + const text = yield* exclude(web); + + // Editing it again doesn't touch the exclude file. + yield* off(web, "other"); + expect(yield* exclude(web)).toBe(text); + + // A file that was already there is not T3 Code's to hide. + yield* off(api, "own"); + expect( + JSON.parse(yield* fs.readFileString(path.join(api, ".claude/settings.local.json"))), + ).toEqual({ theme: "dark", skillOverrides: { own: "off" } }); + expect(yield* exclude(api)).not.toContain("settings.local.json"); + }), + ); + }), + ); + }); }); From 382329f30ef10a8d8a2d1dfade3eae66adcf7f57 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:25:23 -0400 Subject: [PATCH 032/108] feat(web): say when a moved skill won't update from its source any more When a placement can't carry a skill's installer record along, the result line now says so in plain words, for example "write-a-prd won't update from mattpocock/skills any more." Several skills are counted in one sentence. Co-Authored-By: Claude Sonnet 5.5 --- .../settings/SkillsSettings.logic.test.ts | 46 +++++++++++++++++++ .../settings/SkillsSettings.logic.ts | 22 ++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SkillsSettings.logic.test.ts b/apps/web/src/components/settings/SkillsSettings.logic.test.ts index 64e034f49ab7..c89cc2eac1b7 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.test.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.test.ts @@ -1196,3 +1196,49 @@ describe("telling what a change did", () => { ); }); }); + +describe("telling that a moved skill lost its source", () => { + const listed = (name: string, source: string | undefined) => + ({ + name, + scope: "project" as const, + home: `.agents/skills/${name}`, + description: `The ${name} skill.`, + copies: [], + access: [], + ...(source === undefined ? {} : { source }), + }) satisfies SkillListResult["skills"][number]; + const dropped = (name: string) => + outcome({ + name, + skill: { scope: "project", name, home: `.agents/skills/${name}` }, + sourceDropped: true, + }); + + it("names the skill and where it came from, from the plan the page made", () => { + const { skills } = ingestSkills({ + skills: [listed("write-a-prd", "mattpocock/skills")], + unreadable: [], + }); + const plan = planPlace(skills, { kind: "global" }); + + expect(describeResult(plan!.change, [dropped("write-a-prd")], ctx)).toBe( + "Made 1 skill Global. write-a-prd won't update from mattpocock/skills any more.", + ); + }); + + it("counts them when there are several, and says nothing when none lost it", () => { + const { skills } = ingestSkills({ + skills: [listed("a", "acme/skills"), listed("b", "acme/other")], + unreadable: [], + }); + const plan = planPlace(skills, { kind: "global" }); + + expect(describeResult(plan!.change, [dropped("a"), dropped("b")], ctx)).toBe( + "Made 2 skills Global. 2 skills won't update from their sources any more.", + ); + expect( + describeResult(plan!.change, [outcome({ name: "a" }), outcome({ name: "b" })], ctx), + ).toBe("Made 2 skills Global."); + }); +}); diff --git a/apps/web/src/components/settings/SkillsSettings.logic.ts b/apps/web/src/components/settings/SkillsSettings.logic.ts index ac64f1d65f44..f9689b399599 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.ts @@ -194,6 +194,8 @@ export type SkillChange = readonly to: SkillPlacement; /** The projects `to` names, for telling the person where the skills went. */ readonly projectNames: readonly string[]; + /** Where each skill came from (`owner/repo`), by `Skill.id`, for telling one that lost it. */ + readonly sources?: Readonly>; } | { readonly kind: "delete"; readonly skills: readonly SkillRef[] }; @@ -533,6 +535,9 @@ export function planPlace(selected: readonly Skill[], target: PlaceTarget): Skil const alreadyGlobal = coming.every((skill) => skill.scope === "global"); const names = projectNamesOf(target); const where = joinNames(names); + const sources = coming.flatMap((skill) => + skill.source ? [[skill.id, skill.source] as const] : [], + ); const confirmation = (() => { switch (target.kind) { case "project": @@ -564,6 +569,7 @@ export function planPlace(selected: readonly Skill[], target: PlaceTarget): Skil skills: coming.map(skillRef), to: placement(target), projectNames: names, + ...(sources.length > 0 ? { sources: Object.fromEntries(sources) } : {}), }, affected: coming.length, confirmation: { ...confirmation, notes: [], destructive: false }, @@ -762,6 +768,20 @@ export function describeResult( return `Deleted ${count}.`; } })(); + // A skill whose record of where it came from couldn't go along won't be updated from there. + const dropped = changed.filter((outcome) => outcome.sourceDropped === true); + const droppedText = (() => { + const [first] = dropped; + if (first === undefined) return ""; + if (dropped.length > 1) { + return `${plural(dropped.length, "skill")} won't update from their sources any more.`; + } + const source = + change.kind === "place" + ? change.sources?.[`${first.skill.scope}\0${first.skill.name}\0${first.skill.home}`] + : undefined; + return `${first.skill.name} won't update${source === undefined ? "" : ` from ${source}`} any more.`; + })(); // Skills held back for the same agent and reason are counted once, so a bulk change stays short. const held = new Map(); for (const outcome of outcomes) { @@ -813,7 +833,7 @@ export function describeResult( if (problems.length > shown.length) { shown.push(`${problems.length - shown.length} more couldn't be changed.`); } - return [lead, ...shown].filter((part) => part !== "").join(" "); + return [lead, droppedText, ...shown].filter((part) => part !== "").join(" "); } // -- Search ----------------------------------------------------------------------------------- From f230ce489acce4b3d3a8b7d9e1fb018e613b2f76 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:25:25 -0400 Subject: [PATCH 033/108] fix(web): change more than 200 skills at a time The server takes at most 200 skills in one turn-on, turn-off, placement or delete, so a switch on a section with more than that failed whole. The page now sends the change in batches of 200, one after the other, and puts the outcomes together into one result. If a batch fails, what was done before it is still told, with the error after it. Co-Authored-By: Claude Sonnet 5.5 --- .../settings/SkillsSettings.logic.test.ts | 45 +++++++++++++++++++ .../settings/SkillsSettings.logic.ts | 24 ++++++++++ .../components/settings/SkillsSettings.tsx | 37 ++++++++------- 3 files changed, 90 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/settings/SkillsSettings.logic.test.ts b/apps/web/src/components/settings/SkillsSettings.logic.test.ts index c89cc2eac1b7..5fe861cf611c 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.test.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.test.ts @@ -33,6 +33,7 @@ import { projectsBadge, rowSwitchOn, scriptFiles, + sendInBatches, skillBody, skillsEnvironment, skillsToCheckWithGit, @@ -1242,3 +1243,47 @@ describe("telling that a moved skill lost its source", () => { ).toBe("Made 2 skills Global."); }); }); + +describe("a change of more than 200 skills", () => { + const many = (count: number) => Array.from({ length: count }, (_, index) => ref(`s${index}`)); + + it("goes to the server in batches of 200, in order, and comes back as one result", async () => { + const sizes: number[] = []; + const result = await sendInBatches(many(450), async (batch) => { + sizes.push(batch.length); + return batch.map((entry) => outcome({ name: entry.name })); + }); + + expect(sizes).toEqual([200, 200, 50]); + expect(result.failed).toBe(false); + expect(result.outcomes.map((entry) => entry.skill.name)).toEqual( + many(450).map((entry) => entry.name), + ); + }); + + it("sends exactly 200 as one batch, 201 as two, and nothing for no skills", async () => { + const sizes: number[] = []; + const send = async (batch: readonly { name: string }[]) => { + sizes.push(batch.length); + return batch.map((entry) => outcome({ name: entry.name })); + }; + + await sendInBatches(many(200), send); + await sendInBatches(many(201), send); + expect((await sendInBatches([], send)).outcomes).toEqual([]); + + expect(sizes).toEqual([200, 200, 1]); + }); + + it("stops at a batch the server didn't answer, and keeps what was done before it", async () => { + let calls = 0; + const result = await sendInBatches(many(450), async (batch) => { + calls += 1; + return calls === 2 ? null : batch.map((entry) => outcome({ name: entry.name })); + }); + + expect(calls).toBe(2); + expect(result.failed).toBe(true); + expect(result.outcomes).toHaveLength(200); + }); +}); diff --git a/apps/web/src/components/settings/SkillsSettings.logic.ts b/apps/web/src/components/settings/SkillsSettings.logic.ts index f9689b399599..2e8c78646f56 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.ts @@ -836,6 +836,30 @@ export function describeResult( return [lead, droppedText, ...shown].filter((part) => part !== "").join(" "); } +// -- Big changes ------------------------------------------------------------------------------ + +/** The server takes at most this many skills in one change. */ +const MAX_SKILLS_PER_CALL = 200; + +/** + * Sends a change in batches the server accepts, one after the other, and puts the outcomes + * together in order. A batch the server answered nothing for stops the rest, since the page reads + * the folders again afterwards: what was done stays done, and `failed` says the change is not + * complete. + */ +export async function sendInBatches( + skills: readonly SkillRef[], + send: (batch: readonly SkillRef[]) => Promise, +) { + const outcomes: SkillOutcome[] = []; + for (let start = 0; start < skills.length; start += MAX_SKILLS_PER_CALL) { + const batch = await send(skills.slice(start, start + MAX_SKILLS_PER_CALL)); + if (batch === null) return { outcomes, failed: true }; + outcomes.push(...batch); + } + return { outcomes, failed: false }; +} + // -- Search ----------------------------------------------------------------------------------- export const matchesQuery = (skill: Skill, needle: string) => diff --git a/apps/web/src/components/settings/SkillsSettings.tsx b/apps/web/src/components/settings/SkillsSettings.tsx index ed5f3b1c9126..20ff89141d68 100644 --- a/apps/web/src/components/settings/SkillsSettings.tsx +++ b/apps/web/src/components/settings/SkillsSettings.tsx @@ -26,6 +26,7 @@ import { ingestSkills, installedAgents, matchesQuery, + sendInBatches, skillsEnvironment, skillsToCheckWithGit, unreadableNote, @@ -300,27 +301,31 @@ function EnvironmentSkills({ const base = { environmentId: environment.environmentId } as const; const scoped = cwd ? { cwd } : {}; try { - const result = - change.kind === "enable" - ? await enableSkills({ - ...base, - input: { ...scoped, skills: change.skills, agents: change.agents }, - }) - : change.kind === "disable" - ? await disableSkills({ + // The server takes a few hundred skills at a time, so a big change goes in batches. + const { outcomes, failed } = await sendInBatches(change.skills, async (skills) => { + const result = + change.kind === "enable" + ? await enableSkills({ ...base, - input: { ...scoped, skills: change.skills, agents: change.agents }, + input: { ...scoped, skills, agents: change.agents }, }) - : change.kind === "place" - ? await placeSkills({ + : change.kind === "disable" + ? await disableSkills({ ...base, - input: { ...scoped, skills: change.skills, to: change.to }, + input: { ...scoped, skills, agents: change.agents }, }) - : await deleteSkills({ ...base, input: { ...scoped, skills: change.skills } }); + : change.kind === "place" + ? await placeSkills({ ...base, input: { ...scoped, skills, to: change.to } }) + : await deleteSkills({ ...base, input: { ...scoped, skills } }); + return result?._tag === "Success" ? result.value.outcomes : null; + }); + // What was done before a batch failed is still told. setNotice( - result?._tag === "Success" - ? describeResult(change, result.value.outcomes, ctx) - : CHANGE_ERROR, + !failed + ? describeResult(change, outcomes, ctx) + : outcomes.length === 0 + ? CHANGE_ERROR + : `${describeResult(change, outcomes, ctx)} ${CHANGE_ERROR}`, ); } catch { setNotice(CHANGE_ERROR); From fa81b5d3030de68368326f47fd0f2944ce1afcd2 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:25:33 -0400 Subject: [PATCH 034/108] docs: describe agents' own settings and skills used in only some projects Co-Authored-By: Claude Sonnet 5.5 --- docs/user/skills.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/user/skills.md b/docs/user/skills.md index 18e84aa72cd8..415256bcc639 100644 --- a/docs/user/skills.md +++ b/docs/user/skills.md @@ -36,8 +36,11 @@ Turning a skill on for an agent that reads a different folder makes a link in th folder that points at the skill's real folder, so the files stay in one place. Turning it off removes that link and nothing else. -- An agent that reads the skill's own folder directly, with no setting T3 Code can change, has its - switch disabled and stays on. To stop it using the skill, move the skill out of that folder +- An agent that reads the skill's own folder directly has no link to remove. Claude, Codex and + OpenCode (and Pi, for Global skills) have a setting for it, so turning the skill off writes that + setting, and turning it on takes it away again. A project or organization setting can still + decide, and T3 Code says so. Cursor, Grok and Antigravity have no such setting, so their switch is + disabled and the skill stays on. To stop one using the skill, move the skill out of that folder yourself. - Agents that read the same folder share one link, so turning a skill on or off for one can change it for the others. T3 Code says who else is affected. @@ -66,6 +69,12 @@ projects**. Each change asks first, and never merges into or replaces a skill wi T3 Code leaves both and says so. When git tracks a project skill that leaves its project, the confirmation says you can undo it with git. +Such a skill is linked into each project that uses it, outside git, and into the worktrees T3 Code +makes for those projects. The agents that read `.agents/skills` have it in all of them. Turning on +an agent with a folder of its own, such as Claude, adds its link in each of those projects, never +in Global. If a moved skill's installer record can't go with it, T3 Code says the skill won't +update from its source any more. + **Delete** removes the skill's folder and the links to it, and can't be undone. Only a skill kept in an agent's own skill folder can be deleted. One that is only linked there, such as a skill from a synced folder, stays where it is. From ac9518236d1f6c163c8e4d40dd13688c7364cc0f Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:53:07 -0400 Subject: [PATCH 035/108] fix(web): section and group switches follow their rows Co-Authored-By: Claude Sonnet 5.5 --- .../settings/SkillsSettings.logic.test.ts | 28 +++++++++++++------ .../settings/SkillsSettings.logic.ts | 13 ++++----- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/settings/SkillsSettings.logic.test.ts b/apps/web/src/components/settings/SkillsSettings.logic.test.ts index 5fe861cf611c..ee23e21b76c8 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.test.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.test.ts @@ -574,18 +574,13 @@ describe("one switch for every agent", () => { expect(planTurnOnAll([stuck], ctx)).toBeNull(); }); - it("is a section's switch only when every skill is on for every agent that can be switched", () => { + it("is a section's switch exactly when every row switch in it is on", () => { expect(listSwitchOn([on("a"), on("b")], ctx)).toBe(true); - expect(listSwitchOn([on("a"), some("b")], ctx)).toBe(false); + // A row on for only some agents has its switch on, so the section's switch follows. + expect(listSwitchOn([on("a"), some("b")], ctx)).toBe(true); expect(listSwitchOn([on("a"), off("b")], ctx)).toBe(false); expect(listSwitchOn([], ctx)).toBe(false); - // The agent T3 Code can't switch is left out of the question. - const withoutCursor = reached("c", { - claudeAgent: { state: "link", folder: "~/.claude/skills" }, - codex: { state: "direct", folder: "~/.agents/skills" }, - cursor: { state: "none", folder: "~/.cursor/skills", fixed: true }, - }); - expect(listSwitchOn([withoutCursor], ctx)).toBe(true); + expect(listSwitchOn([on("a")], { installed: [] })).toBe(false); }); it("turns a whole section on for all agents without asking", () => { @@ -598,6 +593,21 @@ describe("one switch for every agent", () => { expect(plan?.confirmation).toBeUndefined(); }); + it("fills in the agents that are off on rows that are already on when turning a section on", () => { + const plan = planListSwitch([some("a"), off("b")], ctx); + expect(plan?.change).toEqual({ + kind: "enable", + skills: [ref("a"), ref("b")], + agents: ["claudeAgent", "codex", "cursor"], + }); + }); + + it("turns a section off for every agent when its rows are all on, even for only some agents", () => { + const plan = planListSwitch([on("a"), some("b")], ctx); + expect(plan?.change).toMatchObject({ kind: "disable", skills: [ref("a"), ref("b")] }); + expect(plan?.confirmation?.title).toBe("Turn off 2 skills for every agent?"); + }); + it("asks before turning a whole section off, and says what stays on", () => { const plan = planListSwitch([on("a"), on("b", { fixed: true })], ctx); expect(plan?.change).toEqual({ diff --git a/apps/web/src/components/settings/SkillsSettings.logic.ts b/apps/web/src/components/settings/SkillsSettings.logic.ts index 2e8c78646f56..2b28ee4f9fc8 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.ts @@ -250,13 +250,9 @@ export function switchBlocker(skill: Skill, agent: SkillAgent) { export const rowSwitchOn = (skill: Skill, ctx: SkillsContext) => ctx.installed.some((agent) => hasAccess(skill, agent)); -/** - * A section's or group's switch is on when every skill in it is on for every agent that can be - * switched. Agents T3 Code can't switch are left out, since the switch could never reach them. - */ +/** A section's or group's switch is on when every row switch in it is on. */ export const listSwitchOn = (skills: readonly Skill[], ctx: SkillsContext) => - skills.length > 0 && - skills.every((skill) => switchableAgents(skill, ctx).every((agent) => hasAccess(skill, agent))); + skills.length > 0 && skills.every((skill) => rowSwitchOn(skill, ctx)); /** Turning one agent on for one skill, or off. Off asks first when other agents lose it too. */ export function planToggle(skill: Skill, agent: SkillAgent, ctx: SkillsContext) { @@ -325,7 +321,10 @@ export const planRowSwitch = (skill: Skill, ctx: SkillsContext) => ? planTurnOffAll([skill], ctx, { ask: false }) : planTurnOnAll([skill], ctx); -/** What a section's or group's switch does. Turning off many skills asks first. */ +/** + * What a section's or group's switch does. On turns every skill on for every agent, filling in + * agents that were off on rows already on; off asks first. + */ export const planListSwitch = (skills: readonly Skill[], ctx: SkillsContext) => listSwitchOn(skills, ctx) ? planTurnOffAll(skills, ctx, { ask: true }) From 3506fa784f9e5279810ab4b3d85621565c73f5cc Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:53:10 -0400 Subject: [PATCH 036/108] fix(web): tidy skill row panel and group row on phones Buttons sit on the chips' line in the expanded panel, and group rows hide their agent icons below the sm breakpoint so the source fits. Co-Authored-By: Claude Sonnet 5.5 --- .../web/src/components/settings/SkillList.tsx | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/settings/SkillList.tsx b/apps/web/src/components/settings/SkillList.tsx index f0063829088d..0acaea79699d 100644 --- a/apps/web/src/components/settings/SkillList.tsx +++ b/apps/web/src/components/settings/SkillList.tsx @@ -158,28 +158,29 @@ const SkillRow = memo(function SkillRow({ {open && !selecting && (
    {ctx.installed.length === 0 ? (

    No agents are installed.

    ) : ( -
    - {ctx.installed.map((agent) => ( - { - const plan = planToggle(skill, agent, ctx); - if (plan) onPlan(plan); - }} - /> - ))} -
    + ctx.installed.map((agent) => ( + { + const plan = planToggle(skill, agent, ctx); + if (plan) onPlan(plan); + }} + /> + )) )} -
    +
    - + + + {!selecting && ( <> From a757d5dd3a19ffefe38c7600d2d2842d6819c607 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:11:56 -0400 Subject: [PATCH 037/108] fix(server): change skills under the filesystem write scope, and check tracking's folder Turning skills on or off, moving them and deleting them make and remove links, write the agents' own settings files and move and delete skill folders, but took the orchestration operate scope. They now take the filesystem write scope, as writing a project file does, and the Skills page's switches follow it, since they read the same grant. The git tracking check ran `git ls-files` in any absolute folder it was given. It now takes the filesystem read scope, and the skill catalog's lookup that it goes through refuses a folder that isn't a registered project's workspace root, like the list and a single skill do. The skills MCP tools keep their own gate: reads are open to any caller, and changes need a live full-access thread or a client approved above read-only, which is stricter than the scope. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/auth/RpcAuthorization.test.ts | 16 ++++++------ apps/server/src/auth/RpcAuthorization.ts | 3 ++- apps/server/src/skills/SkillCatalog.ts | 11 +++----- apps/server/src/skills/SkillTracking.test.ts | 25 +++++++++++++++++++ apps/server/src/skills/SkillTracking.ts | 9 ++++--- .../src/state/commandPermissions.test.ts | 23 +++++++++++------ .../contracts/src/clientRpcPermissions.ts | 11 +++++--- packages/contracts/src/rpc.ts | 2 +- 8 files changed, 70 insertions(+), 30 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 62117df8736d..b95089a4a239 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -2,6 +2,7 @@ import { AuthEnvironmentMaintainScope, AuthDiagnosticsReadScope, AuthFilesystemReadScope, + AuthFilesystemWriteScope, AuthProvidersManageScope, AuthSettingsWriteScope, DEFAULT_SERVER_SETTINGS, @@ -60,23 +61,24 @@ describe("RPC authorization scopes", () => { } }); - it("reads skill folders and SKILL.md text under the filesystem read scope", () => { - for (const method of [WS_METHODS.serverListSkills, WS_METHODS.serverGetSkill]) { + it("reads skill folders, SKILL.md text and git tracking under the filesystem read scope", () => { + for (const method of [ + WS_METHODS.serverListSkills, + WS_METHODS.serverGetSkill, + WS_METHODS.serverSkillsTracked, + ]) { expect(requiredScopeForRpcMethod(method)).toBe(AuthFilesystemReadScope); } - expect(requiredScopeForRpcMethod(WS_METHODS.serverSkillsTracked)).toBe( - AuthOrchestrationReadScope, - ); }); - it("doesn't let a read-only client change which agents use skills", () => { + it("changes skills, which writes links, settings files and folders, under filesystem write", () => { for (const method of [ WS_METHODS.serverEnableSkills, WS_METHODS.serverDisableSkills, WS_METHODS.serverPlaceSkills, WS_METHODS.serverDeleteSkills, ]) { - expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationOperateScope); + expect(requiredScopeForRpcMethod(method)).toBe(AuthFilesystemWriteScope); } }); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 8100cd7bf29d..db9b2acc6db0 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -64,7 +64,8 @@ export const RPC_REQUIRED_SCOPES = { // reads take, not the orchestration read scope that thread readers hold. [WS_METHODS.serverListSkills]: AuthFilesystemReadScope, [WS_METHODS.serverGetSkill]: AuthFilesystemReadScope, - [WS_METHODS.serverSkillsTracked]: AuthOrchestrationReadScope, + // `git ls-files` in the project's folder. + [WS_METHODS.serverSkillsTracked]: AuthFilesystemReadScope, [WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope, [WS_METHODS.providerAuthStart]: AuthProvidersManageScope, [WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope, diff --git a/apps/server/src/skills/SkillCatalog.ts b/apps/server/src/skills/SkillCatalog.ts index 592fe14a56e3..1f472e9de30b 100644 --- a/apps/server/src/skills/SkillCatalog.ts +++ b/apps/server/src/skills/SkillCatalog.ts @@ -249,12 +249,13 @@ export class SkillCatalog extends Context.Service< readonly get: (input: SkillGetInput) => Effect.Effect; /** * Every skill in the agents' folders with this scope and name, as the folders hold it now. - * A project skill needs `cwd`. Nothing is written. + * A project skill needs `cwd`, which must be a registered project's workspace root like the + * one `list` takes. Nothing is written. */ readonly resolve: (input: { readonly cwd?: string | undefined; readonly skills: ReadonlyArray<{ readonly scope: SkillScope; readonly name: string }>; - }) => Effect.Effect>; + }) => Effect.Effect, SkillRequestError>; } >()("t3/skills/SkillCatalog") {} @@ -416,10 +417,6 @@ const make = Effect.gen(function* () { return cwd; }); - /** A project's folder as `resolve` takes it: a relative one names no project. */ - const absoluteCwd = (cwd: string | undefined) => - cwd !== undefined && path.isAbsolute(cwd) ? cwd : undefined; - /** A global folder as shown to the user: `~/...` under the home directory, else its path. */ const globalLabel = (directory: string) => { const relative = path.relative(homeDirectory, directory); @@ -900,7 +897,7 @@ const make = Effect.gen(function* () { const resolve: SkillCatalog["Service"]["resolve"] = Effect.fn("SkillCatalog.resolve")( function* (input) { - const cwd = absoluteCwd(input.cwd); + const cwd = yield* requireProject(input.cwd); const wanted = input.skills.filter( (skill) => isSkillFolderName(skill.name) && (skill.scope === "global" || cwd !== undefined), ); diff --git a/apps/server/src/skills/SkillTracking.test.ts b/apps/server/src/skills/SkillTracking.test.ts index 2f865f45c521..93d76405c4d2 100644 --- a/apps/server/src/skills/SkillTracking.test.ts +++ b/apps/server/src/skills/SkillTracking.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { ProjectId, + SkillRequestError, type Project, type SkillRef, type SkillScope, @@ -215,6 +216,30 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillTracking", (it }), ); + it.effect("refuses a folder that isn't a registered project, without running git", () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* git(project, ["init"]); + yield* git(project, ["add", "."]); + yield* git(project, ["commit", "-m", "everything"]); + const skills = [ + { scope: "project", name: "verify", home: ".agents/skills/verify" }, + ] as const; + const refused = yield* onMachine( + home, + ({ tracking }) => tracking.tracked({ cwd: project, skills }).pipe(Effect.flip), + [], + ); + expect(refused).toEqual(new SkillRequestError({ reason: "projectNotRegistered" })); + + // The same folder is read once it is a project. + const result = yield* onMachine(home, ({ tracking }) => + tracking.tracked({ cwd: project, skills }), + ); + expect(result.tracked).toEqual(["verify"]); + }), + ); + it.effect("tracks nothing outside a git repository", () => Effect.gen(function* () { const { home, project } = yield* makeMachine; diff --git a/apps/server/src/skills/SkillTracking.ts b/apps/server/src/skills/SkillTracking.ts index ab24746ff151..3dc1a063954f 100644 --- a/apps/server/src/skills/SkillTracking.ts +++ b/apps/server/src/skills/SkillTracking.ts @@ -8,7 +8,7 @@ * * @module SkillTracking */ -import type { SkillTrackedInput, SkillTrackedResult } from "@t3tools/contracts"; +import type { SkillRequestError, SkillTrackedInput, SkillTrackedResult } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -27,9 +27,12 @@ export class SkillTracking extends Context.Service< * The names of the project skills among `skills` whose SKILL.md git tracks. A skill that * isn't where the client said, isn't a project skill, sits outside the repository, or whose * folder is only reached through a link counts as not tracked, and so does every skill when - * git fails. + * git fails. The folder must be a registered project's workspace root, or the request is + * refused before git runs. */ - readonly tracked: (input: SkillTrackedInput) => Effect.Effect; + readonly tracked: ( + input: SkillTrackedInput, + ) => Effect.Effect; } >()("t3/skills/SkillTracking") {} diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index a488a86831b4..05ae6a8a749d 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -7,6 +7,7 @@ import type { RpcSession } from "../rpc/session.ts"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import { + AuthFilesystemWriteScope, AuthOrchestrationOperateScope, AuthSettingsWriteScope, AuthSourceControlWriteScope, @@ -289,10 +290,15 @@ it.effect("rejects protected unary and streamed RPCs outside a guarded command", }), ); -it.effect("needs the operate grant to change skills, but not to list or read them", () => +it.effect("needs the filesystem write grant to change skills, but not to list or read them", () => Effect.scoped( Effect.gen(function* () { const registry = yield* setup; + const writeGrant: AuthSessionState = { + ...grant(false), + scopes: [AuthFilesystemWriteScope], + permissions: [AuthFilesystemWriteScope], + }; for (const method of [ WS_METHODS.serverEnableSkills, WS_METHODS.serverDisableSkills, @@ -300,12 +306,15 @@ it.effect("needs the operate grant to change skills, but not to list or read the WS_METHODS.serverDeleteSkills, ]) { const change = createCommandPermissions(runtime, method); - registry.set(sessions(env), AsyncResult.success(grant(false))); - expect(registry.get(change.permissionAtom(env))).toBe(false); - expect((yield* change.authorize(registry, env).pipe(Effect.flip)).requiredScope).toBe( - AuthOrchestrationOperateScope, - ); - registry.set(sessions(env), AsyncResult.success(grant(true))); + // Being allowed to operate threads isn't enough to change files. + for (const withoutWrite of [grant(false), grant(true)]) { + registry.set(sessions(env), AsyncResult.success(withoutWrite)); + expect(registry.get(change.permissionAtom(env))).toBe(false); + expect( + (yield* change.authorize(registry, env).pipe(Effect.flip)).requiredPermission, + ).toBe(AuthFilesystemWriteScope); + } + registry.set(sessions(env), AsyncResult.success(writeGrant)); expect(registry.get(change.permissionAtom(env))).toBe(true); yield* change.authorize(registry, env); } diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index f7e22a4f732a..1a00abeb843d 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -1,6 +1,7 @@ import * as Schema from "effect/Schema"; import { GitPreparePullRequestThreadInput } from "./git.ts"; import { + AuthFilesystemWriteScope, AuthOrchestrationOperateScope, AuthSettingsWriteScope, AuthSourceControlWriteScope, @@ -36,10 +37,12 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.vcsSwitchRef]: AuthSourceControlWriteScope, [WS_METHODS.vcsInit]: AuthSourceControlWriteScope, - [WS_METHODS.serverEnableSkills]: AuthOrchestrationOperateScope, - [WS_METHODS.serverDisableSkills]: AuthOrchestrationOperateScope, - [WS_METHODS.serverPlaceSkills]: AuthOrchestrationOperateScope, - [WS_METHODS.serverDeleteSkills]: AuthOrchestrationOperateScope, + // Skill changes create and remove links, write the agents' settings files, and move and delete + // skill folders, so they take the scope the other file writes take. + [WS_METHODS.serverEnableSkills]: AuthFilesystemWriteScope, + [WS_METHODS.serverDisableSkills]: AuthFilesystemWriteScope, + [WS_METHODS.serverPlaceSkills]: AuthFilesystemWriteScope, + [WS_METHODS.serverDeleteSkills]: AuthFilesystemWriteScope, [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5f8f725d0f40..9dda9ef45387 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -658,7 +658,7 @@ const WsServerDeleteSkillsRpc = Rpc.make(WS_METHODS.serverDeleteSkills, { const WsServerSkillsTrackedRpc = Rpc.make(WS_METHODS.serverSkillsTracked, { payload: SkillTrackedInput, success: SkillTrackedResult, - error: EnvironmentAuthorizationError, + error: Schema.Union([SkillRequestError, EnvironmentAuthorizationError]), }); const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { From 943158e3e743a4641777aee4b7279d07d63a8214 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:12:07 -0400 Subject: [PATCH 038/108] fix(server): read a Codex skill's setting from the home Codex runs in An instance with a shadow home runs Codex there, so the app-server writes the skill setting to the shadow home's config.toml, but the switch was read back from the shared home's. When the shared config.toml didn't exist as the instance started, the shadow home keeps a file of its own, so the write landed there and the read-back reported `failed`. The catalog now gives the switches the home Codex runs in, which the page's own "is Codex switched off" check reads too. The skill folders still follow the instance's home, and the shadow home's links are untouched. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/skills/CodexSkillSettings.ts | 29 ++++++++ apps/server/src/skills/SkillCatalog.ts | 8 ++- apps/server/src/skills/SkillSwitches.test.ts | 69 ++++++++++++++++++-- 3 files changed, 99 insertions(+), 7 deletions(-) diff --git a/apps/server/src/skills/CodexSkillSettings.ts b/apps/server/src/skills/CodexSkillSettings.ts index c855c8f09ae4..5af3e574f72e 100644 --- a/apps/server/src/skills/CodexSkillSettings.ts +++ b/apps/server/src/skills/CodexSkillSettings.ts @@ -20,13 +20,21 @@ * - A project's `.codex/config.toml` is not read: its skill rules did not apply in an untrusted * project, and the page keeps to the user's own file. * + * An instance with a shadow home (`shadowHomePath`) runs Codex there, so the app-server writes + * the shadow home's `config.toml` and that is the file to read back (`codexSettingsHome`). The + * shadow home links the shared home's `config.toml` only when it existed as the instance started; + * without one the write lands in a file of the shadow home's own. + * * @module CodexSkillSettings */ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { parse as parseToml } from "smol-toml"; import type { SkillSettingsChange } from "@t3tools/provider-core/server/driver"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import type { SkillSwitchContext, SkillSwitchView, SwitchedSkill } from "./AgentSkillSettings.ts"; @@ -39,6 +47,27 @@ export interface CodexSkillRule { const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +const decodeShadowHome = Schema.decodeUnknownOption( + Schema.Struct({ shadowHomePath: Schema.optional(Schema.String) }), +); + +/** + * The home an instance's Codex runs with, whose `config.toml` its app-server writes: the shadow + * home when the instance has one, else its home (`sharedHome`). The driver's own layout makes + * the same choice (`resolveCodexHomeLayout`); the skill folders stay where `sharedHome` says, + * since the shadow home links the shared `skills` folder. + */ +export const codexSettingsHome = ( + path: Path.Path, + instanceConfig: unknown, + sharedHome: string, + homeDirectory: string, +) => { + const shadow = + Option.getOrUndefined(decodeShadowHome(instanceConfig))?.shadowHomePath?.trim() ?? ""; + return shadow === "" ? sharedHome : path.resolve(expandHomePath(shadow, homeDirectory)); +}; + /** The rules in the user's config, in file order; none when it is missing or can't be parsed. */ export const readCodexSkillRules = (context: SkillSwitchContext) => Effect.gen(function* () { diff --git a/apps/server/src/skills/SkillCatalog.ts b/apps/server/src/skills/SkillCatalog.ts index 1f472e9de30b..3c7b5603f2aa 100644 --- a/apps/server/src/skills/SkillCatalog.ts +++ b/apps/server/src/skills/SkillCatalog.ts @@ -71,6 +71,7 @@ import { type SkillSwitchView, type SwitchedSkill, } from "./AgentSkillSettings.ts"; +import { codexSettingsHome } from "./CodexSkillSettings.ts"; import { LIBRARY_FOLDER, RegisteredProjects, @@ -493,7 +494,12 @@ const make = Effect.gen(function* () { reads, switches: { driver: table.agent, - configHome, + // The skill folders follow the instance's home; its settings file is the one its + // Codex runs with, which is the shadow home's when it has one. + configHome: + table.agent === "codex" + ? codexSettingsHome(path, config.config, configHome, homeDirectory) + : configHome, homeDirectory, environment: yield* mergeProviderInstanceEnvironment( config.environment, diff --git a/apps/server/src/skills/SkillSwitches.test.ts b/apps/server/src/skills/SkillSwitches.test.ts index df5b98d0f003..a02dbbb0737a 100644 --- a/apps/server/src/skills/SkillSwitches.test.ts +++ b/apps/server/src/skills/SkillSwitches.test.ts @@ -91,6 +91,11 @@ const withManager = ( options: { readonly codex?: CodexDouble; readonly env?: NodeJS.ProcessEnv; + /** Provider instances over the defaults, such as a Codex instance with a shadow home. */ + readonly instances?: Record< + string, + { driver: ProviderDriverKind; enabled: boolean; config?: unknown } + >; }, use: (services: { readonly manager: SkillManager.SkillManager["Service"]; @@ -124,12 +129,15 @@ const withManager = ( const catalog = SkillCatalog.layer.pipe( Layer.provide( Settings.layerTest({ - providerInstances: Object.fromEntries( - ["cursor", "grok", "opencode", "antigravity", "pi"].map((driver) => [ - ProviderInstanceId.make(driver), - { driver: ProviderDriverKind.make(driver), enabled: true }, - ]), - ), + providerInstances: { + ...Object.fromEntries( + ["cursor", "grok", "opencode", "antigravity", "pi"].map((driver) => [ + ProviderInstanceId.make(driver), + { driver: ProviderDriverKind.make(driver), enabled: true }, + ]), + ), + ...options.instances, + }, }), ), ); @@ -235,6 +243,55 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("agent skill switche }), ); + it.effect.skipIf(!symlinksSupported)( + "reads the setting back from the shadow home Codex runs in, not the shared home", + () => + Effect.gen(function* () { + const { fs, home } = yield* makeMachine; + // The shared home has no config.toml, so the shadow home keeps a file of its own, and + // that is where Codex's app-server writes. + const shared = `${home}/shared-codex`; + const shadow = `${home}/shadow-codex`; + const codex = yield* makeCodexDouble(shadow); + yield* withManager( + home, + [], + { + codex, + instances: { + codex: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: { homePath: shared, shadowHomePath: shadow }, + }, + }, + }, + ({ manager, catalog }) => + Effect.gen(function* () { + const { skills } = yield* catalog.list({}); + const alpha = refOf(skills, "global", "alpha"); + + const off = yield* manager.disable({ skills: [alpha], agents: [agent("codex")] }); + + expect(off.outcomes).toEqual([ + { skill: alpha, status: "changed", blocked: [], affected: [] }, + ]); + expect(yield* fs.readFileString(codex.file)).toContain("enabled = false"); + expect(yield* fs.exists(`${shared}/config.toml`)).toBe(false); + expect(stateOf((yield* catalog.list({})).skills, "global", "alpha").codex).toBe( + "off", + ); + + const on = yield* manager.enable({ skills: [alpha], agents: [agent("codex")] }); + expect(on.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + expect(stateOf((yield* catalog.list({})).skills, "global", "alpha").codex).toBe( + "direct", + ); + }), + ); + }), + ); + it.effect.skipIf(!symlinksSupported)( "starts Codex once for a whole request, and not at all when nothing needs writing", () => From a827543044ce7ae7b0c1d10892cb988989882b4c Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:14:06 -0400 Subject: [PATCH 039/108] refactor(server): link library skills into a new worktree from the git manager The Git driver imported the skills domain and linked a project's library skills into every new worktree it made. The worktree flows (the Git action, thread launch, turn start, the MCP tool and a pull request thread) all reach the driver through GitManager, which already owns worktree policy (the folder, submodules, the setup script), so the link step lives there now and the driver is git only again. The driver files are back to what they are upstream. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/git/GitManager.test.ts | 154 +++++++++++++++++++ apps/server/src/git/GitManager.ts | 19 ++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 112 -------------- apps/server/src/vcs/GitVcsDriverCore.ts | 8 - 4 files changed, 172 insertions(+), 121 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 45f4ae96a133..4cda0a05d826 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -23,6 +23,8 @@ import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; import { expect } from "vite-plus/test"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import type { ChangeRequest, GitActionProgressEvent, @@ -289,6 +291,36 @@ function createBareRemote(): Effect.Effect< }); } +/** + * A repository whose project uses `db-migrations` from the skill library under `home` and `solo` + * from a folder of its own, linked into `.agents/skills` and, for the library skill, also into + * `.claude/skills`. It is committed, so a worktree has the same files but none of the links. + */ +function repoWithLibrarySkill() { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* makeTempDir("t3code-skill-home-"); + const cwd = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(cwd); + const entry = NodePath.join(home, ".agents/skill-library/db-migrations"); + yield* fileSystem.makeDirectory(entry, { recursive: true }); + yield* fileSystem.writeFileString( + NodePath.join(entry, "SKILL.md"), + "---\nname: db-migrations\n---\n", + ); + yield* fileSystem.makeDirectory(NodePath.join(cwd, "elsewhere/solo"), { recursive: true }); + for (const folder of [".agents/skills", ".claude/skills"]) { + yield* fileSystem.makeDirectory(NodePath.join(cwd, folder), { recursive: true }); + yield* fileSystem.symlink(entry, NodePath.join(cwd, folder, "db-migrations")); + } + yield* fileSystem.symlink( + NodePath.join(cwd, "elsewhere/solo"), + NodePath.join(cwd, ".agents/skills/solo"), + ); + return { home, cwd, entry }; + }); +} + function configureRemote( cwd: string, remoteName: string, @@ -4895,6 +4927,128 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "links a project's library skills into a new worktree, and leaves its other links behind", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { home, cwd, entry } = yield* repoWithLibrarySkill(); + const { manager } = yield* makeManager(); + const worktree = NodePath.join(yield* makeTempDir("t3code-git-worktrees-"), "feature"); + + const created = yield* manager + .createWorktree({ + cwd, + path: worktree, + refName: "main", + newRefName: "feature/library-links", + }) + .pipe(Effect.provideService(HostProcess.HomeDirectory, home)); + + expect(created.worktree.path).toBe(worktree); + for (const folder of [".agents/skills", ".claude/skills"]) { + expect(yield* fileSystem.readLink(NodePath.join(worktree, folder, "db-migrations"))).toBe( + entry, + ); + } + // A link to something other than the library is the project's own business. + expect(yield* fileSystem.exists(NodePath.join(worktree, ".agents/skills/solo"))).toBe( + false, + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "links a project's library skills into the worktree of a pull request thread", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { home, cwd, entry } = yield* repoWithLibrarySkill(); + const remoteDir = yield* createBareRemote(); + yield* runGit(cwd, ["remote", "add", "origin", remoteDir]); + yield* runGit(cwd, ["push", "-u", "origin", "main"]); + yield* runGit(cwd, ["checkout", "-b", "feature/pr-links"]); + yield* fileSystem.writeFileString(NodePath.join(cwd, "pr.txt"), "pr\n"); + yield* runGit(cwd, ["add", "pr.txt"]); + yield* runGit(cwd, ["commit", "-m", "PR branch"]); + yield* runGit(cwd, ["push", "-u", "origin", "feature/pr-links"]); + yield* runGit(cwd, ["push", "origin", "HEAD:refs/pull/78/head"]); + yield* runGit(cwd, ["checkout", "main"]); + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 78, + title: "Library links PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/78", + baseRefName: "main", + headRefName: "feature/pr-links", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd, + reference: "78", + mode: "worktree", + }).pipe(Effect.provideService(HostProcess.HomeDirectory, home)); + + expect(result.worktreePath).not.toBeNull(); + expect( + yield* fileSystem.readLink( + NodePath.join(result.worktreePath as string, ".agents/skills/db-migrations"), + ), + ).toBe(entry); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "makes the worktree all the same when the library links can't be made", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { home, cwd } = yield* repoWithLibrarySkill(); + // On this branch `.agents` is a file, so no folder can be made under it. + yield* runGit(cwd, ["checkout", "-q", "-b", "agents-file"]); + yield* fileSystem.remove(NodePath.join(cwd, ".agents"), { recursive: true }); + yield* fileSystem.writeFileString(NodePath.join(cwd, ".agents"), "not a folder\n"); + yield* runGit(cwd, ["add", "-A"]); + yield* runGit(cwd, ["commit", "-q", "-m", "agents is a file"]); + yield* runGit(cwd, ["checkout", "-q", "main"]); + yield* fileSystem.makeDirectory(NodePath.join(cwd, ".agents/skills"), { recursive: true }); + yield* fileSystem.symlink( + NodePath.join(home, ".agents/skill-library/db-migrations"), + NodePath.join(cwd, ".agents/skills/db-migrations"), + ); + const { manager } = yield* makeManager(); + const worktree = NodePath.join(yield* makeTempDir("t3code-git-worktrees-"), "feature"); + const warnings: string[] = []; + const logger = Logger.make(({ message }) => { + warnings.push(String(message)); + }); + + const created = yield* manager + .createWorktree({ + cwd, + path: worktree, + refName: "agents-file", + newRefName: "feature/no-links", + }) + .pipe( + Effect.provideService(HostProcess.HomeDirectory, home), + Effect.provideService(Logger.CurrentLoggers, new Set([logger])), + ); + + expect(created.worktree.path).toBe(worktree); + expect( + warnings.filter((line) => line.includes("could not link library skills")).length, + ).toBe(1); + expect(yield* fileSystem.readFileString(NodePath.join(worktree, ".agents"))).toBe( + "not a folder\n", + ); + }), + ); + it.effect("prepares pull request threads in worktree mode on the PR head branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8ca0b6b976a9..5a3a94ce3d74 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -82,6 +82,7 @@ import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import { restoreLibraryLinks } from "../skills/SkillLibrary.ts"; import type { ChangeRequest } from "@t3tools/contracts"; export interface GitActionProgressReporter { @@ -746,6 +747,15 @@ export const make = Effect.gen(function* () { Effect.map((settings) => settings.worktreesDirectory), Effect.orElseSucceed(() => ""), ); + /** + * A project's links to its library skills sit outside git, so a new checkout has none until + * they are made. A failure is logged and goes no further: the checkout is made either way. + */ + const linkLibrarySkills = (project: string, worktree: string) => + restoreLibraryLinks({ project, worktree }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); const createWorktree: GitManager["Service"]["createWorktree"] = Effect.fn( "GitManager.createWorktree", )(function* (input, options) { @@ -757,7 +767,13 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => null), ); const worktreesDirectory = yield* readWorktreesDirectory; - return yield* gitCore.createWorktree(input, { worktreesDirectory, ...options, submodules }); + const created = yield* gitCore.createWorktree(input, { + worktreesDirectory, + ...options, + submodules, + }); + yield* linkLibrarySkills(input.cwd, created.worktree.path); + return created; }); const readRepositoryInstructions = (cwd: string, fileName: string) => @@ -2655,6 +2671,7 @@ export const make = Effect.gen(function* () { ), }, ); + yield* linkLibrarySkills(input.cwd, worktree.worktree.path); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); yield* maybeRunSetupScript(worktree.worktree.path); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index cf19412fd1cb..fc0cdef01e5b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2,7 +2,6 @@ import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { assert, it, describe } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -3044,117 +3043,6 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - describe("a project's library skills", () => { - /** A project that uses `db-migrations` from the library and `solo` from a folder of its own. */ - const projectWithLibrarySkill = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const home = yield* makeTmpDir("skill-home-"); - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const entry = pathService.join(home, ".agents/skill-library/db-migrations"); - yield* writeTextFile(entry, "SKILL.md", "---\nname: db-migrations\n---\n"); - yield* writeTextFile(cwd, "elsewhere/solo/SKILL.md", "---\nname: solo\n---\n"); - for (const folder of [".agents/skills", ".claude/skills"]) { - yield* fileSystem.makeDirectory(pathService.join(cwd, folder), { recursive: true }); - yield* fileSystem.symlink(entry, pathService.join(cwd, folder, "db-migrations")); - } - yield* fileSystem.symlink( - pathService.join(cwd, "elsewhere/solo"), - pathService.join(cwd, ".agents/skills/solo"), - ); - return { home, cwd, entry, initialBranch }; - }); - - it.effect.skipIf(!symlinksSupported)( - "links them into a new worktree, and leaves a project's other links behind", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const { home, cwd, entry, initialBranch } = yield* projectWithLibrarySkill; - const driver = yield* GitVcsDriver.GitVcsDriver; - - const created = yield* driver - .createWorktree({ - cwd, - path: pathService.join(yield* makeTmpDir("git-worktrees-"), "feature"), - refName: initialBranch, - newRefName: "feature/library-links", - }) - .pipe(Effect.provideService(HostProcess.HomeDirectory, home)); - - for (const folder of [".agents/skills", ".claude/skills"]) { - assert.equal( - yield* fileSystem.readLink( - pathService.join(created.worktree.path, folder, "db-migrations"), - ), - entry, - ); - } - // A link to something other than the library is the project's own business. - assert.equal( - yield* fileSystem.exists( - pathService.join(created.worktree.path, ".agents/skills/solo"), - ), - false, - ); - }), - ); - - it.effect.skipIf(!symlinksSupported)( - "makes the worktree all the same when the links can't be made", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const { home, cwd, initialBranch } = yield* projectWithLibrarySkill; - // On this branch `.agents` is a file, so no folder can be made under it. - yield* git(cwd, ["checkout", "-q", "-b", "agents-file"]); - yield* fileSystem.remove(pathService.join(cwd, ".agents"), { recursive: true }); - yield* writeTextFile(cwd, ".agents", "not a folder\n"); - yield* git(cwd, ["add", "-A"]); - yield* git(cwd, ["commit", "-q", "-m", "agents is a file"]); - yield* git(cwd, ["checkout", "-q", initialBranch]); - yield* fileSystem.makeDirectory(pathService.join(cwd, ".agents/skills"), { - recursive: true, - }); - yield* fileSystem.symlink( - pathService.join(home, ".agents/skill-library/db-migrations"), - pathService.join(cwd, ".agents/skills/db-migrations"), - ); - const driver = yield* GitVcsDriver.GitVcsDriver; - const worktree = pathService.join(yield* makeTmpDir("git-worktrees-"), "feature"); - const warnings: string[] = []; - const logger = Logger.make(({ message }) => { - warnings.push(String(message)); - }); - - const created = yield* driver - .createWorktree({ - cwd, - path: worktree, - refName: "agents-file", - newRefName: "feature/no-links", - }) - .pipe( - Effect.provideService(HostProcess.HomeDirectory, home), - Effect.provideService(Logger.CurrentLoggers, new Set([logger])), - ); - - assert.equal(created.worktree.path, worktree); - assert.equal( - warnings.filter((line) => line.includes("could not link library skills")).length, - 1, - ); - assert.equal( - yield* fileSystem.readFileString(pathService.join(worktree, ".agents")), - "not a folder\n", - ); - }), - ); - }); - it.effect("resolves the submodule mode from the option, then t3.json", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3dafd5e30cd7..51c1f7b29468 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -36,7 +36,6 @@ import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; -import { restoreLibraryLinks } from "../skills/SkillLibrary.ts"; import { resolveWorktreesDirectory } from "../worktreesDirectory.ts"; import { parseRemoteNames, @@ -3522,13 +3521,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } - // A project's links to its library skills sit outside git, so the new checkout has none until - // they are made. This logs a failure and goes on: the worktree is made either way. - yield* restoreLibraryLinks({ project: input.cwd, worktree: worktreePath }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); - if (input.newRefName && input.baseRefName) { const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); const parsedBaseRef = parseRemoteRefWithRemoteNames( From 3f607f84423fadd96d17a251e090c92d74f7ccfd Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:16:56 -0400 Subject: [PATCH 040/108] fix(server): link a library skill into a worktree at the project's own folder A worktree is a checkout of the whole repository, so when a project is a folder inside its repository (a monorepo's apps/site) its folder in the worktree is under the worktree's root. The links a new worktree gets, and the ones removed from a project's worktrees when it stops using the skill, were joined onto the worktree's root, so they landed in or were looked for in the wrong folder. Both now go through the project's folder in the repository, as `git rev-parse --show-prefix` gives it. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/git/GitManager.test.ts | 41 ++++++++++-- apps/server/src/git/GitManager.ts | 20 ++++-- apps/server/src/skills/SkillGitExclude.ts | 23 +++++++ apps/server/src/skills/SkillLibrary.test.ts | 31 +++++++-- apps/server/src/skills/SkillLibrary.ts | 12 +++- apps/server/src/skills/SkillPlacement.test.ts | 67 +++++++++++++++++-- apps/server/src/skills/SkillPlacement.ts | 6 +- 7 files changed, 178 insertions(+), 22 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 4cda0a05d826..3db1abe6da2f 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -294,14 +294,17 @@ function createBareRemote(): Effect.Effect< /** * A repository whose project uses `db-migrations` from the skill library under `home` and `solo` * from a folder of its own, linked into `.agents/skills` and, for the library skill, also into - * `.claude/skills`. It is committed, so a worktree has the same files but none of the links. + * `.claude/skills`. The project is the repository's `subfolder` when given, else the repository + * itself. The links are untracked, so a worktree has the same files but none of them. */ -function repoWithLibrarySkill() { +function repoWithLibrarySkill(subfolder = "") { return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* makeTempDir("t3code-skill-home-"); - const cwd = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(cwd); + const root = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(root); + const cwd = NodePath.join(root, subfolder); + yield* fileSystem.makeDirectory(cwd, { recursive: true }); const entry = NodePath.join(home, ".agents/skill-library/db-migrations"); yield* fileSystem.makeDirectory(entry, { recursive: true }); yield* fileSystem.writeFileString( @@ -317,7 +320,7 @@ function repoWithLibrarySkill() { NodePath.join(cwd, "elsewhere/solo"), NodePath.join(cwd, ".agents/skills/solo"), ); - return { home, cwd, entry }; + return { home, root, cwd, entry }; }); } @@ -4958,6 +4961,34 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "links a project's library skills at its own folder of the worktree when it is a folder in its repository", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { home, cwd, entry } = yield* repoWithLibrarySkill("apps/site"); + const { manager } = yield* makeManager(); + const worktree = NodePath.join(yield* makeTempDir("t3code-git-worktrees-"), "feature"); + + yield* manager + .createWorktree({ + cwd, + path: worktree, + refName: "main", + newRefName: "feature/site-links", + }) + .pipe(Effect.provideService(HostProcess.HomeDirectory, home)); + + expect( + yield* fileSystem.readLink( + NodePath.join(worktree, "apps/site/.agents/skills/db-migrations"), + ), + ).toBe(entry); + // The worktree's root is not the project, so nothing lands there. + expect(yield* fileSystem.exists(NodePath.join(worktree, ".agents"))).toBe(false); + }), + ); + it.effect.skipIf(!symlinksSupported)( "links a project's library skills into the worktree of a pull request thread", () => diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 5a3a94ce3d74..8f7ad38d2cc7 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -752,10 +752,22 @@ export const make = Effect.gen(function* () { * they are made. A failure is logged and goes no further: the checkout is made either way. */ const linkLibrarySkills = (project: string, worktree: string) => - restoreLibraryLinks({ project, worktree }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); + gitCore + .execute({ + operation: "GitManager.linkLibrarySkills", + cwd: project, + args: ["rev-parse", "--show-prefix"], + allowNonZeroExit: true, + }) + .pipe( + // The project is a folder inside the repository when this isn't empty, and the worktree + // has it at the same path under its own root. + Effect.map((result) => (result.exitCode === 0 ? result.stdout.trim() : "")), + Effect.orElseSucceed(() => ""), + Effect.flatMap((prefix) => restoreLibraryLinks({ project, worktree, prefix })), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); const createWorktree: GitManager["Service"]["createWorktree"] = Effect.fn( "GitManager.createWorktree", )(function* (input, options) { diff --git a/apps/server/src/skills/SkillGitExclude.ts b/apps/server/src/skills/SkillGitExclude.ts index eb63ead65caa..39551c155e4a 100644 --- a/apps/server/src/skills/SkillGitExclude.ts +++ b/apps/server/src/skills/SkillGitExclude.ts @@ -141,6 +141,29 @@ export const worktreesOf = Effect.fn("SkillGitExclude.worktreesOf")(function* ( .map((line) => line.slice("worktree ".length)); }); +/** + * The project's folder relative to its repository's root, as `git rev-parse --show-prefix` says: + * empty when the project is the root, and outside a git repository. A checkout of the repository + * has the project at that path under its own root. + */ +export const projectPrefixOf = Effect.fn("SkillGitExclude.projectPrefixOf")(function* ( + projectRoot: string, +) { + const vcs = yield* VcsProcess.VcsProcess; + const result = yield* vcs + .run({ + operation: "SkillGitExclude.projectPrefixOf", + command: "git", + args: ["rev-parse", "--show-prefix"], + cwd: projectRoot, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 16 * 1024, + }) + .pipe(Effect.orElseSucceed(() => undefined)); + return result === undefined || result.exitCode !== 0 ? "" : result.stdout.trim(); +}); + /** * Keeps a file T3 Code has just created in a project out of git: Claude Code's own * `.claude/settings.local.json`, which is the user's and not the repository's. Claude Code does diff --git a/apps/server/src/skills/SkillLibrary.test.ts b/apps/server/src/skills/SkillLibrary.test.ts index 32d4ee3c6574..df67b20a7f3a 100644 --- a/apps/server/src/skills/SkillLibrary.test.ts +++ b/apps/server/src/skills/SkillLibrary.test.ts @@ -266,7 +266,7 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillLibrary", (it) yield* fs.makeDirectory(path.join(worktree, ".agents/skills/alpha"), { recursive: true }); yield* fs.writeFileString(path.join(worktree, ".agents/skills/alpha/SKILL.md"), "kept"); - yield* restoreLibraryLinks({ project: web, worktree }).pipe( + yield* restoreLibraryLinks({ project: web, worktree, prefix: "" }).pipe( Effect.provideService(HostProcess.HomeDirectory, home), ); @@ -281,17 +281,38 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillLibrary", (it) }), ); + it.effect.skipIf(!symlinksSupported)( + "makes the links in the project's own folder of a worktree of the whole repository", + () => + Effect.gen(function* () { + const { fs, path, home, library, web } = yield* makeMachine; + const worktree = path.join(home, "worktrees/monorepo-feature"); + + // The project is `apps/web` of its repository, and the worktree is the repository. + yield* restoreLibraryLinks({ project: web, worktree, prefix: "apps/web/" }).pipe( + Effect.provideService(HostProcess.HomeDirectory, home), + ); + + expect( + yield* fs.readLink(path.join(worktree, "apps/web/.agents/skills/db-migrations")), + ).toBe(path.join(library, "db-migrations")); + expect(yield* fs.exists(path.join(worktree, ".agents"))).toBe(false); + }), + ); + it.effect("does nothing for a project without links, or one that has gone", () => Effect.gen(function* () { const { fs, path, home, marketing } = yield* makeMachine; const worktree = path.join(home, "worktrees/marketing-feature"); - yield* restoreLibraryLinks({ project: marketing, worktree }).pipe( - Effect.provideService(HostProcess.HomeDirectory, home), - ); - yield* restoreLibraryLinks({ project: path.join(home, "repos/gone"), worktree }).pipe( + yield* restoreLibraryLinks({ project: marketing, worktree, prefix: "" }).pipe( Effect.provideService(HostProcess.HomeDirectory, home), ); + yield* restoreLibraryLinks({ + project: path.join(home, "repos/gone"), + worktree, + prefix: "", + }).pipe(Effect.provideService(HostProcess.HomeDirectory, home)); expect(yield* fs.exists(worktree)).toBe(false); }), diff --git a/apps/server/src/skills/SkillLibrary.ts b/apps/server/src/skills/SkillLibrary.ts index b0c39be165b3..8121a0068b25 100644 --- a/apps/server/src/skills/SkillLibrary.ts +++ b/apps/server/src/skills/SkillLibrary.ts @@ -136,9 +136,17 @@ export const libraryLinksOf = Effect.fn("SkillLibrary.libraryLinksOf")(function* * Links to a project's library skills into a worktree that was just made from it. The links sit * outside git, so a worktree has none until they are made. Nothing in the way is replaced, and a * failure is logged and goes no further: a worktree without the links is still a worktree. + * + * A worktree is a checkout of the whole repository, so a project that is a folder inside its + * repository is at `prefix` under the worktree's root. */ export const restoreLibraryLinks = Effect.fn("SkillLibrary.restoreLibraryLinks")( - function* (input: { readonly project: string; readonly worktree: string }) { + function* (input: { + readonly project: string; + readonly worktree: string; + /** The project's folder relative to its repository's root, empty when it is the root. */ + readonly prefix: string; + }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* HostProcess.HomeDirectory; @@ -155,7 +163,7 @@ export const restoreLibraryLinks = Effect.fn("SkillLibrary.restoreLibraryLinks") ); if (target === undefined) continue; if (path.dirname(path.resolve(path.dirname(linkPath), target)) !== library) continue; - const created = path.join(input.worktree, folder, name); + const created = path.join(input.worktree, input.prefix, folder, name); yield* fileSystem.makeDirectory(path.dirname(created), { recursive: true }); // A bare create: something already there, such as a skill the project commits, stays. yield* fileSystem.symlink(target, created).pipe( diff --git a/apps/server/src/skills/SkillPlacement.test.ts b/apps/server/src/skills/SkillPlacement.test.ts index 40bf48b3328a..513f363fcfde 100644 --- a/apps/server/src/skills/SkillPlacement.test.ts +++ b/apps/server/src/skills/SkillPlacement.test.ts @@ -1730,14 +1730,19 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillPlacement", (i }); describe("the git worktrees of a project that uses a library skill", () => { - /** A worktree of `project` with the links the worktree hook makes in it. */ - const addWorktree = (project: string, name: string) => + /** + * A worktree of the repository `project` is in, with the links the worktree hook makes in it. + * `prefix` is the project's folder in the repository, as git says it. + */ + const addWorktree = (project: string, name: string, prefix = "") => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const worktree = path.join(path.dirname(project), `${path.basename(project)}-${name}`); + const up = prefix.split("/").filter((segment) => segment !== ""); + const root = path.resolve(project, ...up.map(() => "..")); + const worktree = path.join(path.dirname(root), `${path.basename(root)}-${name}`); yield* git(project, ["worktree", "add", "-q", "-b", name, worktree]); - yield* restoreLibraryLinks({ project, worktree }).pipe( + yield* restoreLibraryLinks({ project, worktree, prefix }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), ); @@ -1849,6 +1854,60 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillPlacement", (i ); }), ); + + it.effect.skipIf(!symlinksSupported)( + "keeps its links at the same folder of each worktree, and removes them from there", + () => + Effect.gen(function* () { + const { fs, path, home, api, write, library } = yield* makeMachine; + // `repos/mono` is the repository; the project is its `apps/site` folder. + const mono = path.join(home, "repos/mono"); + const site = path.join(mono, "apps/site"); + yield* write("repos/mono/apps/site/README.md", "# site\n"); + yield* git(mono, ["init", "-q", "-b", "main"]); + yield* git(mono, ["config", "core.excludesFile", path.join(home, "global-ignore")]); + yield* git(mono, ["add", "-A"]); + yield* git(mono, ["commit", "-q", "-m", "init"]); + // Untracked, like the skill of the other projects, so a worktree starts without it. + yield* write( + "repos/mono/apps/site/.agents/skills/db-migrations/SKILL.md", + skillFile("db-migrations"), + ); + yield* withManager(home, [site, api], ({ manager, catalog }) => + Effect.gen(function* () { + const verify = refOf( + (yield* catalog.list({ cwd: site })).skills, + "project", + "db-migrations", + ); + yield* manager.place({ + cwd: site, + skills: [verify], + to: { kind: "projects", cwds: [site, api] }, + }); + const entry = path.join(library, "db-migrations"); + const worktree = yield* addWorktree(site, "feature", "apps/site/"); + // The link is in the project's folder of the worktree, not at its root. + expect( + yield* fs.readLink(path.join(worktree, "apps/site/.agents/skills/db-migrations")), + ).toBe(entry); + expect(yield* fs.exists(path.join(worktree, ".agents"))).toBe(false); + + // The project stops using the skill: the worktree's link goes too. + const skill = refOf((yield* catalog.list({})).skills, "global", "db-migrations"); + const moved = yield* manager.place({ + skills: [skill], + to: { kind: "projects", cwds: [api] }, + }); + expect(moved.outcomes[0]).toMatchObject({ status: "changed", blocked: [] }); + expect(yield* fs.exists(path.join(site, ".agents/skills/db-migrations"))).toBe(false); + expect( + yield* fs.exists(path.join(worktree, "apps/site/.agents/skills/db-migrations")), + ).toBe(false); + }), + ); + }), + ); }); describe("Claude's local settings file", () => { diff --git a/apps/server/src/skills/SkillPlacement.ts b/apps/server/src/skills/SkillPlacement.ts index 0e5c5c3dbe74..67cb6fbc57c8 100644 --- a/apps/server/src/skills/SkillPlacement.ts +++ b/apps/server/src/skills/SkillPlacement.ts @@ -48,7 +48,7 @@ import { import * as VcsProcess from "../vcs/VcsProcess.ts"; import type * as SkillCatalog from "./SkillCatalog.ts"; -import { updateExclude, worktreesOf } from "./SkillGitExclude.ts"; +import { projectPrefixOf, updateExclude, worktreesOf } from "./SkillGitExclude.ts"; import { LIBRARY_FOLDER, libraryLinksOf, linkLeadsTo, type LibraryLink } from "./SkillLibrary.ts"; import { createLink, removeLink, type RemoveLinkResult } from "./SkillLinks.ts"; import { moveRecord, type LockScope, type MoveRecordResult } from "./SkillLockFiles.ts"; @@ -293,13 +293,15 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe for (const project of new Set(links.map((link) => link.project))) { const own = yield* realPath(project); const worktrees = yield* inContext(worktreesOf(project)); + // Each worktree holds the whole repository, so the project's folder is under its root. + const prefix = yield* inContext(projectPrefixOf(project)); for (const worktree of worktrees) { // The checkout the project is in (or is inside) keeps what it has. const real = yield* realPath(worktree); if (own === real || own.startsWith(`${real}${path.sep}`)) continue; const found: Array<{ path: string; target: string }> = []; for (const link of links.filter((item) => item.project === project)) { - const created = path.join(worktree, link.folder, path.basename(link.path)); + const created = path.join(worktree, prefix, link.folder, path.basename(link.path)); const target = yield* fileSystem.readLink(created).pipe( Effect.map((value): string | undefined => value), Effect.orElseSucceed(() => undefined), From 9bb589eff7bba3ca5002ccdb528770930c57a7bc Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:17:22 -0400 Subject: [PATCH 041/108] docs: describe the skill switches by what they do, not by what is on screen The Skills guide described the sparkle and the icons beside a switch, clicking a row, a bar at the bottom, badges and the buttons on a row. Keep what the switches do and where they apply, and drop the rest. Co-Authored-By: Claude Sonnet 5.5 --- docs/user/skills.md | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/docs/user/skills.md b/docs/user/skills.md index 415256bcc639..9508ce35b3d8 100644 --- a/docs/user/skills.md +++ b/docs/user/skills.md @@ -3,8 +3,8 @@ Open **Settings → Skills** on web and desktop to see which skills your agents can use. The page reads the environment and project chosen at the top of Settings, so with a remote environment you see that machine's skills. You can turn each skill on or off for every agent, or for one agent at a -time, and choose which projects use it. To change what a skill says, open it with **Edit skill**, -edit it in your editor or ask an agent. +time, and choose which projects use it. To change what a skill says, edit its `SKILL.md` in your +editor or ask an agent. The agents are your enabled provider instances. Two Claude instances show as two agents, each with its own config folder. @@ -26,10 +26,8 @@ instead of showing it as empty. ## Turning skills on or off -Every skill has a switch. It turns the skill on for every agent, or off for every agent. The icons -beside it show who has the skill on: a sparkle when every agent does, otherwise the agents that -do. Click a row to open it, switch single agents and use **Use in…** or **Edit skill**. The switch -on **This project**, **Global** or a group turns all of its skills on or off at once, and asks +A skill's switch turns it on or off for every agent. Open the skill to switch a single agent. The +switch on **This project**, **Global** or a group changes all of its skills at once, and asks before turning many off. Turning a skill on for an agent that reads a different folder makes a link in that agent's own @@ -51,23 +49,21 @@ removes that link and nothing else. the skill. On Windows, global links are junctions, and project links need Developer Mode or administrator rights. -Skills the installer recorded as coming from the same place, such as a GitHub repo, sit together -under **From owner/repo** when there are two or more. A search lists skills without groups. +Skills the installer recorded as coming from the same place, such as a GitHub repo, are grouped +together when there are two or more. ## Acting on several skills -**Select** turns on checkboxes. A group's box ticks all of its skills. A bar at the bottom turns the -ticked skills on or off for every agent, deletes them, or puts them in a project with **Use in…**. -**Done** goes back to the switches. +Choose **Select** to act on several skills at once: turn them on or off for every agent, delete +them, or put them in projects with **Use in…**. Ticking a group ticks all of its skills. ## Using a skill in projects **Use in…** chooses where a skill is used: **This project only**, **Globally**, or **Only these projects**, which lists the projects of this environment. A skill used in only some projects is -still Global, with one copy, so an edit shows up in all of them. It has a badge such as **2 -projects**. Each change asks first, and never merges into or replaces a skill with the same name; -T3 Code leaves both and says so. When git tracks a project skill that leaves its project, the -confirmation says you can undo it with git. +still Global, with one copy, so an edit shows up in all of them. Each change asks first, and never +merges into or replaces a skill with the same name; T3 Code leaves both and says so. When git +tracks a project skill that leaves its project, the confirmation says you can undo it with git. Such a skill is linked into each project that uses it, outside git, and into the worktrees T3 Code makes for those projects. The agents that read `.agents/skills` have it in all of them. Turning on @@ -86,13 +82,12 @@ move or delete them. **Needs attention** filters the list to skills that need a look. A skill is on it when: -- an installed and enabled agent doesn't use it. Hover the icons to see which agent, or use the - button on the row to turn the skill on for it. An agent - loads one skill per name, the first it finds in its folders (Codex and OpenCode list every - copy), so a copy that another folder shadows is not used by that agent. Claude doesn't use a - skill that its own `skillOverrides` setting switches off either. +- an installed and enabled agent doesn't use it. An agent loads one skill per name, the first it + finds in its folders (Codex and OpenCode list every copy), so a copy that another folder shadows + is not used by that agent. Claude doesn't use a skill that its own `skillOverrides` setting + switches off either. - the same name exists more than once with different text, in **This project**, in **Global**, or - across them. These rows have a **Conflict** badge. + across them. - Claude can't read the skill's header, the YAML between the `---` lines at the top of `SKILL.md`, so it skips the skill. Quote a value that contains a colon or brackets, for example a description. From a5b1a96ec25e8236247851f2f5312bbdd47ca104 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:19:09 -0400 Subject: [PATCH 042/108] fix(server): refuse a skill change in a folder that is gone instead of dying The manager looked up the project with `orDie`, so a folder that no longer exists (which can't be normalized) was a defect instead of the `projectNotRegistered` refusal every other unknown folder gets. The manager now asks the catalog, which holds the rule for what a project is: a missing folder is refused, and only a failure of the lookup itself is a defect. The placement's `refuse` helper that only built its error is gone, per the service conventions. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/skills/SkillCatalog.test.ts | 44 +++++++++++++++++++++ apps/server/src/skills/SkillManager.ts | 17 +++----- apps/server/src/skills/SkillPlacement.ts | 25 +++++++----- 3 files changed, 65 insertions(+), 21 deletions(-) diff --git a/apps/server/src/skills/SkillCatalog.test.ts b/apps/server/src/skills/SkillCatalog.test.ts index 864790385db4..3a15d87042da 100644 --- a/apps/server/src/skills/SkillCatalog.test.ts +++ b/apps/server/src/skills/SkillCatalog.test.ts @@ -13,7 +13,9 @@ import { } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -21,6 +23,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as ProjectService from "../project/ProjectService.ts"; +import { ProjectOperationError } from "../project/ProjectService.ts"; import * as Settings from "../serverSettings.ts"; import * as SkillCatalog from "./SkillCatalog.ts"; @@ -720,6 +723,47 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillCatalog", (it) }); describe("project folders", () => { + it.effect( + "refuses a folder that is gone as not a project, and dies on a failure of the lookup itself", + () => + Effect.gen(function* () { + const { home } = yield* makeMachine; + const projects = Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (root) => + Effect.fail( + new ProjectOperationError({ + operation: root.endsWith("/gone") ? "normalize-workspace" : "list-projects", + workspaceRoot: root, + cause: "stand-in", + }), + ), + }); + const onMachine = ( + use: (catalog: SkillCatalog.SkillCatalog["Service"]) => Effect.Effect, + ) => + Effect.gen(function* () { + return yield* use(yield* SkillCatalog.SkillCatalog); + }).pipe( + Effect.provide( + SkillCatalog.layer.pipe( + Layer.provide(Layer.mergeAll(projects, Settings.layerTest({}))), + ), + ), + Effect.provideService(HostProcess.Environment, { HOME: home }), + ); + + const gone = yield* onMachine((catalog) => + catalog.list({ cwd: `${home}/gone` }).pipe(Effect.flip), + ); + expect(gone).toEqual(new SkillRequestError({ reason: "projectNotRegistered" })); + + const failed = yield* onMachine((catalog) => + catalog.list({ cwd: `${home}/there` }).pipe(Effect.exit), + ); + expect(Exit.isFailure(failed) && Cause.hasDies(failed.cause)).toBe(true); + }), + ); + it.effect.skipIf(!symlinksSupported)( "reads a project's skill folders only when the folder is a registered project", () => diff --git a/apps/server/src/skills/SkillManager.ts b/apps/server/src/skills/SkillManager.ts index cf7d2fdef58b..9c3f5c677b1c 100644 --- a/apps/server/src/skills/SkillManager.ts +++ b/apps/server/src/skills/SkillManager.ts @@ -290,15 +290,11 @@ const make = Effect.gen(function* () { FileSystem.FileSystem | Path.Path | VcsProcess.VcsProcess >(); - /** Links are only written under a folder the environment knows as a project. */ - const requireProject = (cwd: string) => - projects.getByWorkspaceRoot(cwd).pipe( - Effect.orDie, - Effect.filterOrFail( - Option.isSome, - () => new SkillRequestError({ reason: "projectNotRegistered" }), - ), - ); + /** + * Links are only written under a folder the environment knows as a project. The catalog says + * which: it refuses any other folder, one that is gone included, before it reads anything. + */ + const requireProject = (cwd: string) => catalog.resolve({ cwd, skills: [] }).pipe(Effect.asVoid); const removeAll = Effect.fnUntraced(function* ( entries: ReadonlyArray<{ readonly path: string; readonly target: string }>, @@ -670,7 +666,6 @@ const make = Effect.gen(function* () { }) => writeLock.withPermits(1)( Effect.gen(function* () { - if (input.cwd !== undefined) yield* requireProject(input.cwd); const before = yield* catalog.resolve({ cwd: input.cwd, skills: [...input.skills, ...(input.alsoLookUp ?? [])], @@ -781,7 +776,7 @@ const make = Effect.gen(function* () { // Codex, if its setting has to follow a moved folder, stays open for the whole request. const writers = makeWriters(yield* Scope.Scope); const { to } = input; - if (input.cwd !== undefined) yield* requireProject(input.cwd); + // The skills' own folder is checked when they are looked up. if (to.kind === "project") yield* requireProject(to.cwd); if (to.kind === "projects") for (const cwd of to.cwds) yield* requireProject(cwd); return yield* run({ diff --git a/apps/server/src/skills/SkillPlacement.ts b/apps/server/src/skills/SkillPlacement.ts index 67cb6fbc57c8..69336cc8c29b 100644 --- a/apps/server/src/skills/SkillPlacement.ts +++ b/apps/server/src/skills/SkillPlacement.ts @@ -80,8 +80,6 @@ class SkillPlacementRefused extends Schema.TaggedError()( const isRefused = Schema.is(SkillPlacementRefused); -const refuse = (reason: SkillOutcomeReason) => new SkillPlacementRefused({ reason }); - const skipped = (reason: SkillOutcomeReason): PlacementChange => ({ wrote: false, blocked: [], @@ -260,7 +258,9 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe if (result === "created") made.push(link); else if (result === "taken" || result === "notAllowed") { if (folder === STANDARD_SKILL_FOLDER) { - return yield* refuse(result === "taken" ? "destinationTaken" : "linkNotAllowed"); + return yield* new SkillPlacementRefused({ + reason: result === "taken" ? "destinationTaken" : "linkNotAllowed", + }); } for (const instanceId of input.agentsOf(folder)) { blocked.push({ @@ -538,8 +538,9 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe const moved = yield* inContext( moveFolder({ from: skill.home, to: entry, platform: deps.platform }), ); - if (moved === "taken") return yield* refuse("destinationTaken"); - if (moved === "inUse") return yield* refuse("inUse"); + if (moved === "taken") + return yield* new SkillPlacementRefused({ reason: "destinationTaken" }); + if (moved === "inUse") return yield* new SkillPlacementRefused({ reason: "inUse" }); journal.add(inContext(moveFolder({ from: entry, to: skill.home, platform: deps.platform }))); if (moved === "movedWithLeftover") reason = "failed"; } else { @@ -549,7 +550,8 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe { link: entry, target: skill.home, home: skill.home }, "global", ); - if (made !== "created") return yield* refuse("destinationTaken"); + if (made !== "created") + return yield* new SkillPlacementRefused({ reason: "destinationTaken" }); } // The links that led to the old place go before new ones are made: a new link may need the @@ -701,8 +703,9 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe const moved = yield* inContext( moveFolder({ from: skill.library.entry, to: destination, platform: deps.platform }), ); - if (moved === "taken") return yield* refuse("destinationTaken"); - if (moved === "inUse") return yield* refuse("inUse"); + if (moved === "taken") + return yield* new SkillPlacementRefused({ reason: "destinationTaken" }); + if (moved === "inUse") return yield* new SkillPlacementRefused({ reason: "inUse" }); journal.add( inContext( moveFolder({ from: destination, to: skill.library.entry, platform: deps.platform }), @@ -715,11 +718,13 @@ export const makeSkillPlacement = Effect.fnUntraced(function* (deps: PlacementDe { link: destination, target: skill.home, home: skill.home }, dest.scope, ); - if (made !== "created") return yield* refuse("destinationTaken"); + if (made !== "created") + return yield* new SkillPlacementRefused({ reason: "destinationTaken" }); const removed = yield* unlink(journal, [ { path: skill.library.entry, target: skill.library.target ?? skill.home }, ]); - if (removed.get(skill.library.entry) === "failed") return yield* refuse("failed"); + if (removed.get(skill.library.entry) === "failed") + return yield* new SkillPlacementRefused({ reason: "failed" }); } const real = yield* realPath(destination); From 19e81de3d5201e5cab1f1fa745b23806dec40bb9 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:53:57 -0400 Subject: [PATCH 043/108] fix(server): link a library skill into a worktree by its absolute path `restoreLibraryLinks` resolved a project link's target against the link's folder to see if it leads into the library, then wrote the raw target into the worktree. A relative target leads somewhere else from a worktree at another depth. The new link now names the library skill's absolute path. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/skills/SkillLibrary.test.ts | 25 +++++++++++++++++++++ apps/server/src/skills/SkillLibrary.ts | 7 ++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/server/src/skills/SkillLibrary.test.ts b/apps/server/src/skills/SkillLibrary.test.ts index df67b20a7f3a..f11f2aa3537e 100644 --- a/apps/server/src/skills/SkillLibrary.test.ts +++ b/apps/server/src/skills/SkillLibrary.test.ts @@ -300,6 +300,31 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillLibrary", (it) }), ); + it.effect.skipIf(!symlinksSupported)( + "writes a relative project link as the library skill's own path", + () => + Effect.gen(function* () { + const { fs, path, home, library } = yield* makeMachine; + const project = path.join(home, "repos/relative"); + const link = path.join(project, ".agents/skills/db-migrations"); + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* fs.symlink( + path.relative(path.dirname(link), path.join(library, "db-migrations")), + link, + ); + // A depth where the project link's relative target would lead somewhere else. + const worktree = path.join(home, "worktrees/deeper/still/relative-feature"); + + yield* restoreLibraryLinks({ project, worktree, prefix: "" }).pipe( + Effect.provideService(HostProcess.HomeDirectory, home), + ); + + const created = path.join(worktree, ".agents/skills/db-migrations"); + expect(yield* fs.readLink(created)).toBe(path.join(library, "db-migrations")); + expect(yield* fs.exists(path.join(created, "SKILL.md"))).toBe(true); + }), + ); + it.effect("does nothing for a project without links, or one that has gone", () => Effect.gen(function* () { const { fs, path, home, marketing } = yield* makeMachine; diff --git a/apps/server/src/skills/SkillLibrary.ts b/apps/server/src/skills/SkillLibrary.ts index 8121a0068b25..253dcfb6258b 100644 --- a/apps/server/src/skills/SkillLibrary.ts +++ b/apps/server/src/skills/SkillLibrary.ts @@ -162,11 +162,14 @@ export const restoreLibraryLinks = Effect.fn("SkillLibrary.restoreLibraryLinks") Effect.orElseSucceed(() => undefined), ); if (target === undefined) continue; - if (path.dirname(path.resolve(path.dirname(linkPath), target)) !== library) continue; + // A relative link would lead somewhere else from the worktree's own depth, so the new + // link names the library skill's absolute path. + const entry = path.resolve(path.dirname(linkPath), target); + if (path.dirname(entry) !== library) continue; const created = path.join(input.worktree, input.prefix, folder, name); yield* fileSystem.makeDirectory(path.dirname(created), { recursive: true }); // A bare create: something already there, such as a skill the project commits, stays. - yield* fileSystem.symlink(target, created).pipe( + yield* fileSystem.symlink(entry, created).pipe( Effect.catchTags({ PlatformError: (error) => error.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(error), From b5814dfa3976b40584d6269eccd93f8aa2d4d40f Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:54:00 -0400 Subject: [PATCH 044/108] fix(server): leave an unfinished exclude block as it is `editExcludeBlock` documented that a block with a start marker and no end is left alone, but it appended a second complete block. A later edit then paired the dangling start with the new end and could not remove T3 Code's markers. The text is now returned unchanged, so the exclude file isn't written and the link stays visible to git; callers already treat an unchanged file as done. Co-Authored-By: Claude Sonnet 5.5 --- .../server/src/skills/SkillGitExclude.test.ts | 26 ++++++++++++++++--- apps/server/src/skills/SkillGitExclude.ts | 9 ++++--- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/server/src/skills/SkillGitExclude.test.ts b/apps/server/src/skills/SkillGitExclude.test.ts index 39ae16359df1..2bfe1ca39224 100644 --- a/apps/server/src/skills/SkillGitExclude.test.ts +++ b/apps/server/src/skills/SkillGitExclude.test.ts @@ -78,11 +78,10 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillGitExclude", ( }); it("leaves an unfinished block as it found it", () => { - const broken = `${EXCLUDE_BLOCK_START}\n/kept\n`; + const broken = `*.log\n${EXCLUDE_BLOCK_START}\n/kept\n`; - expect(editExcludeBlock(broken, { add: ["/a"], remove: [] })).toBe( - `${broken}${block("/a")}\n`, - ); + expect(editExcludeBlock(broken, { add: ["/a"], remove: [] })).toBe(broken); + expect(editExcludeBlock(broken, { add: [], remove: ["/kept"] })).toBe(broken); }); }); @@ -177,6 +176,25 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("SkillGitExclude", ( }), ); + it.effect("leaves an unfinished block alone, and goes on without failing", () => + Effect.gen(function* () { + const { fs, path, repo } = yield* makeRepo; + const link = path.join(repo, ".agents/skills/db-migrations"); + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* fs.symlink(path.join(repo, "README.md"), link); + const exclude = path.join(repo, ".git/info/exclude"); + const broken = `*.log\n${EXCLUDE_BLOCK_START}\n/kept\n`; + yield* fs.writeFileString(exclude, broken); + + yield* run(updateExclude({ projectRoot: repo, links: [link], action: "add" })); + yield* run(updateExclude({ projectRoot: repo, links: [link], action: "remove" })); + + // No second block is added, so a later edit never pairs the dangling start with a new end. + expect(yield* fs.readFileString(exclude)).toBe(broken); + expect((yield* git(repo, ["status", "--porcelain"])).stdout).toContain("?? .agents/"); + }), + ); + it.effect("fails, and writes nothing, when the exclude file can't be written", () => Effect.gen(function* () { const { fs, path, repo } = yield* makeRepo; diff --git a/apps/server/src/skills/SkillGitExclude.ts b/apps/server/src/skills/SkillGitExclude.ts index 39551c155e4a..c34f173173fc 100644 --- a/apps/server/src/skills/SkillGitExclude.ts +++ b/apps/server/src/skills/SkillGitExclude.ts @@ -42,7 +42,9 @@ const excludeLine = (relative: string) => /** * `text` with the lines in `add` in T3 Code's block and those in `remove` out of it. A block left - * empty is removed whole. An unfinished block (a start without its end) is left as it is. + * empty is removed whole. An unfinished block (a start without its end) is left as it is, and so + * is the text around it: a second block would pair the dangling start with the new end, and T3 + * Code could no longer tell which lines are its own. */ export const editExcludeBlock = ( text: string, @@ -53,14 +55,15 @@ export const editExcludeBlock = ( if (lines.at(-1) === "") lines.pop(); const start = lines.indexOf(block.start); const end = start < 0 ? -1 : lines.indexOf(block.end, start + 1); - const kept = start >= 0 && end > start ? lines.slice(start + 1, end) : []; + if (start >= 0 && end < 0) return text; + const kept = start >= 0 ? lines.slice(start + 1, end) : []; const removed = new Set(change.remove); const inBlock = [...kept.filter((line) => !removed.has(line)), ...change.add].filter( (line, index, all) => all.indexOf(line) === index, ); const marked = inBlock.length === 0 ? [] : [block.start, ...inBlock, block.end]; const next = - start >= 0 && end > start + start >= 0 ? [...lines.slice(0, start), ...marked, ...lines.slice(end + 1)] : [...lines, ...marked]; return next.length === 0 ? "" : `${next.join("\n")}\n`; From d5953ec997b688c80e0cc074280e452ee4e1fb3e Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:05:01 -0400 Subject: [PATCH 045/108] feat(contracts): describe agent instruction files and their RPCs Adds the schemas and ten RPCs for listing, reading, editing and controlling who reads AGENTS.md and CLAUDE.md files. Reads need the read scope and everything else the operate scope, like the skills RPCs. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/auth/RpcAuthorization.test.ts | 21 ++ apps/server/src/auth/RpcAuthorization.ts | 3 + .../src/observability/RpcInstrumentation.ts | 10 + .../src/state/commandPermissions.test.ts | 34 +++ .../contracts/src/clientRpcPermissions.ts | 8 + packages/contracts/src/index.ts | 1 + packages/contracts/src/instructions.ts | 244 ++++++++++++++++++ packages/contracts/src/rpc.ts | 108 +++++++- 8 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 packages/contracts/src/instructions.ts diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index b95089a4a239..6fae19be908a 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -82,6 +82,27 @@ describe("RPC authorization scopes", () => { } }); + it("lets a read-only client read instructions but not change them", () => { + for (const method of [ + WS_METHODS.serverListInstructions, + WS_METHODS.serverReadInstruction, + WS_METHODS.serverInstructionsTracked, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationReadScope); + } + for (const method of [ + WS_METHODS.serverWriteInstruction, + WS_METHODS.serverEnableInstruction, + WS_METHODS.serverDisableInstruction, + WS_METHODS.serverSetClaudeInstructionFiles, + WS_METHODS.serverShareInstruction, + WS_METHODS.serverAdoptInstruction, + WS_METHODS.serverDeleteInstruction, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthOrchestrationOperateScope); + } + }); + it("allows relay status reads without granting relay installation access", () => { expect(requiredScopeForRpcMethod(WS_METHODS.cloudGetRelayClientStatus)).toBe( AuthRelayReadScope, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index db9b2acc6db0..167b7109c04f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -66,6 +66,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetSkill]: AuthFilesystemReadScope, // `git ls-files` in the project's folder. [WS_METHODS.serverSkillsTracked]: AuthFilesystemReadScope, + [WS_METHODS.serverListInstructions]: AuthOrchestrationReadScope, + [WS_METHODS.serverReadInstruction]: AuthOrchestrationReadScope, + [WS_METHODS.serverInstructionsTracked]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateProvider]: AuthProvidersManageScope, [WS_METHODS.providerAuthStart]: AuthProvidersManageScope, [WS_METHODS.providerConsumeResetCredit]: AuthProvidersManageScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 67d471fe2646..c04c5ddda175 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -40,6 +40,16 @@ const RPC_AGGREGATES = { [WS_METHODS.serverPlaceSkills]: "server", [WS_METHODS.serverDeleteSkills]: "server", [WS_METHODS.serverSkillsTracked]: "server", + [WS_METHODS.serverListInstructions]: "server", + [WS_METHODS.serverReadInstruction]: "server", + [WS_METHODS.serverWriteInstruction]: "server", + [WS_METHODS.serverEnableInstruction]: "server", + [WS_METHODS.serverDisableInstruction]: "server", + [WS_METHODS.serverSetClaudeInstructionFiles]: "server", + [WS_METHODS.serverShareInstruction]: "server", + [WS_METHODS.serverAdoptInstruction]: "server", + [WS_METHODS.serverDeleteInstruction]: "server", + [WS_METHODS.serverInstructionsTracked]: "server", [WS_METHODS.serverUpdateProvider]: "server", [WS_METHODS.providerAuthStart]: "provider", [WS_METHODS.providerConsumeResetCredit]: "provider", diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 05ae6a8a749d..a7e3a794992d 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -328,3 +328,37 @@ it.effect("needs the filesystem write grant to change skills, but not to list or }), ), ); + +it.effect("needs the operate grant to change instructions, but not to list or read them", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + for (const method of [ + WS_METHODS.serverWriteInstruction, + WS_METHODS.serverEnableInstruction, + WS_METHODS.serverDisableInstruction, + WS_METHODS.serverSetClaudeInstructionFiles, + WS_METHODS.serverShareInstruction, + WS_METHODS.serverAdoptInstruction, + WS_METHODS.serverDeleteInstruction, + ]) { + const change = createCommandPermissions(runtime, method); + registry.set(sessions(env), AsyncResult.success(grant(false))); + expect(registry.get(change.permissionAtom(env))).toBe(false); + expect((yield* change.authorize(registry, env).pipe(Effect.flip)).requiredScope).toBe( + AuthOrchestrationOperateScope, + ); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(change.permissionAtom(env))).toBe(true); + yield* change.authorize(registry, env); + } + for (const method of [ + WS_METHODS.serverListInstructions, + WS_METHODS.serverReadInstruction, + WS_METHODS.serverInstructionsTracked, + ]) { + expect(createCommandPermissions(runtime, method).requiredScopes()).toEqual([]); + } + }), + ), +); diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 1a00abeb843d..505133df855d 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -44,6 +44,14 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.serverPlaceSkills]: AuthFilesystemWriteScope, [WS_METHODS.serverDeleteSkills]: AuthFilesystemWriteScope, + [WS_METHODS.serverWriteInstruction]: AuthOrchestrationOperateScope, + [WS_METHODS.serverEnableInstruction]: AuthOrchestrationOperateScope, + [WS_METHODS.serverDisableInstruction]: AuthOrchestrationOperateScope, + [WS_METHODS.serverSetClaudeInstructionFiles]: AuthOrchestrationOperateScope, + [WS_METHODS.serverShareInstruction]: AuthOrchestrationOperateScope, + [WS_METHODS.serverAdoptInstruction]: AuthOrchestrationOperateScope, + [WS_METHODS.serverDeleteInstruction]: AuthOrchestrationOperateScope, + [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index cf64ff9170a0..cb864986ef63 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -28,6 +28,7 @@ export * from "./model.ts"; export * from "./keybindings.ts"; export * from "./server.ts"; export * from "./settings.ts"; +export * from "./instructions.ts"; export * from "./skills.ts"; export * from "./git.ts"; export * from "./vcs.ts"; diff --git a/packages/contracts/src/instructions.ts b/packages/contracts/src/instructions.ts new file mode 100644 index 000000000000..985c1b95cde2 --- /dev/null +++ b/packages/contracts/src/instructions.ts @@ -0,0 +1,244 @@ +import * as Schema from "effect/Schema"; +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; + +/** The largest instruction file T3 Code reads or writes, in characters. */ +export const INSTRUCTION_MAX_CHARS = 1_048_576; + +/** `managed` is the organization's file, which T3 Code only reads. */ +export const InstructionScope = Schema.Literals(["project", "global", "managed"]); +export type InstructionScope = typeof InstructionScope.Type; + +/** + * Which file an entry is. `shared` is AGENTS.md (in a project, or the one file all agents share + * across projects), `claude` is a CLAUDE.md, `claudeLocal` is CLAUDE.local.md, `agentOwn` is an + * agent's own file in its home folder, and `nested` is an AGENTS.md or CLAUDE.md in a subfolder. + */ +export const InstructionKind = Schema.Literals([ + "shared", + "claude", + "claudeLocal", + "agentOwn", + "nested", + "managed", +]); +export type InstructionKind = typeof InstructionKind.Type; + +/** + * How one agent reaches an instruction file. `direct`: it reads the file where it is. `link`: its + * own home file links to this one. `import`: Claude's CLAUDE.md imports it. `setting`: Claude + * reads it through its "Project instructions" setting. `none`: it doesn't read this file. + */ +export const InstructionAgentState = Schema.Literals([ + "direct", + "link", + "import", + "setting", + "none", +]); +export type InstructionAgentState = typeof InstructionAgentState.Type; + +/** Why an agent with state `none` doesn't read the file. A client words each one. */ +export const InstructionAgentReason = Schema.Literals([ + /** Claude reads its own CLAUDE.md files instead of AGENTS.md (see `blockingFile`). */ + "claudeFiles", + /** Claude's "Project instructions" setting turns AGENTS.md off. */ + "settingOff", + /** The agent's home file is a real file with different text. */ + "ownFile", + /** The agent's version is too old to read this file. */ + "oldVersion", +]); +export type InstructionAgentReason = typeof InstructionAgentReason.Type; + +export const InstructionAgentAccess = Schema.Struct({ + /** The enabled provider instance this is about. Instances of unknown drivers are never listed. */ + instanceId: ProviderInstanceId, + driver: ProviderDriverKind, + state: InstructionAgentState, + reason: Schema.optional(InstructionAgentReason), + /** The file that makes Claude skip AGENTS.md, relative to the project, e.g. `CLAUDE.local.md`. */ + blockingFile: Schema.optional(Schema.String), +}); +export type InstructionAgentAccess = typeof InstructionAgentAccess.Type; + +export const InstructionEntry = Schema.Struct({ + /** A stable id the server built. Every action takes it back; a client never sends a path. */ + id: TrimmedNonEmptyString, + scope: InstructionScope, + kind: InstructionKind, + /** Absolute path of the file, or where it would be created. */ + path: Schema.String, + /** Relative to the project, for project and nested files. */ + relativePath: Schema.optional(Schema.String), + exists: Schema.Boolean, + /** Size in bytes; 0 when the file doesn't exist. */ + size: NonNegativeInt, + readOnly: Schema.Boolean, + /** The agent whose own file this is, for `agentOwn` and Claude's home CLAUDE.md. */ + owner: Schema.optional(ProviderInstanceId), + access: Schema.Array(InstructionAgentAccess), + /** The file has the same text as the shared all-projects file. */ + sameAsShared: Schema.optional(Schema.Boolean), +}); +export type InstructionEntry = typeof InstructionEntry.Type; + +/** + * Claude's "Project instructions" setting: when it reads AGENTS.md. `claude-md-or-agents-md` is + * its default (AGENTS.md only when there is no CLAUDE.md), `claude-md-and-agents-md` reads both, + * `claude-md` never reads AGENTS.md, and `managed-only` is set by the organization. + */ +export const ClaudeInstructionValue = Schema.Literals([ + "claude-md-or-agents-md", + "claude-md-and-agents-md", + "claude-md", + "managed-only", +]); +export type ClaudeInstructionValue = typeof ClaudeInstructionValue.Type; + +export const ClaudeInstructionChoice = Schema.Struct({ + instanceId: ProviderInstanceId, + /** What applies now, whether the user chose it or it is Claude's default. */ + value: ClaudeInstructionValue, + /** The user set it; false means Claude's default. */ + explicit: Schema.Boolean, + /** This Claude Code version has the setting. */ + supported: Schema.Boolean, + version: Schema.NullOr(Schema.String), +}); +export type ClaudeInstructionChoice = typeof ClaudeInstructionChoice.Type; + +export const InstructionListInput = Schema.Struct({ + /** A registered project's folder, for its own instruction files. */ + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type InstructionListInput = typeof InstructionListInput.Type; + +/** A file that exists but couldn't be read. */ +export const InstructionProblem = Schema.Struct({ + path: Schema.String, + reason: Schema.optional(Schema.String), +}); +export type InstructionProblem = typeof InstructionProblem.Type; + +export const InstructionListResult = Schema.Struct({ + entries: Schema.Array(InstructionEntry), + /** One per enabled Claude instance. */ + claude: Schema.Array(ClaudeInstructionChoice), + /** Where the shared all-projects file is or would be created. */ + sharedPath: Schema.String, + unreadable: Schema.Array(InstructionProblem), +}); +export type InstructionListResult = typeof InstructionListResult.Type; + +export const InstructionReadInput = Schema.Struct({ + cwd: Schema.optional(TrimmedNonEmptyString), + id: TrimmedNonEmptyString, +}); +export type InstructionReadInput = typeof InstructionReadInput.Type; + +export const InstructionReadResult = Schema.Struct({ + id: TrimmedNonEmptyString, + /** The text; null when the file is missing or too large to show. */ + contents: Schema.NullOr(Schema.String), + /** Identifies this version of the file, to pass back as `expectedRevision`. Null when missing. */ + revision: Schema.NullOr(Schema.String), + tooLarge: Schema.Boolean, +}); +export type InstructionReadResult = typeof InstructionReadResult.Type; + +/** Replace a file's text, or create it. Refused when the file changed since it was read. */ +export const InstructionWriteInput = Schema.Struct({ + cwd: Schema.optional(TrimmedNonEmptyString), + id: TrimmedNonEmptyString, + contents: Schema.String.check(Schema.isMaxLength(INSTRUCTION_MAX_CHARS)), + /** The revision that was read; null to create a file that must not exist yet. */ + expectedRevision: Schema.NullOr(Schema.String), +}); +export type InstructionWriteInput = typeof InstructionWriteInput.Type; + +export const InstructionWriteResult = Schema.Struct({ + id: TrimmedNonEmptyString, + revision: Schema.String, +}); +export type InstructionWriteResult = typeof InstructionWriteResult.Type; + +const InstructionAgents = Schema.Union([ + Schema.Literal("all"), + Schema.Array(ProviderInstanceId).check(Schema.isMinLength(1), Schema.isMaxLength(64)), +]); + +/** Make each agent read the shared all-projects file, or stop. `all` means every enabled agent. */ +export const InstructionAgentsInput = Schema.Struct({ + cwd: Schema.optional(TrimmedNonEmptyString), + id: TrimmedNonEmptyString, + agents: InstructionAgents, +}); +export type InstructionAgentsInput = typeof InstructionAgentsInput.Type; + +export const InstructionAgentsResult = Schema.Struct({ + results: Schema.Array( + Schema.Struct({ + instanceId: ProviderInstanceId, + outcome: Schema.Literals(["changed", "unchanged", "failed"]), + reason: Schema.optional(Schema.String), + }), + ), +}); +export type InstructionAgentsResult = typeof InstructionAgentsResult.Type; + +/** Set Claude's "Project instructions" setting; null goes back to Claude's default. */ +export const ClaudeInstructionSettingInput = Schema.Struct({ + instanceId: ProviderInstanceId, + value: Schema.NullOr(ClaudeInstructionValue), +}); +export type ClaudeInstructionSettingInput = typeof ClaudeInstructionSettingInput.Type; + +/** Rename a project's CLAUDE.md to AGENTS.md so every agent reads it. */ +export const InstructionShareInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + id: TrimmedNonEmptyString, +}); +export type InstructionShareInput = typeof InstructionShareInput.Type; + +/** Move an agent's own home file into the shared all-projects file and link it there. */ +export const InstructionAdoptInput = Schema.Struct({ + id: TrimmedNonEmptyString, +}); +export type InstructionAdoptInput = typeof InstructionAdoptInput.Type; + +/** Delete a real instruction file. This can't be undone. */ +export const InstructionDeleteInput = Schema.Struct({ + cwd: Schema.optional(TrimmedNonEmptyString), + id: TrimmedNonEmptyString, +}); +export type InstructionDeleteInput = typeof InstructionDeleteInput.Type; + +/** Which of these project instruction files git tracks, so a rename or delete shows in git. */ +export const InstructionTrackedInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + ids: Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1), Schema.isMaxLength(200)), +}); +export type InstructionTrackedInput = typeof InstructionTrackedInput.Type; + +export const InstructionTrackedResult = Schema.Struct({ + /** Ids of the files git tracks. Never includes a global or managed file. */ + tracked: Schema.Array(TrimmedNonEmptyString), +}); +export type InstructionTrackedResult = typeof InstructionTrackedResult.Type; + +/** A request that couldn't be carried out, with a reason a client can word. */ +export class InstructionError extends Schema.TaggedError()("InstructionError", { + reason: Schema.Literals([ + "changedOnDisk", + "exists", + "notFound", + "readOnly", + "tooLarge", + "unknownEntry", + "unregisteredProject", + "invalidSettings", + "linkFailed", + ]), + message: Schema.String, +}) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9dda9ef45387..0b2d7a739574 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -323,6 +323,23 @@ import { ServerSettingsError, ServerSettingsPatch, } from "./settings.ts"; +import { + ClaudeInstructionSettingInput, + InstructionAdoptInput, + InstructionAgentsInput, + InstructionAgentsResult, + InstructionDeleteInput, + InstructionError, + InstructionListInput, + InstructionListResult, + InstructionReadInput, + InstructionReadResult, + InstructionShareInput, + InstructionTrackedInput, + InstructionTrackedResult, + InstructionWriteInput, + InstructionWriteResult, +} from "./instructions.ts"; import { SkillBatchResult, SkillDeleteInput, @@ -488,6 +505,16 @@ export const WS_METHODS = { serverPlaceSkills: "server.placeSkills", serverDeleteSkills: "server.deleteSkills", serverSkillsTracked: "server.skillsTracked", + serverListInstructions: "server.listInstructions", + serverReadInstruction: "server.readInstruction", + serverWriteInstruction: "server.writeInstruction", + serverEnableInstruction: "server.enableInstruction", + serverDisableInstruction: "server.disableInstruction", + serverSetClaudeInstructionFiles: "server.setClaudeInstructionFiles", + serverShareInstruction: "server.shareInstruction", + serverAdoptInstruction: "server.adoptInstruction", + serverDeleteInstruction: "server.deleteInstruction", + serverInstructionsTracked: "server.instructionsTracked", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", @@ -661,6 +688,66 @@ const WsServerSkillsTrackedRpc = Rpc.make(WS_METHODS.serverSkillsTracked, { error: Schema.Union([SkillRequestError, EnvironmentAuthorizationError]), }); +const WsServerListInstructionsRpc = Rpc.make(WS_METHODS.serverListInstructions, { + payload: InstructionListInput, + success: InstructionListResult, + error: Schema.Union([InstructionError, EnvironmentAuthorizationError]), +}); + +const WsServerReadInstructionRpc = Rpc.make(WS_METHODS.serverReadInstruction, { + payload: InstructionReadInput, + success: InstructionReadResult, + error: Schema.Union([InstructionError, EnvironmentAuthorizationError]), +}); + +const WsServerWriteInstructionRpc = Rpc.make(WS_METHODS.serverWriteInstruction, { + payload: InstructionWriteInput, + success: InstructionWriteResult, + error: Schema.Union([InstructionError, EnvironmentAuthorizationError]), +}); + +const WsServerEnableInstructionRpc = Rpc.make(WS_METHODS.serverEnableInstruction, { + payload: InstructionAgentsInput, + success: InstructionAgentsResult, + error: Schema.Union([InstructionError, EnvironmentAuthorizationError]), +}); + +const WsServerDisableInstructionRpc = Rpc.make(WS_METHODS.serverDisableInstruction, { + payload: InstructionAgentsInput, + success: InstructionAgentsResult, + error: Schema.Union([InstructionError, EnvironmentAuthorizationError]), +}); + +const WsServerSetClaudeInstructionFilesRpc = Rpc.make(WS_METHODS.serverSetClaudeInstructionFiles, { + payload: ClaudeInstructionSettingInput, + success: Schema.Struct({}), + error: Schema.Union([InstructionError, EnvironmentAuthorizationError]), +}); + +const WsServerShareInstructionRpc = Rpc.make(WS_METHODS.serverShareInstruction, { + payload: InstructionShareInput, + success: Schema.Struct({}), + error: Schema.Union([InstructionError, EnvironmentAuthorizationError]), +}); + +const WsServerAdoptInstructionRpc = Rpc.make(WS_METHODS.serverAdoptInstruction, { + payload: InstructionAdoptInput, + success: Schema.Struct({}), + error: Schema.Union([InstructionError, EnvironmentAuthorizationError]), +}); + +const WsServerDeleteInstructionRpc = Rpc.make(WS_METHODS.serverDeleteInstruction, { + payload: InstructionDeleteInput, + success: Schema.Struct({}), + error: Schema.Union([InstructionError, EnvironmentAuthorizationError]), +}); + +const WsServerInstructionsTrackedRpc = Rpc.make(WS_METHODS.serverInstructionsTracked, { + payload: InstructionTrackedInput, + success: InstructionTrackedResult, + error: Schema.Union([InstructionError, EnvironmentAuthorizationError]), +}); + const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** @@ -1890,7 +1977,7 @@ export class RpcScopeAuthorization extends RpcMiddleware.Service Date: Wed, 7 Oct 2026 21:21:22 -0400 Subject: [PATCH 046/108] feat(server): record which instruction files each agent reads Adds a data-only table of the AGENTS.md / CLAUDE.md family that Claude, Codex, OpenCode, Pi, Cursor, Grok and Antigravity read at project, home and managed level, with sources. Also adds pure helpers for Claude's "Project instructions" setting, the AGENTS.md import line, and the Claude Code version check. Co-Authored-By: Claude Sonnet 5.5 --- .../AgentInstructionFiles.test.ts | 86 ++++ .../src/instructions/AgentInstructionFiles.ts | 352 ++++++++++++++ .../ClaudeInstructionSetting.test.ts | 432 ++++++++++++++++++ .../instructions/ClaudeInstructionSetting.ts | 231 ++++++++++ 4 files changed, 1101 insertions(+) create mode 100644 apps/server/src/instructions/AgentInstructionFiles.test.ts create mode 100644 apps/server/src/instructions/AgentInstructionFiles.ts create mode 100644 apps/server/src/instructions/ClaudeInstructionSetting.test.ts create mode 100644 apps/server/src/instructions/ClaudeInstructionSetting.ts diff --git a/apps/server/src/instructions/AgentInstructionFiles.test.ts b/apps/server/src/instructions/AgentInstructionFiles.test.ts new file mode 100644 index 000000000000..437651d35028 --- /dev/null +++ b/apps/server/src/instructions/AgentInstructionFiles.test.ts @@ -0,0 +1,86 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { AGENT_SKILL_FOLDERS } from "@t3tools/provider-core/server/AgentSkillFolders"; + +import { + AGENT_INSTRUCTION_FILES, + claudeManagedInstructionPath, + instructionRulesFor, + projectInstructionFile, +} from "./AgentInstructionFiles.ts"; + +const driver = ProviderDriverKind.make; + +describe("AGENT_INSTRUCTION_FILES", () => { + it("covers every agent that has skill folders, once each", () => { + const agents = AGENT_INSTRUCTION_FILES.map((rules) => rules.agent); + expect(new Set(agents).size).toBe(agents.length); + expect(new Set(agents)).toEqual(new Set(AGENT_SKILL_FOLDERS.map((table) => table.agent))); + }); + + it.each(AGENT_INSTRUCTION_FILES.map((rules) => [rules.agent, rules] as const))( + "%s describes files it can actually use", + (_agent, rules) => { + for (const list of [ + rules.project?.files.map((entry) => entry.name) ?? [], + rules.home?.files ?? [], + ]) { + expect(new Set(list).size).toBe(list.length); + } + if (rules.home !== null) { + // The shared file has to be one the agent reads from its home folder. + expect(rules.home.files).toContain(rules.home.shared.file); + // Only an agent that can import a file is joined by an import line. + expect(rules.home.shared.join === "import").toBe(rules.imports); + } + }, + ); + + it("limits Claude's setting to the files the setting governs", () => { + const governed = instructionRulesFor(driver("claudeAgent"))?.project?.files.filter( + (entry) => entry.governedBy !== undefined, + ); + expect(governed?.map((entry) => entry.name)).toEqual(["AGENTS.md", ".claude/AGENTS.md"]); + for (const rules of AGENT_INSTRUCTION_FILES) { + if (rules.agent === driver("claudeAgent")) continue; + expect(rules.project?.files.some((entry) => entry.governedBy !== undefined)).toBe(false); + } + }); +}); + +describe("instructionRulesFor", () => { + it("finds a known agent and not an unknown one", () => { + expect(instructionRulesFor(driver("codex"))?.agent).toBe(driver("codex")); + expect(instructionRulesFor(driver("ollama"))).toBeUndefined(); + }); +}); + +describe("projectInstructionFile", () => { + it("returns the rule for a name the agent reads", () => { + expect(projectInstructionFile(driver("claudeAgent"), "AGENTS.md")).toEqual({ + name: "AGENTS.md", + governedBy: "claudeProjectInstructions", + }); + expect(projectInstructionFile(driver("claudeAgent"), "CLAUDE.md")).toEqual({ + name: "CLAUDE.md", + }); + expect(projectInstructionFile(driver("opencode"), "CLAUDE.md")).toEqual({ name: "CLAUDE.md" }); + }); + + it("returns nothing for a name the agent doesn't read", () => { + expect(projectInstructionFile(driver("codex"), "CLAUDE.md")).toBeUndefined(); + expect(projectInstructionFile(driver("cursor"), "CLAUDE.local.md")).toBeUndefined(); + expect(projectInstructionFile(driver("ollama"), "AGENTS.md")).toBeUndefined(); + }); +}); + +describe("claudeManagedInstructionPath", () => { + it.each([ + ["darwin", "/Library/Application Support/ClaudeCode/CLAUDE.md"], + ["linux", "/etc/claude-code/CLAUDE.md"], + ["win32", "C:\\Program Files\\ClaudeCode\\CLAUDE.md"], + ["freebsd", undefined], + ] as const)("%s", (platform, expected) => { + expect(claudeManagedInstructionPath(platform)).toBe(expected); + }); +}); diff --git a/apps/server/src/instructions/AgentInstructionFiles.ts b/apps/server/src/instructions/AgentInstructionFiles.ts new file mode 100644 index 000000000000..8e0596fb564a --- /dev/null +++ b/apps/server/src/instructions/AgentInstructionFiles.ts @@ -0,0 +1,352 @@ +/** + * AgentInstructionFiles - the instruction files (AGENTS.md, CLAUDE.md and friends) each agent + * reads, and where. + * + * One table for the Instructions section of the Skills page, in the same spirit as + * `AgentSkillFolders`. Data only: nothing here touches the disk. Paths are relative to a project + * folder (`project`) or to the user's home directory (`home`). Each file list is in the order the + * agent prefers it, and `selection` says what the agent does when several of them exist. + * + * Only whole instruction files are modelled. Rule folders (`.claude/rules`, `.cursor/rules`, + * `.grok/rules`, `.agents/rules`) and files an agent is told to load through its own config + * (OpenCode `instructions`, Codex `project_doc_fallback_filenames`) are out of scope. + * + * A level that can't be confirmed from the agent's documentation or source is `null`, which + * means "no icon, no claim". Reasons for the levels left out: + * - Cursor, home: User Rules live in the app's Customize -> Rules settings, not in a file. + * - Antigravity, home: the agent reads `~/.gemini/AGENTS.md`, but T3 Code starts it with + * `GEMINI_HOME` pointing at a private profile and links only the skill folders back + * (`linkAntigravityUserSkills` in `antigravityAuthSupport.ts`), so the user's file is never seen. + * - Everyone but Claude, managed: none of the others documents a managed instruction file. + * + * Sources, per agent: + * - Claude: https://code.claude.com/docs/en/memory ("Choose where to put CLAUDE.md files", + * "Import additional files", "AGENTS.md", "Choose which instruction files load"). `CLAUDE.md` + * and `CLAUDE.local.md` load from the working folder and every folder above it, subfolders when + * Claude works in them. `AGENTS.md` and `.claude/AGENTS.md` load only as the "Project + * instructions" setting says (see `ClaudeInstructionSetting.ts`, Claude Code 2.1.277 or later). + * The user file is `/CLAUDE.md`; the config dir is `CLAUDE_CONFIG_DIR` or a T3 Code + * instance's `homePath`, resolved as `SkillCatalog` does. The managed file is per OS. + * - Codex: https://learn.chatgpt.com/docs/agent-configuration/agents-md (also served at + * https://developers.openai.com/codex/guides/agents-md) and, in + * https://github.com/openai/codex/tree/fac5d0ba91: `core/src/agents_md.rs` (per folder from the + * project root, the nearest ancestor with a `.git` marker, down to the working folder, the + * first of `AGENTS.override.md` and `AGENTS.md` wins; with no root only the working folder; + * nothing is read in an untrusted project; 32 KiB `project_doc_max_bytes` across all files), + * `codex-home/src/instructions/mod.rs` (the home folder's first non-empty file of + * `AGENTS.override.md` and `AGENTS.md`) and `utils/home-dir/src/lib.rs` (`CODEX_HOME`). + * - OpenCode: https://opencode.ai/docs/rules/ and, in + * https://github.com/anomalyco/opencode/tree/a697115b20, `packages/opencode/src/session/instruction.ts` + * (`AGENTS.md` in every folder from the working folder up to the worktree root; `CLAUDE.md`, and + * the deprecated `CONTEXT.md`, only when no `AGENTS.md` is found anywhere on that walk; a file in + * a subfolder is added when the agent reads a file there; the home folder's `AGENTS.md`, else + * `~/.claude/CLAUDE.md`, which `OPENCODE_DISABLE_CLAUDE_CODE` and + * `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` turn off together with the project `CLAUDE.md`). + * `packages/core/src/global.ts` puts the home folder at `OPENCODE_CONFIG_DIR`, else + * `$XDG_CONFIG_HOME/opencode`, else `~/.config/opencode`. Its docs say it doesn't parse file + * references in AGENTS.md. + * - Pi: https://github.com/earendil-works/pi/blob/43d3763991/packages/coding-agent/docs/configuration.md + * ("Agent directory", "Context files"), `docs/environment-variables.md` (`PI_CODING_AGENT_DIR`) + * and `src/core/resource-loader.ts` (`loadContextFileFromDir`, `loadProjectContextFiles`): the + * first existing of five names in the agent directory, then in the working folder and every + * folder above it, all the way up. T3 Code's adapter leaves context loading on + * (`PiAdapterV2.ts`). + * - Cursor: https://cursor.com/docs/context/rules ("AGENTS.md": the project root and subfolders, + * nested files add to their parents') and https://cursor.com/docs/sdk/typescript (the SDK's + * workspace scan reads `AGENTS.md`; T3 Code's adapter loads the `project` and `user` setting + * sources). The Cursor CLI also reads a root `CLAUDE.md` (https://cursor.com/docs/cli/using), + * but the SDK docs don't say so, so it isn't claimed. + * - Grok: https://docs.x.ai/build/features/project-rules.md (every folder from the repo root down + * to the working folder, or only the working folder outside git; deeper files win) and + * https://docs.x.ai/build/settings/reference.md (`GROK_HOME`); in + * https://github.com/xai-org/grok-build/tree/2bdd1d6a63, `crates/codegen/xai-grok-config/src/compat.rs` + * (`INSTRUCTION_FILENAMES`) and `crates/codegen/xai-grok-agent/src/prompt/agents_md.rs` (every + * existing name loads; gitignored files are skipped; nothing is read in an untrusted folder; + * the home roots are `$GROK_HOME`, `~/.claude` and `~/.cursor`, the last two through the + * Claude and Cursor compatibility scanners that are on by default). + * - Antigravity: https://antigravity.google/docs/rules ("Directory-scoped rules", "Global rules", + * "Managing rules in Antigravity CLI"): `AGENTS.md` and `GEMINI.md`, also under `.agents/`, + * in the workspace root and any subfolder, found by walking up from each file the agent reads + * or edits; every one that exists loads. Imports there are `@[label](path)`, not Claude's. + * + * Claude's `@path` import is the only one that inlines a file. Codex, OpenCode, Pi and Grok don't + * document any; Cursor's `@file` mention only lets the agent read the file; Antigravity's + * `@[label](path)` is a different syntax. So `imports` is true for Claude alone. + * + * @module AgentInstructionFiles + */ +import { ProviderDriverKind } from "@t3tools/contracts"; + +/** What an agent does when several of its instruction files exist side by side. */ +export type InstructionSelection = + /** Every file that exists loads. */ + | "all" + /** Only the first existing file of each folder loads. */ + | "first-per-folder" + /** + * The first name that exists anywhere on the search loads, in every folder that has it; later + * names are fallbacks for when no folder has an earlier one. + */ + | "first-name"; + +/** How far above the working folder an agent looks for project files. */ +export type InstructionParents = + /** Not above the project's top folder. */ + | "none" + /** Up to the root of the repository the working folder is in. */ + | "repo-root" + /** Up to the top of the file system. */ + | "filesystem-root"; + +export interface ProjectInstructionFile { + /** Relative to a folder, so `.claude/CLAUDE.md` is a name too. */ + readonly name: string; + /** Claude reads this one only as its "Project instructions" setting allows. */ + readonly governedBy?: "claudeProjectInstructions"; +} + +export interface ProjectInstructionRules { + /** In the order the agent prefers them. */ + readonly files: readonly ProjectInstructionFile[]; + readonly selection: InstructionSelection; + readonly parents: InstructionParents; + /** + * Whether a file in a subfolder is used when the agent works there (`on-demand`), or only the + * folders on the way up are read (`none`). + */ + readonly subfolders: "none" | "on-demand"; +} + +/** A folder of another agent that this one reads too, at a fixed place under the home directory. */ +export interface AlsoReadFolder { + /** Relative to the home directory. */ + readonly folder: string; + readonly files: readonly string[]; + /** `no-own-file`: only when the agent's own home folder has none of its files. */ + readonly when: "always" | "no-own-file"; + /** Environment variables that switch this fallback off. */ + readonly disabledByEnv?: readonly string[]; +} + +export interface HomeInstructionRules { + /** The agent's own folder under the home directory, by default. */ + readonly folder: string; + /** In the order the agent prefers them. */ + readonly files: readonly string[]; + readonly selection: Exclude; + /** The variable that names the folder itself. */ + readonly folderEnv?: string; + /** The default folder sits under `$XDG_CONFIG_HOME` when that is set, else under `~/.config`. */ + readonly xdgConfigHome?: boolean; + /** A T3 Code provider instance's `homePath` setting moves the folder, ahead of `folderEnv`. */ + readonly instanceHomePath?: boolean; + /** + * How the shared all-projects file reaches the agent: `link` is a symlink at `file` in the + * folder, `import` is a line that imports it as the first line of `file`. + */ + readonly shared: { readonly file: string; readonly join: "link" | "import" }; + readonly alsoReads?: readonly AlsoReadFolder[]; +} + +/** Where an organization puts Claude's managed CLAUDE.md. WSL counts as `linux`. */ +export interface ManagedInstructionPaths { + readonly darwin: string; + readonly linux: string; + readonly win32: string; +} + +export interface AgentInstructionRules { + readonly agent: ProviderDriverKind; + /** `null` when no project file is confirmed. */ + readonly project: ProjectInstructionRules | null; + /** `null` when no file in the user's home is confirmed. */ + readonly home: HomeInstructionRules | null; + readonly managed: ManagedInstructionPaths | null; + /** Whether a file can pull in another with Claude's `@path` line. */ + readonly imports: boolean; +} + +const file = (name: string): ProjectInstructionFile => ({ name }); +const governed = (name: string): ProjectInstructionFile => ({ + name, + governedBy: "claudeProjectInstructions", +}); + +/** The names Grok reads in a folder, in the order of its `INSTRUCTION_FILENAMES`. */ +const GROK_FILE_NAMES = [ + "Agents.md", + "Claude.md", + "CLAUDE.md", + "CLAUDE.local.md", + "AGENT.md", + "AGENTS.md", +] as const; + +export const AGENT_INSTRUCTION_FILES: ReadonlyArray = [ + { + agent: ProviderDriverKind.make("claudeAgent"), + project: { + files: [ + file("CLAUDE.md"), + file(".claude/CLAUDE.md"), + file("CLAUDE.local.md"), + governed("AGENTS.md"), + governed(".claude/AGENTS.md"), + ], + selection: "all", + parents: "filesystem-root", + subfolders: "on-demand", + }, + home: { + folder: ".claude", + files: ["CLAUDE.md"], + selection: "all", + folderEnv: "CLAUDE_CONFIG_DIR", + instanceHomePath: true, + shared: { file: "CLAUDE.md", join: "import" }, + }, + managed: { + darwin: "/Library/Application Support/ClaudeCode/CLAUDE.md", + linux: "/etc/claude-code/CLAUDE.md", + win32: "C:\\Program Files\\ClaudeCode\\CLAUDE.md", + }, + imports: true, + }, + { + agent: ProviderDriverKind.make("codex"), + project: { + files: [file("AGENTS.override.md"), file("AGENTS.md")], + selection: "first-per-folder", + parents: "repo-root", + subfolders: "none", + }, + home: { + folder: ".codex", + files: ["AGENTS.override.md", "AGENTS.md"], + selection: "first-per-folder", + folderEnv: "CODEX_HOME", + instanceHomePath: true, + shared: { file: "AGENTS.md", join: "link" }, + }, + managed: null, + imports: false, + }, + { + agent: ProviderDriverKind.make("opencode"), + project: { + files: [file("AGENTS.md"), file("CLAUDE.md")], + selection: "first-name", + parents: "repo-root", + subfolders: "on-demand", + }, + home: { + folder: ".config/opencode", + files: ["AGENTS.md"], + selection: "first-per-folder", + folderEnv: "OPENCODE_CONFIG_DIR", + xdgConfigHome: true, + shared: { file: "AGENTS.md", join: "link" }, + alsoReads: [ + { + folder: ".claude", + files: ["CLAUDE.md"], + when: "no-own-file", + disabledByEnv: ["OPENCODE_DISABLE_CLAUDE_CODE", "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"], + }, + ], + }, + managed: null, + imports: false, + }, + { + agent: ProviderDriverKind.make("pi"), + project: { + files: [ + file("AGENTS.override.md"), + file("AGENTS.md"), + file("AGENTS.MD"), + file("CLAUDE.md"), + file("CLAUDE.MD"), + ], + selection: "first-per-folder", + parents: "filesystem-root", + subfolders: "none", + }, + home: { + folder: ".pi/agent", + files: ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"], + selection: "first-per-folder", + folderEnv: "PI_CODING_AGENT_DIR", + shared: { file: "AGENTS.md", join: "link" }, + }, + managed: null, + imports: false, + }, + { + agent: ProviderDriverKind.make("cursor"), + project: { + files: [file("AGENTS.md")], + selection: "all", + parents: "none", + subfolders: "on-demand", + }, + home: null, + managed: null, + imports: false, + }, + { + agent: ProviderDriverKind.make("grok"), + project: { + files: [...GROK_FILE_NAMES, ".claude/CLAUDE.md", ".claude/CLAUDE.local.md"].map(file), + selection: "all", + parents: "repo-root", + subfolders: "on-demand", + }, + home: { + folder: ".grok", + files: [...GROK_FILE_NAMES], + selection: "all", + folderEnv: "GROK_HOME", + shared: { file: "AGENTS.md", join: "link" }, + alsoReads: [ + { folder: ".claude", files: [...GROK_FILE_NAMES], when: "always" }, + { folder: ".cursor", files: [...GROK_FILE_NAMES], when: "always" }, + ], + }, + managed: null, + imports: false, + }, + { + agent: ProviderDriverKind.make("antigravity"), + project: { + files: ["AGENTS.md", "GEMINI.md", ".agents/AGENTS.md", ".agents/GEMINI.md"].map(file), + selection: "all", + parents: "none", + subfolders: "on-demand", + }, + home: null, + managed: null, + imports: false, + }, +]; + +/** What one agent reads, or `undefined` for an agent the table doesn't know. */ +export const instructionRulesFor = (agent: ProviderDriverKind): AgentInstructionRules | undefined => + AGENT_INSTRUCTION_FILES.find((entry) => entry.agent === agent); + +/** The agent's rule for a project file name, or `undefined` when it doesn't read that name. */ +export const projectInstructionFile = ( + agent: ProviderDriverKind, + name: string, +): ProjectInstructionFile | undefined => + instructionRulesFor(agent)?.project?.files.find((entry) => entry.name === name); + +/** Claude's managed CLAUDE.md on a platform, or `undefined` on one the docs don't list. */ +export const claudeManagedInstructionPath = (platform: NodeJS.Platform): string | undefined => { + const managed = instructionRulesFor(ProviderDriverKind.make("claudeAgent"))?.managed; + if (managed === null || managed === undefined) return undefined; + if (platform === "darwin") return managed.darwin; + if (platform === "linux") return managed.linux; + if (platform === "win32") return managed.win32; + return undefined; +}; diff --git a/apps/server/src/instructions/ClaudeInstructionSetting.test.ts b/apps/server/src/instructions/ClaudeInstructionSetting.test.ts new file mode 100644 index 000000000000..3c5bdcdfb89b --- /dev/null +++ b/apps/server/src/instructions/ClaudeInstructionSetting.test.ts @@ -0,0 +1,432 @@ +import * as NodePath from "@effect/platform-node/NodePath"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import { + addAgentsMdImport, + agentsMdImportLine, + hasAgentsMdImport, + readClaudeInstructionSetting, + removeAgentsMdImport, + supportsAgentsMd, + withClaudeInstructionSetting, +} from "./ClaudeInstructionSetting.ts"; + +const NEW_ID = "cc-plugin-agents-md@builtin"; +const LEGACY_ID = "agents-md@builtin"; + +const entry = (value: unknown, extra: Record = {}) => ({ + options: { instructionFiles: value, ...extra }, +}); + +describe("readClaudeInstructionSetting", () => { + const cases: Array<[string, unknown, string, boolean]> = [ + ["no settings", undefined, "claude-md-or-agents-md", false], + ["not an object", [], "claude-md-or-agents-md", false], + ["empty object", {}, "claude-md-or-agents-md", false], + ["pluginConfigs of the wrong type", { pluginConfigs: "x" }, "claude-md-or-agents-md", false], + ["current id", { pluginConfigs: { [NEW_ID]: entry("claude-md") } }, "claude-md", true], + [ + "legacy id only", + { pluginConfigs: { [LEGACY_ID]: entry("claude-md-and-agents-md") } }, + "claude-md-and-agents-md", + true, + ], + [ + "both ids, current wins", + { pluginConfigs: { [NEW_ID]: entry("managed-only"), [LEGACY_ID]: entry("claude-md") } }, + "managed-only", + true, + ], + [ + "unknown value in the current id, legacy is valid", + { pluginConfigs: { [NEW_ID]: entry("nonsense"), [LEGACY_ID]: entry("claude-md") } }, + "claude-md", + true, + ], + [ + "unknown value only", + { pluginConfigs: { [NEW_ID]: entry("nonsense") } }, + "claude-md-or-agents-md", + false, + ], + [ + "value of the wrong type", + { pluginConfigs: { [NEW_ID]: entry(3) } }, + "claude-md-or-agents-md", + false, + ], + ]; + + it.each(cases)("%s", (_name, settings, value, explicit) => { + expect(readClaudeInstructionSetting(settings)).toEqual({ value, explicit }); + }); +}); + +describe("withClaudeInstructionSetting", () => { + it("creates the nested objects in empty settings", () => { + expect(withClaudeInstructionSetting({}, "claude-md-and-agents-md")).toEqual({ + pluginConfigs: { [NEW_ID]: entry("claude-md-and-agents-md") }, + }); + }); + + it("keeps other keys at every level, and their order", () => { + const settings = { + theme: "dark", + pluginConfigs: { + "other@marketplace": { enabled: true }, + [NEW_ID]: { enabled: true, options: { other: 1, instructionFiles: "claude-md" } }, + }, + hooks: {}, + }; + const updated = withClaudeInstructionSetting(settings, "managed-only"); + expect(updated).toEqual({ + theme: "dark", + pluginConfigs: { + "other@marketplace": { enabled: true }, + [NEW_ID]: { enabled: true, options: { other: 1, instructionFiles: "managed-only" } }, + }, + hooks: {}, + }); + expect(Object.keys(updated ?? {})).toEqual(["theme", "pluginConfigs", "hooks"]); + }); + + it("does not modify its input", () => { + const settings = { pluginConfigs: { [NEW_ID]: entry("claude-md") } }; + const snapshot = structuredClone(settings); + withClaudeInstructionSetting(settings, "managed-only"); + withClaudeInstructionSetting(settings, null); + expect(settings).toEqual(snapshot); + }); + + it("updates a legacy entry that has a value, and leaves one that has none", () => { + expect( + withClaudeInstructionSetting( + { pluginConfigs: { [LEGACY_ID]: entry("claude-md") } }, + "claude-md-and-agents-md", + ), + ).toEqual({ + pluginConfigs: { + [LEGACY_ID]: entry("claude-md-and-agents-md"), + [NEW_ID]: entry("claude-md-and-agents-md"), + }, + }); + const withoutValue = { pluginConfigs: { [LEGACY_ID]: { options: { other: true } } } }; + expect(withClaudeInstructionSetting(withoutValue, "claude-md")).toEqual({ + pluginConfigs: { [LEGACY_ID]: { options: { other: true } }, [NEW_ID]: entry("claude-md") }, + }); + }); + + it("removes the entry, then every object that it leaves empty", () => { + expect( + withClaudeInstructionSetting({ pluginConfigs: { [NEW_ID]: entry("claude-md") } }, null), + ).toEqual({}); + expect( + withClaudeInstructionSetting( + { theme: "dark", pluginConfigs: { [NEW_ID]: entry("claude-md") } }, + null, + ), + ).toEqual({ theme: "dark" }); + }); + + it("stops cleaning up at the first object that still has something in it", () => { + expect( + withClaudeInstructionSetting( + { pluginConfigs: { [NEW_ID]: entry("claude-md", { other: 1 }) } }, + null, + ), + ).toEqual({ pluginConfigs: { [NEW_ID]: { options: { other: 1 } } } }); + expect( + withClaudeInstructionSetting( + { pluginConfigs: { [NEW_ID]: { enabled: true, ...entry("claude-md") } } }, + null, + ), + ).toEqual({ pluginConfigs: { [NEW_ID]: { enabled: true } } }); + expect( + withClaudeInstructionSetting( + { pluginConfigs: { "other@marketplace": {}, [NEW_ID]: entry("claude-md") } }, + null, + ), + ).toEqual({ pluginConfigs: { "other@marketplace": {} } }); + }); + + it("removes a legacy entry together with the current one", () => { + const updated = withClaudeInstructionSetting( + { + pluginConfigs: { [NEW_ID]: entry("claude-md"), [LEGACY_ID]: entry("claude-md") }, + theme: "dark", + }, + null, + ); + expect(updated).toEqual({ theme: "dark" }); + expect(readClaudeInstructionSetting(updated)).toEqual({ + value: "claude-md-or-agents-md", + explicit: false, + }); + }); + + it("returns the same object when there is nothing to remove", () => { + const settings = { pluginConfigs: {}, theme: "dark" }; + expect(withClaudeInstructionSetting(settings, null)).toBe(settings); + }); + + it("refuses to overwrite a value that isn't an object, and leaves it alone on removal", () => { + for (const settings of [ + { pluginConfigs: "x" }, + { pluginConfigs: [] }, + { pluginConfigs: { [NEW_ID]: true } }, + { pluginConfigs: { [NEW_ID]: { options: "x" } } }, + ]) { + expect(withClaudeInstructionSetting(settings, "claude-md")).toBeUndefined(); + expect(withClaudeInstructionSetting(settings, null)).toBe(settings); + } + }); + + it("reads back what it writes", () => { + const written = withClaudeInstructionSetting({}, "claude-md"); + expect(readClaudeInstructionSetting(written)).toEqual({ value: "claude-md", explicit: true }); + }); +}); + +describe("supportsAgentsMd", () => { + const cases: Array<[string | null | undefined, boolean]> = [ + ["2.1.277", true], + ["2.1.276", false], + ["2.1.291", true], + ["2.2.0", true], + ["3.0.0", true], + ["2.0.999", false], + ["1.9.9", false], + ["v2.1.277", true], + [" 2.1.291 ", true], + ["2.1.291 (Claude Code)", true], + ["2.1.277+build.5", true], + ["2.1.277-beta.1", false], + ["2.1.278-beta.1", true], + ["2.1", false], + ["2", false], + ["", false], + [" ", false], + ["latest", false], + ["2.1.x", false], + ["(Claude Code)", false], + [null, false], + [undefined, false], + ]; + + it.each(cases)("%j", (version, expected) => { + expect(supportsAgentsMd(version)).toBe(expected); + }); +}); + +/** The two kinds of AGENTS.md that a CLAUDE.md imports, on the platform the layer provides. */ +const targets = Effect.gen(function* () { + const path = yield* Path.Path; + return { + /** The shared file in the home directory, imported from Claude's own CLAUDE.md. */ + shared: { + path, + agentsMdPath: "/home/user/.agents/AGENTS.md", + claudeMdDirectory: "/home/user/.claude", + homeDirectory: "/home/user", + }, + /** A project's AGENTS.md, imported from the CLAUDE.md next to it. */ + project: { + path, + agentsMdPath: "/work/acme-web/AGENTS.md", + claudeMdDirectory: "/work/acme-web", + homeDirectory: "/home/user", + }, + sharedWithSpace: { + path, + agentsMdPath: "/home/user/My Notes/AGENTS.md", + claudeMdDirectory: "/home/user/.claude", + homeDirectory: "/home/user", + }, + }; +}); + +type TargetName = "shared" | "project" | "sharedWithSpace"; + +it.layer(NodePath.layerPosix, { excludeTestServices: true })("AGENTS.md imports", (it) => { + describe("agentsMdImportLine", () => { + const cases: Array<[TargetName | { agentsMdPath: string }, string]> = [ + ["shared", "@~/.agents/AGENTS.md"], + ["project", "@/work/acme-web/AGENTS.md"], + ["sharedWithSpace", "@~/My\\ Notes/AGENTS.md"], + [{ agentsMdPath: "/home/username/AGENTS.md" }, "@/home/username/AGENTS.md"], + [{ agentsMdPath: "/home/user/..cache/AGENTS.md" }, "@~/..cache/AGENTS.md"], + ]; + + it.effect.each(cases)("%#", ([which, line]) => + Effect.gen(function* () { + const all = yield* targets; + const target = + typeof which === "string" + ? all[which] + : { ...all.shared, agentsMdPath: which.agentsMdPath }; + expect(agentsMdImportLine(target)).toBe(line); + }), + ); + }); + + describe("hasAgentsMdImport", () => { + const cases: Array<[string, TargetName, string, boolean]> = [ + ["home form", "shared", "@~/.agents/AGENTS.md", true], + ["absolute", "shared", "@/home/user/.agents/AGENTS.md", true], + ["relative with dots", "shared", "@../.agents/AGENTS.md", true], + ["same folder", "project", "@AGENTS.md", true], + ["same folder with ./", "project", "@./AGENTS.md", true], + ["roundabout", "project", "@../acme-web/AGENTS.md", true], + ["indented and padded", "project", " @AGENTS.md ", true], + ["escaped space", "sharedWithSpace", "@~/My\\ Notes/AGENTS.md", true], + ["another file", "project", "@docs/AGENTS.md", false], + ["relative to the wrong folder", "shared", "@AGENTS.md", false], + ["another home", "shared", "@~/other/AGENTS.md", false], + ["unescaped space", "sharedWithSpace", "@~/My Notes/AGENTS.md", false], + ["mentioned in a sentence", "project", "See @AGENTS.md for more", false], + ["text after the path", "project", "@AGENTS.md please", false], + ["not at the start of the line", "project", "- @AGENTS.md", false], + ["quoted", "project", '@"AGENTS.md"', false], + ["bare at sign", "project", "@", false], + ["no at sign", "project", "AGENTS.md", false], + ]; + + it.effect.each(cases)("%s", ([, which, text, expected]) => + Effect.gen(function* () { + expect(hasAgentsMdImport(text, (yield* targets)[which])).toBe(expected); + }), + ); + + it.effect("finds the import anywhere in the text, not only on the first line", () => + Effect.gen(function* () { + const { project } = yield* targets; + expect(hasAgentsMdImport("# Notes\n\n@AGENTS.md\nmore\n", project)).toBe(true); + }), + ); + + it.effect("ignores lines inside fenced code blocks", () => + Effect.gen(function* () { + const { project } = yield* targets; + expect(hasAgentsMdImport("```\n@AGENTS.md\n```\n", project)).toBe(false); + expect(hasAgentsMdImport("~~~md\n@AGENTS.md\n~~~\n", project)).toBe(false); + expect(hasAgentsMdImport("````\n```\n@AGENTS.md\n```\n````\n", project)).toBe(false); + expect(hasAgentsMdImport("```\ncode\n```\n@AGENTS.md\n", project)).toBe(true); + }), + ); + + it.effect("treats an unclosed fence as running to the end of the text", () => + Effect.gen(function* () { + const { project } = yield* targets; + expect(hasAgentsMdImport("```\n@AGENTS.md\n", project)).toBe(false); + }), + ); + }); + + describe("addAgentsMdImport", () => { + const line = "@~/.agents/AGENTS.md"; + const cases: Array<[string, string, string]> = [ + ["empty text", "", `${line}\n`], + ["one line, no line ending", "# Notes", `${line}\n# Notes`], + ["text with a trailing newline", "# Notes\n", `${line}\n# Notes\n`], + ["CRLF text", "# Notes\r\nmore\r\n", `${line}\r\n# Notes\r\nmore\r\n`], + ["leading blank lines", "\n\n# Notes\n", `${line}\n\n\n# Notes\n`], + ["leading blank CRLF lines", "\r\n# Notes\r\n", `${line}\r\n\r\n# Notes\r\n`], + ["other @ lines", "@README.md\n@docs/guide.md\n", `${line}\n@README.md\n@docs/guide.md\n`], + ["a byte order mark", "\uFEFF# Notes\n", `\uFEFF${line}\n# Notes\n`], + ]; + + it.effect.each(cases)("%s", ([, text, expected]) => + Effect.gen(function* () { + const { shared } = yield* targets; + expect(addAgentsMdImport(text, shared)).toBe(expected); + }), + ); + + it.effect("leaves text that already imports the file as it is, wherever the import is", () => + Effect.gen(function* () { + const { shared } = yield* targets; + for (const text of [ + `${line}\n# Notes\n`, + `# Notes\n${line}\n`, + "@../.agents/AGENTS.md\n", + `\n${line}`, + ]) { + expect(addAgentsMdImport(text, shared)).toBe(text); + } + }), + ); + + it.effect("adds the import when the only mention is inside a code block", () => + Effect.gen(function* () { + const { shared } = yield* targets; + expect(addAgentsMdImport("```\n@~/.agents/AGENTS.md\n```\n", shared)).toBe( + `${line}\n\`\`\`\n@~/.agents/AGENTS.md\n\`\`\`\n`, + ); + }), + ); + }); + + describe("removeAgentsMdImport", () => { + const line = "@~/.agents/AGENTS.md"; + const cases: Array<[string, string, string]> = [ + ["only the import", `${line}\n`, ""], + ["the import and no line ending", line, ""], + ["first line", `${line}\n# Notes\n`, "# Notes\n"], + ["middle line", `# Notes\n${line}\nmore\n`, "# Notes\nmore\n"], + ["last line", `# Notes\n${line}`, "# Notes\n"], + ["CRLF text", `${line}\r\n# Notes\r\nmore\r\n`, "# Notes\r\nmore\r\n"], + ["written another way", "@../.agents/AGENTS.md\n# Notes\n", "# Notes\n"], + ["twice", `${line}\n# Notes\n${line}\n`, "# Notes\n"], + ["other @ lines stay", `${line}\n@README.md\n`, "@README.md\n"], + ["blank lines stay", `${line}\n\n# Notes\n`, "\n# Notes\n"], + ["not there", "# Notes\n@README.md\n", "# Notes\n@README.md\n"], + ["a byte order mark", `\uFEFF${line}\n# Notes\n`, "\uFEFF# Notes\n"], + ]; + + it.effect.each(cases)("%s", ([, text, expected]) => + Effect.gen(function* () { + const { shared } = yield* targets; + expect(removeAgentsMdImport(text, shared)).toBe(expected); + }), + ); + + it.effect("leaves a mention inside a code block alone", () => + Effect.gen(function* () { + const { shared } = yield* targets; + const text = `\`\`\`\n${line}\n\`\`\`\n`; + expect(removeAgentsMdImport(text, shared)).toBe(text); + }), + ); + + it.effect.each(["", "# Notes\n", "# Notes", "\n\nA\r\nB\r\n", "@README.md\n"])( + "undoes an add: %j", + (text) => + Effect.gen(function* () { + const { shared } = yield* targets; + expect(removeAgentsMdImport(addAgentsMdImport(text, shared), shared)).toBe(text); + }), + ); + }); +}); + +it.layer(NodePath.layerWin32, { excludeTestServices: true })( + "AGENTS.md imports on Windows", + (it) => { + it.effect("writes forward slashes and reads its own paths back", () => + Effect.gen(function* () { + const target = { + path: yield* Path.Path, + agentsMdPath: "C:\\Users\\user\\.agents\\AGENTS.md", + claudeMdDirectory: "C:\\Users\\user\\.claude", + homeDirectory: "C:\\Users\\user", + }; + expect(agentsMdImportLine(target)).toBe("@~/.agents/AGENTS.md"); + expect(hasAgentsMdImport("@~/.agents/AGENTS.md\r\n", target)).toBe(true); + expect(hasAgentsMdImport("@../.agents/AGENTS.md\r\n", target)).toBe(true); + expect(hasAgentsMdImport("@~/.agents/OTHER.md\r\n", target)).toBe(false); + }), + ); + }, +); diff --git a/apps/server/src/instructions/ClaudeInstructionSetting.ts b/apps/server/src/instructions/ClaudeInstructionSetting.ts new file mode 100644 index 000000000000..34274e458935 --- /dev/null +++ b/apps/server/src/instructions/ClaudeInstructionSetting.ts @@ -0,0 +1,231 @@ +/** + * ClaudeInstructionSetting - Claude's "Project instructions" setting, the import line that lets a + * CLAUDE.md read an AGENTS.md, and the Claude Code version that can read AGENTS.md at all. + * + * Pure functions over parsed JSON and text; the caller reads and writes the files. Sources: + * https://code.claude.com/docs/en/memory ("Choose which instruction files load", "Import + * additional files", "When AGENTS.md support is unavailable"). + * + * The setting is `pluginConfigs["cc-plugin-agents-md@builtin"].options.instructionFiles` in the + * Claude config folder's `settings.json`. Claude Code ignores it in project and local settings. + * Before 2.1.285 the plugin's id was `agents-md@builtin` and Claude Code 2.1.285 and later reads + * either, so reading checks both ids and writing keeps a legacy entry that has a value in step. + * + * Imports are `@path` in the text of a CLAUDE.md. Only an import that is a line by itself is + * managed here; Claude also imports a path mentioned inside a sentence, which these functions + * neither detect nor remove. Lines inside fenced code blocks are not imports, as in Claude. + * + * @module ClaudeInstructionSetting + */ +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; +import type * as Path from "effect/Path"; + +export const CLAUDE_INSTRUCTION_VALUES = [ + "claude-md-or-agents-md", + "claude-md-and-agents-md", + "claude-md", + "managed-only", +] as const; + +export type ClaudeInstructionValue = (typeof CLAUDE_INSTRUCTION_VALUES)[number]; + +/** What Claude does when the setting is absent: AGENTS.md only when there is no CLAUDE.md. */ +export const DEFAULT_CLAUDE_INSTRUCTION_VALUE: ClaudeInstructionValue = "claude-md-or-agents-md"; + +/** The first Claude Code release that reads AGENTS.md. */ +const MIN_AGENTS_MD_CLAUDE_VERSION = "2.1.277"; + +const PLUGIN_ID = "cc-plugin-agents-md@builtin"; +const LEGACY_PLUGIN_ID = "agents-md@builtin"; +const OPTION = "instructionFiles"; + +const settingPath = (pluginId: string) => ["pluginConfigs", pluginId, "options", OPTION] as const; + +/** A parsed JSON object, such as the contents of `settings.json`. */ +export type JsonObject = Record; + +const isObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isInstructionValue = (value: unknown): value is ClaudeInstructionValue => + CLAUDE_INSTRUCTION_VALUES.some((known) => known === value); + +const getIn = (root: unknown, keys: readonly string[]): unknown => { + let current = root; + for (const key of keys) { + if (!isObject(current)) return undefined; + current = current[key]; + } + return current; +}; + +/** A copy of `root` with `keys` set, or `undefined` when a step on the way isn't an object. */ +const setIn = ( + root: JsonObject, + keys: readonly string[], + value: unknown, +): JsonObject | undefined => { + const [key, ...rest] = keys; + if (key === undefined) return undefined; + if (rest.length === 0) return { ...root, [key]: value }; + const child = root[key]; + if (child !== undefined && !isObject(child)) return undefined; + const updated = setIn(child ?? {}, rest, value); + return updated === undefined ? undefined : { ...root, [key]: updated }; +}; + +const withoutKey = (root: JsonObject, key: string): JsonObject => + Object.fromEntries(Object.entries(root).filter(([name]) => name !== key)); + +/** + * A copy of `root` without `keys`. Objects that this empties go too; objects that were already + * empty, and anything the removal doesn't touch, stay. Returns `root` itself when nothing changed. + */ +const deleteIn = (root: JsonObject, keys: readonly string[]): JsonObject => { + const [key, ...rest] = keys; + if (key === undefined || !Object.hasOwn(root, key)) return root; + if (rest.length === 0) return withoutKey(root, key); + const child = root[key]; + if (!isObject(child)) return root; + const updated = deleteIn(child, rest); + if (updated === child) return root; + return Object.keys(updated).length === 0 ? withoutKey(root, key) : { ...root, [key]: updated }; +}; + +export interface ClaudeInstructionSetting { + readonly value: ClaudeInstructionValue; + /** False when no known value is set and `value` is Claude's default. */ + readonly explicit: boolean; +} + +/** The "Project instructions" value in a parsed `settings.json`. */ +export const readClaudeInstructionSetting = (settings: unknown): ClaudeInstructionSetting => { + for (const pluginId of [PLUGIN_ID, LEGACY_PLUGIN_ID]) { + const value = getIn(settings, settingPath(pluginId)); + if (isInstructionValue(value)) return { value, explicit: true }; + } + return { value: DEFAULT_CLAUDE_INSTRUCTION_VALUE, explicit: false }; +}; + +/** + * `settings` with "Project instructions" set to `value`, or back at Claude's default when `value` + * is null: the entry goes, and so do objects it leaves empty. A legacy entry that has a value is + * kept in step. Everything else is untouched and `settings` is never modified. Returns + * `undefined` when `value` is set but `pluginConfigs` or the plugin's entry isn't an object, so + * a caller never overwrites something it doesn't understand. + */ +export const withClaudeInstructionSetting = ( + settings: JsonObject, + value: ClaudeInstructionValue | null, +): JsonObject | undefined => { + if (value === null) { + return deleteIn(deleteIn(settings, settingPath(PLUGIN_ID)), settingPath(LEGACY_PLUGIN_ID)); + } + const updated = setIn(settings, settingPath(PLUGIN_ID), value); + if (updated === undefined) return undefined; + if (getIn(updated, settingPath(LEGACY_PLUGIN_ID)) === undefined) return updated; + return setIn(updated, settingPath(LEGACY_PLUGIN_ID), value); +}; + +/** + * Whether a Claude Code version can read AGENTS.md. Takes the first word, so + * `2.1.291 (Claude Code)` works. Prereleases sort below their release, and anything that isn't a + * version is false. + */ +export const supportsAgentsMd = (version: string | null | undefined): boolean => { + const word = version?.trim().split(/\s+/)[0]?.split("+")[0]; + if (word === undefined || word === "" || parseSemver(word) === null) return false; + return compareSemverVersions(word, MIN_AGENTS_MD_CLAUDE_VERSION) >= 0; +}; + +export interface AgentsMdImportTarget { + /** Resolves the paths, so the answer follows the platform the files are on. */ + readonly path: Path.Path; + /** The AGENTS.md the import points at. */ + readonly agentsMdPath: string; + /** The folder of the CLAUDE.md; a relative import resolves against it. */ + readonly claudeMdDirectory: string; + /** What `~` means in an import. */ + readonly homeDirectory: string; +} + +/** The import line for the target: `@~/...` under the home directory, else the absolute path. */ +export const agentsMdImportLine = (target: AgentsMdImportTarget): string => { + const { path } = target; + const relative = path.relative(target.homeDirectory, target.agentsMdPath); + const inHome = + relative !== "" && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative); + const written = inHome ? `~/${relative.split(path.sep).join("/")}` : target.agentsMdPath; + return `@${written.replaceAll(" ", "\\ ")}`; +}; + +/** The path an import line points at, or `undefined` when the line is not an import by itself. */ +const importedPath = (line: string, target: AgentsMdImportTarget): string | undefined => { + const body = line.trim(); + if (!body.startsWith("@")) return undefined; + const written = body.slice(1); + if (written === "" || /(? { + const resolvedTarget = target.path.resolve(target.agentsMdPath); + let fence: { readonly marker: string; readonly length: number } | undefined; + return text + .split(/(?<=\n)/) + .filter((line) => line !== "") + .map((line) => { + const content = line.replace(/\r?\n$/, ""); + const opened = FENCE.exec(content); + if (fence === undefined) { + if (opened?.[1] !== undefined) { + fence = { marker: opened[1].charAt(0), length: opened[1].length }; + return { line, isImport: false }; + } + return { line, isImport: importedPath(content, target) === resolvedTarget }; + } + const closes = + opened?.[1] !== undefined && + opened[1].charAt(0) === fence.marker && + opened[1].length >= fence.length && + opened[2]?.trim() === ""; + if (closes) fence = undefined; + return { line, isImport: false }; + }); +}; + +/** Whether the text has a line that imports the target. */ +export const hasAgentsMdImport = (text: string, target: AgentsMdImportTarget): boolean => + scanImports(text, target).some((entry) => entry.isImport); + +/** + * The text with an import of the target as its first line and everything else as it was. Text + * that already imports the target comes back as it is. + */ +export const addAgentsMdImport = (text: string, target: AgentsMdImportTarget): string => { + if (hasAgentsMdImport(text, target)) return text; + const lineEnding = /\r?\n/.exec(text)?.[0] ?? "\n"; + const bom = text.startsWith("") ? "" : ""; + return `${bom}${agentsMdImportLine(target)}${lineEnding}${text.slice(bom.length)}`; +}; + +/** The text without any line that imports the target, everything else as it was. */ +export const removeAgentsMdImport = (text: string, target: AgentsMdImportTarget): string => { + const kept = scanImports(text, target) + .filter((entry) => !entry.isImport) + .map((entry) => entry.line) + .join(""); + return kept !== "" && text.startsWith("\uFEFF") && !kept.startsWith("\uFEFF") + ? `\uFEFF${kept}` + : kept; +}; From d5b969ba7d6f0114a368eb456b0df6a45f28f96b Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:32:14 -0400 Subject: [PATCH 047/108] refactor(server): share the agent config folder lookup and git tracking check Skills and the coming instruction files need the same two things: where an agent keeps its config (Claude, Codex and Grok each have a setting or variable that moves it) and which project files git tracks. Both move out of the skills code unchanged so instructions reuse them. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/skills/AgentConfigHome.ts | 54 +++++++++++++++++++++++ apps/server/src/skills/SkillCatalog.ts | 46 ++++++------------- apps/server/src/skills/SkillLinks.ts | 2 +- apps/server/src/skills/SkillTracking.ts | 31 +++---------- apps/server/src/vcs/GitTrackedFiles.ts | 52 ++++++++++++++++++++++ 5 files changed, 128 insertions(+), 57 deletions(-) create mode 100644 apps/server/src/skills/AgentConfigHome.ts create mode 100644 apps/server/src/vcs/GitTrackedFiles.ts diff --git a/apps/server/src/skills/AgentConfigHome.ts b/apps/server/src/skills/AgentConfigHome.ts new file mode 100644 index 000000000000..3cbbdc6d4c1b --- /dev/null +++ b/apps/server/src/skills/AgentConfigHome.ts @@ -0,0 +1,54 @@ +/** + * AgentConfigHome - where a provider instance keeps its config, which its own skill and + * instruction folders live under. + * + * This follows the setting or variable that moves the agent's home, in the order the agent applies + * them: Claude's `homePath` setting, then `CLAUDE_CONFIG_DIR`; Codex's `homePath`, then + * `CODEX_HOME`; Grok's `GROK_HOME`. Anything else stays at the default folder. + * + * @module AgentConfigHome + */ +import type { ProviderInstanceConfig } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; + +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import { resolveClaudeConfigDirPath } from "../provider/Drivers/ClaudeSkills.ts"; + +const decodeHomePath = Schema.decodeUnknownOption( + Schema.Struct({ homePath: Schema.optional(Schema.String) }), +); + +/** The instance's `homePath` setting, trimmed; empty when it has none. */ +const instanceHomePath = (instance: ProviderInstanceConfig) => + Option.getOrUndefined(decodeHomePath(instance.config))?.homePath?.trim() ?? ""; + +export const resolveAgentConfigHome = Effect.fnUntraced(function* (input: { + readonly instance: ProviderInstanceConfig; + /** The agent's default folder, used when nothing moves it. */ + readonly fallback: string; + /** The server process's environment; the instance's own variables are laid over it. */ + readonly environment: NodeJS.ProcessEnv; + readonly cwd: string | undefined; +}) { + const path = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; + const { instance, fallback, cwd } = input; + const env = yield* mergeProviderInstanceEnvironment(instance.environment, input.environment); + const setting = instanceHomePath(instance); + const absoluteOr = (value: string | undefined) => + value && path.isAbsolute(value) ? value : fallback; + if (instance.driver === "claudeAgent") { + return yield* resolveClaudeConfigDirPath({ homePath: setting }, env, cwd); + } + if (instance.driver === "codex") { + return absoluteOr(expandHomePath(setting || (env.CODEX_HOME?.trim() ?? ""), home)); + } + if (instance.driver === "grok") return absoluteOr(env.GROK_HOME?.trim()); + return fallback; +}); diff --git a/apps/server/src/skills/SkillCatalog.ts b/apps/server/src/skills/SkillCatalog.ts index 3c7b5603f2aa..f7a140724d3e 100644 --- a/apps/server/src/skills/SkillCatalog.ts +++ b/apps/server/src/skills/SkillCatalog.ts @@ -41,7 +41,6 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { @@ -55,15 +54,12 @@ import { type SkillCollision, } from "@t3tools/provider-core/server/AgentSkillFolders"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; -import { - parseSkillFrontmatter, - resolveClaudeConfigDirPath, -} from "../provider/Drivers/ClaudeSkills.ts"; +import { parseSkillFrontmatter } from "../provider/Drivers/ClaudeSkills.ts"; import * as ProjectService from "../project/ProjectService.ts"; import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts"; import * as Settings from "../serverSettings.ts"; +import { resolveAgentConfigHome } from "./AgentConfigHome.ts"; import { loadSkillSwitches, skillSwitchKind, @@ -96,10 +92,6 @@ const SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]); const CONCURRENCY = 16; const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; -const decodeHomePath = Schema.decodeUnknownOption( - Schema.Struct({ homePath: Schema.optional(Schema.String) }), -); - /** * A skill's folder name is whatever the agents' scanners accept, short of what could leave the * folder (`.`, `..`, separators, NUL) or hides it (a leading dot). @@ -428,34 +420,24 @@ const make = Effect.gen(function* () { }; /** - * Where an instance keeps its config, which its own global skill folder lives under. This - * follows the setting or variable that moves the agent's home, in the order the agent applies - * them; an agent without one stays at its default folder under the home directory. + * Where an instance keeps its config, which its own global skill folder lives under; an agent + * without a setting or variable that moves it stays at its default folder under the home + * directory. */ - const configHomeOf = Effect.fnUntraced(function* ( + const configHomeOf = ( instance: ProviderInstanceConfig, table: AgentSkillFolderList, cwd: string | undefined, - ) { - const env = yield* mergeProviderInstanceEnvironment(instance.environment, environment).pipe( + ) => + resolveAgentConfigHome({ + instance, + fallback: path.join(homeDirectory, table.configHome ?? ""), + environment, + cwd, + }).pipe( + Effect.provideService(Path.Path, path), Effect.provideService(HostProcess.HomeDirectory, homeDirectory), ); - const setting = Option.getOrUndefined(decodeHomePath(instance.config))?.homePath?.trim() ?? ""; - const fallback = path.join(homeDirectory, table.configHome ?? ""); - const absoluteOr = (value: string | undefined) => - value && path.isAbsolute(value) ? value : fallback; - if (instance.driver === "claudeAgent") { - return yield* resolveClaudeConfigDirPath({ homePath: setting }, env, cwd).pipe( - Effect.provideService(Path.Path, path), - Effect.provideService(HostProcess.HomeDirectory, homeDirectory), - ); - } - if (instance.driver === "codex") { - return absoluteOr(expandHomePath(setting || (env.CODEX_HOME?.trim() ?? ""), homeDirectory)); - } - if (instance.driver === "grok") return absoluteOr(env.GROK_HOME?.trim()); - return fallback; - }); /** The enabled provider instances whose folders T3 Code knows, in the table's order. */ const loadInstances = Effect.fnUntraced(function* (cwd: string | undefined) { diff --git a/apps/server/src/skills/SkillLinks.ts b/apps/server/src/skills/SkillLinks.ts index f30caa402bef..dbf529eb8cca 100644 --- a/apps/server/src/skills/SkillLinks.ts +++ b/apps/server/src/skills/SkillLinks.ts @@ -77,7 +77,7 @@ const errorCode = (error: unknown) => typeof error === "object" && error !== null && "code" in error ? error.code : undefined; /** What a path is, as far as links go. `stat` follows links, so only `readLink` can tell. */ -const readLinkTarget = Effect.fnUntraced(function* (link: string) { +export const readLinkTarget = Effect.fnUntraced(function* (link: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.readLink(link).pipe( Effect.map((target): { readonly _tag: "Link"; readonly target: string } => ({ diff --git a/apps/server/src/skills/SkillTracking.ts b/apps/server/src/skills/SkillTracking.ts index 3dc1a063954f..3548c6c5e637 100644 --- a/apps/server/src/skills/SkillTracking.ts +++ b/apps/server/src/skills/SkillTracking.ts @@ -15,6 +15,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import { trackedFiles } from "../vcs/GitTrackedFiles.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as SkillCatalog from "./SkillCatalog.ts"; @@ -64,31 +65,13 @@ const make = Effect.gen(function* () { } if (files.size === 0) return { tracked: [] }; - const result = yield* vcs - .run({ - operation: "SkillTracking.tracked", - command: "git", - args: [ - "--literal-pathspecs", - "-c", - "core.fsmonitor=false", - "ls-files", - "--cached", - "-z", - "--", - ...files.keys(), - ], - cwd: input.cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - maxOutputBytes: 256 * 1024, - }) - .pipe(Effect.orElseSucceed(() => undefined)); - if (result === undefined || result.exitCode !== 0) return { tracked: [] }; - - const listed = new Set(result.stdout.split("\0")); + const trackedFilePaths = yield* trackedFiles(vcs, { + operation: "SkillTracking.tracked", + cwd: input.cwd, + files: [...files.keys()], + }); return { - tracked: [...files].flatMap(([file, name]) => (listed.has(file) ? [name] : [])), + tracked: [...files].flatMap(([file, name]) => (trackedFilePaths.has(file) ? [name] : [])), }; }, ); diff --git a/apps/server/src/vcs/GitTrackedFiles.ts b/apps/server/src/vcs/GitTrackedFiles.ts new file mode 100644 index 000000000000..f38ad74c0c01 --- /dev/null +++ b/apps/server/src/vcs/GitTrackedFiles.ts @@ -0,0 +1,52 @@ +/** + * GitTrackedFiles - which of some files in a project git tracks. + * + * Moving or deleting a project's skill or instruction file shows in git only if git tracks it, so + * the confirmation asks. It runs one `git ls-files` for all the files, and only when a person is + * about to confirm a change. + * + * @module GitTrackedFiles + */ +import * as Effect from "effect/Effect"; + +import type * as VcsProcess from "./VcsProcess.ts"; + +/** + * The files among `files` that git tracks, as they were given. `files` are paths relative to the + * project's real folder, written with `/`. Every file counts as not tracked when git fails or the + * folder is not in a repository. + */ +export const trackedFiles = Effect.fn("GitTrackedFiles.trackedFiles")(function* ( + vcs: VcsProcess.VcsProcess["Service"], + input: { + /** Names the caller in git's process diagnostics. */ + readonly operation: string; + readonly cwd: string; + readonly files: ReadonlyArray; + }, +) { + if (input.files.length === 0) return new Set(); + const result = yield* vcs + .run({ + operation: input.operation, + command: "git", + args: [ + "--literal-pathspecs", + "-c", + "core.fsmonitor=false", + "ls-files", + "--cached", + "-z", + "--", + ...input.files, + ], + cwd: input.cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 256 * 1024, + }) + .pipe(Effect.orElseSucceed(() => undefined)); + if (result === undefined || result.exitCode !== 0) return new Set(); + const listed = new Set(result.stdout.split("\0")); + return new Set(input.files.filter((file) => listed.has(file))); +}); From ecac0a83325a6bab69b6b62261230a69555d7793 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:32:16 -0400 Subject: [PATCH 048/108] feat(server): edit instruction files and choose which agents read them Lists the AGENTS.md and CLAUDE.md files in a project and in the user's home, says which agent reads each (following each agent's own rules: Claude's Project instructions setting, version and CLAUDE files, Pi and OpenCode CLAUDE.md fallbacks, Codex's override file), and lets a client read, edit, delete and share them. Edits go to the real file behind any link, only when the file is unchanged since it was read. One shared file links into each agent's home (Claude imports it), an agent's own text is added to it before the agent is linked, and Claude's setting is written without touching the rest of settings.json. Co-Authored-By: Claude Sonnet 5.5 --- .../src/instructions/AgentInstructionFiles.ts | 17 +- .../ClaudeInstructionSetting.test.ts | 54 +- .../instructions/ClaudeInstructionSetting.ts | 78 +- .../instructions/InstructionCatalog.test.ts | 874 +++++++++++++ .../src/instructions/InstructionCatalog.ts | 931 ++++++++++++++ .../src/instructions/InstructionFileIO.ts | 128 ++ .../src/instructions/InstructionLinks.ts | 92 ++ .../instructions/InstructionManager.test.ts | 1088 +++++++++++++++++ .../src/instructions/InstructionManager.ts | 603 +++++++++ .../src/instructions/InstructionTracking.ts | 73 ++ .../src/instructions/testing/machine.ts | 187 +++ apps/server/src/server.ts | 9 + apps/server/src/ws.ts | 38 +- 13 files changed, 4106 insertions(+), 66 deletions(-) create mode 100644 apps/server/src/instructions/InstructionCatalog.test.ts create mode 100644 apps/server/src/instructions/InstructionCatalog.ts create mode 100644 apps/server/src/instructions/InstructionFileIO.ts create mode 100644 apps/server/src/instructions/InstructionLinks.ts create mode 100644 apps/server/src/instructions/InstructionManager.test.ts create mode 100644 apps/server/src/instructions/InstructionManager.ts create mode 100644 apps/server/src/instructions/InstructionTracking.ts create mode 100644 apps/server/src/instructions/testing/machine.ts diff --git a/apps/server/src/instructions/AgentInstructionFiles.ts b/apps/server/src/instructions/AgentInstructionFiles.ts index 8e0596fb564a..2d0fdc6ddcab 100644 --- a/apps/server/src/instructions/AgentInstructionFiles.ts +++ b/apps/server/src/instructions/AgentInstructionFiles.ts @@ -115,6 +115,10 @@ export interface ProjectInstructionRules { * folders on the way up are read (`none`). */ readonly subfolders: "none" | "on-demand"; + /** Environment variables that switch off the files after the first name in `files`. */ + readonly fallbackDisabledByEnv?: readonly string[]; + /** The agent skips files git ignores, which `CLAUDE.local.md` is meant to be. */ + readonly skipsGitIgnored?: boolean; } /** A folder of another agent that this one reads too, at a fixed place under the home directory. */ @@ -134,6 +138,8 @@ export interface HomeInstructionRules { /** In the order the agent prefers them. */ readonly files: readonly string[]; readonly selection: Exclude; + /** A file with no text is passed over, so the next name in `files` is the one that loads. */ + readonly skipsEmpty?: boolean; /** The variable that names the folder itself. */ readonly folderEnv?: string; /** The default folder sits under `$XDG_CONFIG_HOME` when that is set, else under `~/.config`. */ @@ -182,6 +188,12 @@ const GROK_FILE_NAMES = [ "AGENTS.md", ] as const; +/** The variables that turn off OpenCode's reading of Claude's files, its project `CLAUDE.md` too. */ +const OPENCODE_CLAUDE_COMPAT_ENV = [ + "OPENCODE_DISABLE_CLAUDE_CODE", + "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT", +] as const; + export const AGENT_INSTRUCTION_FILES: ReadonlyArray = [ { agent: ProviderDriverKind.make("claudeAgent"), @@ -224,6 +236,7 @@ export const AGENT_INSTRUCTION_FILES: ReadonlyArray = [ folder: ".codex", files: ["AGENTS.override.md", "AGENTS.md"], selection: "first-per-folder", + skipsEmpty: true, folderEnv: "CODEX_HOME", instanceHomePath: true, shared: { file: "AGENTS.md", join: "link" }, @@ -238,6 +251,7 @@ export const AGENT_INSTRUCTION_FILES: ReadonlyArray = [ selection: "first-name", parents: "repo-root", subfolders: "on-demand", + fallbackDisabledByEnv: OPENCODE_CLAUDE_COMPAT_ENV, }, home: { folder: ".config/opencode", @@ -251,7 +265,7 @@ export const AGENT_INSTRUCTION_FILES: ReadonlyArray = [ folder: ".claude", files: ["CLAUDE.md"], when: "no-own-file", - disabledByEnv: ["OPENCODE_DISABLE_CLAUDE_CODE", "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"], + disabledByEnv: OPENCODE_CLAUDE_COMPAT_ENV, }, ], }, @@ -301,6 +315,7 @@ export const AGENT_INSTRUCTION_FILES: ReadonlyArray = [ selection: "all", parents: "repo-root", subfolders: "on-demand", + skipsGitIgnored: true, }, home: { folder: ".grok", diff --git a/apps/server/src/instructions/ClaudeInstructionSetting.test.ts b/apps/server/src/instructions/ClaudeInstructionSetting.test.ts index 3c5bdcdfb89b..dcc2b31f484e 100644 --- a/apps/server/src/instructions/ClaudeInstructionSetting.test.ts +++ b/apps/server/src/instructions/ClaudeInstructionSetting.test.ts @@ -6,12 +6,14 @@ import * as Path from "effect/Path"; import { addAgentsMdImport, agentsMdImportLine, + claudeInstructionChanges, hasAgentsMdImport, + parseSettingsJson, readClaudeInstructionSetting, removeAgentsMdImport, supportsAgentsMd, - withClaudeInstructionSetting, } from "./ClaudeInstructionSetting.ts"; +import { editJsoncText } from "../skills/JsoncSettings.ts"; const NEW_ID = "cc-plugin-agents-md@builtin"; const LEGACY_ID = "agents-md@builtin"; @@ -20,6 +22,20 @@ const entry = (value: unknown, extra: Record = {}) => ({ options: { instructionFiles: value, ...extra }, }); +describe("settings.json text", () => { + it.each(["", "{", "null", "[]", "3", '"text"'])("is not a settings object: %j", (text) => { + expect(parseSettingsJson(text)).toBeUndefined(); + }); + + it("parses an object, comments and trailing commas included", () => { + expect(parseSettingsJson('{"theme":"dark","list":[1,2]}')).toEqual({ + theme: "dark", + list: [1, 2], + }); + expect(parseSettingsJson('{\n // a note\n "theme": "dark",\n}')).toEqual({ theme: "dark" }); + }); +}); + describe("readClaudeInstructionSetting", () => { const cases: Array<[string, unknown, string, boolean]> = [ ["no settings", undefined, "claude-md-or-agents-md", false], @@ -64,7 +80,17 @@ describe("readClaudeInstructionSetting", () => { }); }); -describe("withClaudeInstructionSetting", () => { +/** The settings after the shared editor makes the changes, `undefined` when it refuses. */ +const withClaudeInstructionSetting = ( + settings: Record, + value: Parameters[1], +) => { + const text = JSON.stringify(settings, null, 2); + const edited = editJsoncText(text, claudeInstructionChanges(settings, value)); + return edited === undefined ? undefined : parseSettingsJson(edited); +}; + +describe("claudeInstructionChanges", () => { it("creates the nested objects in empty settings", () => { expect(withClaudeInstructionSetting({}, "claude-md-and-agents-md")).toEqual({ pluginConfigs: { [NEW_ID]: entry("claude-md-and-agents-md") }, @@ -92,12 +118,17 @@ describe("withClaudeInstructionSetting", () => { expect(Object.keys(updated ?? {})).toEqual(["theme", "pluginConfigs", "hooks"]); }); - it("does not modify its input", () => { - const settings = { pluginConfigs: { [NEW_ID]: entry("claude-md") } }; - const snapshot = structuredClone(settings); - withClaudeInstructionSetting(settings, "managed-only"); - withClaudeInstructionSetting(settings, null); - expect(settings).toEqual(snapshot); + it("keeps the comments of a file it edits", () => { + const text = '{\n // my theme\n "theme": "dark"\n}\n'; + const edited = editJsoncText( + text, + claudeInstructionChanges(parseSettingsJson(text) ?? {}, "claude-md"), + ); + expect(edited).toContain("// my theme"); + expect(parseSettingsJson(edited ?? "")).toEqual({ + theme: "dark", + pluginConfigs: { [NEW_ID]: entry("claude-md") }, + }); }); it("updates a legacy entry that has a value, and leaves one that has none", () => { @@ -166,9 +197,8 @@ describe("withClaudeInstructionSetting", () => { }); }); - it("returns the same object when there is nothing to remove", () => { - const settings = { pluginConfigs: {}, theme: "dark" }; - expect(withClaudeInstructionSetting(settings, null)).toBe(settings); + it("has nothing to change when there is nothing to remove", () => { + expect(claudeInstructionChanges({ pluginConfigs: {}, theme: "dark" }, null)).toEqual([]); }); it("refuses to overwrite a value that isn't an object, and leaves it alone on removal", () => { @@ -179,7 +209,7 @@ describe("withClaudeInstructionSetting", () => { { pluginConfigs: { [NEW_ID]: { options: "x" } } }, ]) { expect(withClaudeInstructionSetting(settings, "claude-md")).toBeUndefined(); - expect(withClaudeInstructionSetting(settings, null)).toBe(settings); + expect(withClaudeInstructionSetting(settings, null)).toEqual(settings); } }); diff --git a/apps/server/src/instructions/ClaudeInstructionSetting.ts b/apps/server/src/instructions/ClaudeInstructionSetting.ts index 34274e458935..760910c38773 100644 --- a/apps/server/src/instructions/ClaudeInstructionSetting.ts +++ b/apps/server/src/instructions/ClaudeInstructionSetting.ts @@ -17,17 +17,12 @@ * * @module ClaudeInstructionSetting */ +import { ClaudeInstructionValue } from "@t3tools/contracts"; import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import type * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; -export const CLAUDE_INSTRUCTION_VALUES = [ - "claude-md-or-agents-md", - "claude-md-and-agents-md", - "claude-md", - "managed-only", -] as const; - -export type ClaudeInstructionValue = (typeof CLAUDE_INSTRUCTION_VALUES)[number]; +import { parseJsonc, type JsoncChange } from "../skills/JsoncSettings.ts"; /** What Claude does when the setting is absent: AGENTS.md only when there is no CLAUDE.md. */ export const DEFAULT_CLAUDE_INSTRUCTION_VALUE: ClaudeInstructionValue = "claude-md-or-agents-md"; @@ -47,8 +42,7 @@ export type JsonObject = Record; const isObject = (value: unknown): value is JsonObject => typeof value === "object" && value !== null && !Array.isArray(value); -const isInstructionValue = (value: unknown): value is ClaudeInstructionValue => - CLAUDE_INSTRUCTION_VALUES.some((known) => known === value); +const isInstructionValue = Schema.is(ClaudeInstructionValue); const getIn = (root: unknown, keys: readonly string[]): unknown => { let current = root; @@ -59,37 +53,13 @@ const getIn = (root: unknown, keys: readonly string[]): unknown => { return current; }; -/** A copy of `root` with `keys` set, or `undefined` when a step on the way isn't an object. */ -const setIn = ( - root: JsonObject, - keys: readonly string[], - value: unknown, -): JsonObject | undefined => { - const [key, ...rest] = keys; - if (key === undefined) return undefined; - if (rest.length === 0) return { ...root, [key]: value }; - const child = root[key]; - if (child !== undefined && !isObject(child)) return undefined; - const updated = setIn(child ?? {}, rest, value); - return updated === undefined ? undefined : { ...root, [key]: updated }; -}; - -const withoutKey = (root: JsonObject, key: string): JsonObject => - Object.fromEntries(Object.entries(root).filter(([name]) => name !== key)); - /** - * A copy of `root` without `keys`. Objects that this empties go too; objects that were already - * empty, and anything the removal doesn't touch, stay. Returns `root` itself when nothing changed. + * The text of a `settings.json` as an object, or `undefined` when Claude couldn't read it as one. + * Comments and trailing commas are fine, as they are for the skill settings in the same file. */ -const deleteIn = (root: JsonObject, keys: readonly string[]): JsonObject => { - const [key, ...rest] = keys; - if (key === undefined || !Object.hasOwn(root, key)) return root; - if (rest.length === 0) return withoutKey(root, key); - const child = root[key]; - if (!isObject(child)) return root; - const updated = deleteIn(child, rest); - if (updated === child) return root; - return Object.keys(updated).length === 0 ? withoutKey(root, key) : { ...root, [key]: updated }; +export const parseSettingsJson = (text: string): JsonObject | undefined => { + const { value, valid } = parseJsonc(text); + return valid && isObject(value) ? value : undefined; }; export interface ClaudeInstructionSetting { @@ -108,23 +78,29 @@ export const readClaudeInstructionSetting = (settings: unknown): ClaudeInstructi }; /** - * `settings` with "Project instructions" set to `value`, or back at Claude's default when `value` - * is null: the entry goes, and so do objects it leaves empty. A legacy entry that has a value is - * kept in step. Everything else is untouched and `settings` is never modified. Returns - * `undefined` when `value` is set but `pluginConfigs` or the plugin's entry isn't an object, so - * a caller never overwrites something it doesn't understand. + * What to change in a `settings.json` (for `editJsoncFile`) to set "Project instructions" to + * `value`, or back to Claude's default when `value` is null: the entry goes, and the editor takes + * the objects it leaves empty with it. A legacy entry that has a value is kept in step. Everything + * else is left as it is, so the editor refuses (and the caller leaves the file alone) when + * `pluginConfigs` or the plugin's entry exists but isn't an object. */ -export const withClaudeInstructionSetting = ( +export const claudeInstructionChanges = ( settings: JsonObject, value: ClaudeInstructionValue | null, -): JsonObject | undefined => { +): ReadonlyArray => { if (value === null) { - return deleteIn(deleteIn(settings, settingPath(PLUGIN_ID)), settingPath(LEGACY_PLUGIN_ID)); + return [PLUGIN_ID, LEGACY_PLUGIN_ID].flatMap((pluginId) => + getIn(settings, settingPath(pluginId)) === undefined + ? [] + : [{ path: settingPath(pluginId), value: undefined }], + ); } - const updated = setIn(settings, settingPath(PLUGIN_ID), value); - if (updated === undefined) return undefined; - if (getIn(updated, settingPath(LEGACY_PLUGIN_ID)) === undefined) return updated; - return setIn(updated, settingPath(LEGACY_PLUGIN_ID), value); + return [ + { path: settingPath(PLUGIN_ID), value }, + ...(getIn(settings, settingPath(LEGACY_PLUGIN_ID)) === undefined + ? [] + : [{ path: settingPath(LEGACY_PLUGIN_ID), value }]), + ]; }; /** diff --git a/apps/server/src/instructions/InstructionCatalog.test.ts b/apps/server/src/instructions/InstructionCatalog.test.ts new file mode 100644 index 000000000000..68982af25b76 --- /dev/null +++ b/apps/server/src/instructions/InstructionCatalog.test.ts @@ -0,0 +1,874 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + InstructionError, + InstructionListResult, + InstructionReadResult, + ProviderDriverKind, + ProviderInstanceId, + type InstructionAgentAccess, + type InstructionEntry, +} from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as InstructionCatalog from "./InstructionCatalog.ts"; +import { layerFor, makeMachine, type MachineOptions } from "./testing/machine.ts"; + +const encodeList = Schema.encodeUnknownEffect(InstructionListResult); +const encodeRead = Schema.encodeUnknownEffect(InstructionReadResult); + +/** The catalog on the machine at `home`; every list goes through the RPC schema encode. */ +const onMachine = ( + home: string, + options: MachineOptions, + use: (catalog: InstructionCatalog.InstructionCatalog["Service"]) => Effect.Effect, +) => + Effect.gen(function* () { + return yield* use(yield* InstructionCatalog.InstructionCatalog); + }).pipe(Effect.provide(layerFor(home, options))); + +const listed = ( + catalog: InstructionCatalog.InstructionCatalog["Service"], + input: { readonly cwd?: string } = {}, +) => + Effect.gen(function* () { + const result = yield* catalog.list(input); + yield* encodeList(result); + return result; + }); + +const entryOf = (entries: readonly InstructionEntry[], id: string) => { + const entry = entries.find((candidate) => candidate.id === id); + if (!entry) throw new Error(`No entry ${id} in ${entries.map((e) => e.id).join(", ")}`); + return entry; +}; + +/** What each agent does with a file: `state`, plus a reason when it has one. */ +const accessOf = (entry: InstructionEntry) => + Object.fromEntries( + entry.access.map((access) => [ + access.instanceId, + access.reason === undefined ? access.state : `${access.state}:${access.reason}`, + ]), + ); + +const accessFor = (entry: InstructionEntry, instanceId: string): InstructionAgentAccess => { + const access = entry.access.find((candidate) => candidate.instanceId === instanceId); + if (!access) throw new Error(`No access for ${instanceId} on ${entry.id}`); + return access; +}; + +const CLAUDE = { versions: { claudeAgent: "2.1.291" } } satisfies MachineOptions; + +it.layer(NodeServices.layer, { excludeTestServices: true })("InstructionCatalog", (it) => { + describe("project files", () => { + it.effect( + "lists a missing AGENTS.md and CLAUDE.local.md so they can be created, and no other", + () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog, { cwd: project }); + const projectEntries = entries.filter((entry) => entry.scope === "project"); + + expect(projectEntries.map((entry) => entry.id)).toEqual([ + "project:shared:AGENTS.md", + "project:claudeLocal:CLAUDE.local.md", + ]); + expect(accessFor(projectEntries[1]!, "claudeAgent").state).toBe("direct"); + expect(projectEntries[0]).toMatchObject({ + kind: "shared", + exists: false, + size: 0, + readOnly: false, + path: `${project}/AGENTS.md`, + relativePath: "AGENTS.md", + }); + }), + ); + }), + ); + + it.effect("reads no project files without a project", () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "rules"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog); + expect(entries.some((entry) => entry.scope === "project")).toBe(false); + }), + ); + }), + ); + + it.effect("tells which agents read a CLAUDE.md that has no AGENTS.md next to it", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/CLAUDE.md", "claude rules"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog, { cwd: project }); + const claudeMd = entryOf(entries, "project:claude:CLAUDE.md"); + + expect(claudeMd).toMatchObject({ exists: true, size: 12, kind: "claude" }); + expect(accessOf(claudeMd)).toEqual({ + claudeAgent: "direct", + codex: "none", + cursor: "none", + grok: "direct", + // OpenCode and Pi fall back to CLAUDE.md only because nothing named AGENTS.md exists. + opencode: "direct", + antigravity: "none", + pi: "direct", + }); + }), + ); + }), + ); + + it.effect("stops Pi and OpenCode reading CLAUDE.md once AGENTS.md exists", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/CLAUDE.md", "claude rules"); + yield* write("repos/app/AGENTS.md", "shared rules"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog, { cwd: project }); + const claudeMd = entryOf(entries, "project:claude:CLAUDE.md"); + const agentsMd = entryOf(entries, "project:shared:AGENTS.md"); + + expect(accessFor(claudeMd, "pi")).toMatchObject({ + state: "none", + blockingFile: "AGENTS.md", + }); + expect(accessFor(claudeMd, "opencode")).toMatchObject({ + state: "none", + blockingFile: "AGENTS.md", + }); + expect(accessFor(claudeMd, "claudeAgent").state).toBe("direct"); + expect(accessFor(claudeMd, "grok").state).toBe("direct"); + // Everyone but Claude reads AGENTS.md; Claude's own CLAUDE.md wins by default. + expect(accessOf(agentsMd)).toEqual({ + claudeAgent: "none:claudeFiles", + codex: "direct", + cursor: "direct", + grok: "direct", + opencode: "direct", + antigravity: "direct", + pi: "direct", + }); + expect(accessFor(agentsMd, "claudeAgent").blockingFile).toBe("CLAUDE.md"); + }), + ); + }), + ); + + it.effect("makes Codex and Pi prefer AGENTS.override.md", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "shared rules"); + yield* write("repos/app/AGENTS.override.md", "override"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog, { cwd: project }); + const agentsMd = entryOf(entries, "project:shared:AGENTS.md"); + + for (const blocked of ["codex", "pi"]) { + expect(accessFor(agentsMd, blocked)).toMatchObject({ + state: "none", + blockingFile: "AGENTS.override.md", + }); + } + expect(accessFor(agentsMd, "opencode").state).toBe("direct"); + }), + ); + }), + ); + + it.effect("doesn't credit Grok with CLAUDE.local.md, which it skips when git ignores it", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/CLAUDE.local.md", "mine"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog, { cwd: project }); + const local = entryOf(entries, "project:claudeLocal:CLAUDE.local.md"); + + expect(local.kind).toBe("claudeLocal"); + expect(accessFor(local, "claudeAgent").state).toBe("direct"); + expect(accessFor(local, "grok").state).toBe("none"); + }), + ); + }), + ); + + it.effect("switches OpenCode's CLAUDE.md fallback off with its environment variable", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/CLAUDE.md", "claude rules"); + yield* onMachine( + home, + { + ...CLAUDE, + providerInstances: { + [ProviderInstanceId.make("opencode")]: { + driver: ProviderDriverKind.make("opencode"), + enabled: true, + environment: [ + { name: "OPENCODE_DISABLE_CLAUDE_CODE", value: "1", sensitive: false }, + ], + }, + }, + }, + (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog, { cwd: project }); + expect( + accessFor(entryOf(entries, "project:claude:CLAUDE.md"), "opencode").state, + ).toBe("none"); + }), + ); + }), + ); + }); + + describe("Claude and a project's AGENTS.md", () => { + const claudeAccess = (entries: readonly InstructionEntry[]) => + accessFor(entryOf(entries, "project:shared:AGENTS.md"), "claudeAgent"); + + it.effect("is blocked by CLAUDE.local.md and names it", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "rules"); + yield* write("repos/app/CLAUDE.local.md", "mine"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries, claude } = yield* listed(catalog, { cwd: project }); + + expect(claudeAccess(entries)).toEqual({ + instanceId: "claudeAgent", + driver: "claudeAgent", + state: "none", + reason: "claudeFiles", + blockingFile: "CLAUDE.local.md", + }); + expect(claude).toEqual([ + { + instanceId: "claudeAgent", + value: "claude-md-or-agents-md", + explicit: false, + supported: true, + version: "2.1.291", + }, + ]); + }), + ); + }), + ); + + it.effect("reads it through the setting when no CLAUDE file stands in the way", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "rules"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog, { cwd: project }); + expect(claudeAccess(entries)).toMatchObject({ state: "setting" }); + expect(claudeAccess(entries).reason).toBeUndefined(); + }), + ); + }), + ); + + it.effect("reads both files when the setting says so, and none when it says never", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "rules"); + yield* write("repos/app/CLAUDE.md", "claude rules"); + yield* write( + ".claude/settings.json", + JSON.stringify({ + pluginConfigs: { + "cc-plugin-agents-md@builtin": { + options: { instructionFiles: "claude-md-and-agents-md" }, + }, + }, + }), + ); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const both = yield* listed(catalog, { cwd: project }); + expect(claudeAccess(both.entries)).toMatchObject({ state: "setting" }); + expect(both.claude[0]).toMatchObject({ + value: "claude-md-and-agents-md", + explicit: true, + }); + }), + ); + // The legacy plugin id counts too. + yield* write( + ".claude/settings.json", + JSON.stringify({ + pluginConfigs: { "agents-md@builtin": { options: { instructionFiles: "claude-md" } } }, + }), + ); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const never = yield* listed(catalog, { cwd: project }); + expect(claudeAccess(never.entries)).toMatchObject({ + state: "none", + reason: "settingOff", + }); + expect(never.claude[0]).toMatchObject({ value: "claude-md", explicit: true }); + }), + ); + }), + ); + + it.effect( + "reads it through a CLAUDE.md that imports it, unless the setting is managed-only", + () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "rules"); + yield* write("repos/app/CLAUDE.md", "@AGENTS.md\nmore"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog, { cwd: project }); + expect(claudeAccess(entries)).toMatchObject({ state: "import" }); + }), + ); + yield* write( + ".claude/settings.json", + JSON.stringify({ + pluginConfigs: { + "cc-plugin-agents-md@builtin": { options: { instructionFiles: "managed-only" } }, + }, + }), + ); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog, { cwd: project }); + expect(claudeAccess(entries)).toMatchObject({ state: "none", reason: "settingOff" }); + }), + ); + }), + ); + + it.effect( + "can't read it on a Claude Code version before 2.1.277, or with no version known", + () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "rules"); + for (const versions of [{ claudeAgent: "2.1.200" }, {}]) { + yield* onMachine(home, { versions }, (catalog) => + Effect.gen(function* () { + const { entries, claude } = yield* listed(catalog, { cwd: project }); + expect(claudeAccess(entries)).toMatchObject({ + state: "none", + reason: "oldVersion", + }); + expect(claude[0]).toMatchObject({ supported: false }); + }), + ); + } + }), + ); + + it.effect("keeps an import working on an old version", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "rules"); + yield* write("repos/app/CLAUDE.md", "@AGENTS.md"); + yield* onMachine(home, { versions: { claudeAgent: "2.0.0" } }, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog, { cwd: project }); + expect(claudeAccess(entries)).toMatchObject({ state: "import" }); + }), + ); + }), + ); + + it.effect("reports a settings.json that isn't JSON and falls back to Claude's default", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write(".claude/settings.json", "{ not json"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { claude, unreadable } = yield* listed(catalog, { cwd: project }); + expect(claude[0]).toMatchObject({ value: "claude-md-or-agents-md", explicit: false }); + expect(unreadable).toEqual([ + { path: `${home}/.claude/settings.json`, reason: "It isn't valid JSON." }, + ]); + }), + ); + }), + ); + }); + + describe("files in subfolders", () => { + it.effect("comes from the file index, without the top folder's own files or dependencies", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "root"); + yield* write("repos/app/.claude/CLAUDE.md", "root claude"); + yield* write("repos/app/apps/web/AGENTS.md", "web"); + yield* write("repos/app/packages/ui/CLAUDE.md", "ui"); + yield* write("repos/app/node_modules/dep/AGENTS.md", "dependency"); + yield* write("repos/app/apps/web/README.md", "not an instruction file"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog, { cwd: project }); + const nested = entries.filter((entry) => entry.kind === "nested"); + + expect(nested.map((entry) => entry.id)).toEqual([ + "project:nested:apps/web/AGENTS.md", + "project:nested:packages/ui/CLAUDE.md", + ]); + expect(nested[0]).toMatchObject({ + scope: "project", + relativePath: "apps/web/AGENTS.md", + exists: true, + size: 3, + readOnly: false, + access: [], + }); + // The top folder's own `.claude/CLAUDE.md` has its own entry. + expect(entries.map((entry) => entry.id)).toContain("project:claude:.claude/CLAUDE.md"); + }), + ); + }), + ); + + it.effect("caps them at 50", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + for (let index = 0; index < 60; index += 1) { + yield* write(`repos/app/packages/p${String(index).padStart(2, "0")}/AGENTS.md`, "x"); + } + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog, { cwd: project }); + expect(entries.filter((entry) => entry.kind === "nested")).toHaveLength(50); + }), + ); + }), + ); + }); + + describe("the shared file for all projects", () => { + it.effect( + "is ~/.agents/AGENTS.md when nothing links anywhere, and listed even if missing", + () => + Effect.gen(function* () { + const { home } = yield* makeMachine; + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const result = yield* listed(catalog); + const shared = entryOf(result.entries, "global:shared"); + + expect(result.sharedPath).toBe(`${home}/.agents/AGENTS.md`); + expect(shared).toMatchObject({ + scope: "global", + kind: "shared", + path: `${home}/.agents/AGENTS.md`, + exists: false, + size: 0, + }); + // Cursor and Antigravity have no home file, so they have nothing to say about it. + expect(Object.keys(accessOf(shared)).toSorted()).toEqual([ + "claudeAgent", + "codex", + "grok", + "opencode", + "pi", + ]); + expect(Object.values(accessOf(shared)).every((state) => state === "none")).toBe(true); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "keeps the one real file the agents' home files already link to", + () => + Effect.gen(function* () { + const { home, write, link } = yield* makeMachine; + yield* write("library/everything.md", "all my rules"); + yield* link("library/everything.md", ".claude/CLAUDE.md"); + yield* link("library/everything.md", ".codex/AGENTS.md"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const result = yield* listed(catalog); + const shared = entryOf(result.entries, "global:shared"); + + expect(result.sharedPath).toBe(`${home}/library/everything.md`); + expect(shared).toMatchObject({ exists: true, size: 12 }); + // OpenCode and Grok read ~/.claude/CLAUDE.md as well, which is one of the links. + expect(accessOf(shared)).toEqual({ + claudeAgent: "link", + codex: "link", + grok: "link", + opencode: "link", + pi: "none", + }); + // Claude's home file is the link, so there is no row of its own for it. + expect(result.entries.map((entry) => entry.id)).toEqual(["global:shared"]); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "falls back to the default when the links lead to different files", + () => + Effect.gen(function* () { + const { home, write, link } = yield* makeMachine; + yield* write("library/one.md", "one"); + yield* write("library/two.md", "two"); + yield* link("library/one.md", ".claude/CLAUDE.md"); + yield* link("library/two.md", ".codex/AGENTS.md"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + expect((yield* listed(catalog)).sharedPath).toBe(`${home}/.agents/AGENTS.md`); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "sees Claude's import line, and a link that leads to the shared file before it exists", + () => + Effect.gen(function* () { + const { home, path, write, fs } = yield* makeMachine; + yield* write(".claude/CLAUDE.md", "@~/.agents/AGENTS.md\n\nmy own notes\n"); + yield* fs.makeDirectory(path.join(home, ".codex"), { recursive: true }); + yield* fs.symlink(`${home}/.agents/AGENTS.md`, path.join(home, ".codex/AGENTS.md")); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const result = yield* listed(catalog); + const shared = entryOf(result.entries, "global:shared"); + + expect(shared.exists).toBe(false); + expect(accessOf(shared)).toMatchObject({ claudeAgent: "import", codex: "link" }); + // The notes next to the import line are worth a row of their own. + expect(entryOf(result.entries, "global:claude:claudeAgent")).toMatchObject({ + kind: "claude", + owner: "claudeAgent", + sameAsShared: false, + path: `${home}/.claude/CLAUDE.md`, + }); + }), + ); + }), + ); + + it.effect("gives Claude no row of its own when its file is only the import line", () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + yield* write(".claude/CLAUDE.md", "@~/.agents/AGENTS.md\n"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const result = yield* listed(catalog); + expect(accessOf(entryOf(result.entries, "global:shared")).claudeAgent).toBe("import"); + expect(result.entries.map((entry) => entry.id)).toEqual(["global:shared"]); + }), + ); + }), + ); + }); + + describe("an agent's own home file", () => { + it.effect.skipIf(!symlinksSupported)( + "stops the agent joining the shared file when it holds different text", + () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + yield* write(".agents/AGENTS.md", "shared"); + yield* write(".codex/AGENTS.md", "codex notes"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const result = yield* listed(catalog); + + expect(accessFor(entryOf(result.entries, "global:shared"), "codex")).toEqual({ + instanceId: "codex", + driver: "codex", + state: "none", + reason: "ownFile", + }); + expect(entryOf(result.entries, "global:agentOwn:codex")).toMatchObject({ + kind: "agentOwn", + owner: "codex", + path: `${home}/.codex/AGENTS.md`, + sameAsShared: false, + }); + expect(accessOf(entryOf(result.entries, "global:agentOwn:codex"))).toMatchObject({ + codex: "direct", + pi: "none", + }); + }), + ); + }), + ); + + it.effect("notes when the agent's file has the same text as the shared one", () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + yield* write(".agents/AGENTS.md", "same text\n"); + yield* write(".codex/AGENTS.md", "same text"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog); + expect(entryOf(entries, "global:agentOwn:codex").sameAsShared).toBe(true); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "makes a Codex override file win over a link, and names it", + () => + Effect.gen(function* () { + const { home, path, write, fs } = yield* makeMachine; + yield* write(".agents/AGENTS.md", "shared"); + yield* write(".codex/AGENTS.override.md", "override"); + yield* fs.symlink(`${home}/.agents/AGENTS.md`, path.join(home, ".codex/AGENTS.md")); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog); + + expect(accessFor(entryOf(entries, "global:shared"), "codex")).toMatchObject({ + state: "none", + reason: "ownFile", + blockingFile: "AGENTS.override.md", + }); + expect(entryOf(entries, "global:agentOwn:codex").path).toBe( + `${home}/.codex/AGENTS.override.md`, + ); + }), + ); + }), + ); + + it.effect("passes over an empty Codex override file, as Codex does", () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + yield* write(".codex/AGENTS.override.md", ""); + yield* write(".codex/AGENTS.md", "codex notes"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog); + expect(entryOf(entries, "global:agentOwn:codex").path).toBe(`${home}/.codex/AGENTS.md`); + }), + ); + }), + ); + + it.effect("takes Pi's CLAUDE.md as its own file when it has no AGENTS.md", () => + Effect.gen(function* () { + const { home, write } = yield* makeMachine; + yield* write(".pi/agent/CLAUDE.md", "pi notes"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog); + expect(accessFor(entryOf(entries, "global:shared"), "pi")).toMatchObject({ + state: "none", + reason: "ownFile", + blockingFile: "CLAUDE.md", + }); + expect(entryOf(entries, "global:agentOwn:pi").path).toBe(`${home}/.pi/agent/CLAUDE.md`); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "lets OpenCode and Grok reach the shared file through ~/.claude/CLAUDE.md", + () => + Effect.gen(function* () { + const { home, write, link } = yield* makeMachine; + yield* write(".agents/AGENTS.md", "shared"); + yield* link(".agents/AGENTS.md", ".claude/CLAUDE.md"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog); + expect(accessOf(entryOf(entries, "global:shared"))).toEqual({ + claudeAgent: "link", + codex: "none", + grok: "link", + opencode: "link", + pi: "none", + }); + }), + ); + // OpenCode only falls back when it has no file of its own. + yield* write(".config/opencode/AGENTS.md", "opencode notes"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const { entries } = yield* listed(catalog); + expect(accessFor(entryOf(entries, "global:shared"), "opencode")).toMatchObject({ + state: "none", + reason: "ownFile", + }); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "follows each instance's own home folder, not just the default", + () => + Effect.gen(function* () { + const { home, write, link } = yield* makeMachine; + yield* write(".agents/AGENTS.md", "shared"); + yield* write("work-claude/CLAUDE.md", "@~/.agents/AGENTS.md\nnotes"); + yield* link(".agents/AGENTS.md", "codex-work/AGENTS.md"); + yield* onMachine( + home, + { + versions: { claude_work: "2.1.291" }, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + }, + [ProviderInstanceId.make("claude_work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: `${home}/work-claude` }, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + environment: [ + { name: "CODEX_HOME", value: `${home}/codex-work`, sensitive: false }, + ], + }, + }, + }, + (catalog) => + Effect.gen(function* () { + const { entries, claude } = yield* listed(catalog); + + expect(accessOf(entryOf(entries, "global:shared"))).toMatchObject({ + claude_work: "import", + codex: "link", + }); + expect(claude.map((choice) => choice.instanceId)).toEqual(["claude_work"]); + expect(entryOf(entries, "global:claude:claude_work").path).toBe( + `${home}/work-claude/CLAUDE.md`, + ); + }), + ); + }), + ); + }); + + describe("read", () => { + it.effect("returns the text and a revision, through a link too", () => + Effect.gen(function* () { + const { home, project, write, link } = yield* makeMachine; + yield* write(".agents/AGENTS.md", "shared text"); + yield* write("repos/app/AGENTS.md", "project text"); + yield* link(".agents/AGENTS.md", "repos/app/CLAUDE.md"); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const global = yield* catalog.read({ id: "global:shared" }); + const viaLink = yield* catalog.read({ cwd: project, id: "project:claude:CLAUDE.md" }); + yield* encodeRead(global); + + expect(global).toMatchObject({ + id: "global:shared", + contents: "shared text", + tooLarge: false, + }); + expect(global.revision).toMatch(/^[0-9a-f]{64}$/); + expect(viaLink.contents).toBe("shared text"); + expect(viaLink.revision).toBe(global.revision); + }), + ); + }), + ); + + it.effect("says nothing for a missing file, and when a file is over 1 MB", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/CLAUDE.md", "x".repeat(1_048_577)); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + expect(yield* catalog.read({ cwd: project, id: "project:shared:AGENTS.md" })).toEqual({ + id: "project:shared:AGENTS.md", + contents: null, + revision: null, + tooLarge: false, + }); + expect(yield* catalog.read({ cwd: project, id: "project:claude:CLAUDE.md" })).toEqual({ + id: "project:claude:CLAUDE.md", + contents: null, + revision: null, + tooLarge: true, + }); + }), + ); + }), + ); + + it.effect("refuses ids the table doesn't have", () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const ids = [ + "project:shared:../AGENTS.md", + "project:shared:CLAUDE.md", + "project:claude:README.md", + "project:nested:AGENTS.md", + "project:nested:../outside/AGENTS.md", + "project:nested:apps/../../AGENTS.md", + "project:nested:/etc/AGENTS.md", + "project:nested:apps/web/README.md", + "global:agentOwn:claudeAgent", + "global:claude:codex", + "global:agentOwn:cursor", + "global:agentOwn:no-such-agent", + "global:shared:extra", + "managed:codex", + "nonsense", + ]; + for (const id of ids) { + const error = yield* catalog.read({ cwd: project, id }).pipe(Effect.flip); + expect(error, id).toBeInstanceOf(InstructionError); + expect(error.reason, id).toBe("unknownEntry"); + } + // A project id needs a project. + const noProject = yield* catalog + .read({ id: "project:shared:AGENTS.md" }) + .pipe(Effect.flip); + expect(noProject.reason).toBe("unknownEntry"); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "refuses a subfolder that is a link out of the project", + () => + Effect.gen(function* () { + const { home, project, write, fs, path } = yield* makeMachine; + yield* write("elsewhere/AGENTS.md", "outside"); + yield* fs.symlink(path.join(home, "elsewhere"), path.join(project, "linked")); + yield* onMachine(home, CLAUDE, (catalog) => + Effect.gen(function* () { + const error = yield* catalog + .read({ cwd: project, id: "project:nested:linked/AGENTS.md" }) + .pipe(Effect.flip); + expect(error.reason).toBe("unknownEntry"); + }), + ); + }), + ); + }); +}); diff --git a/apps/server/src/instructions/InstructionCatalog.ts b/apps/server/src/instructions/InstructionCatalog.ts new file mode 100644 index 000000000000..b5ca9356de49 --- /dev/null +++ b/apps/server/src/instructions/InstructionCatalog.ts @@ -0,0 +1,931 @@ +/** + * InstructionCatalog - a read-only look at the instruction files (AGENTS.md, CLAUDE.md and + * friends) the enabled agents read, and at which agent reads which. + * + * Files are found by reading the places `AgentInstructionFiles` names, never by asking an agent. + * Nothing is cached, watched, spawned or written, and every read is bounded. A list looks only at + * the project's top folder and at the agents' home files; a file in a subfolder comes from the + * project's file index. What each agent reads follows its rules in the table, not an assumption: + * Pi stops at the first file of a folder, OpenCode falls back to CLAUDE.md only when there is no + * AGENTS.md, Codex prefers AGENTS.override.md, and Claude reads a project AGENTS.md only as its + * "Project instructions" setting, its own CLAUDE.md files and its version allow. + * + * The all-projects file is one real file the agents' home files link to, `~/.agents/AGENTS.md` + * unless the agents' home files already link to one other file, which is then kept (see + * `sharedLocation`). Ids are built here and are the only way to name a file: a client never sends + * a path, so every id is resolved again from the table (see `resolve`). + * + * @module InstructionCatalog + */ +import { + InstructionError, + PROVIDER_DISPLAY_NAMES, + ProviderInstanceId, + resolveProviderInstanceEnabled, + type ClaudeInstructionChoice, + type InstructionAgentAccess, + type InstructionAgentReason, + type InstructionAgentState, + type InstructionEntry, + type InstructionKind, + type InstructionListInput, + type InstructionListResult, + type InstructionProblem, + type InstructionReadInput, + type InstructionReadResult, + type InstructionScope, + type ProviderDriverKind, + type ProviderInstanceConfig, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +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 Option from "effect/Option"; +import * as Path from "effect/Path"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { AGENT_SKILL_FOLDERS } from "@t3tools/provider-core/server/AgentSkillFolders"; + +import { deriveProviderInstanceConfigMap } from "../provider/ProviderInstanceRegistryHydration.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as Settings from "../serverSettings.ts"; +import { resolveAgentConfigHome } from "../skills/AgentConfigHome.ts"; +import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; +import { + AGENT_INSTRUCTION_FILES, + claudeManagedInstructionPath, + type AgentInstructionRules, + type HomeInstructionRules, +} from "./AgentInstructionFiles.ts"; +import { + DEFAULT_CLAUDE_INSTRUCTION_VALUE, + hasAgentsMdImport, + parseSettingsJson, + readClaudeInstructionSetting, + removeAgentsMdImport, + supportsAgentsMd, + type AgentsMdImportTarget, + type ClaudeInstructionSetting, +} from "./ClaudeInstructionSetting.ts"; +import { inspect, readText, type FileFacts, type ReadOutcome } from "./InstructionFileIO.ts"; + +/** Where the all-projects file goes, next to `~/.agents/skills`, unless the agents already share another. */ +const DEFAULT_SHARED_FILE = ".agents/AGENTS.md"; +/** The personal file Claude Code tells people to keep out of git. */ +const PERSONAL_FILE = "CLAUDE.local.md"; +const CLAUDE_DRIVER = "claudeAgent"; +const NESTED_NAMES: ReadonlySet = new Set(["AGENTS.md", "CLAUDE.md"]); +const MAX_NESTED = 50; +const NESTED_SEARCH_LIMIT = 200; +const CONCURRENCY = 8; +/** The default folder of an agent that follows `XDG_CONFIG_HOME` sits under this in the home directory. */ +const XDG_PREFIX = ".config/"; + +/** The files of a project's top folder that get an entry, with the id each one has. */ +const ROOT_FILES = [ + { name: "AGENTS.md", kind: "shared" }, + { name: "CLAUDE.md", kind: "claude" }, + { name: ".claude/CLAUDE.md", kind: "claude" }, + { name: PERSONAL_FILE, kind: "claudeLocal" }, +] as const satisfies ReadonlyArray<{ readonly name: string; readonly kind: InstructionKind }>; + +const GLOBAL_SHARED_ID = "global:shared"; +const MANAGED_ID = "managed:claude"; + +const rootId = (file: (typeof ROOT_FILES)[number]) => `project:${file.kind}:${file.name}`; +const nestedId = (relativePath: string) => `project:nested:${relativePath}`; +const claudeHomeId = (instanceId: string) => `global:claude:${instanceId}`; +const agentOwnId = (instanceId: string) => `global:agentOwn:${instanceId}`; + +const refuse = (reason: InstructionError["reason"], message: string) => + new InstructionError({ reason, message }); + +const unknownEntry = () => + refuse("unknownEntry", "That isn't an instruction file T3 Code manages."); + +/** A flag-style environment variable counts as set unless it is empty, `0` or `false`. */ +const isFlagSet = (value: string | undefined) => + value !== undefined && !["", "0", "false"].includes(value.trim().toLowerCase()); + +/** An enabled provider instance whose instruction files T3 Code knows. */ +interface AgentInstance { + readonly instanceId: ProviderInstanceId; + readonly driver: ProviderDriverKind; + readonly displayName: string; + readonly rules: AgentInstructionRules; + readonly version: string | null; + /** The process environment with the instance's own variables laid over it. */ + readonly env: NodeJS.ProcessEnv; + /** The agent's home folder as this instance resolves it; undefined when it has no home file. */ + readonly directory: string | undefined; +} + +export interface SharedFile { + /** Where the all-projects file is, or would be created. */ + readonly path: string; + /** Its real path after following links; undefined while it doesn't exist. */ + readonly real: string | undefined; + readonly exists: boolean; +} + +/** How one agent reaches the all-projects file, and what it takes to change that. */ +export interface AgentReach { + readonly instanceId: ProviderInstanceId; + readonly driver: ProviderDriverKind; + readonly displayName: string; + /** The agent's home folder. */ + readonly directory: string; + /** `link`: a symlink at `joinPath`. `import`: an import line at the top of the file at `joinPath`. */ + readonly join: "link" | "import"; + readonly joinPath: string; + readonly state: InstructionAgentState; + readonly reason?: InstructionAgentReason | undefined; + readonly blockingFile?: string | undefined; + /** The files the agent reads the shared file through. `own: false` is a folder of another agent. */ + readonly via: ReadonlyArray<{ + readonly path: string; + readonly kind: "direct" | "link" | "import"; + readonly own: boolean; + }>; + /** The agent's own home file, when it has one that is not the shared file. */ + readonly ownFile: string | undefined; + /** Every home file the agent loads, whatever its text. */ + readonly reads: ReadonlySet; +} + +export interface SharedView { + readonly file: SharedFile; + readonly homeDirectory: string; + /** Every enabled agent that has a home file, in the table's order. */ + readonly agents: ReadonlyArray; +} + +/** What an id names, as the table and the disk say now. */ +export interface ResolvedInstruction { + readonly id: string; + readonly scope: InstructionScope; + readonly kind: InstructionKind; + /** Where the file is or would be created; it may be a link. */ + readonly path: string; + readonly relativePath?: string | undefined; + readonly readOnly: boolean; + /** The instance whose home file this is, for `agentOwn` and Claude's own file. */ + readonly owner?: ProviderInstanceId | undefined; +} + +export class InstructionCatalog extends Context.Service< + InstructionCatalog, + { + /** + * The instruction files in the project's top folder (when `cwd` is given) and the user's home + * folder, with the agents that read each. Subfolder files come from the project's file index. + */ + readonly list: (input: InstructionListInput) => Effect.Effect; + /** The text of one file from `list`, or nothing when it is missing or too large. */ + readonly read: ( + input: InstructionReadInput, + ) => Effect.Effect; + /** The file an id names, looked up from the table again. Nothing is read or written. */ + readonly resolve: (input: { + readonly cwd?: string | undefined; + readonly id: string; + }) => Effect.Effect; + /** The all-projects file and how each agent reaches it, for turning agents on or off. */ + readonly shared: Effect.Effect; + } +>()("t3/instructions/InstructionCatalog") {} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const environment = yield* HostProcess.Environment; + const serverSettings = yield* Settings.ServerSettingsService; + const providers = yield* ProviderRegistry.ProviderRegistry; + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const fileSystemContext = yield* Effect.context(); + const homeDirectory = yield* HostProcess.HomeDirectory; + + const inspectAt = (file: string) => inspect(file).pipe(Effect.provideContext(fileSystemContext)); + const readTextAt = (file: string) => + readText(file).pipe(Effect.provideContext(fileSystemContext)); + + /** Text read once per file during one call. */ + const makeTextReader = () => { + const texts = new Map(); + return Effect.fnUntraced(function* (file: string) { + const known = texts.get(file); + if (known !== undefined) return known; + const outcome = yield* readTextAt(file); + texts.set(file, outcome); + return outcome; + }); + }; + type TextReader = ReturnType; + + const absoluteCwd = (cwd: string | undefined) => + cwd !== undefined && path.isAbsolute(cwd) ? cwd : undefined; + + const sizeOf = Effect.fnUntraced(function* (file: string) { + const info = yield* fileSystem.stat(file).pipe(Effect.option); + return Option.isSome(info) && info.value.type === "File" ? Number(info.value.size) : undefined; + }); + + // --- Agents ------------------------------------------------------------------------------- + + /** + * An agent's home folder. Claude, Codex and Grok follow the same settings and variables as their + * skill folders (`resolveAgentConfigHome`); the others follow the variables the table names. + */ + const homeFolderOf = Effect.fnUntraced(function* ( + config: ProviderInstanceConfig, + home: HomeInstructionRules, + env: NodeJS.ProcessEnv, + cwd: string | undefined, + ) { + const fallback = path.join(homeDirectory, home.folder); + if (AGENT_SKILL_FOLDERS.find((table) => table.agent === config.driver)?.configHome) { + return yield* resolveAgentConfigHome({ + instance: config, + fallback, + environment, + cwd, + }).pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(HostProcess.HomeDirectory, homeDirectory), + ); + } + const named = home.folderEnv === undefined ? "" : (env[home.folderEnv]?.trim() ?? ""); + const configured = expandHomePath(named, homeDirectory); + if (configured !== "" && path.isAbsolute(configured)) return configured; + const xdg = env.XDG_CONFIG_HOME?.trim() ?? ""; + if ( + home.xdgConfigHome === true && + xdg !== "" && + path.isAbsolute(xdg) && + home.folder.startsWith(XDG_PREFIX) + ) { + return path.join(xdg, home.folder.slice(XDG_PREFIX.length)); + } + return fallback; + }); + + /** The enabled provider instances whose instruction files T3 Code knows, in the table's order. */ + const loadInstances = Effect.fnUntraced(function* (cwd: string | undefined) { + const settings = yield* serverSettings.getSettings.pipe(Effect.option); + if (Option.isNone(settings)) return []; + const snapshots = new Map( + (yield* providers.getProviders).map((provider) => [provider.instanceId, provider] as const), + ); + const configs = Object.entries(deriveProviderInstanceConfigMap(settings.value)); + const instances: AgentInstance[] = []; + for (const rules of AGENT_INSTRUCTION_FILES) { + for (const [id, config] of configs) { + if (config.driver !== rules.agent || !resolveProviderInstanceEnabled(config)) continue; + const instanceId = ProviderInstanceId.make(id); + const env = yield* mergeProviderInstanceEnvironment(config.environment, environment).pipe( + Effect.provideService(HostProcess.HomeDirectory, homeDirectory), + ); + const snapshot = snapshots.get(instanceId); + instances.push({ + instanceId, + driver: rules.agent, + displayName: + snapshot?.displayName?.trim() || (PROVIDER_DISPLAY_NAMES[rules.agent] ?? rules.agent), + rules, + version: snapshot?.version ?? null, + env, + directory: + rules.home === null ? undefined : yield* homeFolderOf(config, rules.home, env, cwd), + }); + } + } + return instances; + }); + + // --- Claude's "Project instructions" setting ------------------------------------------------ + + const claudeChoiceOf = Effect.fnUntraced(function* (instance: AgentInstance, textOf: TextReader) { + const settingsPath = path.join(instance.directory ?? homeDirectory, "settings.json"); + const outcome = yield* textOf(settingsPath); + let setting: ClaudeInstructionSetting = { + value: DEFAULT_CLAUDE_INSTRUCTION_VALUE, + explicit: false, + }; + let problem: InstructionProblem | undefined; + if (outcome._tag === "Read") { + const settings = parseSettingsJson(outcome.text); + if (settings === undefined) problem = { path: settingsPath, reason: "It isn't valid JSON." }; + else setting = readClaudeInstructionSetting(settings); + } else if (outcome._tag !== "Missing") { + problem = { path: settingsPath, reason: "T3 Code couldn't read it." }; + } + return { + choice: { + instanceId: instance.instanceId, + ...setting, + supported: supportsAgentsMd(instance.version), + version: instance.version, + } satisfies ClaudeInstructionChoice, + problem, + }; + }); + + // --- The all-projects file and how agents reach it ------------------------------------------- + + /** + * The all-projects file. When some agents' home files are links and all of them lead to the same + * real file, that file is the shared one, so people who already link everything to one file keep + * it. Otherwise it is `~/.agents/AGENTS.md`. + */ + const sharedLocation = Effect.fnUntraced(function* (instances: readonly AgentInstance[]) { + const targets = new Set(); + const candidates = instances.flatMap((instance) => { + const { home } = instance.rules; + const { directory } = instance; + return home === null || directory === undefined + ? [] + : home.files.map((name) => path.join(directory, name)); + }); + const found = yield* Effect.forEach(candidates, (file) => inspectAt(file), { + concurrency: CONCURRENCY, + }); + for (const facts of found) { + if (facts.linkTarget !== undefined && facts.isFile && facts.real !== undefined) { + targets.add(facts.real); + } + } + const [only] = targets.size === 1 ? [...targets] : []; + const location = only ?? path.join(homeDirectory, DEFAULT_SHARED_FILE); + const facts = yield* inspectAt(location); + return { + path: location, + real: facts.real, + exists: facts.isFile, + } satisfies SharedFile; + }); + + /** The file leads to the shared file, or is a link to it that leads nowhere yet. */ + const reachesShared = (facts: FileFacts, shared: SharedFile) => + facts.present && + (facts.real !== undefined + ? facts.real === shared.real + : facts.linkTarget !== undefined && facts.linkTarget === shared.path); + + interface NamedFile { + readonly name: string; + readonly path: string; + readonly facts: FileFacts; + } + + /** What an agent finds in its home: its own files, and the other agents' it reads as well. */ + const loadHome = Effect.fnUntraced(function* (instance: AgentInstance, shared: SharedFile) { + const home = instance.rules.home; + const directory = instance.directory; + if (home === null || directory === undefined) return undefined; + const inFolder = yield* Effect.forEach( + home.files, + (name) => { + const file = path.join(directory, name); + return inspectAt(file).pipe( + Effect.map((facts): NamedFile => ({ name, path: file, facts })), + ); + }, + { concurrency: CONCURRENCY }, + ); + const usable = inFolder.filter( + (file) => + (file.facts.isFile && !(home.skipsEmpty === true && file.facts.size === 0)) || + reachesShared(file.facts, shared), + ); + const loaded = home.selection === "all" ? usable : usable.slice(0, 1); + const fallbacks: NamedFile[] = []; + for (const also of home.alsoReads ?? []) { + if (also.disabledByEnv?.some((name) => isFlagSet(instance.env[name]))) continue; + if (also.when === "no-own-file" && usable.length > 0) continue; + for (const name of also.files) { + const file = path.join(homeDirectory, also.folder, name); + const facts = yield* inspectAt(file); + if (facts.isFile || reachesShared(facts, shared)) + fallbacks.push({ name, path: file, facts }); + } + } + // The agent's own file is the one that would be replaced to join: the first file it reads in + // its folder, or for an agent that reads them all, the file the link would take. + const ownFile = + home.selection === "all" ? usable.find((file) => file.name === home.shared.file) : loaded[0]; + return { + home, + directory, + loaded, + fallbacks, + ownFile: + ownFile !== undefined && ownFile.facts.isFile && !reachesShared(ownFile.facts, shared) + ? ownFile + : undefined, + }; + }); + + const reachOf = Effect.fnUntraced(function* ( + instance: AgentInstance, + shared: SharedFile, + textOf: TextReader, + ) { + const loaded = yield* loadHome(instance, shared); + if (loaded === undefined) return undefined; + const { home, directory } = loaded; + const joinPath = path.join(directory, home.shared.file); + const reach = ( + state: InstructionAgentState, + rest: Pick & Partial>, + ): AgentReach => ({ + instanceId: instance.instanceId, + driver: instance.driver, + displayName: instance.displayName, + directory, + join: home.shared.join, + joinPath, + state, + ownFile: loaded.ownFile?.path, + reads: new Set([...loaded.loaded, ...loaded.fallbacks].map((file) => file.path)), + ...rest, + }); + + const through = [ + ...loaded.loaded.map((file) => ({ ...file, own: true })), + ...loaded.fallbacks.map((file) => ({ ...file, own: false })), + ] + .filter((file) => reachesShared(file.facts, shared)) + .map((file) => ({ + path: file.path, + kind: file.facts.linkTarget === undefined ? ("direct" as const) : ("link" as const), + own: file.own, + })); + if (through.length > 0) { + return reach(through.some((entry) => entry.kind === "direct") ? "direct" : "link", { + via: through, + }); + } + if (home.shared.join === "import") { + const read = loaded.loaded[0] === undefined ? undefined : yield* textOf(joinPath); + return read?._tag === "Read" && hasAgentsMdImport(read.text, importTarget(joinPath, shared)) + ? reach("import", { via: [{ path: joinPath, kind: "import", own: true }] }) + : reach("none", { via: [] }); + } + // A file of the agent's own sits where the link would go, or in front of it. + if (loaded.ownFile !== undefined) { + const blockingFile = + loaded.ownFile.name === home.shared.file ? undefined : loaded.ownFile.name; + return reach("none", { via: [], reason: "ownFile", blockingFile }); + } + return reach("none", { via: [] }); + }); + + const importTarget = (claudeMd: string, shared: SharedFile): AgentsMdImportTarget => ({ + path, + agentsMdPath: shared.path, + claudeMdDirectory: path.dirname(claudeMd), + homeDirectory, + }); + + const scanShared = Effect.fnUntraced(function* ( + instances: readonly AgentInstance[], + textOf: TextReader, + ) { + const file = yield* sharedLocation(instances); + const reaches = yield* Effect.forEach( + instances, + (instance) => reachOf(instance, file, textOf), + { + concurrency: CONCURRENCY, + }, + ); + return { + file, + homeDirectory, + agents: reaches.filter((reach) => reach !== undefined), + } satisfies SharedView; + }); + + // --- Project files -------------------------------------------------------------------------- + + interface ProjectFacts { + readonly cwd: string; + /** Names of the project's top-folder files that exist, with their sizes. */ + readonly sizes: ReadonlyMap; + /** Some Claude file in the top folder imports the project's AGENTS.md. */ + readonly claudeImportsAgentsMd: boolean; + } + + const loadProject = Effect.fnUntraced(function* ( + cwd: string, + instances: readonly AgentInstance[], + textOf: TextReader, + ) { + const names = new Set(ROOT_FILES.map((file) => file.name)); + for (const instance of instances) { + for (const file of instance.rules.project?.files ?? []) names.add(file.name); + } + const found = yield* Effect.forEach( + [...names], + (name) => sizeOf(path.join(cwd, name)).pipe(Effect.map((size) => [name, size] as const)), + { concurrency: CONCURRENCY }, + ); + const sizes = new Map(); + for (const [name, size] of found) if (size !== undefined) sizes.set(name, size); + const agentsMd = path.join(cwd, "AGENTS.md"); + let claudeImportsAgentsMd = false; + for (const name of ["CLAUDE.md", ".claude/CLAUDE.md", PERSONAL_FILE]) { + if (!sizes.has(name)) continue; + const file = path.join(cwd, name); + const read = yield* textOf(file); + if ( + read._tag === "Read" && + hasAgentsMdImport(read.text, { + path, + agentsMdPath: agentsMd, + claudeMdDirectory: path.dirname(file), + homeDirectory, + }) + ) { + claudeImportsAgentsMd = true; + } + } + return { cwd, sizes, claudeImportsAgentsMd } satisfies ProjectFacts; + }); + + const accessOf = ( + instance: Pick, + state: InstructionAgentState, + extra: { + readonly reason?: InstructionAgentReason | undefined; + readonly blockingFile?: string | undefined; + } = {}, + ): InstructionAgentAccess => ({ + instanceId: instance.instanceId, + driver: instance.driver, + state, + ...(extra.reason === undefined ? {} : { reason: extra.reason }), + ...(extra.blockingFile === undefined ? {} : { blockingFile: extra.blockingFile }), + }); + + /** How Claude reaches the project's AGENTS.md: through its setting, an import, or not at all. */ + const claudeAgentsMdAccess = ( + instance: AgentInstance, + choice: ClaudeInstructionChoice, + project: ProjectFacts, + ) => { + if (choice.value !== "managed-only" && project.claudeImportsAgentsMd) { + return accessOf(instance, "import"); + } + if (!choice.supported) return accessOf(instance, "none", { reason: "oldVersion" }); + if (choice.value === "managed-only" || choice.value === "claude-md") { + return accessOf(instance, "none", { reason: "settingOff" }); + } + if (choice.value === "claude-md-and-agents-md") return accessOf(instance, "setting"); + const blockingFile = ["CLAUDE.md", ".claude/CLAUDE.md", PERSONAL_FILE].find((name) => + project.sizes.has(name), + ); + return blockingFile === undefined + ? accessOf(instance, "setting") + : accessOf(instance, "none", { reason: "claudeFiles", blockingFile }); + }; + + /** How one agent reaches a file in the project's top folder. */ + const projectAccess = ( + instance: AgentInstance, + name: string, + project: ProjectFacts, + claude: ReadonlyMap, + ): InstructionAgentAccess | undefined => { + const rules = instance.rules.project; + if (rules === null) return undefined; + const index = rules.files.findIndex((file) => file.name === name); + const rule = rules.files[index]; + if (rule === undefined) return accessOf(instance, "none"); + if (rule.governedBy === "claudeProjectInstructions") { + const choice = claude.get(instance.instanceId); + return choice === undefined + ? accessOf(instance, "none") + : claudeAgentsMdAccess(instance, choice, project); + } + // The file is meant to stay out of git, which Grok skips; whether this one is isn't known here. + if (rules.skipsGitIgnored === true && name === PERSONAL_FILE) return accessOf(instance, "none"); + if (rules.selection !== "all") { + const blocker = rules.files.slice(0, index).find((file) => project.sizes.has(file.name)); + if (blocker !== undefined) return accessOf(instance, "none", { blockingFile: blocker.name }); + if (index > 0 && rules.fallbackDisabledByEnv?.some((flag) => isFlagSet(instance.env[flag]))) { + return accessOf(instance, "none"); + } + } + return accessOf(instance, "direct"); + }; + + /** + * AGENTS.md and CLAUDE.md files below the project's top folder, from the file index. Which + * agents read one depends on the folder's other files and on what the agent opens, so these + * entries don't claim any. + */ + const nestedFiles = Effect.fnUntraced(function* (cwd: string) { + const found = new Set(); + for (const name of NESTED_NAMES) { + const result = yield* workspaceEntries + .search({ cwd, query: name, limit: NESTED_SEARCH_LIMIT, kind: "file" }) + .pipe(Effect.option); + for (const entry of Option.isSome(result) ? result.value.entries : []) { + const segments = entry.path.split("/"); + const base = segments.at(-1) ?? ""; + if (entry.ignored === true || !NESTED_NAMES.has(base) || segments.length < 2) continue; + if (segments.includes(".git") || segments.includes("node_modules")) continue; + // `.claude/CLAUDE.md` in the top folder has its own entry. + if (segments.length === 2 && segments[0] === ".claude") continue; + found.add(entry.path); + } + } + return [...found].toSorted().slice(0, MAX_NESTED); + }); + + // --- Resolving ids --------------------------------------------------------------------------- + + /** A relative path with a folder in it, naming an AGENTS.md or CLAUDE.md, that can't leave the project. */ + const isNestedPath = (relative: string) => { + const segments = relative.split("/"); + return ( + segments.length >= 2 && + !relative.includes("\0") && + !relative.includes("\\") && + !segments.some((segment) => segment === "" || segment === "." || segment === "..") && + !segments.includes(".git") && + NESTED_NAMES.has(segments.at(-1) ?? "") + ); + }; + + const resolve: InstructionCatalog["Service"]["resolve"] = Effect.fn("InstructionCatalog.resolve")( + function* (input) { + const [scope = "", kind = "", ...tail] = input.id.split(":"); + const rest = tail.join(":"); + const cwd = absoluteCwd(input.cwd); + + if (scope === "project") { + if (cwd === undefined) return yield* unknownEntry(); + if (kind === "nested") { + if (!isNestedPath(rest)) return yield* unknownEntry(); + const file = path.join(cwd, rest); + const [realFolder, realRoot] = yield* Effect.all([ + fileSystem.realPath(path.dirname(file)).pipe(Effect.option), + fileSystem.realPath(cwd).pipe(Effect.option), + ]); + if (Option.isNone(realFolder) || Option.isNone(realRoot)) { + return yield* refuse("notFound", "That folder doesn't exist."); + } + const inside = path.relative(realRoot.value, realFolder.value); + if (inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) { + return yield* unknownEntry(); + } + return { + id: input.id, + scope: "project", + kind: "nested", + path: file, + relativePath: rest, + readOnly: false, + } satisfies ResolvedInstruction; + } + const root = ROOT_FILES.find((file) => file.kind === kind && file.name === rest); + if (root === undefined) return yield* unknownEntry(); + return { + id: input.id, + scope: "project", + kind: root.kind, + path: path.join(cwd, root.name), + relativePath: root.name, + readOnly: false, + } satisfies ResolvedInstruction; + } + + if (scope === "global" && kind === "shared" && rest === "") { + const instances = yield* loadInstances(cwd); + const shared = yield* sharedLocation(instances); + return { + id: input.id, + scope: "global", + kind: "shared", + path: shared.path, + readOnly: false, + } satisfies ResolvedInstruction; + } + + if (scope === "global" && (kind === "claude" || kind === "agentOwn")) { + const instances = yield* loadInstances(cwd); + const instance = instances.find((candidate) => candidate.instanceId === rest); + const isClaude = instance?.driver === CLAUDE_DRIVER; + if ( + instance?.rules.home == null || + instance.directory === undefined || + isClaude !== (kind === "claude") + ) { + return yield* unknownEntry(); + } + const shared = yield* sharedLocation(instances); + const loaded = yield* loadHome(instance, shared); + const file = + kind === "claude" + ? path.join(instance.directory, instance.rules.home.shared.file) + : (loaded?.ownFile?.path ?? + path.join(instance.directory, instance.rules.home.shared.file)); + return { + id: input.id, + scope: "global", + kind, + path: file, + readOnly: false, + owner: instance.instanceId, + } satisfies ResolvedInstruction; + } + + if (input.id === MANAGED_ID) { + const managed = claudeManagedInstructionPath(platform); + if (managed === undefined) return yield* unknownEntry(); + return { + id: input.id, + scope: "managed", + kind: "managed", + path: managed, + readOnly: true, + } satisfies ResolvedInstruction; + } + return yield* unknownEntry(); + }, + ); + + // --- Listing --------------------------------------------------------------------------------- + + const list: InstructionCatalog["Service"]["list"] = Effect.fn("InstructionCatalog.list")( + function* (input) { + const cwd = absoluteCwd(input.cwd); + const textOf = makeTextReader(); + const instances = yield* loadInstances(cwd); + const claudeInstances = instances.filter((instance) => instance.driver === CLAUDE_DRIVER); + const unreadable: InstructionProblem[] = []; + + const settings = yield* Effect.forEach( + claudeInstances, + (instance) => claudeChoiceOf(instance, textOf), + { concurrency: CONCURRENCY }, + ); + const claude = settings.map((setting) => setting.choice); + for (const { problem } of settings) if (problem !== undefined) unreadable.push(problem); + const claudeByInstance = new Map(claude.map((choice) => [choice.instanceId, choice])); + + const view = yield* scanShared(instances, textOf); + const entries: InstructionEntry[] = []; + + const problemAt = (file: string, reason: string) => unreadable.push({ path: file, reason }); + const note = (file: string, read: ReadOutcome) => { + if (read._tag === "TooLarge") problemAt(file, "It's larger than 1 MB."); + else if (read._tag === "Unreadable") problemAt(file, "T3 Code couldn't read it."); + }; + + if (cwd !== undefined) { + const project = yield* loadProject(cwd, instances, textOf); + for (const root of ROOT_FILES) { + const size = project.sizes.get(root.name); + // The project's AGENTS.md and the user's own CLAUDE.local.md are listed while missing, + // so they can be created. The local one only when an agent would read it. + const creatable = root.kind === "shared" || root.kind === "claudeLocal"; + if (size === undefined && !creatable) continue; + const access = instances.flatMap((instance) => { + const found = projectAccess(instance, root.name, project, claudeByInstance); + return found === undefined ? [] : [found]; + }); + if (size === undefined && root.kind === "claudeLocal") { + if (access.every((entry) => entry.state === "none")) continue; + } + entries.push({ + id: rootId(root), + scope: "project", + kind: root.kind, + path: path.join(cwd, root.name), + relativePath: root.name, + exists: size !== undefined, + size: size ?? 0, + readOnly: false, + access, + }); + } + for (const relative of yield* nestedFiles(cwd)) { + const file = path.join(cwd, relative); + const size = yield* sizeOf(file); + if (size === undefined) continue; + entries.push({ + id: nestedId(relative), + scope: "project", + kind: "nested", + path: file, + relativePath: relative, + exists: true, + size, + readOnly: false, + access: [], + }); + } + } + + // The all-projects file, and the files agents keep in their homes besides it. + const sharedRead = view.file.exists ? yield* textOf(view.file.path) : undefined; + if (sharedRead !== undefined) note(view.file.path, sharedRead); + const sharedText = sharedRead?._tag === "Read" ? sharedRead.text.trim() : undefined; + const sameAsShared = (read: ReadOutcome) => + read._tag === "Read" && sharedText !== undefined && read.text.trim() === sharedText; + entries.push({ + id: GLOBAL_SHARED_ID, + scope: "global", + kind: "shared", + path: view.file.path, + exists: view.file.exists, + size: view.file.exists ? ((yield* sizeOf(view.file.path)) ?? 0) : 0, + readOnly: false, + access: view.agents.map((reach) => + accessOf(reach, reach.state, { + reason: reach.reason, + blockingFile: reach.blockingFile, + }), + ), + }); + + const listedHomeFiles = new Set(); + for (const reach of view.agents) { + const instance = instances.find((candidate) => candidate.instanceId === reach.instanceId); + if (instance === undefined || reach.ownFile === undefined) continue; + if (listedHomeFiles.has(reach.ownFile)) continue; + const isClaude = reach.driver === CLAUDE_DRIVER; + const read = yield* textOf(reach.ownFile); + note(reach.ownFile, read); + // Claude's file is only worth a row when it holds more than the line that joins it. + if (isClaude && read._tag === "Read") { + const target = importTarget(reach.ownFile, view.file); + if (removeAgentsMdImport(read.text, target).trim() === "") continue; + } + listedHomeFiles.add(reach.ownFile); + const access = view.agents.map((other) => + accessOf(other, other.reads.has(reach.ownFile ?? "") ? "direct" : "none"), + ); + entries.push({ + id: isClaude ? claudeHomeId(reach.instanceId) : agentOwnId(reach.instanceId), + scope: "global", + kind: isClaude ? "claude" : "agentOwn", + path: reach.ownFile, + exists: true, + size: (yield* sizeOf(reach.ownFile)) ?? 0, + readOnly: false, + owner: reach.instanceId, + access, + sameAsShared: sameAsShared(read), + }); + } + + const managedPath = claudeManagedInstructionPath(platform); + if (managedPath !== undefined) { + const size = yield* sizeOf(managedPath); + if (size !== undefined) { + entries.push({ + id: MANAGED_ID, + scope: "managed", + kind: "managed", + path: managedPath, + exists: true, + size, + readOnly: true, + access: claudeInstances.map((instance) => accessOf(instance, "direct")), + }); + } + } + + return { entries, claude, sharedPath: view.file.path, unreadable }; + }, + ); + + const read: InstructionCatalog["Service"]["read"] = Effect.fn("InstructionCatalog.read")( + function* (input) { + const entry = yield* resolve(input); + const outcome = yield* readTextAt(entry.path); + return { + id: input.id, + contents: outcome._tag === "Read" ? outcome.text : null, + revision: outcome._tag === "Read" ? outcome.revision : null, + tooLarge: outcome._tag === "TooLarge", + }; + }, + ); + + const shared = Effect.gen(function* () { + const instances = yield* loadInstances(undefined); + return yield* scanShared(instances, makeTextReader()); + }).pipe(Effect.withSpan("InstructionCatalog.shared")); + + return InstructionCatalog.of({ list, read, resolve, shared }); +}); + +export const layer = Layer.effect(InstructionCatalog, make); diff --git a/apps/server/src/instructions/InstructionFileIO.ts b/apps/server/src/instructions/InstructionFileIO.ts new file mode 100644 index 000000000000..0f6e8caae473 --- /dev/null +++ b/apps/server/src/instructions/InstructionFileIO.ts @@ -0,0 +1,128 @@ +/** + * InstructionFileIO - the file reads and writes under instruction files. + * + * An instruction file is often a link: an agent's home file linked to the Global `AGENTS.md`, or + * the Global file itself behind a dotfiles checkout. So a read follows links, and a write goes to + * the file's real path (`writeTargetOf`), where `writeFileStringAtomically` replaces it with a + * temp file and a rename. Renaming over the link instead would swap the link for a copy and leave + * the Global file stale. + * + * @module InstructionFileIO + */ +// @effect-diagnostics nodeBuiltinImport:off - Revisions are sha256 hashes of a file's bytes. +import * as NodeCrypto from "node:crypto"; + +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { readLinkTarget } from "../skills/SkillLinks.ts"; + +/** The largest instruction file T3 Code reads or writes, in bytes. */ +export const INSTRUCTION_MAX_BYTES = 1_048_576; + +const MAX_LINK_HOPS = 32; + +/** What is at a path, looking at the path itself and at where it leads. */ +export interface FileFacts { + readonly path: string; + /** Something is at the path. A link that leads nowhere counts. */ + readonly present: boolean; + /** Absolute path a link points at; undefined when the path is not a link. */ + readonly linkTarget: string | undefined; + /** The path leads to a regular file. */ + readonly isFile: boolean; + /** Bytes; 0 unless the path leads to a regular file. */ + readonly size: number; + /** Where the path really is, when it leads somewhere that exists. */ + readonly real: string | undefined; +} + +export const inspect = Effect.fn("InstructionFileIO.inspect")(function* (target: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const link = yield* readLinkTarget(target).pipe( + Effect.orElseSucceed(() => ({ _tag: "Missing" }) as const), + ); + const info = yield* fileSystem.stat(target).pipe(Effect.option); + const real = info._tag === "Some" ? yield* realPathOf(target) : undefined; + const isFile = info._tag === "Some" && info.value.type === "File"; + return { + path: target, + present: link._tag !== "Missing" || info._tag === "Some", + linkTarget: link._tag === "Link" ? path.resolve(path.dirname(target), link.target) : undefined, + isFile, + size: isFile && info._tag === "Some" ? Number(info.value.size) : 0, + real, + } satisfies FileFacts; +}); + +const realPathOf = (target: string) => + FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => fileSystem.realPath(target)), + Effect.orElseSucceed(() => undefined), + ); + +export const sha256 = (bytes: Uint8Array) => + NodeCrypto.createHash("sha256").update(bytes).digest("hex"); + +export type ReadOutcome = + /** Nothing is there. */ + | { readonly _tag: "Missing" } + /** Something is there that isn't a regular file, couldn't be read, or isn't UTF-8 text. */ + | { readonly _tag: "Unreadable" } + | { readonly _tag: "TooLarge" } + | { readonly _tag: "Read"; readonly text: string; readonly revision: string }; + +const decoder = new TextDecoder("utf-8", { fatal: true }); + +/** The text of the file at a path, following links, bounded to `INSTRUCTION_MAX_BYTES`. */ +export const readText = Effect.fn("InstructionFileIO.readText")(function* (target: string) { + const fileSystem = yield* FileSystem.FileSystem; + const info = yield* fileSystem.stat(target).pipe(Effect.option); + if (info._tag === "None") { + const present = yield* readLinkTarget(target).pipe( + Effect.map((state) => state._tag !== "Missing"), + Effect.orElseSucceed(() => false), + ); + return (present ? { _tag: "Unreadable" } : { _tag: "Missing" }) as ReadOutcome; + } + if (info.value.type !== "File") return { _tag: "Unreadable" } as ReadOutcome; + if (Number(info.value.size) > INSTRUCTION_MAX_BYTES) return { _tag: "TooLarge" } as ReadOutcome; + const bytes = yield* fileSystem.readFile(target).pipe(Effect.option); + if (bytes._tag === "None") return { _tag: "Unreadable" } as ReadOutcome; + if (bytes.value.byteLength > INSTRUCTION_MAX_BYTES) return { _tag: "TooLarge" } as ReadOutcome; + try { + return { + _tag: "Read", + text: decoder.decode(bytes.value), + revision: sha256(bytes.value), + } as ReadOutcome; + } catch { + return { _tag: "Unreadable" } as ReadOutcome; + } +}); + +/** + * The path a write to `target` must go to: the real file behind any links, or where a link that + * leads nowhere would land, or `target` itself when it is not a link and doesn't exist yet. + */ +export const writeTargetOf = Effect.fn("InstructionFileIO.writeTargetOf")(function* ( + target: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const real = yield* fileSystem.realPath(target).pipe(Effect.option); + if (real._tag === "Some") return real.value; + let current = target; + for (let hop = 0; hop < MAX_LINK_HOPS; hop += 1) { + const state = yield* readLinkTarget(current).pipe( + Effect.orElseSucceed(() => ({ _tag: "Missing" }) as const), + ); + if (state._tag !== "Link") break; + current = path.resolve(path.dirname(current), state.target); + } + // A new file lands in its folder as that folder really is. + const folder = yield* realPathOf(path.dirname(current)); + return folder === undefined ? current : path.join(folder, path.basename(current)); +}); diff --git a/apps/server/src/instructions/InstructionLinks.ts b/apps/server/src/instructions/InstructionLinks.ts new file mode 100644 index 000000000000..8ccb49b5ac77 --- /dev/null +++ b/apps/server/src/instructions/InstructionLinks.ts @@ -0,0 +1,92 @@ +/** + * InstructionLinks - the two writes that make an agent read the shared instruction file: a link at + * the agent's own file, and, when the agent already has a file of its own, a link that takes the + * file's place. + * + * Like `SkillLinks`, the operating system is the last guard. A link is made with a bare create, so + * anything already at the path makes it fail and nothing is removed first. Only `replaceWithLink` + * ever takes a file's place, and only after the caller has kept the file's text somewhere else. + * + * @module InstructionLinks + */ +// @effect-diagnostics nodeBuiltinImport:off - A temp link's name needs a random part. +import * as NodeCrypto from "node:crypto"; + +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +export type CreateFileLinkResult = + /** The link was made. */ + | "created" + /** What is there already leads to the shared file. */ + | "unchanged" + /** Something else is there. It was left alone. */ + | "taken" + /** The system doesn't allow links here (Windows without Developer Mode, a read-only folder). */ + | "notAllowed"; + +/** Whether the path leads to `target`, or is a link to it that leads nowhere yet. */ +const leadsTo = Effect.fnUntraced(function* (link: string, target: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const [real, expected] = yield* Effect.all([ + fileSystem.realPath(link).pipe(Effect.option), + fileSystem.realPath(target).pipe(Effect.option), + ]); + if (real._tag === "Some" && expected._tag === "Some") return real.value === expected.value; + const written = yield* fileSystem.readLink(link).pipe(Effect.option); + return written._tag === "Some" && path.resolve(path.dirname(link), written.value) === target; +}); + +/** + * Makes `link` a symlink to `target`, an absolute path. Nothing is replaced: an existing entry + * fails the create, and counts as `unchanged` only when it already leads to `target`. + */ +export const createFileLink = Effect.fn("InstructionLinks.createFileLink")(function* (input: { + readonly link: string; + readonly target: string; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(input.link), { recursive: true }); + return yield* fileSystem.symlink(input.target, input.link).pipe( + Effect.as("created" as CreateFileLinkResult), + Effect.catchTags({ + PlatformError: (error) => { + const reason = error.reason._tag; + if (reason === "AlreadyExists") { + return leadsTo(input.link, input.target).pipe( + Effect.map((same): CreateFileLinkResult => (same ? "unchanged" : "taken")), + ); + } + if (reason === "PermissionDenied") return Effect.succeed("notAllowed" as const); + return Effect.fail(error); + }, + }), + ); +}); + +/** + * Makes `file` a link to `target` in one step: the link is made under a temp name beside the file + * and renamed over it, so there is never a moment without a file. `stillSame` is asked right before + * the rename, and the file is left alone when it says no. Returns whether the file was replaced. + */ +export const replaceWithLink = Effect.fn("InstructionLinks.replaceWithLink")(function* (input: { + readonly file: string; + readonly target: string; + readonly stillSame: Effect.Effect; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temp = path.join( + path.dirname(input.file), + `.${path.basename(input.file)}.${NodeCrypto.randomUUID()}.link`, + ); + yield* fileSystem.symlink(input.target, temp); + return yield* Effect.gen(function* () { + if (!(yield* input.stillSame)) return false; + yield* fileSystem.rename(temp, input.file); + return true; + }).pipe(Effect.ensuring(fileSystem.remove(temp, { force: true }).pipe(Effect.ignore))); +}); diff --git a/apps/server/src/instructions/InstructionManager.test.ts b/apps/server/src/instructions/InstructionManager.test.ts new file mode 100644 index 000000000000..fa67fef175ff --- /dev/null +++ b/apps/server/src/instructions/InstructionManager.test.ts @@ -0,0 +1,1088 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { InstructionAgentsResult, InstructionWriteResult } from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { parseSettingsJson } from "./ClaudeInstructionSetting.ts"; +import { adoptedText } from "./InstructionManager.ts"; +import * as InstructionCatalog from "./InstructionCatalog.ts"; +import * as InstructionManager from "./InstructionManager.ts"; +import * as InstructionTracking from "./InstructionTracking.ts"; +import { + ALL_AGENTS, + agent, + layerFor, + makeMachine, + type MachineOptions, +} from "./testing/machine.ts"; +import * as ProcessRunner from "../processRunner.ts"; + +const encodeWrite = Schema.encodeUnknownEffect(InstructionWriteResult); +const encodeAgents = Schema.encodeUnknownEffect(InstructionAgentsResult); + +const CLAUDE = { versions: { claudeAgent: "2.1.291" } } satisfies MachineOptions; + +const onMachine = ( + home: string, + options: MachineOptions, + use: (services: { + readonly manager: InstructionManager.InstructionManager["Service"]; + readonly catalog: InstructionCatalog.InstructionCatalog["Service"]; + readonly tracking: InstructionTracking.InstructionTracking["Service"]; + }) => Effect.Effect, +) => + Effect.gen(function* () { + return yield* use({ + manager: yield* InstructionManager.InstructionManager, + catalog: yield* InstructionCatalog.InstructionCatalog, + tracking: yield* InstructionTracking.InstructionTracking, + }); + }).pipe(Effect.provide(layerFor(home, options))); + +const stateOf = ( + catalog: InstructionCatalog.InstructionCatalog["Service"], + id: string, + cwd?: string, +) => + catalog + .list(cwd === undefined ? {} : { cwd }) + .pipe( + Effect.map(({ entries }) => + Object.fromEntries( + (entries.find((entry) => entry.id === id)?.access ?? []).map((access) => [ + access.instanceId, + access.state, + ]), + ), + ), + ); + +const agents = (...names: string[]) => names.map((name) => agent(name)); + +it.layer(NodeServices.layer, { excludeTestServices: true })("InstructionManager", (it) => { + describe("write", () => { + it.effect("creates a missing file, then refuses to create it again", () => + Effect.gen(function* () { + const { home, project, read } = yield* makeMachine; + yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) => + Effect.gen(function* () { + const created = yield* manager.write({ + cwd: project, + id: "project:shared:AGENTS.md", + contents: "# Rules\n", + expectedRevision: null, + }); + yield* encodeWrite(created); + + expect(yield* read("repos/app/AGENTS.md")).toBe("# Rules\n"); + expect(created.revision).toMatch(/^[0-9a-f]{64}$/); + const again = yield* manager + .write({ + cwd: project, + id: "project:shared:AGENTS.md", + contents: "other", + expectedRevision: null, + }) + .pipe(Effect.flip); + expect(again.reason).toBe("exists"); + expect(yield* read("repos/app/AGENTS.md")).toBe("# Rules\n"); + }), + ); + }), + ); + + it.effect("keeps a new CLAUDE.local.md out of git, and a new AGENTS.md in", () => + Effect.gen(function* () { + const { home, project, fs, path } = yield* makeMachine; + const processRunner = yield* ProcessRunner.ProcessRunner; + yield* processRunner.run({ command: "git", args: ["-C", project, "init", "-q"] }); + yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) => + Effect.gen(function* () { + for (const id of ["project:claudeLocal:CLAUDE.local.md", "project:shared:AGENTS.md"]) { + yield* manager.write({ cwd: project, id, contents: "notes", expectedRevision: null }); + } + }), + ); + const ignored = (file: string) => + processRunner + .run({ + command: "git", + args: ["-C", project, "check-ignore", "-q", file], + }) + .pipe(Effect.map((result) => result.code === 0)); + expect(yield* ignored("CLAUDE.local.md")).toBe(true); + expect(yield* ignored("AGENTS.md")).toBe(false); + expect(yield* fs.exists(path.join(project, "CLAUDE.local.md"))).toBe(true); + }).pipe(Effect.provide(ProcessRunner.layer)), + ); + + it.effect("replaces a file only when its revision is still the one that was read", () => + Effect.gen(function* () { + const { home, project, write, read } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "first"); + yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager, catalog }) => + Effect.gen(function* () { + const id = "project:shared:AGENTS.md"; + const opened = yield* catalog.read({ cwd: project, id }); + + const saved = yield* manager.write({ + cwd: project, + id, + contents: "second", + expectedRevision: opened.revision, + }); + expect(yield* read("repos/app/AGENTS.md")).toBe("second"); + expect(saved.revision).not.toBe(opened.revision); + expect((yield* catalog.read({ cwd: project, id })).revision).toBe(saved.revision); + + // Someone else edited the file after it was opened. + yield* write("repos/app/AGENTS.md", "edited elsewhere"); + const stale = yield* manager + .write({ cwd: project, id, contents: "third", expectedRevision: saved.revision }) + .pipe(Effect.flip); + expect(stale.reason).toBe("changedOnDisk"); + expect(yield* read("repos/app/AGENTS.md")).toBe("edited elsewhere"); + + // A file that was there and is gone is a change too. + const gone = yield* manager + .write({ + cwd: project, + id: "project:claude:CLAUDE.md", + contents: "x", + expectedRevision: saved.revision, + }) + .pipe(Effect.flip); + expect(gone.reason).toBe("changedOnDisk"); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "writes through a link to the real file and keeps the link", + () => + Effect.gen(function* () { + const { home, project, write, link, fs, path, read } = yield* makeMachine; + yield* write(".agents/AGENTS.md", "shared"); + yield* link(".agents/AGENTS.md", "repos/app/CLAUDE.md"); + yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager, catalog }) => + Effect.gen(function* () { + const id = "project:claude:CLAUDE.md"; + const opened = yield* catalog.read({ cwd: project, id }); + yield* manager.write({ + cwd: project, + id, + contents: "shared, edited", + expectedRevision: opened.revision, + }); + + expect(yield* read(".agents/AGENTS.md")).toBe("shared, edited"); + expect(yield* fs.readLink(path.join(project, "CLAUDE.md"))).toBe( + path.join(home, ".agents/AGENTS.md"), + ); + // No temp file was left in either folder. + expect(yield* fs.readDirectory(path.join(home, ".agents"))).toEqual(["AGENTS.md"]); + expect(yield* fs.readDirectory(project)).toEqual(["CLAUDE.md"]); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "creates the real file behind a link that leads nowhere", + () => + Effect.gen(function* () { + const { home, fs, path, read } = yield* makeMachine; + yield* fs.makeDirectory(path.join(home, ".codex"), { recursive: true }); + yield* fs.symlink( + path.join(home, ".agents/AGENTS.md"), + path.join(home, ".codex/AGENTS.md"), + ); + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + yield* manager.write({ + id: "global:shared", + contents: "hello", + expectedRevision: null, + }); + expect(yield* read(".agents/AGENTS.md")).toBe("hello"); + expect(yield* fs.readLink(path.join(home, ".codex/AGENTS.md"))).toBe( + path.join(home, ".agents/AGENTS.md"), + ); + }), + ); + }), + ); + + it.effect("keeps a file's permissions", () => + Effect.gen(function* () { + const { home, project, write, fs, path } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "first"); + yield* fs.chmod(path.join(project, "AGENTS.md"), 0o600); + yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager, catalog }) => + Effect.gen(function* () { + const id = "project:shared:AGENTS.md"; + const opened = yield* catalog.read({ cwd: project, id }); + yield* manager.write({ + cwd: project, + id, + contents: "second", + expectedRevision: opened.revision, + }); + expect((yield* fs.stat(path.join(project, "AGENTS.md"))).mode & 0o777).toBe(0o600); + }), + ); + }), + ); + + it.effect("refuses an unregistered project, a managed file and too much text", () => + Effect.gen(function* () { + const { home, project, fs, path } = yield* makeMachine; + yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) => + Effect.gen(function* () { + const elsewhere = path.join(home, "repos/other"); + yield* fs.makeDirectory(elsewhere, { recursive: true }); + const unregistered = yield* manager + .write({ + cwd: elsewhere, + id: "project:shared:AGENTS.md", + contents: "x", + expectedRevision: null, + }) + .pipe(Effect.flip); + expect(unregistered.reason).toBe("unregisteredProject"); + expect(yield* fs.exists(path.join(elsewhere, "AGENTS.md"))).toBe(false); + + const managed = yield* manager + .write({ id: "managed:claude", contents: "x", expectedRevision: null }) + .pipe(Effect.flip); + expect(managed.reason).toBe("readOnly"); + + const tooLarge = yield* manager + .write({ + cwd: project, + id: "project:shared:AGENTS.md", + // Over 1 MB in bytes, though not in characters. + contents: "é".repeat(600_000), + expectedRevision: null, + }) + .pipe(Effect.flip); + expect(tooLarge.reason).toBe("tooLarge"); + expect(yield* fs.exists(path.join(project, "AGENTS.md"))).toBe(false); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "refuses a subfolder file whose folder leads out of the project, and ids that aren't in the table", + () => + Effect.gen(function* () { + const { home, project, write, fs, path } = yield* makeMachine; + yield* write("elsewhere/AGENTS.md", "outside"); + yield* fs.symlink(path.join(home, "elsewhere"), path.join(project, "linked")); + yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager, catalog }) => + Effect.gen(function* () { + for (const id of [ + "project:nested:linked/AGENTS.md", + "project:nested:../elsewhere/AGENTS.md", + "project:nested:linked/../../AGENTS.md", + "project:shared:../../etc/passwd", + "global:agentOwn:cursor", + ]) { + const error = yield* manager + .write({ cwd: project, id, contents: "x", expectedRevision: null }) + .pipe(Effect.flip); + expect(error.reason, id).toBe("unknownEntry"); + } + expect(yield* fs.readFileString(path.join(home, "elsewhere/AGENTS.md"))).toBe( + "outside", + ); + + // A folder inside the project is fine. + yield* write("repos/app/apps/web/AGENTS.md", "web"); + const opened = yield* catalog.read({ + cwd: project, + id: "project:nested:apps/web/AGENTS.md", + }); + yield* manager.write({ + cwd: project, + id: "project:nested:apps/web/AGENTS.md", + contents: "web, edited", + expectedRevision: opened.revision, + }); + expect(yield* fs.readFileString(path.join(project, "apps/web/AGENTS.md"))).toBe( + "web, edited", + ); + }), + ); + }), + ); + }); + + describe("turning agents on and off", () => { + it.effect.skipIf(!symlinksSupported)( + "gives a link-based agent an absolute link, creating the shared file first", + () => + Effect.gen(function* () { + const { home, fs, path } = yield* makeMachine; + yield* onMachine(home, CLAUDE, ({ manager, catalog }) => + Effect.gen(function* () { + const result = yield* manager.enable({ + id: "global:shared", + agents: agents("codex"), + }); + yield* encodeAgents(result); + + expect(result.results).toEqual([{ instanceId: "codex", outcome: "changed" }]); + expect(yield* fs.readLink(path.join(home, ".codex/AGENTS.md"))).toBe( + path.join(home, ".agents/AGENTS.md"), + ); + expect(yield* fs.readFileString(path.join(home, ".agents/AGENTS.md"))).toBe(""); + expect(yield* stateOf(catalog, "global:shared")).toMatchObject({ + codex: "link", + pi: "none", + }); + + const again = yield* manager.enable({ id: "global:shared", agents: agents("codex") }); + expect(again.results).toEqual([{ instanceId: "codex", outcome: "unchanged" }]); + }), + ); + }), + ); + + it.effect("gives Claude an import line as the first line and keeps the rest of its file", () => + Effect.gen(function* () { + const { home, write, read } = yield* makeMachine; + yield* write(".claude/CLAUDE.md", "# My notes\n\nBe brief.\n"); + yield* onMachine(home, CLAUDE, ({ manager, catalog }) => + Effect.gen(function* () { + const result = yield* manager.enable({ + id: "global:shared", + agents: agents("claudeAgent"), + }); + + expect(result.results).toEqual([{ instanceId: "claudeAgent", outcome: "changed" }]); + expect(yield* read(".claude/CLAUDE.md")).toBe( + "@~/.agents/AGENTS.md\n# My notes\n\nBe brief.\n", + ); + expect(yield* stateOf(catalog, "global:shared")).toMatchObject({ + claudeAgent: "import", + }); + expect( + (yield* manager.enable({ id: "global:shared", agents: agents("claudeAgent") })) + .results, + ).toEqual([{ instanceId: "claudeAgent", outcome: "unchanged" }]); + + const off = yield* manager.disable({ + id: "global:shared", + agents: agents("claudeAgent"), + }); + expect(off.results).toEqual([{ instanceId: "claudeAgent", outcome: "changed" }]); + expect(yield* read(".claude/CLAUDE.md")).toBe("# My notes\n\nBe brief.\n"); + expect(yield* stateOf(catalog, "global:shared")).toMatchObject({ claudeAgent: "none" }); + }), + ); + }), + ); + + it.effect( + "makes Claude's file for the import, and removes it again when nothing else is in it", + () => + Effect.gen(function* () { + const { home, fs, path, read } = yield* makeMachine; + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + yield* manager.enable({ id: "global:shared", agents: agents("claudeAgent") }); + expect(yield* read(".claude/CLAUDE.md")).toBe("@~/.agents/AGENTS.md\n"); + + yield* manager.disable({ id: "global:shared", agents: agents("claudeAgent") }); + expect(yield* fs.exists(path.join(home, ".claude/CLAUDE.md"))).toBe(false); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "turns everything on for 'all', and names an agent by its driver kind", + () => + Effect.gen(function* () { + const { home, fs, path } = yield* makeMachine; + yield* onMachine(home, CLAUDE, ({ manager, catalog }) => + Effect.gen(function* () { + const result = yield* manager.enable({ id: "global:shared", agents: "all" }); + yield* encodeAgents(result); + + // Cursor and Antigravity have no home file, so they aren't part of it. + expect(result.results.map((item) => item.instanceId).toSorted()).toEqual([ + "claudeAgent", + "codex", + "grok", + "opencode", + "pi", + ]); + expect(result.results.every((item) => item.outcome === "changed")).toBe(true); + expect(yield* fs.readLink(path.join(home, ".config/opencode/AGENTS.md"))).toBe( + path.join(home, ".agents/AGENTS.md"), + ); + expect(yield* fs.readLink(path.join(home, ".pi/agent/AGENTS.md"))).toBe( + path.join(home, ".agents/AGENTS.md"), + ); + expect(Object.values(yield* stateOf(catalog, "global:shared"))).not.toContain("none"); + + const off = yield* manager.disable({ id: "global:shared", agents: agents("codex") }); + expect(off.results).toEqual([{ instanceId: "codex", outcome: "changed" }]); + expect(yield* fs.exists(path.join(home, ".codex/AGENTS.md"))).toBe(false); + expect(yield* fs.exists(path.join(home, ".agents/AGENTS.md"))).toBe(true); + }), + ); + }), + ); + + it.effect("says so for an agent that isn't enabled, and does the rest", () => + Effect.gen(function* () { + const { home, fs, path } = yield* makeMachine; + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + const result = yield* manager.enable({ + id: "global:shared", + agents: agents("no-such-agent", "pi"), + }); + yield* encodeAgents(result); + expect(result.results).toEqual([ + { + instanceId: "no-such-agent", + outcome: "failed", + reason: "That agent isn't enabled in this environment.", + }, + { instanceId: "pi", outcome: "changed" }, + ]); + expect(yield* fs.exists(path.join(home, ".pi/agent/AGENTS.md"))).toBe(true); + }), + ); + }), + ); + + it.effect("leaves an agent's own file alone and says to use Global instead first", () => + Effect.gen(function* () { + const { home, write, read } = yield* makeMachine; + yield* write(".codex/AGENTS.md", "codex notes"); + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + const result = yield* manager.enable({ + id: "global:shared", + agents: agents("codex"), + }); + + expect(result.results).toEqual([ + { + instanceId: "codex", + outcome: "failed", + reason: "Codex has its own instructions. Use Global instead first.", + }, + ]); + expect(yield* read(".codex/AGENTS.md")).toBe("codex notes"); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "never takes something else's place, and doesn't remove a link that isn't the shared one", + () => + Effect.gen(function* () { + const { home, fs, path } = yield* makeMachine; + // A dangling link to somewhere else sits where Pi's link would go. + yield* fs.makeDirectory(path.join(home, ".pi/agent"), { recursive: true }); + yield* fs.symlink(path.join(home, "gone.md"), path.join(home, ".pi/agent/AGENTS.md")); + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + const result = yield* manager.enable({ id: "global:shared", agents: agents("pi") }); + + expect(result.results).toEqual([ + { + instanceId: "pi", + outcome: "failed", + reason: "Something else is already at AGENTS.md.", + }, + ]); + expect(yield* fs.readLink(path.join(home, ".pi/agent/AGENTS.md"))).toBe( + path.join(home, "gone.md"), + ); + // Disabling an agent that doesn't read the shared file touches nothing. + expect( + (yield* manager.disable({ id: "global:shared", agents: agents("pi") })).results, + ).toEqual([{ instanceId: "pi", outcome: "unchanged" }]); + expect(yield* fs.readLink(path.join(home, ".pi/agent/AGENTS.md"))).toBe( + path.join(home, "gone.md"), + ); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "doesn't turn off an agent that reads the Global file itself", + () => + Effect.gen(function* () { + const { home, write, link, read } = yield* makeMachine; + // Everything links to the Codex file, which is therefore the shared file. + yield* write(".codex/AGENTS.md", "the rules"); + yield* link(".codex/AGENTS.md", ".claude/CLAUDE.md"); + yield* onMachine(home, CLAUDE, ({ manager, catalog }) => + Effect.gen(function* () { + expect(yield* stateOf(catalog, "global:shared")).toMatchObject({ + codex: "direct", + claudeAgent: "link", + }); + + const named = yield* manager.disable({ + id: "global:shared", + agents: agents("codex"), + }); + expect(named.results).toEqual([ + { + instanceId: "codex", + outcome: "failed", + reason: "Codex reads the Global instructions where they are.", + }, + ]); + const all = yield* manager.disable({ id: "global:shared", agents: "all" }); + expect(all.results.find((item) => item.instanceId === "codex")).toMatchObject({ + outcome: "unchanged", + }); + expect(yield* read(".codex/AGENTS.md")).toBe("the rules"); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "removes a link Claude joined by, and won't unlink an agent that reads through another agent's file", + () => + Effect.gen(function* () { + const { home, write, link, fs, path, read } = yield* makeMachine; + yield* write(".agents/AGENTS.md", "shared"); + yield* link(".agents/AGENTS.md", ".claude/CLAUDE.md"); + yield* onMachine(home, CLAUDE, ({ manager, catalog }) => + Effect.gen(function* () { + expect(yield* stateOf(catalog, "global:shared")).toMatchObject({ + claudeAgent: "link", + opencode: "link", + }); + + // OpenCode only reads it because Claude's file links to it. + const opencode = yield* manager.disable({ + id: "global:shared", + agents: agents("opencode"), + }); + expect(opencode.results).toEqual([ + { + instanceId: "opencode", + outcome: "failed", + reason: "OpenCode reads the Global instructions through another agent's file.", + }, + ]); + expect(yield* fs.exists(path.join(home, ".claude/CLAUDE.md"))).toBe(true); + + const claude = yield* manager.disable({ + id: "global:shared", + agents: agents("claudeAgent"), + }); + expect(claude.results).toEqual([{ instanceId: "claudeAgent", outcome: "changed" }]); + expect(yield* fs.exists(path.join(home, ".claude/CLAUDE.md"))).toBe(false); + expect(yield* read(".agents/AGENTS.md")).toBe("shared"); + }), + ); + }), + ); + + it.effect("only turns agents on or off for the shared file", () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) => + Effect.gen(function* () { + const error = yield* manager + .enable({ cwd: project, id: "project:shared:AGENTS.md", agents: "all" }) + .pipe(Effect.flip); + expect(error.reason).toBe("unknownEntry"); + }), + ); + }), + ); + }); + + describe("Claude's Project instructions setting", () => { + const setting = (value: string) => ({ + pluginConfigs: { "cc-plugin-agents-md@builtin": { options: { instructionFiles: value } } }, + }); + + it.effect( + "creates settings.json when it is missing, and nothing when removing from nothing", + () => + Effect.gen(function* () { + const { home, fs, path, read } = yield* makeMachine; + yield* onMachine(home, CLAUDE, ({ manager, catalog }) => + Effect.gen(function* () { + yield* manager.setClaudeSetting({ instanceId: agent("claudeAgent"), value: null }); + expect(yield* fs.exists(path.join(home, ".claude/settings.json"))).toBe(false); + + yield* manager.setClaudeSetting({ + instanceId: agent("claudeAgent"), + value: "claude-md-and-agents-md", + }); + expect(parseSettingsJson(yield* read(".claude/settings.json"))).toEqual( + setting("claude-md-and-agents-md"), + ); + expect((yield* read(".claude/settings.json")).endsWith("}\n")).toBe(true); + expect((yield* catalog.list({})).claude[0]).toMatchObject({ + value: "claude-md-and-agents-md", + explicit: true, + }); + }), + ); + }), + ); + + it.effect("keeps every other key, updates a legacy entry, and cleans up when reset", () => + Effect.gen(function* () { + const { home, write, read } = yield* makeMachine; + const other = { + theme: "dark", + permissions: { allow: ["Bash(ls)"] }, + pluginConfigs: { + "some-other@plugin": { options: { keep: true } }, + "agents-md@builtin": { options: { instructionFiles: "claude-md" } }, + }, + }; + yield* write(".claude/settings.json", JSON.stringify(other)); + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + yield* manager.setClaudeSetting({ + instanceId: agent("claudeAgent"), + value: "claude-md-or-agents-md", + }); + const set = parseSettingsJson(yield* read(".claude/settings.json")); + expect(set).toEqual({ + ...other, + pluginConfigs: { + "some-other@plugin": { options: { keep: true } }, + "agents-md@builtin": { options: { instructionFiles: "claude-md-or-agents-md" } }, + "cc-plugin-agents-md@builtin": { + options: { instructionFiles: "claude-md-or-agents-md" }, + }, + }, + }); + + yield* manager.setClaudeSetting({ instanceId: agent("claudeAgent"), value: null }); + expect(parseSettingsJson(yield* read(".claude/settings.json"))).toEqual({ + theme: "dark", + permissions: { allow: ["Bash(ls)"] }, + pluginConfigs: { "some-other@plugin": { options: { keep: true } } }, + }); + }), + ); + }), + ); + + it.effect("keeps the comments in a settings.json, as the skill settings do", () => + Effect.gen(function* () { + const { home, write, read } = yield* makeMachine; + yield* write(".claude/settings.json", '{\n // my theme\n "theme": "dark",\n}\n'); + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + yield* manager.setClaudeSetting({ + instanceId: agent("claudeAgent"), + value: "claude-md", + }); + const text = yield* read(".claude/settings.json"); + expect(text).toContain("// my theme"); + expect(parseSettingsJson(text)).toEqual({ theme: "dark", ...setting("claude-md") }); + }), + ); + }), + ); + + it.effect("refuses a settings.json it can't parse and leaves it as it was", () => + Effect.gen(function* () { + const { home, write, read } = yield* makeMachine; + for (const broken of ["{ not json", "[]", "null", '{"pluginConfigs": "oops"}']) { + yield* write(".claude/settings.json", broken); + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + const error = yield* manager + .setClaudeSetting({ instanceId: agent("claudeAgent"), value: "claude-md" }) + .pipe(Effect.flip); + expect(error.reason, broken).toBe("invalidSettings"); + expect(yield* read(".claude/settings.json")).toBe(broken); + }), + ); + } + }), + ); + + it.effect("only changes a Claude agent that is enabled", () => + Effect.gen(function* () { + const { home } = yield* makeMachine; + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + for (const instanceId of ["codex", "nobody"]) { + const error = yield* manager + .setClaudeSetting({ instanceId: agent(instanceId), value: "claude-md" }) + .pipe(Effect.flip); + expect(error.reason).toBe("unknownEntry"); + } + }), + ); + }), + ); + }); + + describe("share", () => { + it.effect("renames CLAUDE.md to AGENTS.md", () => + Effect.gen(function* () { + const { home, project, write, read, fs, path } = yield* makeMachine; + yield* write("repos/app/CLAUDE.md", "rules"); + yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) => + Effect.gen(function* () { + yield* manager.share({ cwd: project, id: "project:claude:CLAUDE.md" }); + + expect(yield* read("repos/app/AGENTS.md")).toBe("rules"); + expect(yield* fs.exists(path.join(project, "CLAUDE.md"))).toBe(false); + }), + ); + }), + ); + + it.effect( + "refuses when AGENTS.md exists, or the project isn't registered, or the file isn't CLAUDE.md", + () => + Effect.gen(function* () { + const { home, project, write, read } = yield* makeMachine; + yield* write("repos/app/CLAUDE.md", "claude"); + yield* write("repos/app/AGENTS.md", "agents"); + yield* write("repos/app/.claude/CLAUDE.md", "dot claude"); + yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) => + Effect.gen(function* () { + const exists = yield* manager + .share({ cwd: project, id: "project:claude:CLAUDE.md" }) + .pipe(Effect.flip); + expect(exists.reason).toBe("exists"); + expect(yield* read("repos/app/AGENTS.md")).toBe("agents"); + expect(yield* read("repos/app/CLAUDE.md")).toBe("claude"); + + const nested = yield* manager + .share({ cwd: project, id: "project:claude:.claude/CLAUDE.md" }) + .pipe(Effect.flip); + expect(nested.reason).toBe("unknownEntry"); + }), + ); + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + const unregistered = yield* manager + .share({ cwd: project, id: "project:claude:CLAUDE.md" }) + .pipe(Effect.flip); + expect(unregistered.reason).toBe("unregisteredProject"); + }), + ); + }), + ); + + it.effect("says when there is no CLAUDE.md to share", () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) => + Effect.gen(function* () { + const error = yield* manager + .share({ cwd: project, id: "project:claude:CLAUDE.md" }) + .pipe(Effect.flip); + expect(error.reason).toBe("notFound"); + }), + ); + }), + ); + }); + + describe("adopt", () => { + it.effect.skipIf(!symlinksSupported)( + "adds the agent's text to the shared file under its name, then links the agent to it", + () => + Effect.gen(function* () { + const { home, write, read, fs, path } = yield* makeMachine; + yield* write(".agents/AGENTS.md", "# Shared\n\nBe kind.\n"); + yield* write(".codex/AGENTS.md", "Prefer small diffs.\n"); + yield* onMachine(home, CLAUDE, ({ manager, catalog }) => + Effect.gen(function* () { + yield* manager.adopt({ id: "global:agentOwn:codex" }); + + expect(yield* read(".agents/AGENTS.md")).toBe( + "# Shared\n\nBe kind.\n\n## From Codex\n\nPrefer small diffs.\n", + ); + expect(yield* fs.readLink(path.join(home, ".codex/AGENTS.md"))).toBe( + path.join(home, ".agents/AGENTS.md"), + ); + expect(yield* stateOf(catalog, "global:shared")).toMatchObject({ codex: "link" }); + // The agent's file is the link now, so there is nothing of its own left to move. + const list = yield* catalog.list({}); + expect(list.entries.map((entry) => entry.id)).toEqual(["global:shared"]); + yield* manager.adopt({ id: "global:agentOwn:codex" }); + expect(yield* read(".agents/AGENTS.md")).toBe( + "# Shared\n\nBe kind.\n\n## From Codex\n\nPrefer small diffs.\n", + ); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "replaces an agent's link to some other file and leaves that file as it was", + () => + Effect.gen(function* () { + const { home, write, link, read, fs, path } = yield* makeMachine; + yield* write(".agents/AGENTS.md", "shared\n"); + yield* write("dotfiles/grok.md", "grok notes\n"); + yield* link("dotfiles/grok.md", ".grok/AGENTS.md"); + // Two different link targets, so neither one is taken for the shared file. + yield* write("dotfiles/codex.md", "codex notes\n"); + yield* link("dotfiles/codex.md", ".codex/AGENTS.md"); + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + yield* manager.adopt({ id: "global:agentOwn:grok" }); + + expect(yield* read(".agents/AGENTS.md")).toBe( + "shared\n\n## From Grok\n\ngrok notes\n", + ); + expect(yield* read("dotfiles/grok.md")).toBe("grok notes\n"); + expect(yield* fs.readLink(path.join(home, ".grok/AGENTS.md"))).toBe( + path.join(home, ".agents/AGENTS.md"), + ); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)("just links an agent whose text is the same", () => + Effect.gen(function* () { + const { home, write, read, fs, path } = yield* makeMachine; + yield* write(".agents/AGENTS.md", "same\n"); + yield* write(".pi/agent/AGENTS.md", "same"); + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + yield* manager.adopt({ id: "global:agentOwn:pi" }); + + expect(yield* read(".agents/AGENTS.md")).toBe("same\n"); + expect(yield* fs.readLink(path.join(home, ".pi/agent/AGENTS.md"))).toBe( + path.join(home, ".agents/AGENTS.md"), + ); + }), + ); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "creates the shared file when it doesn't exist, and takes the file Pi really reads", + () => + Effect.gen(function* () { + const { home, write, read, fs, path } = yield* makeMachine; + yield* write(".pi/agent/CLAUDE.md", "pi notes"); + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + yield* manager.adopt({ id: "global:agentOwn:pi" }); + + expect(yield* read(".agents/AGENTS.md")).toBe("## From Pi\n\npi notes\n"); + // The file Pi reads is the one that became the link. + expect(yield* fs.readLink(path.join(home, ".pi/agent/CLAUDE.md"))).toBe( + path.join(home, ".agents/AGENTS.md"), + ); + }), + ); + }), + ); + + it.effect( + "refuses Claude's file, ids that aren't an agent's own file, and an agent with none", + () => + Effect.gen(function* () { + const { home, write, read } = yield* makeMachine; + yield* write(".claude/CLAUDE.md", "my claude notes"); + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + for (const [id, reason] of [ + ["global:claude:claudeAgent", "unknownEntry"], + ["global:shared", "unknownEntry"], + ["global:agentOwn:cursor", "unknownEntry"], + ["global:agentOwn:codex", "notFound"], + ] as const) { + const error = yield* manager.adopt({ id }).pipe(Effect.flip); + expect(error.reason, id).toBe(reason); + } + expect(yield* read(".claude/CLAUDE.md")).toBe("my claude notes"); + }), + ); + }), + ); + + it("keeps adopted text exact and doesn't add the same text twice", () => { + expect(adoptedText("", "Grok", "a\n")).toBe("## From Grok\n\na\n"); + expect(adoptedText("shared", "Grok", "a")).toBe("shared\n\n## From Grok\n\na\n"); + expect(adoptedText("shared\n", "Grok", "a")).toBe("shared\n\n## From Grok\n\na\n"); + expect(adoptedText("x\n\n## From Grok\n\na\n", "Grok", "a")).toBe("x\n\n## From Grok\n\na\n"); + expect(adoptedText("a", "Grok", "a\n")).toBe("a"); + expect(adoptedText("shared", "Grok", " \n")).toBe("shared"); + }); + }); + + describe("delete", () => { + it.effect("removes a real file, and a link without touching what it leads to", () => + Effect.gen(function* () { + const { home, project, write, link, fs, path, read } = yield* makeMachine; + yield* write("repos/app/CLAUDE.local.md", "mine"); + yield* write(".agents/AGENTS.md", "shared"); + yield* link(".agents/AGENTS.md", "repos/app/CLAUDE.md"); + yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) => + Effect.gen(function* () { + yield* manager.delete({ cwd: project, id: "project:claudeLocal:CLAUDE.local.md" }); + expect(yield* fs.exists(path.join(project, "CLAUDE.local.md"))).toBe(false); + + yield* manager.delete({ cwd: project, id: "project:claude:CLAUDE.md" }); + expect(yield* fs.exists(path.join(project, "CLAUDE.md"))).toBe(false); + expect(yield* read(".agents/AGENTS.md")).toBe("shared"); + }), + ); + }), + ); + + it.effect( + "refuses the shared files, a missing file, a managed file and an unregistered project", + () => + Effect.gen(function* () { + const { home, project, write, read } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "project shared"); + yield* write(".agents/AGENTS.md", "global shared"); + yield* onMachine(home, { ...CLAUDE, registered: [project] }, ({ manager }) => + Effect.gen(function* () { + const cases = [ + [{ cwd: project, id: "project:shared:AGENTS.md" }, "readOnly"], + [{ id: "global:shared" }, "readOnly"], + [{ cwd: project, id: "project:claude:CLAUDE.md" }, "notFound"], + [{ id: "managed:claude" }, "readOnly"], + ] as const; + for (const [input, reason] of cases) { + const error = yield* manager.delete(input).pipe(Effect.flip); + expect(error.reason, input.id).toBe(reason); + } + expect(yield* read("repos/app/AGENTS.md")).toBe("project shared"); + expect(yield* read(".agents/AGENTS.md")).toBe("global shared"); + }), + ); + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + const error = yield* manager + .delete({ cwd: project, id: "project:claude:CLAUDE.md" }) + .pipe(Effect.flip); + expect(error.reason).toBe("unregisteredProject"); + }), + ); + }), + ); + + it.effect("deletes an agent's own file", () => + Effect.gen(function* () { + const { home, write, fs, path } = yield* makeMachine; + yield* write(".codex/AGENTS.md", "codex notes"); + yield* onMachine(home, CLAUDE, ({ manager }) => + Effect.gen(function* () { + yield* manager.delete({ id: "global:agentOwn:codex" }); + expect(yield* fs.exists(path.join(home, ".codex/AGENTS.md"))).toBe(false); + }), + ); + }), + ); + }); + + describe("tracking", () => { + it.effect("tells which project instruction files git tracks", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "tracked"); + yield* write("repos/app/CLAUDE.md", "untracked"); + yield* write("repos/app/apps/web/CLAUDE.md", "nested, tracked"); + const processRunner = yield* ProcessRunner.ProcessRunner; + const git = (args: ReadonlyArray) => + processRunner.run({ + command: "git", + args: [ + "-C", + project, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + ...args, + ], + }); + yield* git(["init", "-q"]); + yield* git(["add", "AGENTS.md", "apps/web/CLAUDE.md"]); + yield* git(["commit", "-q", "-m", "init"]); + + yield* onMachine(home, CLAUDE, ({ tracking }) => + Effect.gen(function* () { + const result = yield* tracking.tracked({ + cwd: project, + ids: [ + "project:shared:AGENTS.md", + "project:claude:CLAUDE.md", + "project:nested:apps/web/CLAUDE.md", + "project:nested:apps/missing/AGENTS.md", + // Not project files, or not in the table. + "global:shared", + "project:nested:../AGENTS.md", + ], + }); + expect(result.tracked).toEqual([ + "project:shared:AGENTS.md", + "project:nested:apps/web/CLAUDE.md", + ]); + }), + ); + }).pipe(Effect.provide(ProcessRunner.layer)), + ); + + it.effect("counts nothing as tracked outside a repository", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "x"); + yield* onMachine(home, CLAUDE, ({ tracking }) => + Effect.gen(function* () { + expect( + (yield* tracking.tracked({ cwd: project, ids: ["project:shared:AGENTS.md"] })) + .tracked, + ).toEqual([]); + }), + ); + }), + ); + }); + + it.effect("names every agent that has a home file", () => + Effect.gen(function* () { + const { home } = yield* makeMachine; + yield* onMachine(home, CLAUDE, ({ catalog }) => + Effect.gen(function* () { + const view = yield* catalog.shared; + expect(view.agents.map((reach) => reach.instanceId).toSorted()).toEqual( + ALL_AGENTS.filter((id) => id !== "cursor" && id !== "antigravity").toSorted(), + ); + expect(view.agents.find((reach) => reach.instanceId === "claudeAgent")).toMatchObject({ + join: "import", + joinPath: `${home}/.claude/CLAUDE.md`, + }); + }), + ); + }), + ); +}); diff --git a/apps/server/src/instructions/InstructionManager.ts b/apps/server/src/instructions/InstructionManager.ts new file mode 100644 index 000000000000..19ed808bef46 --- /dev/null +++ b/apps/server/src/instructions/InstructionManager.ts @@ -0,0 +1,603 @@ +/** + * InstructionManager - changes instruction files and who reads them. + * + * Every write starts from the id the client sent, which `InstructionCatalog.resolve` looks up in + * the table again, so a client can only reach files the table names. Writes run one request at a + * time, and a project file is only written when its folder is a registered project. + * + * - A file's text is replaced at its real path behind any links, by temp file and rename, and only + * if its revision is still the one the client read. + * - An agent reads the Global file (the one every project shares) because a link at its own home + * file points at it, or, for Claude, because its CLAUDE.md imports it. Both are made without + * replacing anything: a link with a bare create, an import line by adding text. An agent that + * already has a file of its own is moved over with `adopt`, which keeps that file's text in the + * Global file first. + * - The only writes that take a real file are `adopt` (its text is kept first), `share` (a + * rename, refused when AGENTS.md exists) and `delete`. + * + * @module InstructionManager + */ +import { + InstructionError, + ProviderDriverKind, + type ClaudeInstructionSettingInput, + type InstructionAdoptInput, + type InstructionAgentsInput, + type InstructionAgentsResult, + type InstructionDeleteInput, + type InstructionShareInput, + type InstructionWriteInput, + type InstructionWriteResult, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +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 Option from "effect/Option"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import * as Semaphore from "effect/Semaphore"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; + +import * as ProjectService from "../project/ProjectService.ts"; +import { editJsoncFile, readSettingsText } from "../skills/JsoncSettings.ts"; +import { excludeNewFile } from "../skills/SkillGitExclude.ts"; +import { removeLink } from "../skills/SkillLinks.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { + addAgentsMdImport, + claudeInstructionChanges, + parseSettingsJson, + removeAgentsMdImport, + type AgentsMdImportTarget, +} from "./ClaudeInstructionSetting.ts"; +import * as InstructionCatalog from "./InstructionCatalog.ts"; +import { + INSTRUCTION_MAX_BYTES, + inspect, + readText, + sha256, + writeTargetOf, +} from "./InstructionFileIO.ts"; +import { createFileLink, replaceWithLink } from "./InstructionLinks.ts"; + +type AgentResult = InstructionAgentsResult["results"][number]; + +const encoder = new TextEncoder(); + +const refuse = (reason: InstructionError["reason"], message: string) => + new InstructionError({ reason, message }); + +const LIMIT_MESSAGE = "Instruction files can be at most 1 MB."; + +/** + * The text that adopting an agent's own file adds to the Global file: the agent's text under a + * heading with the agent's name. Nothing is added when the Global file has that text already. + */ +export const adoptedText = (sharedText: string, agentName: string, agentText: string) => { + const own = agentText.trim(); + if (own === "" || sharedText.trim() === own) return sharedText; + const section = `## From ${agentName}\n\n${own}\n`; + if (sharedText.includes(section)) return sharedText; + if (sharedText === "") return section; + return `${sharedText}${sharedText.endsWith("\n") ? "" : "\n"}\n${section}`; +}; + +export class InstructionManager extends Context.Service< + InstructionManager, + { + /** + * Replace the text of a file, or create it. `expectedRevision` is the revision that was read, + * or null for a file that must not exist yet. + */ + readonly write: ( + input: InstructionWriteInput, + ) => Effect.Effect; + /** + * Make each agent read the Global file: a link at its own home file, or for + * Claude an import line. `"all"` means every enabled agent. An agent is named by its + * instance id, or by its driver kind to mean every instance of that driver when no instance + * has that id. + */ + readonly enable: ( + input: InstructionAgentsInput, + ) => Effect.Effect; + /** Stop each agent reading the Global file by removing its link or import line. */ + readonly disable: ( + input: InstructionAgentsInput, + ) => Effect.Effect; + /** Set Claude's "Project instructions" setting; null goes back to Claude's default. */ + readonly setClaudeSetting: ( + input: ClaudeInstructionSettingInput, + ) => Effect.Effect; + /** Rename a project's CLAUDE.md to AGENTS.md, when it has no AGENTS.md. */ + readonly share: (input: InstructionShareInput) => Effect.Effect; + /** Add an agent's own text to the Global file, then make the agent's file a link to it. */ + readonly adopt: (input: InstructionAdoptInput) => Effect.Effect; + /** Delete an instruction file. A link is removed and what it points at stays. */ + readonly delete: (input: InstructionDeleteInput) => Effect.Effect; + } +>()("t3/instructions/InstructionManager") {} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const catalog = yield* InstructionCatalog.InstructionCatalog; + const projects = yield* ProjectService.ProjectService; + const writeLock = yield* Semaphore.make(1); + const fileSystemContext = yield* Effect.context< + FileSystem.FileSystem | Path.Path | VcsProcess.VcsProcess + >(); + + const inspectAt = (file: string) => inspect(file).pipe(Effect.provideContext(fileSystemContext)); + const readTextAt = (file: string) => + readText(file).pipe(Effect.provideContext(fileSystemContext)); + const writeTargetAt = (file: string) => + writeTargetOf(file).pipe(Effect.provideContext(fileSystemContext)); + + /** + * Turns a failed file operation into an error a client can word. A refused permission is the + * file being off limits; anything else is a defect. + */ + const guard = ( + effect: Effect.Effect, + denied: InstructionError, + ) => + effect.pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "PermissionDenied" ? Effect.fail(denied) : Effect.die(error), + }), + ); + + const cannotWrite = (file: string) => + refuse("readOnly", `T3 Code isn't allowed to change ${path.basename(file)}.`); + const invalidSettings = refuse( + "invalidSettings", + "Claude's settings.json isn't valid JSON, so T3 Code left it alone.", + ); + const cannotLink = (file: string) => + refuse( + "linkFailed", + `T3 Code couldn't link ${path.basename(file)}. Links need permission on this system.`, + ); + + /** A file keeps its permissions through a write; a new one gets the default. */ + const writeText = (file: string, contents: string) => + guard( + Effect.gen(function* () { + const mode = yield* fileSystem.stat(file).pipe( + Effect.map((info) => info.mode & 0o777), + Effect.orElseSucceed(() => undefined), + ); + yield* writeFileStringAtomically({ + filePath: file, + contents, + ...(mode === undefined ? {} : { mode }), + }); + }).pipe(Effect.provideContext(fileSystemContext)), + cannotWrite(file), + ); + + /** Project files are only written under a folder the environment knows as a project. */ + const requireProject = (cwd: string | undefined) => + cwd === undefined + ? Effect.fail(refuse("unknownEntry", "That isn't an instruction file T3 Code manages.")) + : projects.getByWorkspaceRoot(cwd).pipe( + Effect.orDie, + Effect.filterOrFail(Option.isSome, () => + refuse("unregisteredProject", "That folder isn't a project in this environment."), + ), + ); + + const resolveEntry = Effect.fnUntraced(function* (input: { + readonly cwd?: string | undefined; + readonly id: string; + }) { + if (input.id.startsWith("project:")) yield* requireProject(input.cwd); + return yield* catalog.resolve(input); + }); + + const importTargetOf = ( + claudeMd: string, + view: InstructionCatalog.SharedView, + ): AgentsMdImportTarget => ({ + path, + agentsMdPath: view.file.path, + claudeMdDirectory: path.dirname(claudeMd), + homeDirectory: view.homeDirectory, + }); + + // --- write --------------------------------------------------------------------------------- + + const write: InstructionManager["Service"]["write"] = Effect.fn("InstructionManager.write")( + function* (input) { + return yield* writeLock.withPermits(1)( + Effect.gen(function* () { + const entry = yield* resolveEntry(input); + if (entry.readOnly) { + return yield* refuse("readOnly", "That file is set by your organization."); + } + const bytes = encoder.encode(input.contents); + if (bytes.byteLength > INSTRUCTION_MAX_BYTES) { + return yield* refuse("tooLarge", LIMIT_MESSAGE); + } + const target = yield* writeTargetAt(entry.path); + const current = yield* readTextAt(target); + if (current._tag === "TooLarge") return yield* refuse("tooLarge", LIMIT_MESSAGE); + if (current._tag === "Unreadable") { + return yield* refuse("readOnly", "T3 Code can't read that file as text."); + } + if (current._tag === "Missing" && input.expectedRevision !== null) { + return yield* refuse("changedOnDisk", "That file changed on disk. Reload it first."); + } + if (current._tag === "Read") { + if (input.expectedRevision === null) { + return yield* refuse("exists", "That file already exists."); + } + if (input.expectedRevision !== current.revision) { + return yield* refuse("changedOnDisk", "That file changed on disk. Reload it first."); + } + } + yield* writeText(target, input.contents); + // CLAUDE.local.md is the user's own, not the repository's: a new one stays out of git, as + // Claude Code does for its local settings. + if (current._tag === "Missing" && entry.kind === "claudeLocal") { + yield* excludeNewFile({ + projectRoot: input.cwd ?? path.dirname(entry.path), + file: entry.path, + }).pipe( + Effect.provideContext(fileSystemContext), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning("could not keep CLAUDE.local.md out of git", { + file: entry.path, + cause: Cause.pretty(cause), + }), + ), + ); + } + return { id: input.id, revision: sha256(bytes) }; + }), + ); + }, + ); + + // --- enable and disable ---------------------------------------------------------------------- + + const unchanged = (reach: InstructionCatalog.AgentReach, reason?: string): AgentResult => ({ + instanceId: reach.instanceId, + outcome: "unchanged", + ...(reason === undefined ? {} : { reason }), + }); + const changed = (reach: InstructionCatalog.AgentReach): AgentResult => ({ + instanceId: reach.instanceId, + outcome: "changed", + }); + const failed = (reach: InstructionCatalog.AgentReach, reason: string): AgentResult => ({ + instanceId: reach.instanceId, + outcome: "failed", + reason, + }); + + /** An empty shared file is made first, so a link to it works as soon as it exists. */ + const ensureSharedFile = Effect.fnUntraced(function* (view: InstructionCatalog.SharedView) { + if (view.file.exists) return; + const target = yield* writeTargetAt(view.file.path); + if ((yield* readTextAt(target))._tag === "Missing") { + yield* writeText(target, ""); + } + }); + + const enableOne = Effect.fnUntraced(function* ( + reach: InstructionCatalog.AgentReach, + view: InstructionCatalog.SharedView, + ) { + if (reach.state !== "none") return unchanged(reach); + if (reach.reason === "ownFile") { + return failed( + reach, + `${reach.displayName} has its own instructions. Use Global instead first.`, + ); + } + yield* ensureSharedFile(view); + + if (reach.join === "import") { + const target = yield* writeTargetAt(reach.joinPath); + const current = yield* readTextAt(target); + if (current._tag === "TooLarge" || current._tag === "Unreadable") { + return failed(reach, `T3 Code couldn't read ${path.basename(reach.joinPath)}.`); + } + const text = current._tag === "Read" ? current.text : ""; + const updated = addAgentsMdImport(text, importTargetOf(reach.joinPath, view)); + if (updated === text) return unchanged(reach); + if (encoder.encode(updated).byteLength > INSTRUCTION_MAX_BYTES) { + return failed(reach, LIMIT_MESSAGE); + } + yield* writeText(target, updated); + return changed(reach); + } + + const result = yield* createFileLink({ link: reach.joinPath, target: view.file.path }).pipe( + Effect.provideContext(fileSystemContext), + Effect.catchTags({ PlatformError: () => Effect.succeed("failed" as const) }), + ); + if (result === "created") return changed(reach); + if (result === "unchanged") return unchanged(reach); + if (result === "taken") { + return failed(reach, `Something else is already at ${path.basename(reach.joinPath)}.`); + } + return failed( + reach, + result === "notAllowed" + ? "T3 Code isn't allowed to make links here." + : "T3 Code couldn't make the link.", + ); + }); + + const disableOne = Effect.fnUntraced(function* ( + reach: InstructionCatalog.AgentReach, + view: InstructionCatalog.SharedView, + explicit: boolean, + ) { + if (reach.state === "none") return unchanged(reach); + if (reach.state === "direct") { + const reason = `${reach.displayName} reads the Global instructions where they are.`; + return explicit ? failed(reach, reason) : unchanged(reach, reason); + } + + if (reach.state === "import") { + const target = yield* writeTargetAt(reach.joinPath); + const current = yield* readTextAt(target); + if (current._tag !== "Read") { + return failed(reach, `T3 Code couldn't read ${path.basename(reach.joinPath)}.`); + } + const updated = removeAgentsMdImport(current.text, importTargetOf(reach.joinPath, view)); + if (updated === current.text) return unchanged(reach); + const facts = yield* inspectAt(reach.joinPath); + // The line was all the file held and the file is not a link: nothing is left worth keeping. + if (updated.trim() === "" && facts.linkTarget === undefined) { + yield* guard(fileSystem.remove(reach.joinPath), cannotWrite(reach.joinPath)); + } else { + yield* writeText(target, updated); + } + return changed(reach); + } + + const links = reach.via.filter((entry) => entry.own && entry.kind === "link"); + if (links.length === 0) { + return failed( + reach, + `${reach.displayName} reads the Global instructions through another agent's file.`, + ); + } + let removed = false; + for (const link of links) { + const written = yield* fileSystem.readLink(link.path).pipe(Effect.option); + if (Option.isNone(written)) continue; + const result = yield* removeLink({ path: link.path, expectedTarget: written.value }).pipe( + Effect.provideContext(fileSystemContext), + Effect.catchTags({ SkillLinkError: () => Effect.succeed("failed" as const) }), + ); + if (result === "failed" || result === "changed") { + return failed(reach, "The link changed, so it was left alone."); + } + removed = removed || result === "removed"; + } + return removed ? changed(reach) : unchanged(reach); + }); + + /** Looks up the agents asked for among those with a home file; a name that matches none fails. */ + const pickAgents = ( + view: InstructionCatalog.SharedView, + agents: InstructionAgentsInput["agents"], + ) => { + const picked = new Map(); + const unknown: ProviderInstanceId[] = []; + if (agents === "all") { + for (const reach of view.agents) picked.set(reach.instanceId, reach); + return { picked, unknown }; + } + for (const name of agents) { + const driver = ProviderDriverKind.make(name); + const byId = view.agents.filter((reach) => reach.instanceId === name); + const matches = + byId.length > 0 ? byId : view.agents.filter((reach) => reach.driver === driver); + if (matches.length === 0) unknown.push(name); + for (const reach of matches) picked.set(reach.instanceId, reach); + } + return { picked, unknown }; + }; + + const changeAgents = ( + input: InstructionAgentsInput, + change: ( + reach: InstructionCatalog.AgentReach, + view: InstructionCatalog.SharedView, + ) => Effect.Effect, + ) => + writeLock.withPermits(1)( + Effect.gen(function* () { + const entry = yield* resolveEntry(input); + if (entry.scope !== "global" || entry.kind !== "shared") { + return yield* refuse( + "unknownEntry", + "Only the Global instructions can be turned on or off.", + ); + } + const view = yield* catalog.shared; + const { picked, unknown } = pickAgents(view, input.agents); + const results: AgentResult[] = unknown.map((instanceId) => ({ + instanceId, + outcome: "failed", + reason: "That agent isn't enabled in this environment.", + })); + for (const reach of picked.values()) results.push(yield* change(reach, view)); + return { results } satisfies InstructionAgentsResult; + }), + ); + + // --- Claude's setting ------------------------------------------------------------------------ + + const setClaudeSetting: InstructionManager["Service"]["setClaudeSetting"] = Effect.fn( + "InstructionManager.setClaudeSetting", + )(function* (input) { + yield* writeLock.withPermits(1)( + Effect.gen(function* () { + const view = yield* catalog.shared; + const claude = view.agents.find( + (reach) => reach.instanceId === input.instanceId && reach.driver === "claudeAgent", + ); + if (claude === undefined) { + return yield* refuse("unknownEntry", "That isn't a Claude agent in this environment."); + } + const file = path.join(claude.directory, "settings.json"); + const text = yield* readSettingsText(file).pipe(Effect.provideContext(fileSystemContext)); + // A missing file is an empty object; one Claude couldn't read is never written over. + const settings = text === undefined || text.trim() === "" ? {} : parseSettingsJson(text); + if (settings === undefined) return yield* invalidSettings; + // Nothing to take away from a file that isn't there. + if (text === undefined && input.value === null) return; + const result = yield* editJsoncFile({ + file, + changes: claudeInstructionChanges(settings, input.value), + }).pipe(Effect.provideContext(fileSystemContext)); + if (result === "invalid") return yield* invalidSettings; + if (result === "failed") return yield* cannotWrite(file); + }), + ); + }); + + // --- share, adopt, delete -------------------------------------------------------------------- + + const share: InstructionManager["Service"]["share"] = Effect.fn("InstructionManager.share")( + function* (input) { + yield* writeLock.withPermits(1)( + Effect.gen(function* () { + const entry = yield* resolveEntry(input); + if (entry.kind !== "claude" || entry.relativePath !== "CLAUDE.md") { + return yield* refuse("unknownEntry", "Only a project's CLAUDE.md can be shared."); + } + const from = yield* inspectAt(entry.path); + if (!from.isFile) return yield* refuse("notFound", "That file doesn't exist."); + const agentsMd = path.join(path.dirname(entry.path), "AGENTS.md"); + if ((yield* inspectAt(agentsMd)).present) { + return yield* refuse("exists", "This project already has an AGENTS.md."); + } + yield* guard(fileSystem.rename(entry.path, agentsMd), cannotWrite(agentsMd)); + }), + ); + }, + ); + + const adopt: InstructionManager["Service"]["adopt"] = Effect.fn("InstructionManager.adopt")( + function* (input) { + yield* writeLock.withPermits(1)( + Effect.gen(function* () { + const entry = yield* resolveEntry(input); + if (entry.kind !== "agentOwn" || entry.owner === undefined) { + return yield* refuse("unknownEntry", "Only an agent's own instructions can be moved."); + } + const view = yield* catalog.shared; + const reach = view.agents.find((candidate) => candidate.instanceId === entry.owner); + if (reach === undefined) { + return yield* refuse("unknownEntry", "That agent isn't enabled in this environment."); + } + // Nothing of its own to keep: it already reads the shared file, or has no file. + if (reach.ownFile === undefined) { + if (reach.state !== "none") return; + return yield* refuse("notFound", "That agent has no instructions of its own."); + } + const ownFile = reach.ownFile; + const own = yield* readTextAt(ownFile); + if (own._tag === "TooLarge") return yield* refuse("tooLarge", LIMIT_MESSAGE); + if (own._tag !== "Read") { + return yield* refuse("notFound", "T3 Code can't read that agent's instructions."); + } + const before = yield* inspectAt(ownFile); + + const sharedTarget = yield* writeTargetAt(view.file.path); + const shared = yield* readTextAt(sharedTarget); + if (shared._tag === "TooLarge") return yield* refuse("tooLarge", LIMIT_MESSAGE); + if (shared._tag === "Unreadable") { + return yield* refuse("readOnly", "T3 Code can't read the Global instructions as text."); + } + const sharedText = shared._tag === "Read" ? shared.text : ""; + const merged = adoptedText(sharedText, reach.displayName, own.text); + const mergedBytes = encoder.encode(merged); + if (mergedBytes.byteLength > INSTRUCTION_MAX_BYTES) { + return yield* refuse("tooLarge", LIMIT_MESSAGE); + } + if (shared._tag === "Missing" || merged !== sharedText) { + yield* writeText(sharedTarget, merged); + } + + // The agent's text is in the Global file now; its file can become the link. + const replaced = yield* guard( + replaceWithLink({ + file: ownFile, + target: view.file.path, + stillSame: Effect.gen(function* () { + const now = yield* inspectAt(ownFile); + const text = yield* readTextAt(ownFile); + return ( + now.linkTarget === before.linkTarget && + text._tag === "Read" && + text.revision === own.revision + ); + }), + }).pipe(Effect.provideContext(fileSystemContext)), + cannotLink(ownFile), + ); + if (!replaced) { + return yield* refuse("changedOnDisk", "That file changed on disk. Nothing was linked."); + } + }), + ); + }, + ); + + const remove: InstructionManager["Service"]["delete"] = Effect.fn("InstructionManager.delete")( + function* (input) { + yield* writeLock.withPermits(1)( + Effect.gen(function* () { + const entry = yield* resolveEntry(input); + if (entry.readOnly) { + return yield* refuse("readOnly", "That file is set by your organization."); + } + if (entry.kind === "shared") { + return yield* refuse("readOnly", "AGENTS.md files can't be deleted here."); + } + const facts = yield* inspectAt(entry.path); + if (!facts.present) return yield* refuse("notFound", "That file doesn't exist."); + if (!facts.isFile && facts.linkTarget === undefined) { + return yield* refuse("unknownEntry", "That isn't a file."); + } + // A non-recursive remove: a link goes and its target stays, a file goes, a folder fails. + yield* guard(fileSystem.remove(entry.path), cannotWrite(entry.path)); + }), + ); + }, + ); + + return InstructionManager.of({ + write, + enable: Effect.fn("InstructionManager.enable")(function* (input) { + return yield* changeAgents(input, (reach, view) => enableOne(reach, view)); + }), + disable: Effect.fn("InstructionManager.disable")(function* (input) { + return yield* changeAgents(input, (reach, view) => + disableOne(reach, view, input.agents !== "all"), + ); + }), + setClaudeSetting, + share, + adopt, + delete: remove, + }); +}); + +export const layer = Layer.effect(InstructionManager, make); diff --git a/apps/server/src/instructions/InstructionTracking.ts b/apps/server/src/instructions/InstructionTracking.ts new file mode 100644 index 000000000000..4fee09b865bc --- /dev/null +++ b/apps/server/src/instructions/InstructionTracking.ts @@ -0,0 +1,73 @@ +/** + * InstructionTracking - tells which project instruction files git tracks, so a confirmation for + * renaming or deleting one can say whether git can undo it. + * + * It is separate from `InstructionCatalog` because the catalog's list spawns nothing and loads on + * every page open; this runs one `git ls-files` for all the files asked about, and only when a + * person is about to confirm a change. Nothing is written. + * + * @module InstructionTracking + */ +import type { InstructionTrackedInput, InstructionTrackedResult } 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 { trackedFiles } from "../vcs/GitTrackedFiles.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as InstructionCatalog from "./InstructionCatalog.ts"; + +export class InstructionTracking extends Context.Service< + InstructionTracking, + { + /** + * The ids among `ids` of project files that git tracks. An id that doesn't name a project + * file, a file that sits outside the repository, and every file when git fails count as not + * tracked. A file is tracked by its own path, so a link git tracks counts, whatever it + * points at. + */ + readonly tracked: (input: InstructionTrackedInput) => Effect.Effect; + } +>()("t3/instructions/InstructionTracking") {} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const catalog = yield* InstructionCatalog.InstructionCatalog; + const vcs = yield* VcsProcess.VcsProcess; + + const tracked: InstructionTracking["Service"]["tracked"] = Effect.fn( + "InstructionTracking.tracked", + )(function* (input) { + const realCwd = yield* fileSystem + .realPath(input.cwd) + .pipe(Effect.orElseSucceed(() => input.cwd)); + + // The path git knows each file by, from the project's real folder. + const files = new Map(); + for (const id of input.ids) { + const entry = yield* catalog.resolve({ cwd: input.cwd, id }).pipe(Effect.option); + if (entry._tag === "None" || entry.value.scope !== "project") continue; + const folder = yield* fileSystem + .realPath(path.dirname(entry.value.path)) + .pipe(Effect.orElseSucceed(() => path.dirname(entry.value.path))); + const inside = path.relative(realCwd, path.join(folder, path.basename(entry.value.path))); + if (inside === "" || inside.startsWith("..") || path.isAbsolute(inside)) continue; + files.set(inside.replaceAll("\\", "/"), id); + } + if (files.size === 0) return { tracked: [] }; + + const trackedPaths = yield* trackedFiles(vcs, { + operation: "InstructionTracking.tracked", + cwd: input.cwd, + files: [...files.keys()], + }); + return { tracked: [...files].flatMap(([file, id]) => (trackedPaths.has(file) ? [id] : [])) }; + }); + + return InstructionTracking.of({ tracked }); +}); + +export const layer = Layer.effect(InstructionTracking, make); diff --git a/apps/server/src/instructions/testing/machine.ts b/apps/server/src/instructions/testing/machine.ts new file mode 100644 index 000000000000..efd4c4ad5d8d --- /dev/null +++ b/apps/server/src/instructions/testing/machine.ts @@ -0,0 +1,187 @@ +/** + * A made-up machine for instruction tests: a temp home folder with real files and links, a project + * in it, and the instruction services running over it. Only what lives outside the files is a + * stand-in: the provider snapshots (versions), the project registry, and the project's file index, + * which walks the real folder. + */ +import { + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type Project, + type ServerProvider, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import * as ProjectService from "../../project/ProjectService.ts"; +import * as ProviderRegistry from "../../provider/ProviderRegistry.ts"; +import * as Settings from "../../serverSettings.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; +import * as InstructionCatalog from "../InstructionCatalog.ts"; +import * as InstructionManager from "../InstructionManager.ts"; +import * as InstructionTracking from "../InstructionTracking.ts"; + +export const agent = ProviderInstanceId.make; + +/** Every agent that has instruction files, by instance id. */ +export const ALL_AGENTS = [ + "claudeAgent", + "codex", + "cursor", + "grok", + "opencode", + "antigravity", + "pi", +]; + +export const makeProject = (workspaceRoot: string): Project => ({ + id: ProjectId.make("project-instructions"), + title: "App", + workspaceRoot, + repositoryIdentity: null, + faviconPath: null, + projectIcon: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}); + +/** A temp home folder, with helpers to put files and links in it, and a project folder. */ +export const makeMachine = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "t3code-instructions-" }), + ); + const project = path.join(home, "repos/app"); + yield* fs.makeDirectory(project, { recursive: true }); + const write = (relative: string, contents: string) => + Effect.gen(function* () { + const target = path.join(home, relative); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, contents); + }); + const link = (target: string, from: string) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.dirname(path.join(home, from)), { recursive: true }); + yield* fs.symlink(path.join(home, target), path.join(home, from)); + }); + const read = (relative: string) => fs.readFileString(path.join(home, relative)); + return { fs, path, home, project, write, link, read }; +}); + +export interface MachineOptions { + /** Folders that are projects. */ + readonly registered?: readonly string[]; + /** Claude Code's version, by instance id; absent means the status has no version. */ + readonly versions?: Readonly>; + /** Extra enabled providers beyond the default Claude and Codex. */ + readonly providers?: readonly string[]; + readonly providerInstances?: NonNullable< + Parameters[0] + >["providerInstances"]; + /** Environment variables of the server process, besides HOME. */ + readonly env?: Readonly>; + readonly platform?: NodeJS.Platform; +} + +const snapshotOf = ( + instanceId: string, + driver: string, + version: string | null, +): ServerProvider => ({ + instanceId: ProviderInstanceId.make(instanceId), + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + version, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}); + +/** + * The instruction services on a machine whose home is `home`. The file index is a walk of the real + * project folder that matches names exactly, skipping `.git` and `node_modules`. + */ +export const layerFor = (home: string, options: MachineOptions = {}) => { + const registered = options.registered ?? []; + const versions = options.versions ?? {}; + const enabled = Object.fromEntries( + (options.providers ?? ["cursor", "grok", "opencode", "antigravity", "pi"]).map((id) => [ + ProviderInstanceId.make(id), + { driver: ProviderDriverKind.make(id), enabled: true }, + ]), + ); + const settings = Settings.layerTest({ + providerInstances: { ...enabled, ...options.providerInstances }, + }); + const registry = Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([ + snapshotOf("claudeAgent", "claudeAgent", versions.claudeAgent ?? null), + ...Object.entries(versions) + .filter(([id]) => id !== "claudeAgent") + .map(([id, version]) => snapshotOf(id, "claudeAgent", version)), + ]), + }); + const projects = Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (root) => + Effect.succeed(registered.includes(root) ? Option.some(makeProject(root)) : Option.none()), + }); + const index = Layer.effect( + WorkspaceEntries.WorkspaceEntries, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return WorkspaceEntries.WorkspaceEntries.of({ + ...({} as WorkspaceEntries.WorkspaceEntries["Service"]), + search: (input) => + fs.readDirectory(input.cwd, { recursive: true }).pipe( + Effect.map((paths) => ({ + entries: paths + .map((entry) => entry.replaceAll("\\", "/")) + .filter( + (entry) => + !entry.split("/").some((part) => part === ".git" || part === "node_modules") && + entry.split("/").at(-1) === input.query, + ) + .toSorted() + .slice(0, input.limit) + .map((entry) => ({ path: entry, kind: "file" as const })), + truncated: false, + })), + Effect.orDie, + ), + }); + }), + ); + const catalog = InstructionCatalog.layer.pipe( + Layer.provide(settings), + Layer.provide(registry), + Layer.provide(index), + ); + return Layer.mergeAll(InstructionManager.layer, InstructionTracking.layer).pipe( + Layer.provideMerge(catalog), + Layer.provide(projects), + Layer.provide(VcsProcess.layer), + Layer.provide( + Layer.mergeAll( + Layer.succeed(HostProcess.Environment, { HOME: home, ...options.env }), + Layer.succeed(HostProcess.HomeDirectory, home), + Layer.succeed(HostProcess.Platform, options.platform ?? "linux"), + ), + ), + ); +}; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index afd1ebfaec28..9f23bf40000b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -87,6 +87,9 @@ import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; +import * as InstructionCatalog from "./instructions/InstructionCatalog.ts"; +import * as InstructionManager from "./instructions/InstructionManager.ts"; +import * as InstructionTracking from "./instructions/InstructionTracking.ts"; import * as SkillCatalog from "./skills/SkillCatalog.ts"; import { RegisteredProjects } from "./skills/SkillLibrary.ts"; import * as SkillManager from "./skills/SkillManager.ts"; @@ -600,6 +603,12 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( SkillManager.layer, // Reads through SkillCatalog and runs git through VcsProcess. SkillTracking.layer, + // Instruction files. The manager checks folders against ProjectService, so, like SkillManager, + // being here makes it one instance and instruction writes run one request at a time. All three + // read through one InstructionCatalog, which reads the file index and the provider snapshots. + Layer.mergeAll(InstructionManager.layer, InstructionTracking.layer).pipe( + Layer.provideMerge(InstructionCatalog.layer), + ), ).pipe( // Core Services // It checks a project's folder against ProjectService, which the next layer provides. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0b0d3fb553ac..8d912b2aec64 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -100,6 +100,8 @@ import { type TerminalMetadataStreamEvent, type PullRequestRef, WS_METHODS, + WsBaseRpcGroup, + WsInstructionRpcGroup, WsRpcGroup, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; @@ -190,6 +192,9 @@ import { parseBase64DataUrl } from "./imageMime.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; +import * as InstructionCatalog from "./instructions/InstructionCatalog.ts"; +import * as InstructionManager from "./instructions/InstructionManager.ts"; +import * as InstructionTracking from "./instructions/InstructionTracking.ts"; import * as SkillCatalog from "./skills/SkillCatalog.ts"; import * as SkillManager from "./skills/SkillManager.ts"; import * as SkillTracking from "./skills/SkillTracking.ts"; @@ -543,6 +548,10 @@ const PROVIDER_STATUS_DEBOUNCE_MS = 200; // Middleware added later wraps middleware added earlier, so instrumentation wraps authorization. const ServerWsRpcGroup = WsRpcGroup.middleware(RpcInstrumentation); +// Handlers are registered per group: one `toLayer` over every RPC is near the compiler's +// instantiation limit. `ServerWsRpcGroup` is only what the RPC server serves. +const ServerWsBaseRpcGroup = WsBaseRpcGroup.middleware(RpcInstrumentation); +const ServerWsInstructionRpcGroup = WsInstructionRpcGroup.middleware(RpcInstrumentation); // When a resuming client's cursor is more than this many events behind the // current head, skip the per-event catch-up replay and send a fresh shell // snapshot instead. Replaying each intervening event costs a shell refetch; @@ -1190,7 +1199,7 @@ const layerWsRpc = ( previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], serverBrowser: ServerBrowser.ServerBrowser["Service"], ) => - ServerWsRpcGroup.toLayer( + ServerWsBaseRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; const sql = yield* SqlClient.SqlClient; @@ -1818,7 +1827,7 @@ const layerWsRpc = ( return result; }); - const handlers = ServerWsRpcGroup.of({ + const handlers = ServerWsBaseRpcGroup.of({ [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, @@ -3131,6 +3140,30 @@ const layerWsRpc = ( }), ); +const layerWsInstructionRpc = ServerWsInstructionRpcGroup.toLayer( + Effect.gen(function* () { + const instructionCatalog = yield* InstructionCatalog.InstructionCatalog; + const instructionManager = yield* InstructionManager.InstructionManager; + const instructionTracking = yield* InstructionTracking.InstructionTracking; + return ServerWsInstructionRpcGroup.of({ + [WS_METHODS.serverListInstructions]: (input) => instructionCatalog.list(input), + [WS_METHODS.serverReadInstruction]: (input) => instructionCatalog.read(input), + [WS_METHODS.serverWriteInstruction]: (input) => instructionManager.write(input), + [WS_METHODS.serverEnableInstruction]: (input) => instructionManager.enable(input), + [WS_METHODS.serverDisableInstruction]: (input) => instructionManager.disable(input), + [WS_METHODS.serverSetClaudeInstructionFiles]: (input) => + instructionManager.setClaudeSetting(input).pipe(Effect.as({})), + [WS_METHODS.serverShareInstruction]: (input) => + instructionManager.share(input).pipe(Effect.as({})), + [WS_METHODS.serverAdoptInstruction]: (input) => + instructionManager.adopt(input).pipe(Effect.as({})), + [WS_METHODS.serverDeleteInstruction]: (input) => + instructionManager.delete(input).pipe(Effect.as({})), + [WS_METHODS.serverInstructionsTracked]: (input) => instructionTracking.tracked(input), + }); + }), +); + // A defect in a handler's effect fails only its own request. RpcServer's default // sends a socket-level Defect frame instead, and the client ends every pending // request on the socket with it. DefectReporter logs these defects. @@ -3200,6 +3233,7 @@ export const layer = Layer.unwrap( previewAutomationBroker, serverBrowser, ).pipe( + Layer.merge(layerWsInstructionRpc), Layer.provideMerge(RpcSerialization.layerJson), // Request fibers run in the handlers' context, so this reporter sees // their defects, not the rest of the server's. From 79be4fae455fbbfca988e48edf16e5abf6ed3792 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:32:19 -0400 Subject: [PATCH 049/108] feat(server): let agents list and turn instruction files on or off through MCP tools Adds t3_instructions_list, t3_instructions_get, t3_instructions_enable and t3_instructions_disable, like the skills tools. Enabling and disabling only affect the shared file for all projects and need full access; editing and deleting stay with the user. Co-Authored-By: Claude Sonnet 5.5 --- apps/server/src/mcp/McpHttpServer.ts | 8 + .../toolkits/instructions/handlers.test.ts | 226 ++++++++++++++++++ .../src/mcp/toolkits/instructions/handlers.ts | 73 ++++++ .../src/mcp/toolkits/instructions/tools.ts | 86 +++++++ .../toolkits/worktree/registration.test.ts | 4 + .../Adapters/ClaudeAdapterV2.test.ts | 2 + .../Adapters/ClaudeAdapterV2.ts | 2 + packages/client-runtime/src/t3ToolSummary.ts | 12 + .../src/work-log/presentation.ts | 2 + packages/shared/src/t3McpToolPresentation.ts | 17 ++ 10 files changed, 432 insertions(+) create mode 100644 apps/server/src/mcp/toolkits/instructions/handlers.test.ts create mode 100644 apps/server/src/mcp/toolkits/instructions/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/instructions/tools.ts diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 58b8a573343d..dff14d60dbfc 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -30,6 +30,8 @@ import { EnvironmentToolkit } from "./toolkits/environment/tools.ts"; import * as EnvironmentHandlers from "./toolkits/environment/handlers.ts"; import { ProjectToolkit } from "./toolkits/project/tools.ts"; import * as ProjectHandlers from "./toolkits/project/handlers.ts"; +import { InstructionsToolkit } from "./toolkits/instructions/tools.ts"; +import * as InstructionsHandlers from "./toolkits/instructions/handlers.ts"; import { SkillsToolkit } from "./toolkits/skills/tools.ts"; import * as SkillsHandlers from "./toolkits/skills/handlers.ts"; import { AttachmentToolkit } from "./toolkits/attachment/tools.ts"; @@ -837,6 +839,11 @@ const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHand export const layerSkillsToolkit = toolkitRegistration(SkillsToolkit, SkillsHandlers.layer); +export const layerInstructionsToolkit = toolkitRegistration( + InstructionsToolkit, + InstructionsHandlers.layer, +); + export const layerAttachmentToolkit = toolkitRegistration( AttachmentToolkit, AttachmentHandlers.layer, @@ -877,6 +884,7 @@ export const layer = Layer.mergeAll( layerAttachmentToolkit, layerProjectRegistration, layerSkillsToolkit, + layerInstructionsToolkit, layerEnvironmentToolkit, layerPreviewControlsRegistration, layerWorktreeToolkitRegistration, diff --git a/apps/server/src/mcp/toolkits/instructions/handlers.test.ts b/apps/server/src/mcp/toolkits/instructions/handlers.test.ts new file mode 100644 index 000000000000..7873dca11b46 --- /dev/null +++ b/apps/server/src/mcp/toolkits/instructions/handlers.test.ts @@ -0,0 +1,226 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + InstructionAgentsResult, + InstructionListResult, + InstructionReadResult, + ProjectId, + ProviderInstanceId, + RunId, + ThreadId, + type OrchestrationV2ThreadShell, + type RuntimeMode, +} from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { McpSchema, McpServer } from "effect/ai"; + +import { layerFor, makeMachine, makeProject } from "../../../instructions/testing/machine.ts"; +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../../../project/ProjectService.ts"; +import * as McpHttpServer from "../../McpHttpServer.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const threadId = ThreadId.make("instructions-mcp-thread"); +const projectId = ProjectId.make("project-instructions"); +const callingInstance = ProviderInstanceId.make("codex"); + +const client = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "instructions-mcp", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "instructions-mcp", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +const scope: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("instructions-mcp-environment"), + requestNamespace: "instructions-mcp-session", + thread: { + threadId, + providerSessionId: "instructions-mcp-session", + providerInstanceId: callingInstance, + }, + client: undefined, + issuedAt: 0, + capabilities: new Set(["orchestration"]), +}; + +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +// Effect returns a declared tool failure as `isError` with its encoded payload as JSON text. +const declaredFailure = (result: McpSchema.CallToolResult) => { + const text = result.content[0]; + return result.isError === true && text?.type === "text" ? decodeJson(text.text) : undefined; +}; + +/** The production instructions registration over the real catalog and manager at `home`. */ +const mcpLayerFor = ( + home: string, + project: string, + options: { readonly runtimeMode?: RuntimeMode } = {}, +) => + McpHttpServer.layerInstructionsToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(layerFor(home, { registered: [project], versions: { claudeAgent: "2.1.291" } })), + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: (id) => + Effect.succeed(id === projectId ? Option.some(makeProject(project)) : Option.none()), + }), + ), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ + id: threadId, + projectId, + providerInstanceId: callingInstance, + runtimeMode: options.runtimeMode ?? "full-access", + interactionMode: "default", + activeRunId: RunId.make("instructions-mcp-run"), + archivedAt: null, + deletedAt: null, + } as OrchestrationV2ThreadShell), + }), + ), + ); + +const call = (name: string, args: Record) => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + return yield* server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + }); + +const decodeList = Schema.decodeUnknownSync(InstructionListResult); +const decodeRead = Schema.decodeUnknownSync(InstructionReadResult); +const decodeAgents = Schema.decodeUnknownSync(InstructionAgentsResult); + +describe("instructions MCP tools", () => { + it.layer(NodeServices.layer, { excludeTestServices: true })("over a real layout", (it) => { + it.effect("lists the calling thread's project files and the global ones", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "project rules"); + yield* write(".codex/AGENTS.md", "codex notes"); + yield* Effect.gen(function* () { + const result = decodeList((yield* call("t3_instructions_list", {})).structuredContent); + + // CLAUDE.local.md is listed while missing, like the project's AGENTS.md was. + expect(result.entries.map((entry) => entry.id)).toEqual([ + "project:shared:AGENTS.md", + "project:claudeLocal:CLAUDE.local.md", + "global:shared", + "global:agentOwn:codex", + ]); + expect(result.sharedPath).toBe(`${home}/.agents/AGENTS.md`); + }).pipe(Effect.provide(mcpLayerFor(home, project))); + }), + ); + + it.effect("reads one file's text by the id the list gave", () => + Effect.gen(function* () { + const { home, project, write } = yield* makeMachine; + yield* write("repos/app/AGENTS.md", "project rules"); + yield* Effect.gen(function* () { + const result = decodeRead( + (yield* call("t3_instructions_get", { id: "project:shared:AGENTS.md" })) + .structuredContent, + ); + expect(result).toMatchObject({ contents: "project rules", tooLarge: false }); + + const missing = yield* call("t3_instructions_get", { id: "global:shared" }); + expect(decodeRead(missing.structuredContent)).toMatchObject({ + contents: null, + revision: null, + }); + + const unknown = yield* call("t3_instructions_get", { id: "project:claude:README.md" }); + expect(declaredFailure(unknown)).toMatchObject({ code: "invalid_request" }); + }).pipe(Effect.provide(mcpLayerFor(home, project))); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "turns the shared file on for named agents, and off again", + () => + Effect.gen(function* () { + const { home, project, fs, path, write, read } = yield* makeMachine; + yield* write(".claude/CLAUDE.md", "my notes\n"); + yield* Effect.gen(function* () { + const on = yield* call("t3_instructions_enable", { + agents: ["codex", "claudeAgent"], + }); + expect(on.isError).toBe(false); + expect(decodeAgents(on.structuredContent).results).toEqual([ + { instanceId: "codex", outcome: "changed" }, + { instanceId: "claudeAgent", outcome: "changed" }, + ]); + expect(yield* fs.readLink(path.join(home, ".codex/AGENTS.md"))).toBe( + path.join(home, ".agents/AGENTS.md"), + ); + expect(yield* read(".claude/CLAUDE.md")).toBe("@~/.agents/AGENTS.md\nmy notes\n"); + + const off = yield* call("t3_instructions_disable", { agents: ["codex"] }); + expect(decodeAgents(off.structuredContent).results).toEqual([ + { instanceId: "codex", outcome: "changed" }, + ]); + expect(yield* fs.exists(path.join(home, ".codex/AGENTS.md"))).toBe(false); + // The shared file stays. + expect(yield* fs.exists(path.join(home, ".agents/AGENTS.md"))).toBe(true); + }).pipe(Effect.provide(mcpLayerFor(home, project))); + }), + ); + + it.effect("lets a supervised thread read instructions but not change who reads them", () => + Effect.gen(function* () { + const { home, project, fs, path } = yield* makeMachine; + yield* Effect.gen(function* () { + expect((yield* call("t3_instructions_list", {})).isError).toBe(false); + + for (const [name, args] of [ + ["t3_instructions_enable", { agents: "all" }], + ["t3_instructions_disable", { agents: ["codex"] }], + ] as const) { + const result = yield* call(name, args); + expect(declaredFailure(result), name).toMatchObject({ code: "capability_denied" }); + } + expect(yield* fs.exists(path.join(home, ".codex"))).toBe(false); + }).pipe(Effect.provide(mcpLayerFor(home, project, { runtimeMode: "approval-required" }))); + }), + ); + + it.effect("rejects inputs the tools do not accept before touching any service", () => + Effect.gen(function* () { + const { home, project } = yield* makeMachine; + yield* Effect.gen(function* () { + for (const [name, args] of [ + ["t3_instructions_enable", { agents: [] }], + ["t3_instructions_enable", { agents: ["not a slug"] }], + // Only enabling takes "all". + ["t3_instructions_disable", { agents: "all" }], + ["t3_instructions_get", {}], + ["t3_instructions_get", { id: "" }], + ] as const) { + const error = yield* call(name, args).pipe(Effect.flip); + expect(error._tag, `${name} ${Object.keys(args).join()}`).toBe("InvalidParams"); + } + }).pipe(Effect.provide(mcpLayerFor(home, project))); + }), + ); + }); +}); diff --git a/apps/server/src/mcp/toolkits/instructions/handlers.ts b/apps/server/src/mcp/toolkits/instructions/handlers.ts new file mode 100644 index 000000000000..81c52d179de8 --- /dev/null +++ b/apps/server/src/mcp/toolkits/instructions/handlers.ts @@ -0,0 +1,73 @@ +import { OrchestratorMcpFailure, type InstructionError, type ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as InstructionCatalog from "../../../instructions/InstructionCatalog.ts"; +import * as InstructionManager from "../../../instructions/InstructionManager.ts"; +import * as ProjectService from "../../../project/ProjectService.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { readCaller, resolveProjectId, unavailable, type Caller } from "../../threadAccess.ts"; +import { InstructionsToolkit } from "./tools.ts"; + +const GLOBAL_SHARED_ID = "global:shared"; + +const instructionFailure = (error: InstructionError) => + new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }); + +/** + * The folder of the project the call is about: the one passed, else the calling thread's. Without + * either, the call is about the user's home files alone. + */ +const projectFolder = Effect.fnUntraced(function* ( + context: Caller, + projectId: ProjectId | undefined, +) { + if (projectId === undefined && context.caller === undefined) return undefined; + const id = yield* resolveProjectId(context, projectId); + const projects = yield* ProjectService.ProjectService; + const project = yield* projects.getById(id).pipe(Effect.mapError(unavailable)); + if (Option.isNone(project) || project.value.deletedAt !== null) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "The project was not found.", + }); + return project.value.workspaceRoot; +}); + +/** + * Linking agents to the Global file rewrites files agents run from, so turning it on or off needs + * full access; `McpToolAccess.writesEnvironment` checks that. + */ +export const layer = McpToolAccess.toLayer(InstructionsToolkit, { + t3_instructions_list: McpToolAccess.reads((input) => + Effect.gen(function* () { + const context = yield* readCaller(); + const cwd = yield* projectFolder(context, input.projectId); + const catalog = yield* InstructionCatalog.InstructionCatalog; + return yield* catalog.list({ cwd }); + }), + ), + t3_instructions_get: McpToolAccess.reads(({ projectId, id }) => + Effect.gen(function* () { + const context = yield* readCaller(); + const cwd = yield* projectFolder(context, projectId); + const catalog = yield* InstructionCatalog.InstructionCatalog; + return yield* catalog.read({ cwd, id }).pipe(Effect.mapError(instructionFailure)); + }), + ), + t3_instructions_enable: McpToolAccess.writesEnvironment(({ agents }) => + Effect.gen(function* () { + const manager = yield* InstructionManager.InstructionManager; + return yield* manager + .enable({ id: GLOBAL_SHARED_ID, agents }) + .pipe(Effect.mapError(instructionFailure)); + }), + ), + t3_instructions_disable: McpToolAccess.writesEnvironment(({ agents }) => + Effect.gen(function* () { + const manager = yield* InstructionManager.InstructionManager; + return yield* manager + .disable({ id: GLOBAL_SHARED_ID, agents }) + .pipe(Effect.mapError(instructionFailure)); + }), + ), +}); diff --git a/apps/server/src/mcp/toolkits/instructions/tools.ts b/apps/server/src/mcp/toolkits/instructions/tools.ts new file mode 100644 index 000000000000..d0f33bd733c0 --- /dev/null +++ b/apps/server/src/mcp/toolkits/instructions/tools.ts @@ -0,0 +1,86 @@ +import { + InstructionAgentsResult, + InstructionListResult, + InstructionReadInput, + InstructionReadResult, + OrchestratorMcpFailure, + ProjectId, + ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/ai"; +import * as ProjectService from "../../../project/ProjectService.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as InstructionCatalog from "../../../instructions/InstructionCatalog.ts"; +import * as InstructionManager from "../../../instructions/InstructionManager.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const shared = { + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ProjectService.ProjectService, + ], +}; + +const projectId = Schema.optional(ProjectId).annotate({ + description: + "The project whose instruction files to use. Defaults to the calling thread's project; a client outside a T3 thread passes it for project files.", +}); +const agentNames = Schema.Array(ProviderInstanceId).check( + Schema.isMinLength(1), + Schema.isMaxLength(64), +); +const agentsDescription = + "agents are named by provider instance id or driver kind, as in the access entries t3_instructions_list returns."; +const resultNotes = + 'Each result says changed, unchanged or failed, with a reason when it failed. Only the Global instructions (id "global:shared") can be turned on or off. An agent that has its own instructions in its home folder is not changed; the user moves those into the Global instructions in T3 Code\'s settings.'; + +const InstructionListTool = Tool.make("t3_instructions_list", { + ...shared, + description: + "List the instruction files (AGENTS.md, CLAUDE.md and the like) T3 Code can see, in a project and in the user's home folder, and which agents read each (access: direct = reads the file where it is, link = its own file links to it, import = Claude's CLAUDE.md imports it, setting = Claude reads it through its Project instructions setting, none = does not read it). Each file has an id; pass it to t3_instructions_get. Use t3_instructions_enable and t3_instructions_disable to change which agents read the Global instructions, the one file every project shares. Editing the files, moving or deleting them, and Claude's Project instructions setting are not available to agents; edit the files directly.", + parameters: Schema.Struct({ projectId }), + success: InstructionListResult, + dependencies: [...shared.dependencies, InstructionCatalog.InstructionCatalog], +}) + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); + +const InstructionGetTool = Tool.make("t3_instructions_get", { + ...shared, + description: + "Read one instruction file's whole text. Name it by the id t3_instructions_list returned. contents is null when the file does not exist or is too large.", + parameters: Schema.Struct({ projectId, id: InstructionReadInput.fields.id }), + success: InstructionReadResult, + dependencies: [...shared.dependencies, InstructionCatalog.InstructionCatalog], +}) + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); + +const InstructionEnableTool = Tool.make("t3_instructions_enable", { + ...shared, + description: `Let agents read the Global instructions: a link from the agent's own home file to it, or for Claude an import line in its CLAUDE.md. Nothing else is changed or deleted. agents is "all" for every enabled agent, or a list; ${agentsDescription} ${resultNotes} Requires a live full-access/default calling thread or a full-access client.`, + parameters: Schema.Struct({ + agents: Schema.Union([Schema.Literal("all"), agentNames]), + }), + success: InstructionAgentsResult, + dependencies: [...shared.dependencies, InstructionManager.InstructionManager], +}).annotate(Tool.Destructive, false); + +const InstructionDisableTool = Tool.make("t3_instructions_disable", { + ...shared, + description: `Stop agents reading the Global instructions by removing the agent's link, or Claude's import line. The Global file itself is never deleted, and an agent that reads it where it is stays on. Turn it back on with t3_instructions_enable. ${agentsDescription} ${resultNotes} Requires a live full-access/default calling thread or a full-access client.`, + parameters: Schema.Struct({ agents: agentNames }), + success: InstructionAgentsResult, + dependencies: [...shared.dependencies, InstructionManager.InstructionManager], +}).annotate(Tool.Destructive, false); + +export const InstructionsToolkit = Toolkit.make( + InstructionListTool, + InstructionGetTool, + InstructionEnableTool, + InstructionDisableTool, +); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 870730f279e5..74ff83914e71 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -21,6 +21,8 @@ import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../../../scheduledTasks/ScheduledTaskService.ts"; import * as SecretRequests from "../../../secrets/SecretRequests.ts"; import * as ServerSettings from "../../../serverSettings.ts"; +import * as InstructionCatalog from "../../../instructions/InstructionCatalog.ts"; +import * as InstructionManager from "../../../instructions/InstructionManager.ts"; import * as SkillCatalog from "../../../skills/SkillCatalog.ts"; import * as SkillManager from "../../../skills/SkillManager.ts"; import * as VcsStatusBroadcaster from "../../../vcs/VcsStatusBroadcaster.ts"; @@ -59,6 +61,8 @@ const layerStubServices = Layer.mergeAll( Layer.mock(ThreadSearch.ThreadSearch)({}), Layer.mock(SkillCatalog.SkillCatalog)({}), Layer.mock(SkillManager.SkillManager)({}), + Layer.mock(InstructionCatalog.InstructionCatalog)({}), + Layer.mock(InstructionManager.InstructionManager)({}), ); const ToolsListPayload = Schema.fromJsonString( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 48749aafb40a..2760acfde1d9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -58,6 +58,7 @@ import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts"; import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts"; import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts"; +import { InstructionsToolkit } from "../../mcp/toolkits/instructions/tools.ts"; import { SkillsToolkit } from "../../mcp/toolkits/skills/tools.ts"; import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; @@ -644,6 +645,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { ...Object.values(WorktreeToolkit.tools), ...Object.values(ProjectToolkit.tools), ...Object.values(SkillsToolkit.tools), + ...Object.values(InstructionsToolkit.tools), ...Object.values(EnvironmentToolkit.tools), ...Object.values(PreviewControlsToolkit.tools), ...Object.values(HtmlToolkit.tools), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index e63e7c301fc5..34e5d8d984dc 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -949,6 +949,8 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__t3_environment_read", "mcp__t3-code__t3_skill_list", "mcp__t3-code__t3_skill_get", + "mcp__t3-code__t3_instructions_list", + "mcp__t3-code__t3_instructions_get", "mcp__t3-code__t3_queue_list", "mcp__t3-code__t3_queue_read", "mcp__t3-code__html_preview", diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index 59f1e751542d..e5317da428d0 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -335,6 +335,18 @@ export function summarizeT3ToolCalls( case "skill-disable": label = phrase("Disabled", "disable", `skills for agents ${times}`); break; + case "instruction-list": + label = phrase("Listed", "list", `instruction files ${times}`); + break; + case "instruction-read": + label = phrase("Read", "read", quantity(selected.length, "instruction file")); + break; + case "instruction-enable": + label = phrase("Enabled", "enable", `instruction files for agents ${times}`); + break; + case "instruction-disable": + label = phrase("Disabled", "disable", `instruction files for agents ${times}`); + break; case "environment-read": label = phrase("Checked", "check", `environment preferences ${times}`); break; diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index d802c35d0e3e..2aabc429d9ba 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -631,6 +631,8 @@ function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction) case "environment-update": case "skill-enable": case "skill-disable": + case "instruction-enable": + case "instruction-disable": case "attachment-prepare": case "attachment-discard": case "attachment-send": diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 1b5a2dee599f..e32c725f3a88 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -52,6 +52,10 @@ export type T3McpToolSummaryAction = | "skill-read" | "skill-enable" | "skill-disable" + | "instruction-list" + | "instruction-read" + | "instruction-enable" + | "instruction-disable" | "environment-read" | "environment-update" | "attachment-prepare" @@ -315,6 +319,19 @@ const T3_MCP_TOOLS: Readonly> = { ["Disable", "Disabling", "Disabled", "skills for agents"], "skill-disable", ), + t3_instructions_list: tool( + ["List", "Listing", "Listed", "instruction files"], + "instruction-list", + ), + t3_instructions_get: tool(["Read", "Reading", "Read", "an instruction file"], "instruction-read"), + t3_instructions_enable: tool( + ["Enable", "Enabling", "Enabled", "instruction files for agents"], + "instruction-enable", + ), + t3_instructions_disable: tool( + ["Disable", "Disabling", "Disabled", "instruction files for agents"], + "instruction-disable", + ), t3_attachment_prepare_upload: tool( ["Prepare", "Preparing", "Prepared", "an attachment upload"], "attachment-prepare", From f8ae0ec10416647d035632c67200c1e86e58c971 Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:02:53 -0400 Subject: [PATCH 050/108] feat(client-runtime): add commands for the agent instruction RPCs Declares the ten instruction commands (list, read, write, enable, disable, set the Claude setting, share, adopt, delete, tracked) the same way the skills commands are, so the web client can call them. Co-Authored-By: Claude Sonnet 5.5 --- packages/client-runtime/src/state/server.ts | 40 +++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index a3b48434d69b..f70343fbbf23 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1198,6 +1198,46 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:skills-tracked", tag: WS_METHODS.serverSkillsTracked, }), + listInstructions: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:list-instructions", + tag: WS_METHODS.serverListInstructions, + }), + readInstruction: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:read-instruction", + tag: WS_METHODS.serverReadInstruction, + }), + writeInstruction: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:write-instruction", + tag: WS_METHODS.serverWriteInstruction, + }), + enableInstruction: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:enable-instruction", + tag: WS_METHODS.serverEnableInstruction, + }), + disableInstruction: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:disable-instruction", + tag: WS_METHODS.serverDisableInstruction, + }), + setClaudeInstructionFiles: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:set-claude-instruction-files", + tag: WS_METHODS.serverSetClaudeInstructionFiles, + }), + shareInstruction: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:share-instruction", + tag: WS_METHODS.serverShareInstruction, + }), + adoptInstruction: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:adopt-instruction", + tag: WS_METHODS.serverAdoptInstruction, + }), + deleteInstruction: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:delete-instruction", + tag: WS_METHODS.serverDeleteInstruction, + }), + instructionsTracked: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:instructions-tracked", + tag: WS_METHODS.serverInstructionsTracked, + }), refreshProviders: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-providers", tag: WS_METHODS.serverRefreshProviders, From 3212287863eae842c1487df96930b380b9cfa32a Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:00:41 -0400 Subject: [PATCH 051/108] refactor(web): share the skill page's Escape handling, path copy, Markdown and agent chip The Instructions section needs the same pieces the skill page already has: Escape to go back, copying a path, the safe Markdown renderer, the agent chip with its switch, and the confirm dialog for any plan with a confirmation. They move out of the skill components unchanged, so skills behave as before. Co-Authored-By: Claude Sonnet 5.5 --- .../components/settings/SkillAgentSwitch.tsx | 58 ++++++++++++++----- .../src/components/settings/SkillBulkBar.tsx | 8 ++- .../src/components/settings/SkillDetail.tsx | 49 +--------------- .../components/settings/SkillDetailChrome.tsx | 47 +++++++++++++++ .../src/components/settings/SkillFiles.tsx | 25 +------- .../src/components/settings/SkillMarkdown.tsx | 24 ++++++++ .../settings/SkillsSettings.logic.ts | 25 ++++---- .../components/settings/skillAgentIcon.tsx | 12 +++- 8 files changed, 150 insertions(+), 98 deletions(-) create mode 100644 apps/web/src/components/settings/SkillDetailChrome.tsx create mode 100644 apps/web/src/components/settings/SkillMarkdown.tsx diff --git a/apps/web/src/components/settings/SkillAgentSwitch.tsx b/apps/web/src/components/settings/SkillAgentSwitch.tsx index 871d3d97e420..940216c83720 100644 --- a/apps/web/src/components/settings/SkillAgentSwitch.tsx +++ b/apps/web/src/components/settings/SkillAgentSwitch.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SkillAgentIcon } from "./skillAgentIcon"; @@ -10,34 +12,35 @@ import { } from "./SkillsSettings.logic"; /** - * One agent and its own switch for a skill. An agent T3 Code can't switch has the switch disabled, - * and says why when pointed at. + * One agent and its own switch. A switch that can't be flipped is disabled and says why when the + * chip is pointed at; `blocker` is that reason, or null when the switch works. */ -export function AgentSwitchChip({ - skill, +export function AgentChip({ agent, - ctx, - busy, + agents, + on, + blocker, + disabled, onToggle, }: { - skill: Skill; agent: SkillAgent; - ctx: SkillsContext; - /** A change is being made, so nothing else can start. */ - busy: boolean; + /** Every agent on the page, to tell instances of one driver apart. */ + agents: readonly SkillAgent[]; + on: boolean; + blocker: ReactNode; + /** Nothing can be switched now, such as while a change is being made. */ + disabled: boolean; onToggle: () => void; }) { - const on = hasAccess(skill, agent); - const blocker = switchBlocker(skill, agent); const chip = ( <> - + {agent.displayName} @@ -53,3 +56,30 @@ export function AgentSwitchChip({ {chip} ); } + +/** An agent's switch for one skill. An agent T3 Code can't switch for it says why. */ +export function AgentSwitchChip({ + skill, + agent, + ctx, + busy, + onToggle, +}: { + skill: Skill; + agent: SkillAgent; + ctx: SkillsContext; + /** A change is being made, so nothing else can start. */ + busy: boolean; + onToggle: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/settings/SkillBulkBar.tsx b/apps/web/src/components/settings/SkillBulkBar.tsx index 6211a8a0556b..f8821fbfcea6 100644 --- a/apps/web/src/components/settings/SkillBulkBar.tsx +++ b/apps/web/src/components/settings/SkillBulkBar.tsx @@ -16,6 +16,7 @@ import { planDelete, planTurnOffAll, planTurnOnAll, + type PlanConfirmation, type Skill, type SkillPlan, type SkillsContext, @@ -117,14 +118,17 @@ export function BulkBar({ ); } -/** Asks before a plan changes anything, with the same plain words for one skill or many. */ +/** + * Asks before a plan changes anything, with the same plain words for one skill or many. Any plan + * with a confirmation will do, so the Instructions section asks the same way. + */ export function ConfirmPlan({ plan, onCancel, onConfirm, }: { /** The plan to confirm; a plan without a confirmation never opens the dialog. */ - plan: SkillPlan | null; + plan: { readonly confirmation?: PlanConfirmation } | null; onCancel: () => void; onConfirm: () => void; }) { diff --git a/apps/web/src/components/settings/SkillDetail.tsx b/apps/web/src/components/settings/SkillDetail.tsx index 2e9c9f98fe35..5e6d8f4a5165 100644 --- a/apps/web/src/components/settings/SkillDetail.tsx +++ b/apps/web/src/components/settings/SkillDetail.tsx @@ -1,16 +1,15 @@ import type { EnvironmentId, SkillGetResult } from "@t3tools/contracts"; import { AlertTriangleIcon, ArrowLeftIcon, MoreHorizontalIcon } from "lucide-react"; -import { lazy, Suspense, useEffect, useEffectEvent, useMemo, useState } from "react"; +import { lazy, Suspense, useEffect, useMemo, useState } from "react"; -import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; import { useAfterDelay } from "../../hooks/useAfterDelay"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; import { Skeleton } from "../ui/skeleton"; -import { toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { copyPath, useEscapeToList } from "./SkillDetailChrome"; import { AgentSwitchChip } from "./SkillAgentSwitch"; import { UseInPopover, type PlaceOptions } from "./SkillUseIn"; import { @@ -48,33 +47,6 @@ function BackBar({ scope, onBack }: { scope: string; onBack: () => void }) { ); } -/** - * Escape goes from a skill back to the list. Settings leaves the page on Escape from its own - * window listener, so this one runs first, in the capture phase, and keeps Escape from reaching - * it. Escape inside a field, dialog or menu belongs to that control. - */ -function useEscapeToList(onBack: () => void) { - const goBack = useEffectEvent((event: KeyboardEvent) => { - if (event.key !== "Escape" || event.defaultPrevented || event.repeat || event.isComposing) - return; - if ( - event.target instanceof Element && - event.target.closest( - 'input,textarea,select,[contenteditable],[role="dialog"],[role="alertdialog"],[role="menu"]', - ) - ) - return; - event.preventDefault(); - event.stopImmediatePropagation(); - onBack(); - }); - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => goBack(event); - window.addEventListener("keydown", onKeyDown, { capture: true }); - return () => window.removeEventListener("keydown", onKeyDown, { capture: true }); - }, []); -} - export function SkillDetail({ skill, ctx, @@ -137,21 +109,6 @@ export function SkillDetail({ const del = planDelete([skill], ctx); const own = useMemo(() => [skill], [skill]); - const copyPath = (path: string) => { - void writeTextToClipboard(path, "skill path").then( - (didCopy) => { - if (didCopy) toastManager.add({ type: "success", title: "Path copied", description: path }); - }, - (error: unknown) => { - toastManager.add({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }, - ); - }; - return (
    @@ -205,7 +162,7 @@ export function SkillDetail({ {skillFolder && ( - copyPath(skillFolder)}>Copy path + copyPath(skillFolder, "skill path")}>Copy path )} {turnOnAll && ( onPlan(turnOnAll)}> diff --git a/apps/web/src/components/settings/SkillDetailChrome.tsx b/apps/web/src/components/settings/SkillDetailChrome.tsx new file mode 100644 index 000000000000..82b39c1b8f1a --- /dev/null +++ b/apps/web/src/components/settings/SkillDetailChrome.tsx @@ -0,0 +1,47 @@ +import { useEffect, useEffectEvent } from "react"; + +import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; +import { toastManager } from "../ui/toast"; + +/** + * Escape goes from an open skill or instruction file back to the list. Settings leaves the page + * on Escape from its own window listener, so this one runs first, in the capture phase, and keeps + * Escape from reaching it. Escape inside a field, dialog or menu belongs to that control. + */ +export function useEscapeToList(onBack: () => void) { + const goBack = useEffectEvent((event: KeyboardEvent) => { + if (event.key !== "Escape" || event.defaultPrevented || event.repeat || event.isComposing) + return; + if ( + event.target instanceof Element && + event.target.closest( + 'input,textarea,select,[contenteditable],[role="dialog"],[role="alertdialog"],[role="menu"]', + ) + ) + return; + event.preventDefault(); + event.stopImmediatePropagation(); + onBack(); + }); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => goBack(event); + window.addEventListener("keydown", onKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", onKeyDown, { capture: true }); + }, []); +} + +/** Copies a path to the clipboard and says so in a toast. */ +export function copyPath(path: string, what: string) { + void writeTextToClipboard(path, what).then( + (didCopy) => { + if (didCopy) toastManager.add({ type: "success", title: "Path copied", description: path }); + }, + (error: unknown) => { + toastManager.add({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + ); +} diff --git a/apps/web/src/components/settings/SkillFiles.tsx b/apps/web/src/components/settings/SkillFiles.tsx index 01749f811e49..3fa4bd152ac5 100644 --- a/apps/web/src/components/settings/SkillFiles.tsx +++ b/apps/web/src/components/settings/SkillFiles.tsx @@ -2,7 +2,6 @@ import { FileTree, useFileTree } from "@pierre/trees/react"; import type { EnvironmentId, SkillFile } from "@t3tools/contracts"; import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; import { useMemo, useState } from "react"; -import ReactMarkdown, { type Components } from "react-markdown"; import { useTheme } from "../../hooks/useTheme"; import { T3_PIERRE_ICONS } from "../../pierre-icons"; @@ -10,6 +9,7 @@ import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "../../pierre-tree-theme import { Button } from "../ui/button"; import { useProjectFileQuery } from "../files/projectFilesQueryState"; import ReadOnlySourcePreview from "../files/ReadOnlySourcePreview"; +import { SkillMarkdown } from "./SkillMarkdown"; import { compareSkillFiles, scriptFiles, skillBody } from "./SkillsSettings.logic"; const SKILL_FILE = "SKILL.md"; @@ -130,9 +130,7 @@ function SkillTextPane({ skillText }: { skillText: string | null }) { ) : (
    - - {skillBody(skillText)} - +
    )}
    @@ -185,22 +183,3 @@ function OtherFilePane({
    ); } - -/** - * The body without its header. Code wraps at phone width. Links and images stay plain text, so - * reading a skill never opens a page or fetches anything. - */ -const SKILL_MARKDOWN_COMPONENTS = { - pre: ({ children }) => ( -
    {children}
    - ), - code: ({ children }) => {children}, - h1: ({ children }) =>

    {children}

    , - h2: ({ children }) =>

    {children}

    , - h3: ({ children }) =>

    {children}

    , - p: ({ children }) =>

    {children}

    , - ul: ({ children }) =>
      {children}
    , - ol: ({ children }) =>
      {children}
    , - a: ({ children }) => {children}, - img: ({ alt }) => {alt}, -} satisfies Components; diff --git a/apps/web/src/components/settings/SkillMarkdown.tsx b/apps/web/src/components/settings/SkillMarkdown.tsx new file mode 100644 index 000000000000..43c8b5b1675b --- /dev/null +++ b/apps/web/src/components/settings/SkillMarkdown.tsx @@ -0,0 +1,24 @@ +import ReactMarkdown, { type Components } from "react-markdown"; + +/** + * Markdown the way settings pages read it. Code wraps at phone width. Links and images stay plain + * text, so reading a skill or an instruction file never opens a page or fetches anything. + */ +const SETTINGS_MARKDOWN_COMPONENTS = { + pre: ({ children }) => ( +
    {children}
    + ), + code: ({ children }) => {children}, + h1: ({ children }) =>

    {children}

    , + h2: ({ children }) =>

    {children}

    , + h3: ({ children }) =>

    {children}

    , + p: ({ children }) =>

    {children}

    , + ul: ({ children }) =>
      {children}
    , + ol: ({ children }) =>
      {children}
    , + a: ({ children }) => {children}, + img: ({ alt }) => {alt}, +} satisfies Components; + +export function SkillMarkdown({ text }: { text: string }) { + return {text}; +} diff --git a/apps/web/src/components/settings/SkillsSettings.logic.ts b/apps/web/src/components/settings/SkillsSettings.logic.ts index 2b28ee4f9fc8..0e26a205f415 100644 --- a/apps/web/src/components/settings/SkillsSettings.logic.ts +++ b/apps/web/src/components/settings/SkillsSettings.logic.ts @@ -20,7 +20,7 @@ export type SkillAgent = Pick< "instanceId" | "driverKind" | "displayName" | "accentColor" >; -const joinNames = (names: readonly string[]) => +export const joinNames = (names: readonly string[]) => names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; @@ -199,20 +199,23 @@ export type SkillChange = } | { readonly kind: "delete"; readonly skills: readonly SkillRef[] }; +/** What the confirm dialog says before a change is made, in plain words. */ +export type PlanConfirmation = { + readonly title: string; + /** May be empty when the title says it all. */ + readonly body: string; + /** Lines under the body, such as what stays on and why. */ + readonly notes: readonly string[]; + readonly confirm: string; + readonly destructive: boolean; +}; + export type SkillPlan = { readonly change: SkillChange; /** How many skills it changes. */ readonly affected: number; - /** Present when the change should be confirmed first, in plain words. */ - readonly confirmation?: { - readonly title: string; - /** May be empty when the title says it all. */ - readonly body: string; - /** Lines under the body, such as what stays on and why. */ - readonly notes: readonly string[]; - readonly confirm: string; - readonly destructive: boolean; - }; + /** Present when the change should be confirmed first. */ + readonly confirmation?: PlanConfirmation; }; const skillRef = (skill: Skill): SkillRef => ({ diff --git a/apps/web/src/components/settings/skillAgentIcon.tsx b/apps/web/src/components/settings/skillAgentIcon.tsx index 28a6539c1b04..8a45c1945c2c 100644 --- a/apps/web/src/components/settings/skillAgentIcon.tsx +++ b/apps/web/src/components/settings/skillAgentIcon.tsx @@ -56,8 +56,16 @@ function IconRow({ label, children }: { label: string; children: ReactNode }) { * the agents that do, and nothing when none does. Agents that aren't installed and enabled never * show. */ -export function SkillAgents({ value, ctx }: { value: Availability; ctx: SkillsContext }) { - const label = availabilityNote(value); +export function SkillAgents({ + value, + ctx, + label = availabilityNote(value), +}: { + value: Availability; + ctx: SkillsContext; + /** What the tooltip says; skills and instructions word it differently. */ + label?: string; +}) { if (value.everyone) return ( From 99d81940b06722f3f5f804c69dc5a4d7e965ebff Mon Sep 17 00:00:00 2001 From: n0mahd <39080654+n0mahd@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:01:10 -0400 Subject: [PATCH 052/108] feat(web): see and edit agent instructions on the Skills page An Instructions section at the top of the Skills page lists the files agents read: This project, Just you (CLAUDE.local.md), files in subfolders, Global, each agent's own, and the organization's. Each row shows the agents that use it and, when something is off, says so with a one-click fix (Turn on for Claude, Use Global instead, Share with all agents). Global opens in place into one switch per agent. Claude reads AGENTS.md has its own choice. A file opens in an editor that saves as you type, previews Markdown, and asks before overwriting a file that changed on disk. Co-Authored-By: Claude Sonnet 5.5 --- .../components/settings/InstructionDetail.tsx | 310 ++++++ .../components/settings/InstructionEditor.tsx | 239 +++++ .../components/settings/InstructionList.tsx | 404 +++++++ .../InstructionsSettings.logic.test.ts | 991 ++++++++++++++++++ .../settings/InstructionsSettings.logic.ts | 946 +++++++++++++++++ .../components/settings/SkillsSettings.tsx | 110 +- .../settings/settingsSearch.test.ts | 4 + .../src/components/settings/settingsSearch.ts | 2 +- .../components/settings/useInstructions.ts | 254 +++++ 9 files changed, 3249 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/settings/InstructionDetail.tsx create mode 100644 apps/web/src/components/settings/InstructionEditor.tsx create mode 100644 apps/web/src/components/settings/InstructionList.tsx create mode 100644 apps/web/src/components/settings/InstructionsSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/InstructionsSettings.logic.ts create mode 100644 apps/web/src/components/settings/useInstructions.ts diff --git a/apps/web/src/components/settings/InstructionDetail.tsx b/apps/web/src/components/settings/InstructionDetail.tsx new file mode 100644 index 000000000000..bcc70fdd214b --- /dev/null +++ b/apps/web/src/components/settings/InstructionDetail.tsx @@ -0,0 +1,310 @@ +import type { EditorId, EnvironmentId, InstructionReadResult } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { ChevronLeftIcon, MoreHorizontalIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { useAfterDelay } from "../../hooks/useAfterDelay"; +import { openInEditorMenuLabel } from "../../editorLabels"; +import { usePreferredEditor, useOpenInPreferredEditor } from "../../editorPreferences"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu"; +import { Skeleton } from "../ui/skeleton"; +import { toastManager } from "../ui/toast"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { InstructionEditor, type InstructionText } from "./InstructionEditor"; +import { InstructionAgentChip } from "./InstructionList"; +import { + entryFileName, + instructionActions, + instructionChips, + type InstructionData, + type InstructionPlan, + type InstructionRow, +} from "./InstructionsSettings.logic"; +import { copyPath, useEscapeToList } from "./SkillDetailChrome"; +import type { SkillsContext } from "./SkillsSettings.logic"; + +/** How long the file area waits before showing placeholders for a quick read. */ +const SKELETON_DELAY_MS = 150; + +type LoadState = + | { status: "loading" } + | { status: "ready"; text: InstructionText; tooLarge: boolean; resave: boolean } + | { status: "error" }; + +const toLoadState = (value: InstructionReadResult | null): LoadState => + value + ? { + status: "ready", + text: { contents: value.contents ?? "", revision: value.revision }, + tooLarge: value.tooLarge, + resave: false, + } + : { status: "error" }; + +export function InstructionDetail({ + row, + ctx, + data, + environmentId, + projectRoot, + availableEditors, + busy, + locked, + onBack, + onPlan, + onSaved, +}: { + row: InstructionRow; + ctx: SkillsContext; + data: InstructionData; + environmentId: EnvironmentId; + projectRoot: string | null; + availableEditors: readonly EditorId[]; + /** A change is being made, so nothing else can start. */ + busy: boolean; + /** The session can't change instructions. */ + locked: boolean; + onBack: () => void; + /** Turns agents on or off, shares, moves or deletes; a plan with a confirmation asks first. */ + onPlan: (plan: InstructionPlan) => void; + /** A save went through, so the list can read the files again. */ + onSaved: () => void; +}) { + useEscapeToList(onBack); + const readInstruction = useAtomCommand(serverEnvironment.readInstruction, { + reportFailure: false, + }); + const [load, setLoad] = useState({ status: "loading" }); + const { entry } = row; + const id = entry.id; + const fileName = entryFileName(entry); + const canEdit = !entry.readOnly && !locked; + const [mode, setMode] = useState<"edit" | "preview">("edit"); + + const fetchText = useCallback(async () => { + const result = await readInstruction({ + environmentId, + input: { id, ...(projectRoot ? { cwd: projectRoot } : {}) }, + }); + return result._tag === "Success" ? result.value : null; + }, [readInstruction, environmentId, id, projectRoot]); + + useEffect(() => { + let cancelled = false; + void fetchText().then((value) => { + if (!cancelled) setLoad(toLoadState(value)); + }); + return () => { + cancelled = true; + }; + }, [fetchText]); + + // Bumped when the editor opens again from the file or from the person's text. + const [editorVersion, setEditorVersion] = useState(0); + const resolveConflict = useCallback( + async (choice: "reload" | "keep", mine: string) => { + const value = await fetchText(); + if (!value || value.tooLarge) return false; + const fresh = { contents: value.contents ?? "", revision: value.revision }; + setLoad({ + status: "ready", + // Keeping the person's text builds on the file as it is now, so the save is accepted. + text: choice === "keep" ? { contents: mine, revision: fresh.revision } : fresh, + tooLarge: false, + resave: choice === "keep", + }); + setEditorVersion((count) => count + 1); + return true; + }, + [fetchText], + ); + + const showSkeleton = useAfterDelay(load.status === "loading", SKELETON_DELAY_MS); + const chips = useMemo(() => instructionChips(entry, ctx, data), [entry, ctx, data]); + const actions = useMemo(() => instructionActions(row, ctx, data), [row, ctx, data]); + const disabled = busy || locked; + + const [preferredEditor] = usePreferredEditor(availableEditors); + const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); + const openInEditor = () => { + void (async () => { + const result = await openInPreferredEditor(entry.path); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "An error occurred.", + }); + })(); + }; + const canOpen = entry.exists && availableEditors.length > 0; + + return ( +
    +
    + +
    +

    + + }> + {row.heading} + + + {entry.path} + + +

    + {row.headingNote !== "" && ( +

    {row.headingNote}

    + )} +
    +
    + {canEdit && ( + { + const picked = next[0]; + if (picked === "edit" || picked === "preview") setMode(picked); + }} + > + Edit + Preview + + )} + + } + > + + + + {canOpen && ( + {openInEditorMenuLabel(preferredEditor)} + )} + copyPath(entry.path, "instruction path")}> + Copy path + + {actions.turnOnAll && ( + onPlan(actions.turnOnAll!)}> + Turn on for all agents + + )} + {actions.share && ( + onPlan(actions.share!)}> + Share with all agents… + + )} + {actions.useGlobal && ( + onPlan(actions.useGlobal!)}> + Use Global instead… + + )} + {(actions.removeFromAgents || actions.remove) && } + {actions.removeFromAgents && ( + onPlan(actions.removeFromAgents!)} + > + Remove from agents… + + )} + {actions.remove && ( + onPlan(actions.remove!)} + > + Delete… + + )} + + +
    +
    + + {entry.exists && chips.length > 0 && ( +
    + Used by + {chips.map((chip) => ( + + ))} +
    + )} + + {load.status === "loading" && ( +
    + {showSkeleton ? ( + <> + + + + + ) : ( + + )} +
    + )} + {load.status === "error" && ( +
    +

    The file couldn't be read.

    + +
    + )} + {load.status === "ready" && load.tooLarge && ( +

    + This file is too large to show here. +

    + )} + {load.status === "ready" && !load.tooLarge && ( + + )} +
    + ); +} diff --git a/apps/web/src/components/settings/InstructionEditor.tsx b/apps/web/src/components/settings/InstructionEditor.tsx new file mode 100644 index 000000000000..87b61b41e738 --- /dev/null +++ b/apps/web/src/components/settings/InstructionEditor.tsx @@ -0,0 +1,239 @@ +import { INSTRUCTION_MAX_CHARS, type EnvironmentId } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; + +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; +import { Button } from "../ui/button"; +import { FileSaveCoordinator } from "../files/fileSaveCoordinator"; +import { instructionErrorReason, isSaveConflict } from "./InstructionsSettings.logic"; +import { SkillMarkdown } from "./SkillMarkdown"; + +/** The same wait the files panel gives an edit before it saves. */ +const AUTOSAVE_DEBOUNCE_MS = 500; +/** The editor and the preview share one height, so the page doesn't jump between them. */ +const PANE_HEIGHT = "h-[26rem]"; + +type SaveStatus = "idle" | "saving" | "saved" | "failed" | "conflict"; + +export type InstructionText = { + readonly contents: string; + /** Null when the file isn't there yet. */ + readonly revision: string | null; +}; + +/** + * Saves edits as they are made. Each save says which version of the file it was made from, so a + * file that changed on disk is never overwritten: the save is refused, saving stops, and the + * person picks which version to keep. + */ +function useInstructionAutosave({ + environmentId, + cwd, + id, + initial, + resave, + onSaved, +}: { + environmentId: EnvironmentId; + cwd: string | null; + id: string; + initial: InstructionText; + /** The text should be saved as soon as the editor opens. */ + resave: boolean; + /** A save went through; the list can read the file again. */ + onSaved: () => void; +}) { + const write = useAtomCommand(serverEnvironment.writeInstruction, { reportFailure: false }); + const [status, setStatus] = useState("idle"); + // What the editor opened with. Later saves build on the version the last one made. + const start = useRef({ ...initial, resave }); + const revision = useRef(initial.revision); + const latest = useRef(initial.contents); + const coordinator = useRef(null); + const notifySaved = useEffectEvent(onSaved); + + useEffect(() => { + // Set once the file turns out to have changed: nothing more is sent until a person decides. + let refused: Awaited> | null = null; + const current = new FileSaveCoordinator({ + debounceMs: AUTOSAVE_DEBOUNCE_MS, + onPendingChange: (pending) => { + if (!refused) setStatus(pending ? "saving" : "saved"); + }, + onConfirmed: () => undefined, + persist: async (contents) => { + if (refused) return refused; + const result = await write({ + environmentId, + input: { + ...(cwd ? { cwd } : {}), + id, + contents, + expectedRevision: revision.current, + }, + }); + if (result._tag === "Success") { + revision.current = result.value.revision; + notifySaved(); + } else if (isSaveConflict(instructionErrorReason(squashAtomCommandFailure(result)))) { + refused = result; + setStatus("conflict"); + } else if (!isAtomCommandInterrupted(result)) { + setStatus("failed"); + } + return result; + }, + }); + coordinator.current = current; + if (start.current.resave) { + start.current.resave = false; + current.change(start.current.contents); + } + return () => { + coordinator.current = null; + // Whatever is still waiting goes out now, unless the file changed under it. + current.dispose(); + }; + }, [write, environmentId, cwd, id]); + + return { + status, + change: (contents: string) => { + latest.current = contents; + coordinator.current?.change(contents); + }, + retry: () => coordinator.current?.change(latest.current), + }; +} + +/** + * The file's text, edited in place and saved as it changes, or just read. The Edit / Preview + * choice lives in the page's header, so `mode` comes from there. + */ +export function InstructionEditor({ + environmentId, + cwd, + id, + fileName, + initial, + canEdit, + mode, + creating, + resave, + onSaved, + onResolve, +}: { + environmentId: EnvironmentId; + cwd: string | null; + id: string; + fileName: string; + initial: InstructionText; + /** Reading is all there is: a managed file, or a session that can't change things. */ + canEdit: boolean; + mode: "edit" | "preview"; + /** The file doesn't exist yet; the first edit creates it. */ + creating: boolean; + /** The text came from a choice to keep it over the file, so it is saved at once. */ + resave: boolean; + onSaved: () => void; + /** + * The file changed on disk and the person chose: the file as it is now (`reload`), or this text + * over it (`keep`). The editor opens again from the result; false when the file couldn't be read. + */ + onResolve: (choice: "reload" | "keep", mine: string) => Promise; +}) { + const [text, setText] = useState(initial.contents); + const autosave = useInstructionAutosave({ environmentId, cwd, id, initial, resave, onSaved }); + const [resolving, setResolving] = useState(false); + const [resolveFailed, setResolveFailed] = useState(false); + const resolve = async (choice: "reload" | "keep") => { + setResolving(true); + setResolveFailed(false); + // Success opens a fresh editor in this one's place. + if (!(await onResolve(choice, text))) { + setResolving(false); + setResolveFailed(true); + } + }; + const editing = canEdit && mode === "edit"; + + return ( +
    + {autosave.status === "conflict" && ( + + Changed outside T3 Code + {resolveFailed && Couldn't read the file. Try again.} + + + + + + )} + +
    + {editing ? ( + // An editor surface, not a form field: it needs a fixed, taller pane and a monospace + // face, which the Textarea control doesn't offer. +