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 511fd77cd147..3bfa02988260 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -60,6 +60,10 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: 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, 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..dbbc9c4dae4e 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" }; @@ -235,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, @@ -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..88984b1eeffa 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"; @@ -576,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)), diff --git a/apps/server/src/skills/SkillCatalog.test.ts b/apps/server/src/skills/SkillCatalog.test.ts new file mode 100644 index 000000000000..49c39bd15819 --- /dev/null +++ b/apps/server/src/skills/SkillCatalog.test.ts @@ -0,0 +1,961 @@ +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"; +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"; + +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 }, + ]), +); + +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* () { + 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), + ); + +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)( + "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", + () => + 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("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", + () => + 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..97cc27b3d198 --- /dev/null +++ b/apps/server/src/skills/SkillCatalog.ts @@ -0,0 +1,680 @@ +/** + * 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, + SkillRequestError, +} 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, + readSkillOverrides, + 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"; + +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[]; + /** + * 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. */ +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. A `cwd` that isn't a registered project's workspace root is refused. + */ + 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; + 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) { + 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; + }; + + /** + * 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) => { + 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 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); + 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, + switchedOff: + table.agent === "claudeAgent" + ? yield* claudeSwitchedOff(config, configHome, cwd) + : new Set(), + }); + } + } + 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 = yield* requireProject(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 = + 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) { + 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 = 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. + 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/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..b4dae3b8f911 --- /dev/null +++ b/apps/web/src/components/settings/SkillsSettings.logic.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from "vite-plus/test"; +import { EnvironmentId, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import type { ServerProvider, SkillAgentAccess, SkillListResult } from "@t3tools/contracts"; + +import { + agentSkillPath, + attention, + availability, + availabilityNote, + compareSkillFiles, + ingestSkills, + installedAgents, + matchesQuery, + scriptFiles, + skillBody, + skillsEnvironment, + 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("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) => + 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..a8bb30947c82 --- /dev/null +++ b/apps/web/src/components/settings/SkillsSettings.logic.ts @@ -0,0 +1,230 @@ +import type { + EnvironmentId, + 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[]; +}; + +/** + * 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, + 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..31b27ab1e2c1 --- /dev/null +++ b/apps/web/src/components/settings/SkillsSettings.tsx @@ -0,0 +1,360 @@ +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, + skillsEnvironment, + 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 = 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" + ? 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 ? ( +

    + {scope.environmentIds.length > 0 + ? "This environment isn't available." + : "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); + 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); + 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 (!mounted.current) return; + if (!loaded) { + setLoadError(LOAD_ERROR); + return; + } + setData(loaded); + show({ kind: "list" }); + }) + .catch(() => { + if (mounted.current) setLoadError(LOAD_ERROR); + }) + .finally(() => { + if (mounted.current) 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/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..6df379a48f2e --- /dev/null +++ b/docs/user/skills.md @@ -0,0 +1,44 @@ +# 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. 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 + `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. 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, 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..82be36f4a8d5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -323,6 +323,13 @@ import { ServerSettingsError, ServerSettingsPatch, } from "./settings.ts"; +import { + SkillGetInput, + SkillGetResult, + SkillListInput, + SkillListResult, + SkillRequestError, +} from "./skills.ts"; import { ScheduledTaskDeleteInput, ScheduledTaskDeleteResult, @@ -467,6 +474,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 +607,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: Schema.Union([SkillRequestError, EnvironmentAuthorizationError]), +}); + +const WsServerGetSkillRpc = Rpc.make(WS_METHODS.serverGetSkill, { + payload: SkillGetInput, + success: SkillGetResult, + error: Schema.Union([SkillRequestError, EnvironmentAuthorizationError]), +}); + const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** @@ -1831,6 +1852,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..60325955b44c --- /dev/null +++ b/packages/contracts/src/skills.ts @@ -0,0 +1,118 @@ +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, 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; + +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; + +/** + * 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."; + } +} 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 = {