From 6396c81c4e132ba6f8be6eb1557e5e00fb533a3d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 18:55:11 -0700 Subject: [PATCH 01/26] feat(server): install skills from a source with the bundled skills CLI Adds a hidden `t3 skills-cli` subcommand that runs the pinned skills CLI (vercel-labs/skills 1.7.1, telemetry off) and a SkillLibrary service with preview, install, update, remove and inspect, exposed as skills.* RPCs. Installs land where `npx skills` puts them, with links for agents that read only their own folder, chosen from a per-agent folder table. Co-Authored-By: Claude Opus 5.5 --- apps/server/package.json | 1 + apps/server/src/auth/RpcAuthorization.ts | 4 + apps/server/src/bin.ts | 4 + apps/server/src/binCli.ts | 2 + apps/server/src/cli/skillsCli.ts | 24 + .../src/observability/RpcInstrumentation.ts | 5 + apps/server/src/server.ts | 2 + apps/server/src/skills/SkillLibrary.test.ts | 229 +++++++++ apps/server/src/skills/SkillLibrary.ts | 444 ++++++++++++++++++ apps/server/src/skills/SkillsCli.ts | 139 ++++++ apps/server/src/ws.ts | 7 + .../contracts/src/clientRpcPermissions.ts | 7 + packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 50 ++ packages/contracts/src/skills.ts | 133 ++++++ packages/provider-core/package.json | 4 + .../src/server/AgentSkillFolders.ts | 93 ++++ pnpm-lock.yaml | 13 + 18 files changed, 1162 insertions(+) create mode 100644 apps/server/src/cli/skillsCli.ts create mode 100644 apps/server/src/skills/SkillLibrary.test.ts create mode 100644 apps/server/src/skills/SkillLibrary.ts create mode 100644 apps/server/src/skills/SkillsCli.ts create mode 100644 packages/contracts/src/skills.ts create mode 100644 packages/provider-core/src/server/AgentSkillFolders.ts diff --git a/apps/server/package.json b/apps/server/package.json index bb166039abd9..b3dbc1f9a157 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -51,6 +51,7 @@ "node-pty": "^1.2.0-beta.15", "playwright-core": "1.60.0", "proper-lockfile": "4.1.2", + "skills": "1.7.1", "stream-chain": "^4.2.5", "stream-json": "3.6.0", "yaml": "catalog:", diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 0f752c94f360..9d32a456c8d4 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -142,6 +142,10 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, + // The server lists skill folders itself; a client never names a path. + [WS_METHODS.skillsInspect]: AuthFilesystemReadScope, + // A preview unpacks a source into a temp folder and deletes it. + [WS_METHODS.skillsPreview]: AuthFilesystemReadScope, [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope, [WS_METHODS.projectsReadFile]: AuthFilesystemReadScope, [WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope, diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 063093874503..cf53340ad863 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -20,6 +20,10 @@ if ( if (command === "acp-mcp-bridge" || command === "acp-mcp-call") { const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts"); await runAcpMcpCliFastPath(command, process.argv.slice(3)); + } else if (command === "skills-cli") { + // Option parsing belongs to the skills CLI, so it skips the T3 CLI entirely. + const { runSkillsCli } = await import("./cli/skillsCli.ts"); + await runSkillsCli(process.argv.slice(3)); } else { const { runCli } = await import("./binCli.ts"); runCli(); diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index 76035ded632b..3cacc7861b3d 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -8,6 +8,7 @@ import * as CliError from "effect/cli/CliError"; import * as NetService from "@t3tools/shared/Net"; import packageJson from "../package.json" with { type: "json" }; import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts"; +import { skillsCliCommand } from "./cli/skillsCli.ts"; import { authCommand } from "./cli/auth.ts"; import { appCommand } from "./cli/app.ts"; import { browserCommand } from "./cli/browser.ts"; @@ -67,6 +68,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => ), acpMcpBridgeCommand, acpMcpCallCommand, + skillsCliCommand, startCommand, serveCommand, appCommand, diff --git a/apps/server/src/cli/skillsCli.ts b/apps/server/src/cli/skillsCli.ts new file mode 100644 index 000000000000..42f0f44922ae --- /dev/null +++ b/apps/server/src/cli/skillsCli.ts @@ -0,0 +1,24 @@ +import * as Effect from "effect/Effect"; +import { Argument, Command } from "effect/cli"; + +/** + * `t3 skills-cli ` runs the bundled `skills` CLI (vercel-labs/skills), + * the installer `npx skills` runs. The server spawns it through this hidden + * subcommand so installs need no Node or npm of their own: the CLI is part of + * this build, and runs as a child so its `process.exit` and prompts stay out + * of the server. + */ +export async function runSkillsCli(args: ReadonlyArray): Promise { + // The CLI reads its command from argv[2] onward. + process.argv = [process.argv[0] ?? "node", "skills", ...args]; + await import("skills/dist/cli.mjs"); +} + +/** Real invocations dispatch through the bin.ts fast path before the CLI graph loads. */ +export const skillsCliCommand = Command.make("skills-cli", { + args: Argument.String("args").pipe(Argument.variadic()), +}).pipe( + Command.withDescription("Run the bundled skills installer."), + Command.unlisted, + Command.withHandler(({ args }) => Effect.promise(() => runSkillsCli(args))), +); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index b1abaf8d465c..0447400994fa 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -33,6 +33,11 @@ const RPC_AGGREGATES = { [WS_METHODS.serverProbe]: "server", [WS_METHODS.serverGetConfig]: "server", [WS_METHODS.serverRefreshProviders]: "server", + [WS_METHODS.skillsInspect]: "skills", + [WS_METHODS.skillsPreview]: "skills", + [WS_METHODS.skillsInstall]: "skills", + [WS_METHODS.skillsUpdate]: "skills", + [WS_METHODS.skillsRemove]: "skills", [WS_METHODS.serverUpdateProvider]: "server", [WS_METHODS.providerAuthStart]: "provider", [WS_METHODS.providerConsumeResetCredit]: "provider", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1bbe647dcd0c..f032fa74ff85 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -86,6 +86,7 @@ import * as CodexInstallation from "./provider/CodexInstallation.ts"; import * as ProviderInstanceRegistry from "./provider/ProviderInstanceRegistry.ts"; import * as ProviderAdapterRegistry from "./orchestration-v2/ProviderAdapterRegistry.ts"; import * as ProviderRegistry from "./provider/ProviderRegistry.ts"; +import * as SkillLibrary from "./skills/SkillLibrary.ts"; import * as ProviderUsageLimitsIngestion from "./provider/ProviderUsageLimitsIngestion.ts"; import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; @@ -589,6 +590,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( ProviderUsageLimitsIngestion.layer, layerProviderInstallationRefresh, ReplayMarkers.layer, + SkillLibrary.layer.pipe(Layer.provide(ProcessRunner.layer)), ).pipe( // Core Services Layer.provideMerge(layerOrchestrationApplication), diff --git a/apps/server/src/skills/SkillLibrary.test.ts b/apps/server/src/skills/SkillLibrary.test.ts new file mode 100644 index 000000000000..da5962e86d45 --- /dev/null +++ b/apps/server/src/skills/SkillLibrary.test.ts @@ -0,0 +1,229 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { + HostProcessArguments, + HostProcessEnvironment, + HostProcessIsExecutable, +} from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; + +import * as ProcessRunner from "../processRunner.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as SkillLibrary from "./SkillLibrary.ts"; + +const SKILL = (name: string, description: string) => + `---\nname: ${name}\ndescription: ${description}\n---\n\nUse ${name}.\n`; + +const claude: ServerProvider = { + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claude"), + enabled: true, + installed: true, + version: "2.1.0", + status: "ready", + checkedAt: "2026-10-09T00:00:00Z", + auth: { status: "authenticated" }, + models: [], + skills: [], + slashCommands: [], +}; + +/** + * A temp HOME, a registered project and a local source with two skills (one + * with a script), plus the service built against them. The real `skills` CLI + * runs through the `t3 skills-cli` subcommand of this checkout's bin.ts. + */ +const withLibrary = ( + body: (input: { + readonly library: SkillLibrary.SkillLibrary["Service"]; + readonly home: string; + readonly project: string; + readonly source: string; + readonly providers: Ref.Ref>; + readonly refreshed: Ref.Ref>; + }) => Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-skill-library-" }); + const home = path.join(base, "home"); + const project = path.join(base, "project"); + const source = path.join(base, "source"); + yield* fileSystem.makeDirectory(home, { recursive: true }); + yield* fileSystem.makeDirectory(project, { recursive: true }); + yield* fileSystem.makeDirectory(path.join(source, "review", "bin"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(source, "notes"), { recursive: true }); + yield* fileSystem.writeFileString( + path.join(source, "review", "SKILL.md"), + SKILL("review", "Review a change."), + ); + yield* fileSystem.writeFileString(path.join(source, "review", "bin", "run"), "#!/bin/sh\n"); + yield* fileSystem.chmod(path.join(source, "review", "bin", "run"), 0o755); + yield* fileSystem.writeFileString( + path.join(source, "notes", "SKILL.md"), + SKILL("notes", "Take notes."), + ); + + const providers = yield* Ref.make>([claude]); + const refreshed = yield* Ref.make>([]); + const record = (entry: string) => + Ref.update(refreshed, (entries) => [...entries, entry]).pipe( + Effect.andThen(Ref.get(providers)), + ); + const registry = ProviderRegistry.ProviderRegistry.of({ + getProviders: Ref.get(providers), + refreshInstance: (instanceId) => record(`instance:${instanceId}`), + refreshWorkspaceSnapshot: ({ instanceId, cwd }) => record(`workspace:${instanceId}:${cwd}`), + } as unknown as ProviderRegistry.ProviderRegistry["Service"]); + const realProject = yield* fileSystem.realPath(project); + const projects = ProjectService.ProjectService.of({ + getByWorkspaceRoot: (root: string) => + Effect.succeed( + path.resolve(root) === project || path.resolve(root) === realProject + ? Option.some({ + id: ProjectId.make("project"), + title: "Project", + workspaceRoot: project, + }) + : Option.none(), + ), + } as unknown as ProjectService.ProjectService["Service"]); + + const entry = path.resolve(import.meta.dirname, "../bin.ts"); + const library = yield* SkillLibrary.SkillLibrary.pipe( + Effect.provide( + SkillLibrary.layer.pipe( + Layer.provide(ProcessRunner.layer), + Layer.provide(Layer.succeed(ProviderRegistry.ProviderRegistry, registry)), + Layer.provide(Layer.succeed(ProjectService.ProjectService, projects)), + ), + ), + Effect.provideService(HostProcessEnvironment, { + ...process.env, + HOME: home, + XDG_STATE_HOME: "", + }), + Effect.provideService(HostProcessArguments, [process.execPath, entry]), + Effect.provideService(HostProcessIsExecutable, false), + ); + return yield* body({ library, home, project, source, providers, refreshed }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +it.effect( + "previews a source without installing it", + () => + withLibrary(({ library, home, source }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const preview = yield* library.preview({ source }); + assert.deepEqual( + preview.skills.map((skill) => [skill.name, skill.description, skill.scripts]), + [ + ["notes", "Take notes.", false], + ["review", "Review a change.", true], + ], + ); + assert.deepEqual( + preview.skills[1]?.files.map((file) => [file.path, file.executable]), + [ + ["SKILL.md", false], + ["bin/run", true], + ], + ); + assert.isFalse(yield* fileSystem.exists(`${home}/.agents`)); + }).pipe(Effect.provide(NodeServices.layer)), + ), + 120_000, +); + +it.effect( + "installs into a project, reports the source, and removes it again", + () => + withLibrary(({ library, project, source, providers, refreshed }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* library.install({ + source, + skills: ["review"], + target: { kind: "project", cwd: project }, + }); + assert.deepEqual(result.outcomes, [{ name: "review", status: "installed" }]); + // Claude reads only `.claude/skills`, so it gets a link there. + const claudeLink = path.join(project, ".claude", "skills", "review", "SKILL.md"); + assert.isTrue(yield* fileSystem.exists(claudeLink)); + assert.isFalse(yield* fileSystem.exists(path.join(project, ".agents/skills/notes"))); + assert.deepEqual(yield* Ref.get(refreshed), [`workspace:claude:${project}`]); + + yield* Ref.set(providers, [ + { + ...claude, + workspaceSnapshots: [ + { + cwd: project, + checkedAt: "2026-10-09T00:00:00Z", + slashCommands: [], + skills: [{ name: "review", path: claudeLink, scope: "project", enabled: true }], + }, + ], + }, + ]); + const inspected = yield* library.inspect({ cwd: project }); + assert.equal(inspected.folders.length, 1); + const folder = inspected.folders[0]; + assert.equal(folder?.path, claudeLink); + assert.equal( + folder?.folder, + yield* fileSystem.realPath(path.join(project, ".agents/skills/review")), + ); + assert.isTrue(folder?.scripts); + // A project lock keeps a local source relative to the project, so it travels with it. + assert.deepEqual(folder?.installed, { + target: { kind: "project", cwd: project }, + source: path.relative(yield* fileSystem.realPath(project), source), + }); + + const updated = yield* library.update({ + name: "review", + target: { kind: "project", cwd: project }, + }); + assert.deepEqual(updated.outcomes, [{ name: "review", status: "installed" }]); + + yield* library.remove({ name: "review", target: { kind: "project", cwd: project } }); + assert.isFalse(yield* fileSystem.exists(path.join(project, ".agents/skills/review"))); + assert.isFalse(yield* fileSystem.exists(path.join(project, ".claude/skills/review"))); + }).pipe(Effect.provide(NodeServices.layer)), + ), + 180_000, +); + +it.effect( + "refuses folders that aren't projects and skills the CLI didn't install", + () => + withLibrary(({ library, home, source }) => + Effect.gen(function* () { + const notProject = yield* library + .install({ source, skills: ["review"], target: { kind: "project", cwd: home } }) + .pipe(Effect.flip); + assert.equal(notProject.reason, "projectNotRegistered"); + const notInstalled = yield* library + .remove({ name: "review", target: { kind: "environment" } }) + .pipe(Effect.flip); + assert.equal(notInstalled.reason, "notInstalled"); + }), + ), + 60_000, +); diff --git a/apps/server/src/skills/SkillLibrary.ts b/apps/server/src/skills/SkillLibrary.ts new file mode 100644 index 000000000000..d3d35a33a269 --- /dev/null +++ b/apps/server/src/skills/SkillLibrary.ts @@ -0,0 +1,444 @@ +/** + * SkillLibrary - installs skills from a source and describes the skill + * folders the environment's agents report. + * + * Installs go through the `skills` CLI (see SkillsCli.ts), into + * `.agents/skills` at home or in a project, with links for agents that read + * their own folder. The CLI's lock files (`~/.agents/.skill-lock.json`, or + * `$XDG_STATE_HOME/skills/.skill-lock.json`, and a project's + * `skills-lock.json`) record where each install came from; this module reads + * them to group skills by source and to update or remove only what the CLI + * installed. Turning skills on or off stays in settings. + * + * @module SkillLibrary + */ +import { + type InstalledSkillInput, + type ProviderDriverKind, + type ServerProvider, + type SkillFileEntry, + type SkillFolderInfo, + type SkillInspectInput, + type SkillInspectResult, + type SkillInstallInput, + type SkillInstallResult, + type SkillInstallTarget, + SkillLibraryError, + type SkillPreviewInput, + type SkillPreviewResult, +} from "@t3tools/contracts"; +import { + STANDARD_SKILL_FOLDER, + skillsCliInstallAgents, +} from "@t3tools/provider-core/server/AgentSkillFolders"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Hex from "effect/encoding/Hex"; +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 { parse as parseYamlDocument } from "yaml"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as SkillsCli from "./SkillsCli.ts"; + +export class SkillLibrary extends Context.Service< + SkillLibrary, + { + /** Facts about each SKILL.md the enabled agents report, for `cwd` and the environment. */ + readonly inspect: (input: SkillInspectInput) => Effect.Effect; + /** The skills in a source, read from a throwaway install. */ + readonly preview: ( + input: SkillPreviewInput, + ) => Effect.Effect; + readonly install: ( + input: SkillInstallInput, + ) => Effect.Effect; + /** Reinstall a skill the CLI installed, from the source its lock records. */ + readonly update: ( + input: InstalledSkillInput, + ) => Effect.Effect; + /** Remove a skill the CLI installed, with every agent's link to it. */ + readonly remove: (input: InstalledSkillInput) => Effect.Effect; + } +>()("t3/skills/SkillLibrary") {} + +/** A lock entry, as both lock versions write it. */ +const LockEntry = Schema.Struct({ + source: Schema.String, + sourceType: Schema.optional(Schema.String), + sourceUrl: Schema.optional(Schema.String), + ref: Schema.optional(Schema.String), + skillPath: Schema.optional(Schema.String), +}); +type LockEntry = typeof LockEntry.Type; +const decodeLock = Schema.decodeUnknownOption( + fromLenientJson(Schema.Struct({ skills: Schema.Record(Schema.String, LockEntry) })), +); + +const SCRIPT_EXTENSION = /\.(?:sh|bash|zsh|fish|ps1|bat|cmd|py|rb|pl|js|mjs|cjs|ts|mts|php)$/i; +/** Bounds on the walk of one skill folder, so a huge tree costs a fixed amount. */ +const MAX_FILES = 500; +const MAX_DIRECTORIES = 200; +/** Folders an installer or VCS leaves behind, never part of the skill. */ +const SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]); + +/** + * The CLI's install source for one locked skill: the skill's own folder in + * the source, at the recorded ref, as `buildUpdateInstallSource` builds it. + */ +export function lockedInstallSource(entry: LockEntry): string { + const base = entry.sourceUrl ?? entry.source; + const ref = entry.ref === undefined ? "" : `#${entry.ref}`; + const folder = entry.skillPath?.replace(/\/?SKILL\.md$/i, "") ?? ""; + // Only GitHub-style shorthands and GitHub or GitLab URLs take a subpath. + const takesSubpath = + folder !== "" && + !entry.source.endsWith(".git") && + !/^(?:git@|ssh:\/\/)/.test(entry.source) && + (!/^https?:\/\//.test(entry.source) || + /^https?:\/\/(?:github|gitlab)\.com\//.test(entry.source)); + return takesSubpath ? `${entry.source}/${folder}${ref}` : `${base}${ref}`; +} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const cli = yield* SkillsCli.SkillsCli; + const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const projectService = yield* ProjectService.ProjectService; + const environment = yield* HostProcessEnvironment; + + const home = environment.HOME?.trim() || environment.USERPROFILE?.trim() || ""; + const globalLockPath = environment.XDG_STATE_HOME?.trim() + ? path.join(environment.XDG_STATE_HOME.trim(), "skills", ".skill-lock.json") + : path.join(home, ".agents", ".skill-lock.json"); + + const readLock = (file: string) => + fileSystem.readFileString(file).pipe( + Effect.map((text) => + Option.match(decodeLock(text), { + onNone: () => new Map(), + onSome: (lock) => new Map(Object.entries(lock.skills)), + }), + ), + Effect.orElseSucceed(() => new Map()), + ); + + const realPath = (target: string) => + fileSystem.realPath(target).pipe(Effect.orElseSucceed(() => target)); + + /** Where a target's installs live, and its lock file. */ + const targetPaths = (target: SkillInstallTarget) => { + const root = target.kind === "environment" ? home : target.cwd; + return { + root, + folder: path.join(root, STANDARD_SKILL_FOLDER), + lock: target.kind === "environment" ? globalLockPath : path.join(root, "skills-lock.json"), + }; + }; + + /** The project's root as the registry stores it, refusing an unregistered folder. */ + const resolveTarget = Effect.fnUntraced(function* (target: SkillInstallTarget) { + if (target.kind === "environment") return target; + const project = yield* projectService.getByWorkspaceRoot(target.cwd).pipe( + Effect.map(Option.getOrUndefined), + Effect.orElseSucceed(() => undefined), + ); + if (project === undefined) { + return yield* new SkillLibraryError({ + reason: "projectNotRegistered", + message: "That folder isn't a project on this environment.", + }); + } + return { kind: "project", cwd: project.workspaceRoot } satisfies SkillInstallTarget; + }); + + const enabledProviders = providerRegistry.getProviders.pipe( + Effect.map((providers) => providers.filter((provider) => provider.enabled)), + ); + + /** Ask the agents to rescan, so lists and the composer show the change. */ + const refreshAgents = (target: SkillInstallTarget) => + Effect.gen(function* () { + const providers = yield* enabledProviders; + yield* Effect.forEach( + providers, + (provider) => + target.kind === "environment" + ? providerRegistry.refreshInstance(provider.instanceId) + : providerRegistry.refreshWorkspaceSnapshot({ + instanceId: provider.instanceId, + cwd: target.cwd, + fresh: true, + }), + { concurrency: "unbounded", discard: true }, + ); + }); + + /** A skill folder's files, breadth first and bounded; links are listed, never followed. */ + const listFiles = Effect.fnUntraced(function* (root: string) { + const files: SkillFileEntry[] = []; + const pending = [""]; + let directories = 0; + let truncated = false; + while (pending.length > 0) { + const relative = pending.shift() ?? ""; + directories += 1; + const names = yield* fileSystem + .readDirectory(path.join(root, relative)) + .pipe(Effect.orElseSucceed((): string[] => [])); + for (const name of names.toSorted()) { + const childPath = relative === "" ? name : `${relative}/${name}`; + const absolute = path.join(root, childPath); + 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)); + if (info?.type === "Directory") { + if (SKIPPED_DIRECTORIES.has(name)) continue; + if (directories + pending.length >= MAX_DIRECTORIES) truncated = true; + else pending.push(childPath); + continue; + } + if (!link && info?.type !== "File") continue; + if (files.length >= MAX_FILES) return { files, truncated: true }; + files.push({ + path: childPath, + size: info === undefined ? 0 : Number(info.size), + executable: info !== undefined && (info.mode & 0o111) !== 0, + }); + } + } + return { files, truncated }; + }); + + const hasScripts = (files: ReadonlyArray) => + files.some((file) => file.executable || SCRIPT_EXTENSION.test(file.path)); + + const sha256 = (text: string) => + crypto.digest("SHA-256", new TextEncoder().encode(text)).pipe( + Effect.map(Hex.encode), + Effect.orElseSucceed(() => ""), + ); + + const inspect: SkillLibrary["Service"]["inspect"] = Effect.fn("SkillLibrary.inspect")( + function* (input) { + const providers = yield* enabledProviders; + const reported = new Set(); + for (const provider of providers) { + const snapshot = + input.cwd === undefined + ? undefined + : provider.workspaceSnapshots?.find((candidate) => candidate.cwd === input.cwd); + for (const skill of snapshot?.skills ?? provider.skills) { + if (path.isAbsolute(skill.path) && path.basename(skill.path) === "SKILL.md") { + reported.add(skill.path); + } + } + } + + const targets: ReadonlyArray = [ + { kind: "environment" }, + ...(input.cwd === undefined ? [] : [{ kind: "project" as const, cwd: input.cwd }]), + ]; + const installs = yield* Effect.forEach(targets, (target) => + Effect.gen(function* () { + const paths = targetPaths(target); + return { + target, + folder: yield* realPath(paths.folder), + lock: yield* readLock(paths.lock), + }; + }), + ); + + const folders = yield* Effect.forEach( + [...reported], + (skillPath) => + Effect.gen(function* () { + const contents = yield* fileSystem + .readFileString(skillPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (contents === undefined) return undefined; + const folder = yield* realPath(path.dirname(skillPath)); + const name = path.basename(folder); + const install = installs.find( + (candidate) => path.dirname(folder) === candidate.folder && candidate.lock.has(name), + ); + const entry = install?.lock.get(name); + const { files } = yield* listFiles(folder); + return { + path: skillPath, + folder, + hash: yield* sha256(contents), + scripts: hasScripts(files), + ...(install === undefined || entry === undefined + ? {} + : { installed: { target: install.target, source: entry.source } }), + } satisfies SkillFolderInfo; + }), + { concurrency: 8 }, + ); + return { folders: folders.filter((folder) => folder !== undefined) }; + }, + ); + + const describeSkill = (contents: string) => { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(contents); + try { + const parsed: unknown = match === null ? undefined : parseYamlDocument(match[1] ?? ""); + const description = + typeof parsed === "object" && parsed !== null && "description" in parsed + ? parsed.description + : undefined; + return typeof description === "string" ? description.trim() : ""; + } catch { + return ""; + } + }; + + const cliFailed = (message: string) => new SkillLibraryError({ reason: "cliFailed", message }); + + const runAdd = (input: SkillsCli.SkillsCliAddInput) => + cli.add(input).pipe( + Effect.catchTags({ + SkillsCliError: (error) => Effect.fail(cliFailed(error.message)), + }), + ); + + const preview: SkillLibrary["Service"]["preview"] = Effect.fn("SkillLibrary.preview")( + function* (input) { + return yield* Effect.scoped( + Effect.gen(function* () { + const scratch = yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3-skills-preview-" }) + .pipe(Effect.mapError(() => cliFailed("Couldn't make a folder to read the source."))); + const results = yield* runAdd({ + source: input.source, + skills: ["*"], + agents: ["universal"], + global: false, + cwd: scratch, + }); + const installed = results.filter((result) => result.status === "installed"); + if (installed.length === 0) { + const failure = results.find((result) => result.error)?.error; + return yield* new SkillLibraryError({ + reason: failure === undefined || failure === null ? "noSkills" : "cliFailed", + message: failure ?? "No skills were found in that source.", + }); + } + const folder = path.join(scratch, STANDARD_SKILL_FOLDER); + const names = yield* fileSystem + .readDirectory(folder) + .pipe(Effect.orElseSucceed((): string[] => [])); + const skills = yield* Effect.forEach(names.toSorted(), (name) => + Effect.gen(function* () { + const root = path.join(folder, name); + const contents = yield* fileSystem + .readFileString(path.join(root, "SKILL.md")) + .pipe(Effect.orElseSucceed(() => "")); + const { files, truncated } = yield* listFiles(root); + return { + name, + description: describeSkill(contents), + files, + filesTruncated: truncated, + scripts: hasScripts(files), + }; + }), + ); + return { skills }; + }), + ); + }, + ); + + const outcomes = (results: ReadonlyArray) => ({ + outcomes: results.map((result) => ({ + name: result.name ?? "", + status: result.status, + ...(result.error === undefined || result.error === null ? {} : { error: result.error }), + })), + }); + + const installInto = (target: SkillInstallTarget, source: string, skills: ReadonlyArray) => + Effect.gen(function* () { + const drivers: ReadonlyArray = (yield* enabledProviders).map( + (provider: ServerProvider) => provider.driver, + ); + const results = yield* runAdd({ + source, + skills, + agents: skillsCliInstallAgents(drivers, target.kind === "environment" ? "home" : "project"), + global: target.kind === "environment", + cwd: targetPaths(target).root, + }); + yield* refreshAgents(target); + return outcomes(results); + }); + + const install: SkillLibrary["Service"]["install"] = Effect.fn("SkillLibrary.install")( + function* (input) { + const target = yield* resolveTarget(input.target); + return yield* installInto(target, input.source, input.skills); + }, + ); + + const lockedEntry = (target: SkillInstallTarget, name: string) => + readLock(targetPaths(target).lock).pipe( + Effect.flatMap((lock) => { + const entry = lock.get(name); + return entry === undefined + ? Effect.fail( + new SkillLibraryError({ + reason: "notInstalled", + message: "Only skills installed from a source can be updated or removed here.", + }), + ) + : Effect.succeed(entry); + }), + ); + + const update: SkillLibrary["Service"]["update"] = Effect.fn("SkillLibrary.update")( + function* (input) { + const target = yield* resolveTarget(input.target); + const entry = yield* lockedEntry(target, input.name); + return yield* installInto(target, lockedInstallSource(entry), [input.name]); + }, + ); + + const remove: SkillLibrary["Service"]["remove"] = Effect.fn("SkillLibrary.remove")( + function* (input) { + const target = yield* resolveTarget(input.target); + yield* lockedEntry(target, input.name); + yield* cli + .remove({ + skills: [input.name], + global: target.kind === "environment", + cwd: targetPaths(target).root, + }) + .pipe( + Effect.catchTags({ + SkillsCliError: (error) => Effect.fail(cliFailed(error.message)), + }), + ); + yield* refreshAgents(target); + }, + ); + + return SkillLibrary.of({ inspect, preview, install, update, remove }); +}); + +export const layer = Layer.effect(SkillLibrary, make).pipe(Layer.provide(SkillsCli.layer)); diff --git a/apps/server/src/skills/SkillsCli.ts b/apps/server/src/skills/SkillsCli.ts new file mode 100644 index 000000000000..44fecad739a1 --- /dev/null +++ b/apps/server/src/skills/SkillsCli.ts @@ -0,0 +1,139 @@ +/** + * SkillsCli - runs the bundled `skills` CLI (vercel-labs/skills) through the + * hidden `t3 skills-cli` subcommand, so installs write exactly what + * `npx skills` would: the same folders, links and lock files. + * + * Arguments are built here from typed input only. Telemetry is off, git never + * prompts, and stdin is closed, so the CLI can't wait on anyone. + * + * @module SkillsCli + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { resolveSelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; + +import * as ProcessRunner from "../processRunner.ts"; + +export class SkillsCliError extends Schema.TaggedError()("SkillsCliError", { + command: Schema.Literals(["add", "remove", "list"]), + exitCode: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `The skills installer failed to ${this.command}.`; + } +} + +/** One entry of `add --json`. */ +const AddResult = Schema.Struct({ + name: Schema.optional(Schema.String), + status: Schema.Literals(["installed", "skipped", "failed"]), + path: Schema.optional(Schema.String), + reason: Schema.optional(Schema.NullOr(Schema.String)), + error: Schema.optional(Schema.NullOr(Schema.String)), +}); +export type SkillsCliAddResult = typeof AddResult.Type; +const decodeAddResults = Schema.decodeUnknownEffect(fromLenientJson(Schema.Array(AddResult))); + +export interface SkillsCliAddInput { + readonly source: string; + /** Skill names, or `*` for every skill in the source. */ + readonly skills: ReadonlyArray; + /** `skills` CLI agent ids, such as `universal` and `claude-code`. */ + readonly agents: ReadonlyArray; + /** Install into the home folders instead of `cwd`. */ + readonly global: boolean; + /** The project root, or the temp folder a preview unpacks into. */ + readonly cwd: string; +} + +export interface SkillsCliRemoveInput { + readonly skills: ReadonlyArray; + readonly global: boolean; + readonly cwd: string; +} + +export class SkillsCli extends Context.Service< + SkillsCli, + { + /** + * Install skills. Resolves with each skill's result, including failures the + * CLI reports for the whole source (a result with no name). + */ + readonly add: ( + input: SkillsCliAddInput, + ) => Effect.Effect, SkillsCliError>; + /** Remove skills: their folders, every agent's link to them, and their lock entries. */ + readonly remove: (input: SkillsCliRemoveInput) => Effect.Effect; + } +>()("t3/skills/SkillsCli") {} + +/** Fetching a large repository is slow; nothing here should take longer. */ +const TIMEOUT = "3 minutes"; +const MAX_OUTPUT_BYTES = 4 * 1024 * 1024; + +const make = Effect.gen(function* () { + const processRunner = yield* ProcessRunner.ProcessRunner; + const self = yield* resolveSelfInvocation(); + + const run = (command: "add" | "remove", args: ReadonlyArray, cwd: string) => + processRunner + .run({ + command: self.command, + args: [...selfInvocationArgs(self, ["skills-cli", command, ...args])], + cwd, + env: { + ELECTRON_RUN_AS_NODE: "1", + DISABLE_TELEMETRY: "1", + DO_NOT_TRACK: "1", + GIT_TERMINAL_PROMPT: "0", + }, + // An empty stdin closes it, so a prompt the flags missed ends instead of waiting. + stdin: "", + timeout: TIMEOUT, + maxOutputBytes: MAX_OUTPUT_BYTES, + }) + .pipe(Effect.mapError((cause) => new SkillsCliError({ command, cause }))); + + const add: SkillsCli["Service"]["add"] = Effect.fn("SkillsCli.add")(function* (input) { + const result = yield* run( + "add", + [ + input.source, + "--skill", + ...input.skills, + "--agent", + ...input.agents, + ...(input.global ? ["--global"] : []), + "--yes", + "--json", + ], + input.cwd, + ); + // A non-zero exit still prints the per-skill results when any were reached. + return yield* decodeAddResults(result.stdout).pipe( + Effect.mapError( + (cause) => + new SkillsCliError({ command: "add", exitCode: result.code ?? undefined, cause }), + ), + ); + }); + + const remove: SkillsCli["Service"]["remove"] = Effect.fn("SkillsCli.remove")(function* (input) { + const result = yield* run( + "remove", + [...input.skills, ...(input.global ? ["--global"] : []), "--yes"], + input.cwd, + ); + if (result.code !== 0) { + return yield* new SkillsCliError({ command: "remove", exitCode: result.code ?? undefined }); + } + }); + + return SkillsCli.of({ add, remove }); +}); + +export const layer = Layer.effect(SkillsCli, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5aa1681ea586..5619ed6a8c09 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -190,6 +190,7 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import * as SkillLibrary from "./skills/SkillLibrary.ts"; import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; @@ -1286,6 +1287,7 @@ const layerWsRpc = ( const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const skillLibrary = yield* SkillLibrary.SkillLibrary; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); @@ -2642,6 +2644,11 @@ const layerWsRpc = ( }), ), ), + [WS_METHODS.skillsInspect]: (input) => skillLibrary.inspect(input), + [WS_METHODS.skillsPreview]: (input) => skillLibrary.preview(input), + [WS_METHODS.skillsInstall]: (input) => skillLibrary.install(input), + [WS_METHODS.skillsUpdate]: (input) => skillLibrary.update(input), + [WS_METHODS.skillsRemove]: (input) => skillLibrary.remove(input).pipe(Effect.as({})), [WS_METHODS.projectsReadFile]: (input) => workspaceFileSystem.readFile(input).pipe( Effect.mapError( diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 9605829e47d7..90fb22d6b1e2 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { GitPreparePullRequestThreadInput } from "./git.ts"; import { AuthOrchestrationOperateScope, + AuthProvidersManageScope, AuthSourceControlWriteScope, type AuthEnvironmentScope, } from "./auth.ts"; @@ -34,6 +35,12 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.vcsSwitchRef]: AuthSourceControlWriteScope, [WS_METHODS.vcsInit]: AuthSourceControlWriteScope, + // Skills from a source may carry scripts every agent on the environment can + // run, the same reach as adding an MCP server. + [WS_METHODS.skillsInstall]: AuthProvidersManageScope, + [WS_METHODS.skillsUpdate]: AuthProvidersManageScope, + [WS_METHODS.skillsRemove]: AuthProvidersManageScope, + [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksDelete]: AuthOrchestrationOperateScope, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 4cf7eb6d9318..cdfd8cf68b7f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -29,6 +29,7 @@ export * from "./keybindings.ts"; export * from "./server.ts"; export * from "./settings.ts"; export * from "./agentTools.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 ed14e2e16b7c..6002cebbdcaf 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -148,6 +148,16 @@ import { ReviewDiffPreviewResult, } from "./review.ts"; import { KeybindingsConfigError } from "./keybindings.ts"; +import { + InstalledSkillInput, + SkillInspectInput, + SkillInspectResult, + SkillInstallInput, + SkillInstallResult, + SkillLibraryError, + SkillPreviewInput, + SkillPreviewResult, +} from "./skills.ts"; import { OrchestrationSearchThreadsError, OrchestrationSearchThreadsInput, @@ -462,6 +472,11 @@ export const WS_METHODS = { serverProbe: "server.probe", serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", + skillsInspect: "skills.inspect", + skillsPreview: "skills.preview", + skillsInstall: "skills.install", + skillsUpdate: "skills.update", + skillsRemove: "skills.remove", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", @@ -585,6 +600,36 @@ const WsServerProbeRpc = Rpc.make(WS_METHODS.serverProbe, { error: EnvironmentAuthorizationError, }); +const WsSkillsInspectRpc = Rpc.make(WS_METHODS.skillsInspect, { + payload: SkillInspectInput, + success: SkillInspectResult, + error: EnvironmentAuthorizationError, +}); + +const WsSkillsPreviewRpc = Rpc.make(WS_METHODS.skillsPreview, { + payload: SkillPreviewInput, + success: SkillPreviewResult, + error: Schema.Union([SkillLibraryError, EnvironmentAuthorizationError]), +}); + +const WsSkillsInstallRpc = Rpc.make(WS_METHODS.skillsInstall, { + payload: SkillInstallInput, + success: SkillInstallResult, + error: Schema.Union([SkillLibraryError, EnvironmentAuthorizationError]), +}); + +const WsSkillsUpdateRpc = Rpc.make(WS_METHODS.skillsUpdate, { + payload: InstalledSkillInput, + success: SkillInstallResult, + error: Schema.Union([SkillLibraryError, EnvironmentAuthorizationError]), +}); + +const WsSkillsRemoveRpc = Rpc.make(WS_METHODS.skillsRemove, { + payload: InstalledSkillInput, + success: Schema.Struct({}), + error: Schema.Union([SkillLibraryError, EnvironmentAuthorizationError]), +}); + const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { payload: Schema.Struct({}), success: ServerConfig, @@ -1812,6 +1857,11 @@ export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, WsServerRefreshProvidersRpc, + WsSkillsInspectRpc, + WsSkillsPreviewRpc, + WsSkillsInstallRpc, + WsSkillsUpdateRpc, + WsSkillsRemoveRpc, WsServerUpdateProviderRpc, WsProviderConsumeResetCreditRpc, WsProviderAuthStartRpc, diff --git a/packages/contracts/src/skills.ts b/packages/contracts/src/skills.ts new file mode 100644 index 000000000000..f83e7bb69a38 --- /dev/null +++ b/packages/contracts/src/skills.ts @@ -0,0 +1,133 @@ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** + * Installing skills from a source, as `npx skills` does: the server runs the + * `skills` CLI (vercel-labs/skills), so skills land in the same folders and + * lock files a terminal install would write. Turning skills on or off stays in + * `ServerSettings.disabledSkills`; this only adds and removes skill folders. + */ + +/** Where an install goes: the environment's home folders, or a registered project. */ +export const SkillInstallTarget = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("environment") }), + Schema.Struct({ kind: Schema.Literal("project"), cwd: TrimmedNonEmptyString }), +]); +export type SkillInstallTarget = typeof SkillInstallTarget.Type; + +/** + * What a user pastes: `owner/repo`, a git or GitHub tree URL, or an absolute + * path on the environment. Never an option, which the CLI would parse as one. + */ +export const SkillSource = TrimmedNonEmptyString.check( + Schema.isMaxLength(2048), + Schema.makeFilter((source: string) => + source.startsWith("-") ? "A source can't start with a dash." : true, + ), +); + +/** A skill's folder name, the name agents invoke it by, as the CLI sanitizes it. */ +export const InstalledSkillName = TrimmedNonEmptyString.check( + Schema.isMaxLength(255), + Schema.isPattern(/^[a-z0-9._][a-z0-9._-]*$/), +); +export type InstalledSkillName = typeof InstalledSkillName.Type; + +export const SkillFileEntry = Schema.Struct({ + /** Relative to the skill's folder, with `/` separators. */ + path: Schema.String, + size: NonNegativeInt, + executable: Schema.Boolean, +}); +export type SkillFileEntry = typeof SkillFileEntry.Type; + +export const SkillPreviewInput = Schema.Struct({ source: SkillSource }); +export type SkillPreviewInput = typeof SkillPreviewInput.Type; + +export const SkillPreviewItem = Schema.Struct({ + name: InstalledSkillName, + description: Schema.String, + /** Up to a limit; `filesTruncated` says when some were left out. */ + files: Schema.Array(SkillFileEntry), + filesTruncated: Schema.Boolean, + /** Some file is executable or a script, which an agent may run. */ + scripts: Schema.Boolean, +}); +export type SkillPreviewItem = typeof SkillPreviewItem.Type; + +export const SkillPreviewResult = Schema.Struct({ skills: Schema.Array(SkillPreviewItem) }); +export type SkillPreviewResult = typeof SkillPreviewResult.Type; + +export const SkillInstallInput = Schema.Struct({ + source: SkillSource, + skills: Schema.Array(InstalledSkillName).check(Schema.isMinLength(1), Schema.isMaxLength(200)), + target: SkillInstallTarget, +}); +export type SkillInstallInput = typeof SkillInstallInput.Type; + +/** One skill an `installed` lock entry names, to update or remove. */ +export const InstalledSkillInput = Schema.Struct({ + name: InstalledSkillName, + target: SkillInstallTarget, +}); +export type InstalledSkillInput = typeof InstalledSkillInput.Type; + +export const SkillInstallOutcome = Schema.Struct({ + name: Schema.String, + status: Schema.Literals(["installed", "skipped", "failed"]), + error: Schema.optional(Schema.String), +}); +export type SkillInstallOutcome = typeof SkillInstallOutcome.Type; + +export const SkillInstallResult = Schema.Struct({ outcomes: Schema.Array(SkillInstallOutcome) }); +export type SkillInstallResult = typeof SkillInstallResult.Type; + +export const SkillInspectInput = Schema.Struct({ + /** A project checkout whose skills are inspected besides the environment's. */ + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SkillInspectInput = typeof SkillInspectInput.Type; + +/** + * One SKILL.md an enabled agent reported, keyed by that path. The server + * finds the paths itself, so a client can't point it at other files. + */ +export const SkillFolderInfo = Schema.Struct({ + /** The SKILL.md path the agent reported. */ + path: Schema.String, + /** The skill's folder after following links, for viewing its files. */ + folder: Schema.String, + /** SHA-256 of SKILL.md, so two copies of a name can be compared. */ + hash: Schema.String, + scripts: Schema.Boolean, + /** Set when the `skills` CLI installed this folder: its lock names the source. */ + installed: Schema.optional( + Schema.Struct({ + target: SkillInstallTarget, + /** `owner/repo`, a URL or a path, as the lock records it. */ + source: Schema.String, + }), + ), +}); +export type SkillFolderInfo = typeof SkillFolderInfo.Type; + +export const SkillInspectResult = Schema.Struct({ folders: Schema.Array(SkillFolderInfo) }); +export type SkillInspectResult = typeof SkillInspectResult.Type; + +export class SkillLibraryError extends Schema.TaggedError()( + "SkillLibraryError", + { + reason: Schema.Literals([ + /** The CLI failed; `message` carries what it printed. */ + "cliFailed", + /** The source has no skills the CLI could find. */ + "noSkills", + /** The project isn't one of this environment's projects. */ + "projectNotRegistered", + /** Only skills the CLI installed (with a lock entry) can be updated or removed. */ + "notInstalled", + ]), + message: Schema.String, + }, +) {} diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index fdf4c7fd5288..264d8a90f1d9 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -7,6 +7,10 @@ "types": "./src/client.ts", "import": "./src/client.ts" }, + "./server/AgentSkillFolders": { + "types": "./src/server/AgentSkillFolders.ts", + "import": "./src/server/AgentSkillFolders.ts" + }, "./server/adapterDriver": { "types": "./src/server/adapterDriver.ts", "import": "./src/server/adapterDriver.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..4921305ad196 --- /dev/null +++ b/packages/provider-core/src/server/AgentSkillFolders.ts @@ -0,0 +1,93 @@ +/** + * AgentSkillFolders - where each agent reads skills from, and which `skills` + * CLI agent id installs into them. + * + * Paths are relative to the home folder (`home`) or the project root + * (`project`), in the order the agent looks. `.agents/skills` is the folder the + * `skills` CLI writes every install to; agents that read only their own folder + * get a link there, made by naming their CLI id. Sources for each agent: + * - Claude: `ClaudeSkills.ts` (`~/.claude/skills`, `.claude/skills`; it does + * not read `.agents/skills`). + * - Codex: https://developers.openai.com/codex/skills (`.agents/skills` at both + * levels; `~/.codex/skills` is the deprecated user folder). + * - Cursor: provider-cursor's `skills.ts`. + * - OpenCode: https://opencode.ai/docs/skills/ (its own folders plus the + * `.claude` and `.agents` ones). + * - Grok: https://docs.x.ai/build/features/skills-plugins-marketplaces.md. + * - Antigravity: `AntigravitySkills.ts` (`.agents/skills` only in a project). + * - Pi: its docs/skills.md (`.pi/skills`, `~/.pi/agent/skills`, `.agents`). + * The CLI ids and folders follow vercel-labs/skills' `src/agents.ts`. + * + * @module AgentSkillFolders + */ +import type { ProviderDriverKind } from "@t3tools/contracts"; + +/** The folder the `skills` CLI installs into, at home and in a project. */ +export const STANDARD_SKILL_FOLDER = ".agents/skills"; + +export interface AgentSkillFolders { + /** Folders under the home folder, in the order the agent looks. */ + readonly home: ReadonlyArray; + /** Folders under a project root, in the order the agent looks. */ + readonly project: ReadonlyArray; + /** + * The `skills` CLI agent that links an install into a folder this agent + * reads, when `.agents/skills` alone doesn't reach it at that level. + */ + readonly installAgent?: { readonly home?: string; readonly project?: string }; +} + +const AGENT_SKILL_FOLDERS: Readonly> = { + claudeAgent: { + home: [".claude/skills"], + project: [".claude/skills"], + installAgent: { home: "claude-code", project: "claude-code" }, + }, + codex: { + home: [STANDARD_SKILL_FOLDER, ".codex/skills"], + project: [STANDARD_SKILL_FOLDER, ".codex/skills"], + }, + cursor: { + home: [".cursor/skills", STANDARD_SKILL_FOLDER, ".codex/skills", ".claude/skills"], + project: [".cursor/skills", STANDARD_SKILL_FOLDER, ".codex/skills", ".claude/skills"], + }, + opencode: { + home: [".config/opencode/skills", ".claude/skills", STANDARD_SKILL_FOLDER], + project: [".opencode/skills", ".claude/skills", STANDARD_SKILL_FOLDER], + }, + grok: { + home: [".grok/skills", STANDARD_SKILL_FOLDER], + project: [".grok/skills"], + installAgent: { project: "grok" }, + }, + antigravity: { + home: [".gemini/config/skills", ".gemini/antigravity-cli/skills"], + project: [".gemini/skills", STANDARD_SKILL_FOLDER, ".agent/skills"], + installAgent: { home: "antigravity-cli" }, + }, + pi: { + home: [".pi/agent/skills", STANDARD_SKILL_FOLDER], + project: [".pi/skills", STANDARD_SKILL_FOLDER], + }, +}; + +/** The folders a driver reads, or undefined for a driver this table doesn't know. */ +export const agentSkillFolders = (driver: ProviderDriverKind): AgentSkillFolders | undefined => + AGENT_SKILL_FOLDERS[driver]; + +/** + * The `skills` CLI agents an install names so every listed driver can use the + * skill: `universal` writes `.agents/skills`, and each driver that doesn't read + * it at that level adds the agent that links into its own folder. + */ +export function skillsCliInstallAgents( + drivers: ReadonlyArray, + level: "home" | "project", +): ReadonlyArray { + const agents = new Set(["universal"]); + for (const driver of drivers) { + const agent = agentSkillFolders(driver)?.installAgent?.[level]; + if (agent !== undefined) agents.add(agent); + } + return [...agents]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 711ef69a8bbd..13ae026c4e7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -645,6 +645,9 @@ importers: proper-lockfile: specifier: 4.1.2 version: 4.1.2 + skills: + specifier: 1.7.1 + version: 1.7.1 stream-chain: specifier: ^4.2.5 version: 4.2.5 @@ -10940,6 +10943,11 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + skills@1.7.1: + resolution: {integrity: sha512-LUHR/+3D3evhwejiMfBStH7ndqJkV9UlwkjH1LpQ8j1g1cJO1yrXs+R+xS0AuV6E7iX0uZt44xEMAPZC+o40nA==} + engines: {node: '>=22.20.0'} + hasBin: true + slugify@1.6.9: resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} engines: {node: '>=8.0.0'} @@ -22299,6 +22307,11 @@ snapshots: sisteransi@1.0.5: {} + skills@1.7.1: + dependencies: + tar: 7.5.22 + yaml: 2.9.0 + slugify@1.6.9: {} smart-buffer@4.2.0: From 458f63083bad930a94eb736d1a377d04fa097e12 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 19:07:27 -0700 Subject: [PATCH 02/26] feat: add skills from a source, view their files, and let agents change Tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Tools → Skills gets Add skills (preview a source's skills, then install them at the current scope), groups installed skills under the source they came from with one switch, marks conflicting copies of a name, and adds View files, Update and Remove to each skill's menu. Agents get t3_tools_read, t3_tools_update, t3_skills_install and t3_skills_remove. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpHttpServer.ts | 5 + .../src/mcp/toolkits/tools/handlers.test.ts | 123 +++++++ .../server/src/mcp/toolkits/tools/handlers.ts | 164 +++++++++ apps/server/src/mcp/toolkits/tools/tools.ts | 101 ++++++ apps/server/src/skills/SkillLibrary.test.ts | 9 +- apps/server/src/skills/SkillLibrary.ts | 6 +- .../components/settings/AddSkillsDialog.tsx | 219 +++++++++++ .../components/settings/SkillFilesDialog.tsx | 95 +++++ .../src/components/settings/ToolsSettings.tsx | 342 ++++++++++++++---- .../src/components/settings/settingsSearch.ts | 2 +- .../settings/toolsSettings.logic.test.ts | 79 ++++ .../settings/toolsSettings.logic.ts | 78 +++- docs/user/tools.md | 20 + packages/client-runtime/src/state/server.ts | 27 ++ packages/client-runtime/src/t3ToolSummary.ts | 12 + packages/contracts/src/skills.ts | 3 + .../src/server/AgentSkillFolders.ts | 2 +- packages/shared/package.json | 4 + packages/shared/src/agentTools.ts | 112 ++++++ packages/shared/src/t3McpToolPresentation.ts | 11 + 20 files changed, 1318 insertions(+), 96 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/tools/handlers.test.ts create mode 100644 apps/server/src/mcp/toolkits/tools/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/tools/tools.ts create mode 100644 apps/web/src/components/settings/AddSkillsDialog.tsx create mode 100644 apps/web/src/components/settings/SkillFilesDialog.tsx create mode 100644 packages/shared/src/agentTools.ts diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index f4da6e051a7c..d22a48d0e6b6 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -26,7 +26,9 @@ import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import { PreviewControlsToolkit } from "./toolkits/previewControls/tools.ts"; import * as PreviewControlsHandlers from "./toolkits/previewControls/handlers.ts"; import { EnvironmentToolkit } from "./toolkits/environment/tools.ts"; +import { ToolsToolkit } from "./toolkits/tools/tools.ts"; import * as EnvironmentHandlers from "./toolkits/environment/handlers.ts"; +import * as ToolsHandlers from "./toolkits/tools/handlers.ts"; import { ProjectToolkit } from "./toolkits/project/tools.ts"; import * as ProjectHandlers from "./toolkits/project/handlers.ts"; import { AttachmentToolkit } from "./toolkits/attachment/tools.ts"; @@ -818,6 +820,8 @@ const layerEnvironmentRegistration = toolkitRegistration( const layerProjectRegistration = toolkitRegistration(ProjectToolkit, ProjectHandlers.layer); +const layerToolsRegistration = toolkitRegistration(ToolsToolkit, ToolsHandlers.layer); + export const layerAttachmentToolkit = toolkitRegistration( AttachmentToolkit, AttachmentHandlers.layer, @@ -857,6 +861,7 @@ export const layer = Layer.mergeAll( layerAttachmentToolkit, layerProjectRegistration, layerEnvironmentRegistration, + layerToolsRegistration, layerPreviewControlsRegistration, layerWorktreeToolkitRegistration, layerPullRequestsToolkit, diff --git a/apps/server/src/mcp/toolkits/tools/handlers.test.ts b/apps/server/src/mcp/toolkits/tools/handlers.test.ts new file mode 100644 index 000000000000..fd99c4faed9e --- /dev/null +++ b/apps/server/src/mcp/toolkits/tools/handlers.test.ts @@ -0,0 +1,123 @@ +import { expect, it } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + ProviderInstanceId, + type ServerSettings, + type ServerSettingsPatch, + ThreadId, +} from "@t3tools/contracts"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import * as Environment from "../../../environment/ServerEnvironment.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../../../project/ProjectService.ts"; +import * as Settings from "../../../serverSettings.ts"; +import * as SkillLibrary from "../../../skills/SkillLibrary.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { liveThreadShell } from "../../McpToolAccess.testkit.ts"; +import * as ToolsHandlers from "./handlers.ts"; +import { ToolsToolkit } from "./tools.ts"; + +const environmentId = EnvironmentId.make("environment:tools"); +const threadId = ThreadId.make("thread:tools"); +const projectId = ProjectId.make("project:tools"); + +const initial: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + disabledSkills: ["grill-me"], + mcpServers: { + context7: { + enabled: true, + transport: { + type: "stdio", + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + env: [{ name: "CONTEXT7_API_KEY", value: "secret", sensitive: true }], + }, + }, + }, +}; + +it.effect("switches skills and servers for a project as one patch, and never returns secrets", () => + Effect.gen(function* () { + const settings = yield* Ref.make(initial); + const patches = yield* Ref.make>([]); + const layerDependencies = Layer.mergeAll( + ThreadCommandExecutor.layer, + Layer.succeed(McpInvocationContext.McpInvocationContext, { + environmentId, + requestNamespace: "provider:tools", + thread: { + threadId, + providerSessionId: "provider:tools", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration" as const]), + issuedAt: 0, + }), + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: () => Effect.succeed(liveThreadShell(threadId)), + }), + Layer.mock(Environment.ServerEnvironment)({ + getDescriptor: Effect.succeed({ + environmentId, + label: "Test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0", + capabilities: { repositoryIdentity: false }, + }), + }), + Layer.mock(Settings.ServerSettingsService)({ + getSettings: Ref.get(settings), + updateSettings: (patch) => + Ref.update(patches, (list) => [...list, patch]).pipe( + Effect.andThen( + Ref.updateAndGet(settings, (current) => applyServerSettingsPatch(current, patch)), + ), + ), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SkillLibrary.SkillLibrary)({}), + ); + yield* Effect.gen(function* () { + const toolkit = yield* ToolsToolkit; + const result = yield* toolkit + .handle("t3_tools_update", { + projectId, + skills: [ + { name: "grill-me", enabled: true }, + { name: "tdd", enabled: false }, + ], + mcpServers: [{ name: "context7", enabled: false }], + }) + .pipe(Stream.unwrap, Stream.runCollect); + expect(result.at(-1)?.result).toEqual({ + disabledSkills: ["tdd"], + mcpServers: [{ name: "context7", enabled: false, summary: "npx -y @upstash/context7-mcp" }], + }); + // One write, so the project never sits half-changed. + expect(yield* Ref.get(patches)).toHaveLength(1); + expect((yield* Ref.get(settings)).projectSettingsOverrides[projectId]).toEqual({ + disabledSkills: { "grill-me": false, tdd: true }, + mcpServers: { context7: { enabled: false } }, + }); + // The environment itself is unchanged. + expect((yield* Ref.get(settings)).disabledSkills).toEqual(["grill-me"]); + }).pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ToolsHandlers.layer).pipe( + Layer.provideMerge(layerDependencies), + ), + ), + ); + }), +); diff --git a/apps/server/src/mcp/toolkits/tools/handlers.ts b/apps/server/src/mcp/toolkits/tools/handlers.ts new file mode 100644 index 000000000000..c8e0909a2ba3 --- /dev/null +++ b/apps/server/src/mcp/toolkits/tools/handlers.ts @@ -0,0 +1,164 @@ +import { + mcpServerTransportSummary, + OrchestratorMcpFailure, + type ProjectId, + type ServerSettings, + type ServerSettingsPatch, + type SkillInstallTarget, +} from "@t3tools/contracts"; +import { mcpServerEnabledPatch, skillsDisabledPatch } from "@t3tools/shared/agentTools"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as Environment from "../../../environment/ServerEnvironment.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as ProjectService from "../../../project/ProjectService.ts"; +import * as Settings from "../../../serverSettings.ts"; +import * as SkillLibrary from "../../../skills/SkillLibrary.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { readCaller, unavailable } from "../../threadAccess.ts"; +import { ToolsToolkit } from "./tools.ts"; + +/** What agents and the Tools page see for one scope; secrets never leave the server. */ +function toolsState(settings: ServerSettings, projectId: ProjectId | undefined) { + const resolved = resolveProjectSettings(settings, projectId ?? null).settings; + return { + disabledSkills: [...resolved.disabledSkills], + mcpServers: Object.entries(resolved.mcpServers).map(([name, server]) => ({ + name, + enabled: server.enabled, + summary: mcpServerTransportSummary(server.transport), + })), + }; +} + +const access = Effect.gen(function* () { + const context = yield* readCaller(); + const environment = yield* Environment.ServerEnvironment; + const descriptor = yield* environment.getDescriptor; + if (descriptor.environmentId !== context.scope.environmentId) + return yield* new OrchestratorMcpFailure({ + code: "capability_denied", + message: "This credential belongs to another environment.", + }); + return { settings: yield* Settings.ServerSettingsService }; +}); + +/** The project's checkout, as the target of an install. */ +const projectTarget = (projectId: ProjectId | undefined) => + Effect.gen(function* () { + if (projectId === undefined) return { kind: "environment" } satisfies SkillInstallTarget; + const projects = yield* ProjectService.ProjectService; + const project = yield* projects.getById(projectId).pipe(Effect.mapError(unavailable)); + if (Option.isNone(project)) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "The project was not found.", + }); + return { kind: "project", cwd: project.value.workspaceRoot } satisfies SkillInstallTarget; + }); + +const libraryFailure = (error: { readonly message: string }) => + new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }); + +/** Run a change while the calling thread holds its lock, re-checking it first. */ +const asCaller = ( + check: Effect.Effect, + change: Effect.Effect, +) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const guarded = check.pipe(Effect.andThen(change)); + return yield* scope.thread === undefined + ? guarded + : executor.withLock(scope.thread.threadId, guarded); + }); + +export const layer = McpToolAccess.toLayer(ToolsToolkit, { + t3_tools_read: McpToolAccess.reads((input) => + Effect.gen(function* () { + const { settings } = yield* access; + const current = yield* settings.getSettings.pipe(Effect.mapError(unavailable)); + return toolsState(current, input.projectId); + }), + ), + t3_tools_update: McpToolAccess.writesEnvironment((input, check) => + asCaller( + check, + Effect.gen(function* () { + const { settings } = yield* access; + const projectId = input.projectId ?? null; + // Each change builds on the last; the patch is then read off the result, + // so everything lands in one write. + let next = yield* settings.getSettings.pipe(Effect.mapError(unavailable)); + for (const enabled of [true, false]) { + const names = (input.skills ?? []) + .filter((skill) => skill.enabled === enabled) + .map((skill) => skill.name); + if (names.length > 0) { + next = applyServerSettingsPatch( + next, + skillsDisabledPatch(next, projectId, names, !enabled), + ); + } + } + for (const server of input.mcpServers ?? []) { + const step = mcpServerEnabledPatch(next, projectId, server.name, server.enabled); + if (step === null) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: `There is no Settings → Tools server named ${server.name}.`, + }); + next = applyServerSettingsPatch(next, step); + } + const servers = (input.mcpServers ?? []).map((server) => server.name); + const patch: ServerSettingsPatch = + projectId === null + ? { + ...(input.skills === undefined ? {} : { disabledSkills: next.disabledSkills }), + ...(servers.length === 0 + ? {} + : { + mcpServers: Object.fromEntries( + servers.map((name) => [name, next.mcpServers[name] ?? null]), + ), + }), + } + : ({ + projectSettingsOverrides: { + [projectId]: next.projectSettingsOverrides[projectId] ?? null, + }, + } as ServerSettingsPatch); + const saved = yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)); + return toolsState(saved, input.projectId); + }), + ), + ), + t3_skills_install: McpToolAccess.writesEnvironment((input, check) => + asCaller( + check, + Effect.gen(function* () { + const library = yield* SkillLibrary.SkillLibrary; + const target = yield* projectTarget(input.projectId); + return yield* library + .install({ source: input.source, skills: input.skills, target }) + .pipe(Effect.mapError(libraryFailure)); + }), + ), + ), + t3_skills_remove: McpToolAccess.writesEnvironment((input, check) => + asCaller( + check, + Effect.gen(function* () { + const library = yield* SkillLibrary.SkillLibrary; + const target = yield* projectTarget(input.projectId); + yield* library.remove({ name: input.name, target }).pipe(Effect.mapError(libraryFailure)); + return {}; + }), + ), + ), +}); diff --git a/apps/server/src/mcp/toolkits/tools/tools.ts b/apps/server/src/mcp/toolkits/tools/tools.ts new file mode 100644 index 000000000000..c39c26540a1c --- /dev/null +++ b/apps/server/src/mcp/toolkits/tools/tools.ts @@ -0,0 +1,101 @@ +import { + InstalledSkillName, + McpServerName, + OrchestratorMcpFailure, + ProjectId, + SkillInstallResult, + SkillSource, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/ai"; + +import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts"; +import * as ThreadCommandExecutor from "../../../orchestration-v2/ThreadCommandExecutor.ts"; +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectService from "../../../project/ProjectService.ts"; +import * as Settings from "../../../serverSettings.ts"; +import * as SkillLibrary from "../../../skills/SkillLibrary.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const shared = { + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ServerEnvironment.ServerEnvironment, + Settings.ServerSettingsService, + ThreadCommandExecutor.ThreadCommandExecutor, + ProjectService.ProjectService, + SkillLibrary.SkillLibrary, + ], +}; + +/** Omit for the environment; a project id scopes the read or change to that project. */ +const Scope = { projectId: Schema.optional(ProjectId) }; + +const ToolsState = Schema.Struct({ + disabledSkills: Schema.Array(Schema.String), + mcpServers: Schema.Array( + Schema.Struct({ + name: Schema.String, + enabled: Schema.Boolean, + /** The command line or URL; secret values are never returned. */ + summary: Schema.String, + }), + ), +}); + +const ToolsReadTool = Tool.make("t3_tools_read", { + ...shared, + description: + "Read Settings → Tools for this environment or one project: the skills switched off and the MCP servers T3 gives every agent session. Changes apply to new agent sessions.", + parameters: Schema.Struct(Scope), + success: ToolsState, +}) + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); + +const ToolsUpdateTool = Tool.make("t3_tools_update", { + ...shared, + description: + "Turn skills or Settings → Tools MCP servers on or off for this environment or one project. Skills are named as agents invoke them. Requires a live full-access/default calling thread. Applies to new agent sessions.", + parameters: Schema.Struct({ + ...Scope, + skills: Schema.optional( + Schema.Array(Schema.Struct({ name: TrimmedNonEmptyString, enabled: Schema.Boolean })), + ), + mcpServers: Schema.optional( + Schema.Array(Schema.Struct({ name: McpServerName, enabled: Schema.Boolean })), + ), + }), + success: ToolsState, +}).annotate(Tool.Destructive, true); + +const SkillsInstallTool = Tool.make("t3_skills_install", { + ...shared, + description: + "Install skills from a source (GitHub owner/repo, git URL, or a folder on this environment) for every agent, the way `npx skills add` does. With projectId they go into that project's .agents/skills; without, into the environment's ~/.agents/skills. Skills may include scripts agents can run: install only sources the user asked for. Requires a live full-access/default calling thread.", + parameters: Schema.Struct({ + ...Scope, + source: SkillSource, + skills: Schema.Array(InstalledSkillName).check(Schema.isMinLength(1)), + }), + success: SkillInstallResult, +}).annotate(Tool.Destructive, true); + +const SkillsRemoveTool = Tool.make("t3_skills_remove", { + ...shared, + description: + "Remove a skill installed from a source (with t3_skills_install, the Tools page, or `npx skills`), with every agent's link to it. Requires a live full-access/default calling thread.", + parameters: Schema.Struct({ ...Scope, name: InstalledSkillName }), + success: Schema.Struct({}), +}).annotate(Tool.Destructive, true); + +export const ToolsToolkit = Toolkit.make( + ToolsReadTool, + ToolsUpdateTool, + SkillsInstallTool, + SkillsRemoveTool, +); diff --git a/apps/server/src/skills/SkillLibrary.test.ts b/apps/server/src/skills/SkillLibrary.test.ts index da5962e86d45..b9218fd74025 100644 --- a/apps/server/src/skills/SkillLibrary.test.ts +++ b/apps/server/src/skills/SkillLibrary.test.ts @@ -83,11 +83,16 @@ const withLibrary = ( Ref.update(refreshed, (entries) => [...entries, entry]).pipe( Effect.andThen(Ref.get(providers)), ); - const registry = ProviderRegistry.ProviderRegistry.of({ + const stub: Pick< + ProviderRegistry.ProviderRegistry["Service"], + "getProviders" | "refreshInstance" | "refreshWorkspaceSnapshot" + > = { getProviders: Ref.get(providers), refreshInstance: (instanceId) => record(`instance:${instanceId}`), refreshWorkspaceSnapshot: ({ instanceId, cwd }) => record(`workspace:${instanceId}:${cwd}`), - } as unknown as ProviderRegistry.ProviderRegistry["Service"]); + }; + // SkillLibrary reads provider snapshots and asks for rescans, nothing else. + const registry = stub as ProviderRegistry.ProviderRegistry["Service"]; const realProject = yield* fileSystem.realPath(project); const projects = ProjectService.ProjectService.of({ getByWorkspaceRoot: (root: string) => diff --git a/apps/server/src/skills/SkillLibrary.ts b/apps/server/src/skills/SkillLibrary.ts index d3d35a33a269..fb2d1e02fefc 100644 --- a/apps/server/src/skills/SkillLibrary.ts +++ b/apps/server/src/skills/SkillLibrary.ts @@ -93,7 +93,7 @@ const SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]); * The CLI's install source for one locked skill: the skill's own folder in * the source, at the recorded ref, as `buildUpdateInstallSource` builds it. */ -export function lockedInstallSource(entry: LockEntry): string { +function lockedInstallSource(entry: LockEntry): string { const base = entry.sourceUrl ?? entry.source; const ref = entry.ref === undefined ? "" : `#${entry.ref}`; const folder = entry.skillPath?.replace(/\/?SKILL\.md$/i, "") ?? ""; @@ -277,11 +277,13 @@ const make = Effect.gen(function* () { (candidate) => path.dirname(folder) === candidate.folder && candidate.lock.has(name), ); const entry = install?.lock.get(name); - const { files } = yield* listFiles(folder); + const { files, truncated } = yield* listFiles(folder); return { path: skillPath, folder, hash: yield* sha256(contents), + files, + filesTruncated: truncated, scripts: hasScripts(files), ...(install === undefined || entry === undefined ? {} diff --git a/apps/web/src/components/settings/AddSkillsDialog.tsx b/apps/web/src/components/settings/AddSkillsDialog.tsx new file mode 100644 index 000000000000..8528144c7a7e --- /dev/null +++ b/apps/web/src/components/settings/AddSkillsDialog.tsx @@ -0,0 +1,219 @@ +import type { EnvironmentId, SkillInstallTarget, SkillPreviewItem } from "@t3tools/contracts"; +import { useId, useState } from "react"; + +import { serverEnvironment } from "../../state/server"; +import { formatEnvironmentQueryError } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Spinner } from "../ui/spinner"; + +/** + * Install skills from a source, as `npx skills add` does: paste the source, + * pick from the skills it holds, install for every agent at the current scope. + */ +export function AddSkillsDialog({ + open, + onOpenChange, + environmentId, + target, + scopeLabel, + onInstalled, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly environmentId: EnvironmentId; + readonly target: SkillInstallTarget; + /** Where the skills go, e.g. "This Mac" or a project name. */ + readonly scopeLabel: string; + readonly onInstalled: () => void; +}) { + const id = useId(); + const preview = useAtomCommand(serverEnvironment.previewSkills, { reportFailure: false }); + const install = useAtomCommand(serverEnvironment.installSkills, { reportFailure: false }); + const [source, setSource] = useState(""); + const [found, setFound] = useState<{ + readonly source: string; + readonly skills: ReadonlyArray; + } | null>(null); + const [selected, setSelected] = useState>(new Set()); + const [busy, setBusy] = useState<"finding" | "installing" | null>(null); + const [error, setError] = useState(null); + + const find = async () => { + const trimmed = source.trim(); + if (trimmed.length === 0 || busy !== null) return; + setBusy("finding"); + setError(null); + const result = await preview({ environmentId, input: { source: trimmed } }); + setBusy(null); + if (result._tag === "Failure") { + setFound(null); + setError(formatEnvironmentQueryError(result.cause)); + return; + } + setFound({ source: trimmed, skills: result.value.skills }); + setSelected(new Set(result.value.skills.map((skill) => skill.name))); + }; + + const installSelected = async () => { + if (found === null || selected.size === 0 || busy !== null) return; + setBusy("installing"); + setError(null); + const result = await install({ + environmentId, + input: { source: found.source, skills: [...selected], target }, + }); + setBusy(null); + if (result._tag === "Failure") { + setError(formatEnvironmentQueryError(result.cause)); + return; + } + const failed = result.value.outcomes.filter((outcome) => outcome.status !== "installed"); + onInstalled(); + if (failed.length > 0) { + setError( + failed + .map((outcome) => `${outcome.name || found.source}: ${outcome.error ?? outcome.status}`) + .join("\n"), + ); + return; + } + onOpenChange(false); + }; + + const toggle = (name: string, checked: boolean) => + setSelected((current) => { + const next = new Set(current); + if (checked) next.add(name); + else next.delete(name); + return next; + }); + + return ( + { + if (busy === null) onOpenChange(next); + }} + > + + + Add skills + + Installs for every agent on {scopeLabel}, the same way npx skills add does. + + + +
{ + event.preventDefault(); + void find(); + }} + > +
+ +
+ { + setSource(event.target.value); + setFound(null); + }} + spellCheck={false} + autoFocus + /> + +
+

+ A GitHub owner/repo, a git URL, or a folder on the environment. +

+
+ {found !== null ? ( +
+ + {found.skills.length === 1 ? "1 skill" : `${found.skills.length} skills`} + +
+ {found.skills.map((skill) => ( + + ))} +
+

+ Skills can tell agents to run their scripts. Install only sources you trust. +

+
+ ) : null} + {error !== null ? ( +

{error}

+ ) : null} +
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/SkillFilesDialog.tsx b/apps/web/src/components/settings/SkillFilesDialog.tsx new file mode 100644 index 000000000000..38f111531012 --- /dev/null +++ b/apps/web/src/components/settings/SkillFilesDialog.tsx @@ -0,0 +1,95 @@ +import type { EnvironmentId, SkillFolderInfo } from "@t3tools/contracts"; +import { FileIcon, TriangleAlertIcon } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "~/lib/utils"; +import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview"; +import { + FileSurfaceFailure, + FileSurfaceLoading, + FileSurfaceNotice, +} from "../files/fileSurfaceChrome"; +import { useProjectFileQuery } from "../files/projectFilesQueryState"; +import { Badge } from "../ui/badge"; +import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog"; + +/** A skill's files and one file's text, read-only, to check what a skill does. */ +export function SkillFilesDialog({ + open, + onOpenChange, + environmentId, + name, + folder, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly environmentId: EnvironmentId; + readonly name: string; + readonly folder: SkillFolderInfo; +}) { + const [selected, setSelected] = useState("SKILL.md"); + const file = useProjectFileQuery(environmentId, folder.folder, selected, open); + + return ( + + + +
+ + {name} + + {folder.scripts ? ( + + + Includes scripts + + ) : null} +
+

{folder.folder}

+
+
+
    + {folder.files.map((entry) => ( +
  • + +
  • + ))} + {folder.filesTruncated ? ( +
  • More files not shown
  • + ) : null} +
+
+ {file.data?.truncated ? ( + Showing the first 1 MB. + ) : null} + {file.data !== null ? ( +
+ +
+ ) : file.readError?.failure === "binary_file" ? ( + + ) : file.error !== null ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/ToolsSettings.tsx b/apps/web/src/components/settings/ToolsSettings.tsx index f54b0a5b342b..ec8fb2fdaefb 100644 --- a/apps/web/src/components/settings/ToolsSettings.tsx +++ b/apps/web/src/components/settings/ToolsSettings.tsx @@ -8,12 +8,21 @@ import { type ProjectId, type ServerProvider, type ServerSettingsPatch, + type SkillFolderInfo, + type SkillInstallTarget, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; +import { + isSkillDisabled, + mcpServerEnabledPatch, + projectOverridePatch, + skillsDisabledPatch, +} from "@t3tools/shared/agentTools"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { MoreHorizontalIcon, PlusIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { formatEnvironmentQueryError, useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; import { useEnvironmentsWithScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -34,7 +43,9 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Switch } from "../ui/switch"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { AddSkillsDialog } from "./AddSkillsDialog"; import { McpServerDialog } from "./McpServerDialog"; +import { SkillFilesDialog } from "./SkillFilesDialog"; import { useSettingsScope } from "./SettingsScopeContext"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -42,14 +53,15 @@ import { collectSkillRows, filterSkillRows, groupSkillRows, - isSkillDisabled, listMcpServerRows, type McpServerRow, SKILL_GROUP_LABELS, skillReachesSomeProviders, type SkillRow, + type SkillRowDetails, + skillRowDetails, + splitRowsBySource, type ToolsTab, - withSkillDisabled, } from "./toolsSettings.logic"; import { useScopedSettings, useScopedSettingsWriteAllowed } from "./useScopedSettings"; @@ -154,23 +166,6 @@ function usePersistToolsPatch() { * Replace one key of a project's override entry, dropping the key (or the entry) when empty. * The rest of the entry is resent as stored, since the patch replaces the whole entry. */ -function projectOverridePatch( - settings: { - readonly projectSettingsOverrides: Readonly>>; - }, - projectId: ProjectId, - key: K, - value: Readonly>, -): ServerSettingsPatch { - const { [key]: _previous, ...rest } = settings.projectSettingsOverrides[projectId] ?? {}; - const next = Object.keys(value).length === 0 ? rest : { ...rest, [key]: value }; - return { - projectSettingsOverrides: { - [projectId]: Object.keys(next).length === 0 ? null : next, - }, - } as ServerSettingsPatch; -} - export function ToolsSettings({ tab = "skills", onTabChange, @@ -223,6 +218,47 @@ function SkillsPanel() { [providers], ); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; + // Installs go to the checkout shown, or to the environment's home folders. + const installTarget: SkillInstallTarget | null = + environmentId === null + ? null + : isProjectScope + ? cwd === null + ? null + : { kind: "project", cwd } + : { kind: "environment" }; + const canInstall = useAtomValue(serverEnvironment.installSkills.permissionAtom(environmentId)); + const updateSkill = useAtomCommand(serverEnvironment.updateSkill, { reportFailure: false }); + const removeSkill = useAtomCommand(serverEnvironment.removeSkill, { reportFailure: false }); + const [adding, setAdding] = useState(false); + const [viewing, setViewing] = useState<{ + readonly name: string; + readonly folder: SkillFolderInfo; + } | null>(null); + const [removingSkill, setRemovingSkill] = useState<{ + readonly name: string; + readonly target: SkillInstallTarget; + } | null>(null); + const [skillActionError, setSkillActionError] = useState(null); + const inspected = useEnvironmentQuery( + environmentId === null + ? null + : serverEnvironment.inspectSkills({ + environmentId, + input: cwd === null ? {} : { cwd }, + }), + ); + const foldersByPath = useMemo( + () => new Map((inspected.data?.folders ?? []).map((folder) => [folder.path, folder])), + [inspected.data], + ); + const scopeLabel = isProjectScope + ? scope.kind === "project" || scope.kind === "checkout" + ? scope.group.displayName + : "" + : scope.kind === "environment" + ? scope.label + : "this environment"; const refresh = async (fresh: boolean) => { if (environmentId === null) return; @@ -242,6 +278,29 @@ function SkillsPanel() { ); } finally { setRefreshing(false); + inspected.refresh(); + } + }; + + const afterSkillChange = () => { + inspected.refresh(); + }; + + const runSkillAction = async ( + action: "update" | "remove", + input: { readonly name: string; readonly target: SkillInstallTarget }, + ) => { + if (environmentId === null) return; + setSkillActionError(null); + const result = + action === "update" + ? await updateSkill({ environmentId, input }) + : await removeSkill({ environmentId, input }); + afterSkillChange(); + if (result._tag === "Failure") { + setSkillActionError( + `Couldn't ${action} ${input.name}: ${formatEnvironmentQueryError(result.cause)}`, + ); } }; @@ -259,19 +318,26 @@ function SkillsPanel() { const rows = useMemo(() => collectSkillRows(providers, cwd), [cwd, providers]); const groups = useMemo(() => groupSkillRows(filterSkillRows(rows, query)), [query, rows]); + const detailsByName = useMemo( + () => new Map(rows.map((row) => [row.name, skillRowDetails(row, foldersByPath)])), + [foldersByPath, rows], + ); + const detailsOf = (row: SkillRow) => + detailsByName.get(row.name) ?? skillRowDetails(row, foldersByPath); - const setSkillDisabled = (name: string, disabled: boolean) => - persist(({ settings, projectId }) => { - if (projectId === null) { - return { disabledSkills: [...withSkillDisabled(settings.disabledSkills, name, disabled)] }; - } - const switches = { ...settings.projectSettingsOverrides[projectId]?.disabledSkills }; - const inheritedOff = isSkillDisabled(settings.disabledSkills, name); - delete switches[name]; - // A switch that matches the environment is not an override. - if (disabled !== inheritedOff) switches[name] = disabled; - return projectOverridePatch(settings, projectId, "disabledSkills", switches); - }); + const setSkillsDisabled = (skillRows: ReadonlyArray, disabled: boolean) => + persist(({ settings, projectId }) => + skillsDisabledPatch( + settings, + projectId, + skillRows.map((row) => row.name), + disabled, + ), + ); + const setSkillDisabled = (name: string, disabled: boolean) => { + const row = rows.find((candidate) => candidate.name === name); + if (row !== undefined) setSkillsDisabled([row], disabled); + }; return ( <> @@ -310,7 +376,16 @@ function SkillsPanel() { Rescan skill folders + {installTarget !== null && canInstall ? ( + + ) : null} + {skillActionError !== null ? ( +

{skillActionError}

+ ) : null} {rows.length === 0 ? ( @@ -330,28 +405,74 @@ function SkillsPanel() { ) : ( groups.map(({ group, rows: groupRows }) => ( - {groupRows.map((row) => ( - - candidate.projectId !== null && - Object.hasOwn( - target?.settings.projectSettingsOverrides[candidate.projectId] - ?.disabledSkills ?? {}, - row.name, - ), - ) - } - showProviders={skillReachesSomeProviders(row, enabledProviders.length)} - canWrite={canWrite} - onChange={(enabled) => setSkillDisabled(row.name, !enabled)} - /> - ))} + {splitRowsBySource(groupRows, (row) => detailsOf(row).installed?.source ?? null).map( + ({ source, rows: sourceRows }) => { + const renderRow = (row: SkillRow) => { + const details = detailsOf(row); + const { folder, installed } = details; + return ( + + candidate.projectId !== null && + Object.hasOwn( + target?.settings.projectSettingsOverrides[candidate.projectId] + ?.disabledSkills ?? {}, + row.name, + ), + ) + } + showProviders={skillReachesSomeProviders(row, enabledProviders.length)} + canWrite={canWrite} + onChange={(enabled) => setSkillDisabled(row.name, !enabled)} + onView={folder === null ? null : () => setViewing({ name: row.name, folder })} + onUpdate={ + installed === null || !canInstall + ? null + : () => + void runSkillAction("update", { + name: row.name, + target: installed.target, + }) + } + onRemove={ + installed === null || !canInstall + ? null + : () => setRemovingSkill({ name: row.name, target: installed.target }) + } + /> + ); + }; + if (source === null) return sourceRows.map(renderRow); + const allOff = sourceRows.every((row) => isSkillDisabled(disabledSkills, row.name)); + return ( +
+ + From {source} + + } + control={ + setSkillsDisabled(sourceRows, !enabled)} + /> + } + /> +
{sourceRows.map(renderRow)}
+
+ ); + }, + )}
)) )} @@ -359,24 +480,79 @@ function SkillsPanel() { Turning a skill off hides it from Claude, Codex and OpenCode, and from the composer's skill menu. Other agents may still load it on their own. Changes apply to new sessions.

+ {adding && environmentId !== null && installTarget !== null ? ( + + ) : null} + {viewing !== null && environmentId !== null ? ( + { + if (!open) setViewing(null); + }} + environmentId={environmentId} + name={viewing.name} + folder={viewing.folder} + /> + ) : null} + !open && setRemovingSkill(null)} + > + + + Remove {removingSkill?.name}? + + Deletes the skill's folder and every agent's link to it, as{" "} + npx skills remove does. + + + + }>Cancel + + + + ); } function SkillSettingsRow({ row, + details, disabled, overridden, showProviders, canWrite, onChange, + onView, + onUpdate, + onRemove, }: { readonly row: SkillRow; + readonly details: SkillRowDetails; readonly disabled: boolean; readonly overridden: boolean; readonly showProviders: boolean; readonly canWrite: boolean; readonly onChange: (enabled: boolean) => void; + readonly onView: (() => void) | null; + readonly onUpdate: (() => void) | null; + readonly onRemove: (() => void) | null; }) { const lockedOff = row.disabledByProvider; return ( @@ -389,6 +565,23 @@ function SkillSettingsRow({ This project ) : null} + {details.conflict ? ( + + }> + + Conflict + + + + Different skills share this name. The switch turns all of them on or off: + {row.paths.map((path) => ( + + {path} + + ))} + + + ) : null} } description={ @@ -422,6 +615,26 @@ function SkillSettingsRow({ disabled={!canWrite || lockedOff} onCheckedChange={onChange} /> + {onView !== null || onUpdate !== null || onRemove !== null ? ( + + + } + > + + + + {onView !== null ? View files : null} + {onUpdate !== null ? Update : null} + {onRemove !== null ? ( + + Remove + + ) : null} + + + ) : null} } /> @@ -504,26 +717,9 @@ function McpServersPanel() { }); const setEnabled = (row: McpServerRow, enabled: boolean) => - persist(({ settings, projectId: targetProject }) => { - if (targetProject === null) { - const current = settings.mcpServers[row.name]; - return current ? { mcpServers: { [row.name]: { ...current, enabled } } } : null; - } - const entries: Record = { - ...settings.projectSettingsOverrides[targetProject]?.mcpServers, - }; - const own = entries[row.name]; - const inherited = settings.mcpServers[row.name]; - if (own?.transport !== undefined) { - entries[row.name] = { ...own, enabled }; - } else if (inherited !== undefined && inherited.enabled === enabled) { - // Back to the environment's value: no override needed. - delete entries[row.name]; - } else { - entries[row.name] = { enabled }; - } - return projectOverridePatch(settings, targetProject, "mcpServers", entries); - }); + persist(({ settings, projectId: targetProject }) => + mcpServerEnabledPatch(settings, targetProject, row.name, enabled), + ); const remove = (row: McpServerRow) => persist(({ settings, projectId: targetProject }) => { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 21253e922b51..493502be6edb 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -631,7 +631,7 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/tools", scope: "project-defaults", searchTerms: [ - "agent skills SKILL.md disable enable hide turn off slash commands .agents claude codex cursor", + "agent skills SKILL.md disable enable hide turn off slash commands .agents claude codex cursor install add npx skills github repo update remove scripts conflict", ], }, { diff --git a/apps/web/src/components/settings/toolsSettings.logic.test.ts b/apps/web/src/components/settings/toolsSettings.logic.test.ts index 8b3c6bb37a1a..a8a31416237c 100644 --- a/apps/web/src/components/settings/toolsSettings.logic.test.ts +++ b/apps/web/src/components/settings/toolsSettings.logic.test.ts @@ -14,6 +14,9 @@ import { parseArgs, parseMcpServerJson, formatArgs, + skillRowDetails, + splitRowsBySource, + type SkillRow, } from "./toolsSettings.logic"; const provider = ( @@ -99,6 +102,82 @@ describe("collectSkillRows", () => { }); }); +describe("skill row details", () => { + const row = (paths: ReadonlyArray): SkillRow => ({ + name: "review", + description: undefined, + group: "personal", + paths, + providers: [], + disabledByProvider: false, + }); + const folder = (path: string, folderPath: string, hash: string, scripts = false) => ({ + path, + folder: folderPath, + hash, + files: [], + filesTruncated: false, + scripts, + }); + + it("flags a conflict only when two folders hold different text", () => { + const linked = new Map([ + // Claude reaches the same folder through its link. + [ + "/h/.claude/skills/review/SKILL.md", + folder("/h/.claude/skills/review/SKILL.md", "/h/.agents/skills/review", "a"), + ], + [ + "/h/.agents/skills/review/SKILL.md", + folder("/h/.agents/skills/review/SKILL.md", "/h/.agents/skills/review", "a"), + ], + ]); + expect(skillRowDetails(row([...linked.keys()]), linked).conflict).toBe(false); + + const copies = new Map([ + [ + "/h/.claude/skills/review/SKILL.md", + folder("/h/.claude/skills/review/SKILL.md", "/h/.claude/skills/review", "a"), + ], + [ + "/p/.agents/skills/review/SKILL.md", + folder("/p/.agents/skills/review/SKILL.md", "/p/.agents/skills/review", "b", true), + ], + ]); + const details = skillRowDetails(row([...copies.keys()]), copies); + expect(details.conflict).toBe(true); + expect(details.scripts).toBe(true); + }); + + it("knows nothing about a row whose folders weren't inspected", () => { + expect(skillRowDetails(row(["/x/SKILL.md"]), new Map())).toEqual({ + folder: null, + scripts: false, + conflict: false, + installed: null, + }); + }); + + it("splits rows by source, unsourced first, then by source name", () => { + const rows = [ + { name: "tdd", source: "mattpocock/skills" }, + { name: "mine", source: null }, + { name: "deploy", source: "acme/tools" }, + { name: "grill-me", source: "mattpocock/skills" }, + ]; + expect( + splitRowsBySource(rows, (entry) => entry.source).map((group) => [ + group.source, + group.rows.map((entry) => entry.name), + ]), + ).toEqual([ + [null, ["mine"]], + ["acme/tools", ["deploy"]], + ["mattpocock/skills", ["tdd", "grill-me"]], + ]); + }); +}); + describe("listMcpServerRows", () => { const linear: McpServerConfig = { enabled: true, diff --git a/apps/web/src/components/settings/toolsSettings.logic.ts b/apps/web/src/components/settings/toolsSettings.logic.ts index 0562ebfad81a..862f43fa6387 100644 --- a/apps/web/src/components/settings/toolsSettings.logic.ts +++ b/apps/web/src/components/settings/toolsSettings.logic.ts @@ -8,6 +8,7 @@ import { type ProviderInstanceId, type ServerProvider, type ServerProviderSkill, + type SkillFolderInfo, } from "@t3tools/contracts"; import { resolveProviderSkillSourceKind, @@ -168,27 +169,70 @@ export function filterSkillRows(rows: ReadonlyArray, query: string) { ); } -/** Whether `providers` covers only some of the enabled instances, so the row should name them. */ -export function skillReachesSomeProviders(row: SkillRow, enabledProviderCount: number): boolean { - return row.providers.length < enabledProviderCount; +/** What the server knows about a row's folders: see `skills.inspect`. */ +export interface SkillRowDetails { + /** The first copy's folder, to view its files; null when no folder was inspected. */ + readonly folder: SkillFolderInfo | null; + /** Some copy holds a script an agent could run. */ + readonly scripts: boolean; + /** Two copies of the name have different SKILL.md text. */ + readonly conflict: boolean; + /** Set when the `skills` CLI installed the row's skill, so it can be updated or removed. */ + readonly installed: NonNullable | null; } -/** The environment's list with one skill switched. Sorted so equal lists compare equal. */ -export function withSkillDisabled( - disabledSkills: ReadonlyArray, - name: string, - disabled: boolean, -): ReadonlyArray { - const next = new Set( - disabledSkills.filter((entry) => entry.toLowerCase() !== name.toLowerCase()), - ); - if (disabled) next.add(name); - return [...next].toSorted(); +const NO_DETAILS: SkillRowDetails = { + folder: null, + scripts: false, + conflict: false, + installed: null, +}; + +export function skillRowDetails( + row: SkillRow, + folders: ReadonlyMap, +): SkillRowDetails { + const infos = row.paths.flatMap((path) => { + const info = folders.get(path); + return info === undefined ? [] : [info]; + }); + if (infos.length === 0) return NO_DETAILS; + // Two agents reaching one folder through a link report two paths for one copy. + const hashByFolder = new Map(infos.map((info) => [info.folder, info.hash])); + return { + folder: infos[0] ?? null, + scripts: infos.some((info) => info.scripts), + conflict: new Set(hashByFolder.values()).size > 1, + installed: infos.find((info) => info.installed !== undefined)?.installed ?? null, + }; } -export function isSkillDisabled(disabledSkills: ReadonlyArray, name: string): boolean { - const lowered = name.toLowerCase(); - return disabledSkills.some((entry) => entry.toLowerCase() === lowered); +/** + * A group's rows split by the source the `skills` CLI installed them from, + * so a pack of skills reads as one block with one switch. Rows with no + * recorded source come first, under no heading. + */ +export function splitRowsBySource( + rows: ReadonlyArray, + sourceOf: (row: Row) => string | null, +): ReadonlyArray<{ readonly source: string | null; readonly rows: ReadonlyArray }> { + const bySource = new Map(); + for (const row of rows) { + const source = sourceOf(row); + const list = bySource.get(source) ?? []; + list.push(row); + bySource.set(source, list); + } + return [...bySource.entries()] + .toSorted(([left], [right]) => + left === null ? -1 : right === null ? 1 : left.localeCompare(right), + ) + .map(([source, sourceRows]) => ({ source, rows: sourceRows })); +} + +/** Whether `providers` covers only some of the enabled instances, so the row should name them. */ +export function skillReachesSomeProviders(row: SkillRow, enabledProviderCount: number): boolean { + return row.providers.length < enabledProviderCount; } // ── MCP servers ──────────────────────────────────────────────────── diff --git a/docs/user/tools.md b/docs/user/tools.md index 96d04496a645..83384521a7aa 100644 --- a/docs/user/tools.md +++ b/docs/user/tools.md @@ -19,6 +19,26 @@ off and can't be turned on here. With a project selected, a switch overrides the environment for that project only: you can turn a skill off for one project, or back on where the environment has it off. +A **Conflict** mark means two different skills share a name. The switch turns both of them on or +off. **View files** in a skill's menu shows what is in its folder; a skill marked **Includes +scripts** has files an agent could run. + +### Adding skills + +**Add skills** installs skills from a GitHub `owner/repo`, a git URL, or a folder on the +environment, the same way `npx skills add` does. T3 Code lists the skills in the source first, so +you can pick some and check which include scripts. Install only sources you trust. + +With a project selected, skills go into the project's `.agents/skills` folder and its +`skills-lock.json`, which you can commit for your team. Otherwise they go into `~/.agents/skills` +for every project. Agents that only read their own folder, such as Claude, get a link to the skill. + +Skills installed this way, here or with `npx skills` in a terminal, are grouped by where they came +from, with one switch for the group. Their menu has **Update**, which installs the latest version +from the same source, and **Remove**. Installing runs the open-source +[`skills`](https://github.com/vercel-labs/skills) installer built into T3 Code, with its telemetry +off. Installing, updating and removing skills needs permission to manage providers. + ## MCP servers The **MCP servers** tab lists servers T3 Code adds to every agent session, next to its own diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 91464b16a9b1..aabc46cf1be1 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1175,6 +1175,33 @@ export function createServerEnvironmentAtoms( ]), }, }), + // Skills installed from a source: read-only facts about the skill folders + // the agents report, and the installs that change them. + inspectSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:skills:inspect", + tag: WS_METHODS.skillsInspect, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, + }), + previewSkills: createEnvironmentRpcCommand(runtime, { + label: "environment-data:skills:preview", + tag: WS_METHODS.skillsPreview, + }), + installSkills: createEnvironmentRpcCommand(runtime, { + label: "environment-data:skills:install", + tag: WS_METHODS.skillsInstall, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), + updateSkill: createEnvironmentRpcCommand(runtime, { + label: "environment-data:skills:update", + tag: WS_METHODS.skillsUpdate, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), + removeSkill: createEnvironmentRpcCommand(runtime, { + label: "environment-data:skills:remove", + tag: WS_METHODS.skillsRemove, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index b9a46fa65287..b13635bd173a 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -329,6 +329,18 @@ export function summarizeT3ToolCalls( case "environment-update": label = phrase("Updated", "update", `environment preferences ${times}`); break; + case "tools-read": + label = phrase("Checked", "check", `skills and MCP servers ${times}`); + break; + case "tools-update": + label = phrase("Updated", "update", `skills and MCP servers ${times}`); + break; + case "skills-install": + label = phrase("Installed", "install", `skills ${times}`); + break; + case "skills-remove": + label = phrase("Removed", "remove", `skills ${times}`); + break; case "attachment-prepare": label = phrase( "Prepared", diff --git a/packages/contracts/src/skills.ts b/packages/contracts/src/skills.ts index f83e7bb69a38..06912bca49d7 100644 --- a/packages/contracts/src/skills.ts +++ b/packages/contracts/src/skills.ts @@ -100,6 +100,9 @@ export const SkillFolderInfo = Schema.Struct({ folder: Schema.String, /** SHA-256 of SKILL.md, so two copies of a name can be compared. */ hash: Schema.String, + /** The folder's files, up to a limit, for viewing them with `projects.readFile`. */ + files: Schema.Array(SkillFileEntry), + filesTruncated: Schema.Boolean, scripts: Schema.Boolean, /** Set when the `skills` CLI installed this folder: its lock names the source. */ installed: Schema.optional( diff --git a/packages/provider-core/src/server/AgentSkillFolders.ts b/packages/provider-core/src/server/AgentSkillFolders.ts index 4921305ad196..3635267470e5 100644 --- a/packages/provider-core/src/server/AgentSkillFolders.ts +++ b/packages/provider-core/src/server/AgentSkillFolders.ts @@ -72,7 +72,7 @@ const AGENT_SKILL_FOLDERS: Readonly> = { }; /** The folders a driver reads, or undefined for a driver this table doesn't know. */ -export const agentSkillFolders = (driver: ProviderDriverKind): AgentSkillFolders | undefined => +const agentSkillFolders = (driver: ProviderDriverKind): AgentSkillFolders | undefined => AGENT_SKILL_FOLDERS[driver]; /** diff --git a/packages/shared/package.json b/packages/shared/package.json index fdc368b6c399..98e359d5897b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -135,6 +135,10 @@ "types": "./src/serverSettings.ts", "import": "./src/serverSettings.ts" }, + "./agentTools": { + "types": "./src/agentTools.ts", + "import": "./src/agentTools.ts" + }, "./backgroundActivitySettings": { "types": "./src/backgroundActivitySettings.ts", "import": "./src/backgroundActivitySettings.ts" diff --git a/packages/shared/src/agentTools.ts b/packages/shared/src/agentTools.ts new file mode 100644 index 000000000000..299b961d37a1 --- /dev/null +++ b/packages/shared/src/agentTools.ts @@ -0,0 +1,112 @@ +import type { + McpServerProjectOverride, + ProjectId, + ServerSettings, + ServerSettingsPatch, +} from "@t3tools/contracts"; + +/** + * Settings patches for Settings → Tools, shared by the web page and the + * agents' `t3_tools_update` tool so both write the same overrides. A project + * patch replaces that project's whole entry, so each one is built from the + * current settings rather than merged later. + */ + +type ToolsSettings = Pick< + ServerSettings, + "disabledSkills" | "mcpServers" | "projectSettingsOverrides" +>; + +/** One project's override entry with `key` replaced, dropping what became empty. */ +export function projectOverridePatch( + settings: Pick, + projectId: ProjectId, + key: "mcpServers" | "disabledSkills", + value: Readonly>, +): ServerSettingsPatch { + const { [key]: _previous, ...rest } = settings.projectSettingsOverrides[projectId] ?? {}; + const next = Object.keys(value).length === 0 ? rest : { ...rest, [key]: value }; + return { + projectSettingsOverrides: { + [projectId]: Object.keys(next).length === 0 ? null : next, + }, + } as ServerSettingsPatch; +} + +/** The environment's list with one skill switched. Sorted so equal lists compare equal. */ +function withSkillDisabled( + disabledSkills: ReadonlyArray, + name: string, + disabled: boolean, +): ReadonlyArray { + const next = new Set( + disabledSkills.filter((entry) => entry.toLowerCase() !== name.toLowerCase()), + ); + if (disabled) next.add(name); + return [...next].sort(); +} + +export function isSkillDisabled(disabledSkills: ReadonlyArray, name: string): boolean { + const lowered = name.toLowerCase(); + return disabledSkills.some((entry) => entry.toLowerCase() === lowered); +} + +/** + * Switch skills on or off for the environment, or for one project. A project + * switch that matches the environment is dropped, since it overrides nothing. + */ +export function skillsDisabledPatch( + settings: ToolsSettings, + projectId: ProjectId | null, + names: ReadonlyArray, + disabled: boolean, +): ServerSettingsPatch { + if (projectId === null) { + return { + disabledSkills: [ + ...names.reduce( + (list, name) => withSkillDisabled(list, name, disabled), + settings.disabledSkills, + ), + ], + }; + } + const switches = { ...settings.projectSettingsOverrides[projectId]?.disabledSkills }; + for (const name of names) { + delete switches[name]; + if (disabled !== isSkillDisabled(settings.disabledSkills, name)) switches[name] = disabled; + } + return projectOverridePatch(settings, projectId, "disabledSkills", switches); +} + +/** + * Switch an MCP server on or off for the environment, or for one project. + * Null when the environment has no server of that name. A project's own server + * keeps its definition; an inherited one gets a switch, or loses it when it + * matches the environment again. + */ +export function mcpServerEnabledPatch( + settings: ToolsSettings, + projectId: ProjectId | null, + name: string, + enabled: boolean, +): ServerSettingsPatch | null { + const inherited = settings.mcpServers[name]; + if (projectId === null) { + return inherited ? { mcpServers: { [name]: { ...inherited, enabled } } } : null; + } + const entries: Record = { + ...settings.projectSettingsOverrides[projectId]?.mcpServers, + }; + const own = entries[name]; + if (own?.transport !== undefined) { + entries[name] = { ...own, enabled }; + } else if (inherited === undefined) { + return null; + } else if (inherited.enabled === enabled) { + delete entries[name]; + } else { + entries[name] = { enabled }; + } + return projectOverridePatch(settings, projectId, "mcpServers", entries); +} diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 11abf77f704b..4b96d6efe528 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -50,6 +50,10 @@ export type T3McpToolSummaryAction = | "project-clone" | "environment-read" | "environment-update" + | "tools-read" + | "tools-update" + | "skills-install" + | "skills-remove" | "attachment-prepare" | "attachment-discard" | "attachment-send" @@ -297,6 +301,13 @@ const T3_MCP_TOOLS: Readonly> = { ["Update", "Updating", "Updated", "environment preferences"], "environment-update", ), + t3_tools_read: tool(["Read", "Reading", "Read", "skills and MCP servers"], "tools-read"), + t3_tools_update: tool( + ["Update", "Updating", "Updated", "skills and MCP servers"], + "tools-update", + ), + t3_skills_install: tool(["Install", "Installing", "Installed", "skills"], "skills-install"), + t3_skills_remove: tool(["Remove", "Removing", "Removed", "a skill"], "skills-remove"), t3_thread_launch: tool(["Launch", "Launching", "Launched", "a project thread"], "thread-create"), t3_project_list: tool(["List", "Listing", "Listed", "projects"], "project-list"), t3_project_read: tool(["Read", "Reading", "Read", "a project"], "project-read"), From ec2b4753c20f0df06d9529c0636409881f764b02 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 19:39:33 -0700 Subject: [PATCH 03/26] fix: address review findings on the skills installer - remove: the skills CLI exits 0 when it can't delete a skill, so check the folder and lock entry afterwards and report the failure. - update: follow the CLI's own update source rules, using a GitLab or git lock's recorded URL and full-depth discovery when a source can't take a folder. - Show a per-skill update failure, and lock the source field while finding. - t3_tools_update builds its patch inside the settings write lock (updateSettingsWith), so a concurrent change isn't overwritten. - Project skill switches match names ignoring case, like the environment's. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.test.ts | 1 + .../src/mcp/toolkits/tools/handlers.test.ts | 41 ++++++- .../server/src/mcp/toolkits/tools/handlers.ts | 102 ++++++++++-------- .../ThreadSettlementService.test.ts | 1 + .../src/provider/ProviderRegistry.test.ts | 9 ++ apps/server/src/serverSettings.ts | 16 +++ apps/server/src/skills/SkillLibrary.test.ts | 58 +++++++++- apps/server/src/skills/SkillLibrary.ts | 90 +++++++++++++--- apps/server/src/skills/SkillsCli.ts | 3 + apps/server/src/terminal/Manager.test.ts | 1 + .../components/settings/AddSkillsDialog.tsx | 2 + .../src/components/settings/ToolsSettings.tsx | 22 ++-- packages/shared/src/agentTools.test.ts | 18 ++++ packages/shared/src/agentTools.ts | 5 +- packages/shared/src/projectSettings.ts | 13 ++- 15 files changed, 305 insertions(+), 77 deletions(-) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 55a7ac28a67c..ea23209f3301 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -142,6 +142,7 @@ const fixture = Effect.fn("fixture")(function* ( ServerSettings.ServerSettingsService, ServerSettings.ServerSettingsService.of({ updateProviderInstance: () => Effect.die("Unexpected provider mutation"), + updateSettingsWith: () => Effect.die("Unexpected settings update"), withSettingsSnapshot: (use) => Effect.flatMap(Ref.get(settings), use), start: Effect.void, ready: Effect.void, diff --git a/apps/server/src/mcp/toolkits/tools/handlers.test.ts b/apps/server/src/mcp/toolkits/tools/handlers.test.ts index fd99c4faed9e..b8c0012a3029 100644 --- a/apps/server/src/mcp/toolkits/tools/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/tools/handlers.test.ts @@ -50,6 +50,8 @@ it.effect("switches skills and servers for a project as one patch, and never ret Effect.gen(function* () { const settings = yield* Ref.make(initial); const patches = yield* Ref.make>([]); + // Applied once, right after the next read, as a concurrent write would be. + const interleave = yield* Ref.make<((current: ServerSettings) => ServerSettings) | null>(null); const layerDependencies = Layer.mergeAll( ThreadCommandExecutor.layer, Layer.succeed(McpInvocationContext.McpInvocationContext, { @@ -77,10 +79,20 @@ it.effect("switches skills and servers for a project as one patch, and never ret }), }), Layer.mock(Settings.ServerSettingsService)({ - getSettings: Ref.get(settings), - updateSettings: (patch) => - Ref.update(patches, (list) => [...list, patch]).pipe( - Effect.andThen( + getSettings: Ref.get(settings).pipe( + Effect.tap(() => + Ref.getAndSet(interleave, null).pipe( + Effect.flatMap((change) => + change === null ? Effect.void : Ref.update(settings, change), + ), + ), + ), + ), + updateSettingsWith: (build) => + Ref.get(settings).pipe( + Effect.map(build), + Effect.tap((patch) => Ref.update(patches, (list) => [...list, patch])), + Effect.flatMap((patch) => Ref.updateAndGet(settings, (current) => applyServerSettingsPatch(current, patch)), ), ), @@ -112,6 +124,27 @@ it.effect("switches skills and servers for a project as one patch, and never ret }); // The environment itself is unchanged. expect((yield* Ref.get(settings)).disabledSkills).toEqual(["grill-me"]); + + // Another change lands after this call reads settings, before it writes; it survives. + yield* Ref.set(interleave, (current) => ({ + ...current, + projectSettingsOverrides: { + ...current.projectSettingsOverrides, + [projectId]: { + ...current.projectSettingsOverrides[projectId], + disabledSkills: { + ...current.projectSettingsOverrides[projectId]?.disabledSkills, + review: true, + }, + }, + }, + })); + yield* toolkit + .handle("t3_tools_update", { projectId, skills: [{ name: "tdd", enabled: true }] }) + .pipe(Stream.unwrap, Stream.runCollect); + expect( + (yield* Ref.get(settings)).projectSettingsOverrides[projectId]?.disabledSkills, + ).toEqual({ "grill-me": false, review: true }); }).pipe( Effect.provide( McpToolAccess.HandlersLayer.layer(ToolsHandlers.layer).pipe( diff --git a/apps/server/src/mcp/toolkits/tools/handlers.ts b/apps/server/src/mcp/toolkits/tools/handlers.ts index c8e0909a2ba3..e196af17d59c 100644 --- a/apps/server/src/mcp/toolkits/tools/handlers.ts +++ b/apps/server/src/mcp/toolkits/tools/handlers.ts @@ -35,6 +35,53 @@ function toolsState(settings: ServerSettings, projectId: ProjectId | undefined) }; } +/** + * Every requested switch as one patch: each change builds on the last, and the + * patch carries the resulting entries, so they land in one write. + */ +function toolsUpdatePatch( + current: ServerSettings, + projectId: ProjectId | null, + input: { + readonly skills?: + | ReadonlyArray<{ readonly name: string; readonly enabled: boolean }> + | undefined; + readonly mcpServers?: + | ReadonlyArray<{ readonly name: string; readonly enabled: boolean }> + | undefined; + }, +): ServerSettingsPatch { + let next = current; + for (const enabled of [true, false]) { + const names = (input.skills ?? []) + .filter((skill) => skill.enabled === enabled) + .map((skill) => skill.name); + if (names.length > 0) { + next = applyServerSettingsPatch(next, skillsDisabledPatch(next, projectId, names, !enabled)); + } + } + for (const server of input.mcpServers ?? []) { + const step = mcpServerEnabledPatch(next, projectId, server.name, server.enabled); + if (step !== null) next = applyServerSettingsPatch(next, step); + } + if (projectId !== null) { + return { + projectSettingsOverrides: { [projectId]: next.projectSettingsOverrides[projectId] ?? null }, + } as ServerSettingsPatch; + } + const servers = (input.mcpServers ?? []).map((server) => server.name); + return { + ...(input.skills === undefined ? {} : { disabledSkills: next.disabledSkills }), + ...(servers.length === 0 + ? {} + : { + mcpServers: Object.fromEntries( + servers.map((name) => [name, next.mcpServers[name] ?? null]), + ), + }), + }; +} + const access = Effect.gen(function* () { const context = yield* readCaller(); const environment = yield* Environment.ServerEnvironment; @@ -92,48 +139,19 @@ export const layer = McpToolAccess.toLayer(ToolsToolkit, { Effect.gen(function* () { const { settings } = yield* access; const projectId = input.projectId ?? null; - // Each change builds on the last; the patch is then read off the result, - // so everything lands in one write. - let next = yield* settings.getSettings.pipe(Effect.mapError(unavailable)); - for (const enabled of [true, false]) { - const names = (input.skills ?? []) - .filter((skill) => skill.enabled === enabled) - .map((skill) => skill.name); - if (names.length > 0) { - next = applyServerSettingsPatch( - next, - skillsDisabledPatch(next, projectId, names, !enabled), - ); - } - } - for (const server of input.mcpServers ?? []) { - const step = mcpServerEnabledPatch(next, projectId, server.name, server.enabled); - if (step === null) - return yield* new OrchestratorMcpFailure({ - code: "invalid_request", - message: `There is no Settings → Tools server named ${server.name}.`, - }); - next = applyServerSettingsPatch(next, step); - } - const servers = (input.mcpServers ?? []).map((server) => server.name); - const patch: ServerSettingsPatch = - projectId === null - ? { - ...(input.skills === undefined ? {} : { disabledSkills: next.disabledSkills }), - ...(servers.length === 0 - ? {} - : { - mcpServers: Object.fromEntries( - servers.map((name) => [name, next.mcpServers[name] ?? null]), - ), - }), - } - : ({ - projectSettingsOverrides: { - [projectId]: next.projectSettingsOverrides[projectId] ?? null, - }, - } as ServerSettingsPatch); - const saved = yield* settings.updateSettings(patch).pipe(Effect.mapError(unavailable)); + const current = yield* settings.getSettings.pipe(Effect.mapError(unavailable)); + const missing = (input.mcpServers ?? []).find( + (server) => mcpServerEnabledPatch(current, projectId, server.name, true) === null, + ); + if (missing !== undefined) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: `There is no Settings → Tools server named ${missing.name}.`, + }); + // Built from the settings the write sees, so a concurrent change isn't lost. + const saved = yield* settings + .updateSettingsWith((latest) => toolsUpdatePatch(latest, projectId, input)) + .pipe(Effect.mapError(unavailable)); return toolsState(saved, input.projectId); }), ), diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 4b0e451e0ae6..e6942a083343 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -572,6 +572,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: ready: Effect.void, getSettings: Ref.get(settings), updateSettings, + updateSettingsWith: () => Effect.die("Unexpected settings update"), updateProviderInstance: () => Effect.die("Unexpected provider mutation"), withSettingsSnapshot: (use) => Ref.get(settings).pipe(Effect.flatMap(use)), streamChanges: Stream.fromPubSub(settingsChanges), diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index d0c1f348a736..0debbc0c7bad 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -361,6 +361,15 @@ function makeMutableServerSettingsService( yield* PubSub.publish(changes, next); return next; }), + updateSettingsWith: (patch) => + Effect.gen(function* () { + const current = yield* Ref.get(settingsRef); + const next = applyServerSettingsPatch(current, patch(current)); + encodeServerSettings(next); + yield* Ref.set(settingsRef, next); + yield* PubSub.publish(changes, next); + return next; + }), updateProviderInstance: (mutation, patch = {}) => Effect.gen(function* () { const current = yield* Ref.get(settingsRef); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 660b7310eedd..ccf0dd11f9c5 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -282,6 +282,14 @@ export class ServerSettingsService extends Context.Service< patch: ServerSettingsPatch, ) => Effect.Effect; + /** + * Patch settings with a patch built from the latest settings inside the + * write lock, so a change derived from them can't undo a concurrent one. + */ + readonly updateSettingsWith: ( + patch: (current: ServerSettings) => ServerSettingsPatch, + ) => Effect.Effect; + /** Apply a patch and one provider-instance mutation against the same latest settings snapshot. */ readonly updateProviderInstance: ( mutation: ProviderInstanceMutation, @@ -346,6 +354,10 @@ const makeTest = (overrides: DeepPartial = {}) => updateTestSettings((currentSettings) => Effect.succeed(applyServerSettingsPatch(currentSettings, patch)), ), + updateSettingsWith: (patch) => + updateTestSettings((currentSettings) => + Effect.succeed(applyServerSettingsPatch(currentSettings, patch(currentSettings))), + ), updateProviderInstance: (mutation, patch = {}) => updateTestSettings((currentSettings) => Effect.gen(function* () { @@ -1471,6 +1483,10 @@ const make = Effect.gen(function* () { updateAndPersistSettings((current) => Effect.succeed(applyServerSettingsPatch(current, patch)), ), + updateSettingsWith: (patch) => + updateAndPersistSettings((current) => + Effect.succeed(applyServerSettingsPatch(current, patch(current))), + ), updateProviderInstance: (mutation, patch = {}) => updateAndPersistSettings((current) => Effect.gen(function* () { diff --git a/apps/server/src/skills/SkillLibrary.test.ts b/apps/server/src/skills/SkillLibrary.test.ts index b9218fd74025..8956adb999a1 100644 --- a/apps/server/src/skills/SkillLibrary.test.ts +++ b/apps/server/src/skills/SkillLibrary.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import { ProjectId, ProviderDriverKind, @@ -22,6 +22,7 @@ import * as ProcessRunner from "../processRunner.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as SkillLibrary from "./SkillLibrary.ts"; +import { lockedInstallSource } from "./SkillLibrary.ts"; const SKILL = (name: string, description: string) => `---\nname: ${name}\ndescription: ${description}\n---\n\nUse ${name}.\n`; @@ -232,3 +233,58 @@ it.effect( ), 60_000, ); + +it.effect( + "fails a removal the CLI couldn't finish, which it reports with a zero exit", + () => + withLibrary(({ library, project, source }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = { kind: "project", cwd: project } as const; + yield* library.install({ source, skills: ["notes"], target }); + const folder = path.join(project, ".agents", "skills"); + yield* fileSystem.chmod(folder, 0o555); + const failure = yield* library + .remove({ name: "notes", target }) + .pipe(Effect.flip, Effect.ensuring(fileSystem.chmod(folder, 0o755).pipe(Effect.ignore))); + assert.equal(failure.reason, "cliFailed"); + assert.isTrue(yield* fileSystem.exists(path.join(folder, "notes"))); + }).pipe(Effect.provide(NodeServices.layer)), + ), + 120_000, +); + +describe("lockedInstallSource", () => { + it("updates one skill from its folder, as `npx skills update` does", () => { + const skillPath = "skills/productivity/grill-me/SKILL.md"; + expect( + lockedInstallSource({ source: "mattpocock/skills", sourceType: "github", skillPath }, "home"), + ).toEqual({ source: "mattpocock/skills/skills/productivity/grill-me", fullDepth: false }); + // A source that can't take a folder is searched in full, so a root SKILL.md can't win. + expect( + lockedInstallSource( + { source: "git@example.com:team/skills.git", sourceType: "git", skillPath, ref: "v2" }, + "project", + ), + ).toEqual({ source: "git@example.com:team/skills.git#v2", fullDepth: true }); + }); + + it("uses a recorded GitLab URL, and refuses a GitLab shorthand GitHub would answer", () => { + const skillPath = "tools/review/SKILL.md"; + expect( + lockedInstallSource( + { + source: "group/repo", + sourceType: "gitlab", + sourceUrl: "https://gitlab.com/group/repo", + skillPath, + }, + "project", + ), + ).toEqual({ source: "https://gitlab.com/group/repo/tools/review", fullDepth: false }); + expect( + lockedInstallSource({ source: "group/repo", sourceType: "gitlab", skillPath }, "project"), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/skills/SkillLibrary.ts b/apps/server/src/skills/SkillLibrary.ts index fb2d1e02fefc..cbe412002d7a 100644 --- a/apps/server/src/skills/SkillLibrary.ts +++ b/apps/server/src/skills/SkillLibrary.ts @@ -89,22 +89,57 @@ const MAX_DIRECTORIES = 200; /** Folders an installer or VCS leaves behind, never part of the skill. */ const SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]); +/* + * Where to reinstall a locked skill from. A port of the CLI's own update + * (`update-source.ts` in vercel-labs/skills 1.7.1), so Update here and + * `npx skills update` fetch the same thing. + */ + +/** Only GitHub-style shorthands and GitHub or GitLab URLs can take a folder after them. */ +function takesSubpath(source: string): boolean { + if (source.startsWith("git@") || source.startsWith("ssh://") || source.endsWith(".git")) { + return false; + } + if (!/^https?:\/\//.test(source)) return true; + return /^https?:\/\/(?:github|gitlab)\.com\//.test(source); +} + +/** + * A source other than GitHub, as a project lock records it. Older locks wrote + * GitLab and generic Git sources as a bare `group/repo`, which would be read + * as GitHub, so those need the recorded URL. + */ +function nonGithubSource(entry: LockEntry): string | null { + if (entry.sourceUrl !== undefined) return entry.sourceUrl; + const bare = !entry.source.includes(":") && !/^[./]/.test(entry.source); + return (entry.sourceType === "git" || entry.sourceType === "gitlab") && bare + ? null + : entry.source; +} + /** - * The CLI's install source for one locked skill: the skill's own folder in - * the source, at the recorded ref, as `buildUpdateInstallSource` builds it. + * The CLI's `add` source for one locked skill: its folder in the source at the + * recorded ref, or the whole repository with `fullDepth` when the source can't + * take a folder. Null when the lock doesn't say which host it came from. */ -function lockedInstallSource(entry: LockEntry): string { - const base = entry.sourceUrl ?? entry.source; +export function lockedInstallSource( + entry: LockEntry, + level: "home" | "project", +): { readonly source: string; readonly fullDepth: boolean } | null { + const github = entry.sourceType === undefined || entry.sourceType === "github"; + const source = + level === "project" || !github + ? nonGithubSource(entry) + : entry.skillPath === undefined + ? (entry.sourceUrl ?? entry.source) + : entry.source; + if (source === null) return null; const ref = entry.ref === undefined ? "" : `#${entry.ref}`; const folder = entry.skillPath?.replace(/\/?SKILL\.md$/i, "") ?? ""; - // Only GitHub-style shorthands and GitHub or GitLab URLs take a subpath. - const takesSubpath = - folder !== "" && - !entry.source.endsWith(".git") && - !/^(?:git@|ssh:\/\/)/.test(entry.source) && - (!/^https?:\/\//.test(entry.source) || - /^https?:\/\/(?:github|gitlab)\.com\//.test(entry.source)); - return takesSubpath ? `${entry.source}/${folder}${ref}` : `${base}${ref}`; + if (folder !== "" && takesSubpath(source)) { + return { source: `${source}/${folder}${ref}`, fullDepth: false }; + } + return { source: `${source}${ref}`, fullDepth: folder !== "" }; } const make = Effect.gen(function* () { @@ -375,7 +410,12 @@ const make = Effect.gen(function* () { })), }); - const installInto = (target: SkillInstallTarget, source: string, skills: ReadonlyArray) => + const installInto = ( + target: SkillInstallTarget, + source: string, + skills: ReadonlyArray, + fullDepth = false, + ) => Effect.gen(function* () { const drivers: ReadonlyArray = (yield* enabledProviders).map( (provider: ServerProvider) => provider.driver, @@ -385,6 +425,7 @@ const make = Effect.gen(function* () { skills, agents: skillsCliInstallAgents(drivers, target.kind === "environment" ? "home" : "project"), global: target.kind === "environment", + fullDepth, cwd: targetPaths(target).root, }); yield* refreshAgents(target); @@ -417,7 +458,15 @@ const make = Effect.gen(function* () { function* (input) { const target = yield* resolveTarget(input.target); const entry = yield* lockedEntry(target, input.name); - return yield* installInto(target, lockedInstallSource(entry), [input.name]); + const from = lockedInstallSource(entry, target.kind === "environment" ? "home" : "project"); + if (from === null) { + return yield* new SkillLibraryError({ + reason: "notInstalled", + message: + "This skill's lock doesn't record which host it came from. Install it again from its URL.", + }); + } + return yield* installInto(target, from.source, [input.name], from.fullDepth); }, ); @@ -425,11 +474,12 @@ const make = Effect.gen(function* () { function* (input) { const target = yield* resolveTarget(input.target); yield* lockedEntry(target, input.name); + const paths = targetPaths(target); yield* cli .remove({ skills: [input.name], global: target.kind === "environment", - cwd: targetPaths(target).root, + cwd: paths.root, }) .pipe( Effect.catchTags({ @@ -437,6 +487,16 @@ const make = Effect.gen(function* () { }), ); yield* refreshAgents(target); + // The CLI reports a skill it couldn't delete without failing, so check what's left. + const folderLeft = yield* fileSystem + .exists(path.join(paths.folder, input.name)) + .pipe(Effect.orElseSucceed(() => false)); + const lockLeft = (yield* readLock(paths.lock)).has(input.name); + if (folderLeft || lockLeft) { + return yield* cliFailed( + `Couldn't remove ${input.name}. Check the permissions of ${folderLeft ? paths.folder : paths.lock}.`, + ); + } }, ); diff --git a/apps/server/src/skills/SkillsCli.ts b/apps/server/src/skills/SkillsCli.ts index 44fecad739a1..33a7aa1df331 100644 --- a/apps/server/src/skills/SkillsCli.ts +++ b/apps/server/src/skills/SkillsCli.ts @@ -46,6 +46,8 @@ export interface SkillsCliAddInput { readonly agents: ReadonlyArray; /** Install into the home folders instead of `cwd`. */ readonly global: boolean; + /** Look through the whole repository, not just its root skill. */ + readonly fullDepth?: boolean; /** The project root, or the temp folder a preview unpacks into. */ readonly cwd: string; } @@ -108,6 +110,7 @@ const make = Effect.gen(function* () { "--agent", ...input.agents, ...(input.global ? ["--global"] : []), + ...(input.fullDepth === true ? ["--full-depth"] : []), "--yes", "--json", ], diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 5a40f7bbb933..21637dce8c1e 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2200,6 +2200,7 @@ it.layer( ready: Effect.void, getSettings: Effect.fail(settingsError), updateSettings: () => Effect.fail(settingsError), + updateSettingsWith: () => Effect.fail(settingsError), updateProviderInstance: () => Effect.fail(settingsError), withSettingsSnapshot: () => Effect.fail(settingsError), streamChanges: Stream.empty, diff --git a/apps/web/src/components/settings/AddSkillsDialog.tsx b/apps/web/src/components/settings/AddSkillsDialog.tsx index 8528144c7a7e..01394b3b423c 100644 --- a/apps/web/src/components/settings/AddSkillsDialog.tsx +++ b/apps/web/src/components/settings/AddSkillsDialog.tsx @@ -129,6 +129,8 @@ export function AddSkillsDialog({
{ if (environmentId === null) return; setSkillActionError(null); - const result = - action === "update" - ? await updateSkill({ environmentId, input }) - : await removeSkill({ environmentId, input }); + const failure = async () => { + if (action === "remove") { + const result = await removeSkill({ environmentId, input }); + return result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null; + } + const result = await updateSkill({ environmentId, input }); + if (result._tag === "Failure") return formatEnvironmentQueryError(result.cause); + // The CLI reports a skill it couldn't reinstall as a result, not an error. + const failed = result.value.outcomes.find((outcome) => outcome.status !== "installed"); + return failed === undefined ? null : (failed.error ?? failed.status); + }; + const message = await failure(); afterSkillChange(); - if (result._tag === "Failure") { - setSkillActionError( - `Couldn't ${action} ${input.name}: ${formatEnvironmentQueryError(result.cause)}`, - ); - } + if (message !== null) setSkillActionError(`Couldn't ${action} ${input.name}: ${message}`); }; // A project's skills live in its checkout, which each provider only scans diff --git a/packages/shared/src/agentTools.test.ts b/packages/shared/src/agentTools.test.ts index 1641e5e15104..88ac48272c4f 100644 --- a/packages/shared/src/agentTools.test.ts +++ b/packages/shared/src/agentTools.test.ts @@ -10,6 +10,7 @@ import { import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; +import { skillsDisabledPatch } from "./agentTools.ts"; import { resolveProjectSettings } from "./projectSettings.ts"; import { applyServerSettingsPatch } from "./serverSettings.ts"; @@ -92,6 +93,23 @@ describe("project tools overrides", () => { }); }); +describe("skill switches", () => { + it("turns an inherited skill back on for a project whatever its case", () => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + disabledSkills: ["Grill-Me"], + projectSettingsOverrides: { [projectId]: { disabledSkills: { "GRILL-ME": true } } }, + } satisfies ServerSettings; + const next = applyServerSettingsPatch( + settings, + skillsDisabledPatch(settings, projectId, ["grill-me"], false), + ); + // One switch replaces the differently cased one, and it wins over the environment's spelling. + expect(next.projectSettingsOverrides[projectId]?.disabledSkills).toEqual({ "grill-me": false }); + expect(resolveProjectSettings(next, projectId).settings.disabledSkills).toEqual([]); + }); +}); + describe("mcpServers patches", () => { it("replaces one server at a time and removes it with null", () => { const current = { ...DEFAULT_SERVER_SETTINGS, mcpServers: { linear, supabase } }; diff --git a/packages/shared/src/agentTools.ts b/packages/shared/src/agentTools.ts index 299b961d37a1..bd9a98551749 100644 --- a/packages/shared/src/agentTools.ts +++ b/packages/shared/src/agentTools.ts @@ -73,7 +73,10 @@ export function skillsDisabledPatch( } const switches = { ...settings.projectSettingsOverrides[projectId]?.disabledSkills }; for (const name of names) { - delete switches[name]; + // One switch per name, whatever its case. + for (const existing of Object.keys(switches)) { + if (existing.toLowerCase() === name.toLowerCase()) delete switches[existing]; + } if (disabled !== isSkillDisabled(settings.disabledSkills, name)) switches[name] = disabled; } return projectOverridePatch(settings, projectId, "disabledSkills", switches); diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index 5895f2226b37..a6d0b1f44a0b 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -232,18 +232,21 @@ function mergeProjectMcpServers( return merged as McpServers; } -/** The environment's disabled skills with a project's per-name switches applied. */ +/** + * The environment's disabled skills with a project's per-name switches + * applied. Names match ignoring case, as agents and the composer match them. + */ export function mergeProjectDisabledSkills( environment: DisabledSkills, project: DisabledSkillsProjectOverride | undefined, ): DisabledSkills { if (project === undefined) return environment; - const disabled = new Set(environment); + const disabled = new Map(environment.map((name) => [name.toLowerCase(), name])); for (const [name, off] of Object.entries(project)) { - if (off) disabled.add(name); - else disabled.delete(name); + if (off) disabled.set(name.toLowerCase(), disabled.get(name.toLowerCase()) ?? name); + else disabled.delete(name.toLowerCase()); } - return [...disabled].sort(); + return [...disabled.values()].sort(); } /** Replace the project's entry, dropping it entirely when nothing is overridden. */ From 64303a5bb980fa8166422cf8bfb34bb88dbf551d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 19:51:27 -0700 Subject: [PATCH 04/26] fix: run skill changes one at a time, and keep the environment's spelling - install, update and remove share a lock: each skills CLI run rewrites a whole lock file, so two at once could drop each other's entries. - A project skill switch is written with the environment's spelling of the name. - The CLI child gets the host environment it resolved (HostProcessEnvironment) rather than the process's own, so tests run it against their temp home. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/skills/SkillLibrary.test.ts | 24 +++++++++++++ apps/server/src/skills/SkillLibrary.ts | 38 ++++++++++++++------- apps/server/src/skills/SkillsCli.ts | 5 +++ packages/shared/src/agentTools.test.ts | 4 +-- packages/shared/src/agentTools.ts | 5 ++- 5 files changed, 61 insertions(+), 15 deletions(-) diff --git a/apps/server/src/skills/SkillLibrary.test.ts b/apps/server/src/skills/SkillLibrary.test.ts index 8956adb999a1..757af762d2d1 100644 --- a/apps/server/src/skills/SkillLibrary.test.ts +++ b/apps/server/src/skills/SkillLibrary.test.ts @@ -255,6 +255,30 @@ it.effect( 120_000, ); +it.effect( + "keeps both lock entries when two installs run at once", + () => + withLibrary(({ library, home, source }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = { kind: "environment" } as const; + yield* Effect.all( + [ + library.install({ source, skills: ["notes"], target }), + library.install({ source, skills: ["review"], target }), + ], + { concurrency: "unbounded" }, + ); + const lock = JSON.parse( + yield* fileSystem.readFileString(path.join(home, ".agents", ".skill-lock.json")), + ) as { skills: Record }; + expect(Object.keys(lock.skills).toSorted()).toEqual(["notes", "review"]); + }).pipe(Effect.provide(NodeServices.layer)), + ), + 120_000, +); + describe("lockedInstallSource", () => { it("updates one skill from its folder, as `npx skills update` does", () => { const skillPath = "skills/productivity/grill-me/SKILL.md"; diff --git a/apps/server/src/skills/SkillLibrary.ts b/apps/server/src/skills/SkillLibrary.ts index cbe412002d7a..2e08a83c117a 100644 --- a/apps/server/src/skills/SkillLibrary.ts +++ b/apps/server/src/skills/SkillLibrary.ts @@ -42,6 +42,7 @@ 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 Semaphore from "effect/Semaphore"; import { parse as parseYamlDocument } from "yaml"; import * as ProjectService from "../project/ProjectService.ts"; @@ -147,6 +148,9 @@ const make = Effect.gen(function* () { const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; const cli = yield* SkillsCli.SkillsCli; + // Each CLI run rewrites a whole lock file without locking it, so two at once + // can drop each other's entries. Installs are rare; run them one at a time. + const changes = yield* Semaphore.make(1); const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const projectService = yield* ProjectService.ProjectService; const environment = yield* HostProcessEnvironment; @@ -435,7 +439,7 @@ const make = Effect.gen(function* () { const install: SkillLibrary["Service"]["install"] = Effect.fn("SkillLibrary.install")( function* (input) { const target = yield* resolveTarget(input.target); - return yield* installInto(target, input.source, input.skills); + return yield* installInto(target, input.source, input.skills).pipe(changes.withPermits(1)); }, ); @@ -457,7 +461,13 @@ const make = Effect.gen(function* () { const update: SkillLibrary["Service"]["update"] = Effect.fn("SkillLibrary.update")( function* (input) { const target = yield* resolveTarget(input.target); - const entry = yield* lockedEntry(target, input.name); + return yield* updateLocked(target, input.name).pipe(changes.withPermits(1)); + }, + ); + + const updateLocked = (target: SkillInstallTarget, name: string) => + Effect.gen(function* () { + const entry = yield* lockedEntry(target, name); const from = lockedInstallSource(entry, target.kind === "environment" ? "home" : "project"); if (from === null) { return yield* new SkillLibraryError({ @@ -466,18 +476,23 @@ const make = Effect.gen(function* () { "This skill's lock doesn't record which host it came from. Install it again from its URL.", }); } - return yield* installInto(target, from.source, [input.name], from.fullDepth); - }, - ); + return yield* installInto(target, from.source, [name], from.fullDepth); + }); const remove: SkillLibrary["Service"]["remove"] = Effect.fn("SkillLibrary.remove")( function* (input) { const target = yield* resolveTarget(input.target); - yield* lockedEntry(target, input.name); + yield* removeLocked(target, input.name).pipe(changes.withPermits(1)); + }, + ); + + const removeLocked = (target: SkillInstallTarget, name: string) => + Effect.gen(function* () { + yield* lockedEntry(target, name); const paths = targetPaths(target); yield* cli .remove({ - skills: [input.name], + skills: [name], global: target.kind === "environment", cwd: paths.root, }) @@ -489,16 +504,15 @@ const make = Effect.gen(function* () { yield* refreshAgents(target); // The CLI reports a skill it couldn't delete without failing, so check what's left. const folderLeft = yield* fileSystem - .exists(path.join(paths.folder, input.name)) + .exists(path.join(paths.folder, name)) .pipe(Effect.orElseSucceed(() => false)); - const lockLeft = (yield* readLock(paths.lock)).has(input.name); + const lockLeft = (yield* readLock(paths.lock)).has(name); if (folderLeft || lockLeft) { return yield* cliFailed( - `Couldn't remove ${input.name}. Check the permissions of ${folderLeft ? paths.folder : paths.lock}.`, + `Couldn't remove ${name}. Check the permissions of ${folderLeft ? paths.folder : paths.lock}.`, ); } - }, - ); + }); return SkillLibrary.of({ inspect, preview, install, update, remove }); }); diff --git a/apps/server/src/skills/SkillsCli.ts b/apps/server/src/skills/SkillsCli.ts index 33a7aa1df331..215202394ec8 100644 --- a/apps/server/src/skills/SkillsCli.ts +++ b/apps/server/src/skills/SkillsCli.ts @@ -13,6 +13,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { resolveSelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; import * as ProcessRunner from "../processRunner.ts"; @@ -80,6 +81,9 @@ const MAX_OUTPUT_BYTES = 4 * 1024 * 1024; const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; const self = yield* resolveSelfInvocation(); + // The host's environment, including the PATH the server resolved, decides + // which home the CLI installs into, so it's passed in whole. + const environment = yield* HostProcessEnvironment; const run = (command: "add" | "remove", args: ReadonlyArray, cwd: string) => processRunner @@ -88,6 +92,7 @@ const make = Effect.gen(function* () { args: [...selfInvocationArgs(self, ["skills-cli", command, ...args])], cwd, env: { + ...environment, ELECTRON_RUN_AS_NODE: "1", DISABLE_TELEMETRY: "1", DO_NOT_TRACK: "1", diff --git a/packages/shared/src/agentTools.test.ts b/packages/shared/src/agentTools.test.ts index 88ac48272c4f..c82b5af448e3 100644 --- a/packages/shared/src/agentTools.test.ts +++ b/packages/shared/src/agentTools.test.ts @@ -104,8 +104,8 @@ describe("skill switches", () => { settings, skillsDisabledPatch(settings, projectId, ["grill-me"], false), ); - // One switch replaces the differently cased one, and it wins over the environment's spelling. - expect(next.projectSettingsOverrides[projectId]?.disabledSkills).toEqual({ "grill-me": false }); + // One switch replaces the differently cased one, spelled as the environment spells it. + expect(next.projectSettingsOverrides[projectId]?.disabledSkills).toEqual({ "Grill-Me": false }); expect(resolveProjectSettings(next, projectId).settings.disabledSkills).toEqual([]); }); }); diff --git a/packages/shared/src/agentTools.ts b/packages/shared/src/agentTools.ts index bd9a98551749..5a863afd0585 100644 --- a/packages/shared/src/agentTools.ts +++ b/packages/shared/src/agentTools.ts @@ -77,7 +77,10 @@ export function skillsDisabledPatch( for (const existing of Object.keys(switches)) { if (existing.toLowerCase() === name.toLowerCase()) delete switches[existing]; } - if (disabled !== isSkillDisabled(settings.disabledSkills, name)) switches[name] = disabled; + // Spelled as the environment spells it, so the switch reads as the same skill. + const spelling = + settings.disabledSkills.find((entry) => entry.toLowerCase() === name.toLowerCase()) ?? name; + if (disabled !== isSkillDisabled(settings.disabledSkills, name)) switches[spelling] = disabled; } return projectOverridePatch(settings, projectId, "disabledSkills", switches); } From 28399ce84d23883db3f93ee33be7982afabd93b9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 19:57:41 -0700 Subject: [PATCH 05/26] fix: rescan listed projects after a global skill change, and handle any skill name - A global install, update or remove now rescans every project an agent has listed, since a project's skill list includes the global skills. - Project skill switches use a null-prototype map, so a skill named __proto__ gets a switch like any other. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/skills/SkillLibrary.test.ts | 14 +++++++++-- apps/server/src/skills/SkillLibrary.ts | 26 ++++++++++++++------- packages/shared/src/agentTools.test.ts | 11 +++++++++ packages/shared/src/agentTools.ts | 6 ++++- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/apps/server/src/skills/SkillLibrary.test.ts b/apps/server/src/skills/SkillLibrary.test.ts index 757af762d2d1..ebec7741b376 100644 --- a/apps/server/src/skills/SkillLibrary.test.ts +++ b/apps/server/src/skills/SkillLibrary.test.ts @@ -256,13 +256,22 @@ it.effect( ); it.effect( - "keeps both lock entries when two installs run at once", + "keeps both lock entries when two installs run at once, and rescans listed projects", () => - withLibrary(({ library, home, source }) => + withLibrary(({ library, home, project, source, providers, refreshed }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const target = { kind: "environment" } as const; + // Claude has listed this project's skills, which include the global ones. + yield* Ref.set(providers, [ + { + ...claude, + workspaceSnapshots: [ + { cwd: project, checkedAt: "2026-10-09T00:00:00Z", slashCommands: [], skills: [] }, + ], + }, + ]); yield* Effect.all( [ library.install({ source, skills: ["notes"], target }), @@ -274,6 +283,7 @@ it.effect( yield* fileSystem.readFileString(path.join(home, ".agents", ".skill-lock.json")), ) as { skills: Record }; expect(Object.keys(lock.skills).toSorted()).toEqual(["notes", "review"]); + expect(yield* Ref.get(refreshed)).toContain(`workspace:claude:${project}`); }).pipe(Effect.provide(NodeServices.layer)), ), 120_000, diff --git a/apps/server/src/skills/SkillLibrary.ts b/apps/server/src/skills/SkillLibrary.ts index 2e08a83c117a..2b78b0b4a363 100644 --- a/apps/server/src/skills/SkillLibrary.ts +++ b/apps/server/src/skills/SkillLibrary.ts @@ -208,16 +208,24 @@ const make = Effect.gen(function* () { const refreshAgents = (target: SkillInstallTarget) => Effect.gen(function* () { const providers = yield* enabledProviders; + // A project's skill list includes the global skills, so a global change + // rescans every project an agent has listed, not just its own folders. + const scans = providers.flatMap((provider) => + target.kind === "environment" + ? (provider.workspaceSnapshots ?? []).map((snapshot) => ({ + instanceId: provider.instanceId, + cwd: snapshot.cwd, + })) + : [{ instanceId: provider.instanceId, cwd: target.cwd }], + ); yield* Effect.forEach( - providers, - (provider) => - target.kind === "environment" - ? providerRegistry.refreshInstance(provider.instanceId) - : providerRegistry.refreshWorkspaceSnapshot({ - instanceId: provider.instanceId, - cwd: target.cwd, - fresh: true, - }), + target.kind === "environment" ? providers : [], + (provider) => providerRegistry.refreshInstance(provider.instanceId), + { concurrency: "unbounded", discard: true }, + ); + yield* Effect.forEach( + scans, + (scan) => providerRegistry.refreshWorkspaceSnapshot({ ...scan, fresh: true }), { concurrency: "unbounded", discard: true }, ); }); diff --git a/packages/shared/src/agentTools.test.ts b/packages/shared/src/agentTools.test.ts index c82b5af448e3..592b2067788f 100644 --- a/packages/shared/src/agentTools.test.ts +++ b/packages/shared/src/agentTools.test.ts @@ -110,6 +110,17 @@ describe("skill switches", () => { }); }); +describe("skill switch names", () => { + it("switches a skill named __proto__ like any other", () => { + const settings = { ...DEFAULT_SERVER_SETTINGS, disabledSkills: [] } satisfies ServerSettings; + const next = applyServerSettingsPatch( + settings, + skillsDisabledPatch(settings, projectId, ["__proto__"], true), + ); + expect(resolveProjectSettings(next, projectId).settings.disabledSkills).toEqual(["__proto__"]); + }); +}); + describe("mcpServers patches", () => { it("replaces one server at a time and removes it with null", () => { const current = { ...DEFAULT_SERVER_SETTINGS, mcpServers: { linear, supabase } }; diff --git a/packages/shared/src/agentTools.ts b/packages/shared/src/agentTools.ts index 5a863afd0585..b041b3362abf 100644 --- a/packages/shared/src/agentTools.ts +++ b/packages/shared/src/agentTools.ts @@ -71,7 +71,11 @@ export function skillsDisabledPatch( ], }; } - const switches = { ...settings.projectSettingsOverrides[projectId]?.disabledSkills }; + // Null-prototype, so a skill named like an Object property (`__proto__`) is a key like any other. + const switches: Record = Object.assign( + Object.create(null), + settings.projectSettingsOverrides[projectId]?.disabledSkills, + ); for (const name of names) { // One switch per name, whatever its case. for (const existing of Object.keys(switches)) { From 58f6a829ee44807b2044085d20c4f625a2adf41a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 9 Oct 2026 20:02:53 -0700 Subject: [PATCH 06/26] fix: scan one folder's agents in turn, and ignore inherited server names - After a skill change, agents that list the same folder rescan it one after another: a fresh scan drops the others' snapshots of that folder, and the registry discards a scan whose starting snapshot changed. - mcpServerEnabledPatch reads only own keys, so `constructor` isn't taken for an inherited server. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/skills/SkillLibrary.test.ts | 37 +++++++++++++++++---- apps/server/src/skills/SkillLibrary.ts | 30 +++++++++++------ packages/shared/src/agentTools.test.ts | 11 +++++- packages/shared/src/agentTools.ts | 7 ++-- 4 files changed, 66 insertions(+), 19 deletions(-) diff --git a/apps/server/src/skills/SkillLibrary.test.ts b/apps/server/src/skills/SkillLibrary.test.ts index ebec7741b376..aac1b96891a9 100644 --- a/apps/server/src/skills/SkillLibrary.test.ts +++ b/apps/server/src/skills/SkillLibrary.test.ts @@ -84,13 +84,28 @@ const withLibrary = ( Ref.update(refreshed, (entries) => [...entries, entry]).pipe( Effect.andThen(Ref.get(providers)), ); + // Folders being scanned right now: a second scan of one is an overlap. + const scanning = yield* Ref.make>([]); + const scan = (instanceId: string, cwd: string) => + Ref.get(scanning).pipe( + Effect.tap((busy) => (busy.includes(cwd) ? record(`overlap:${cwd}`) : Effect.void)), + Effect.andThen(Ref.update(scanning, (busy) => [...busy, cwd])), + Effect.andThen(Effect.yieldNow), + Effect.andThen(record(`workspace:${instanceId}:${cwd}`)), + Effect.ensuring( + Ref.update(scanning, (busy) => { + const index = busy.indexOf(cwd); + return index === -1 ? busy : [...busy.slice(0, index), ...busy.slice(index + 1)]; + }), + ), + ); const stub: Pick< ProviderRegistry.ProviderRegistry["Service"], "getProviders" | "refreshInstance" | "refreshWorkspaceSnapshot" > = { getProviders: Ref.get(providers), refreshInstance: (instanceId) => record(`instance:${instanceId}`), - refreshWorkspaceSnapshot: ({ instanceId, cwd }) => record(`workspace:${instanceId}:${cwd}`), + refreshWorkspaceSnapshot: ({ instanceId, cwd }) => scan(instanceId, cwd), }; // SkillLibrary reads provider snapshots and asks for rescans, nothing else. const registry = stub as ProviderRegistry.ProviderRegistry["Service"]; @@ -263,13 +278,19 @@ it.effect( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const target = { kind: "environment" } as const; - // Claude has listed this project's skills, which include the global ones. + // Two agents have listed this project's skills, which include the global ones. + const listed = { + workspaceSnapshots: [ + { cwd: project, checkedAt: "2026-10-09T00:00:00Z", slashCommands: [], skills: [] }, + ], + }; yield* Ref.set(providers, [ + { ...claude, ...listed }, { ...claude, - workspaceSnapshots: [ - { cwd: project, checkedAt: "2026-10-09T00:00:00Z", slashCommands: [], skills: [] }, - ], + ...listed, + driver: ProviderDriverKind.make("codex"), + instanceId: ProviderInstanceId.make("codex"), }, ]); yield* Effect.all( @@ -283,7 +304,11 @@ it.effect( yield* fileSystem.readFileString(path.join(home, ".agents", ".skill-lock.json")), ) as { skills: Record }; expect(Object.keys(lock.skills).toSorted()).toEqual(["notes", "review"]); - expect(yield* Ref.get(refreshed)).toContain(`workspace:claude:${project}`); + const refreshes = yield* Ref.get(refreshed); + expect(refreshes).toContain(`workspace:claude:${project}`); + expect(refreshes).toContain(`workspace:codex:${project}`); + // A fresh scan drops other agents' snapshots of the folder, so they take turns. + expect(refreshes.filter((entry) => entry.startsWith("overlap:"))).toEqual([]); }).pipe(Effect.provide(NodeServices.layer)), ), 120_000, diff --git a/apps/server/src/skills/SkillLibrary.ts b/apps/server/src/skills/SkillLibrary.ts index 2b78b0b4a363..ed5268894fb7 100644 --- a/apps/server/src/skills/SkillLibrary.ts +++ b/apps/server/src/skills/SkillLibrary.ts @@ -210,22 +210,32 @@ const make = Effect.gen(function* () { const providers = yield* enabledProviders; // A project's skill list includes the global skills, so a global change // rescans every project an agent has listed, not just its own folders. - const scans = providers.flatMap((provider) => - target.kind === "environment" - ? (provider.workspaceSnapshots ?? []).map((snapshot) => ({ - instanceId: provider.instanceId, - cwd: snapshot.cwd, - })) - : [{ instanceId: provider.instanceId, cwd: target.cwd }], - ); + const scansByCwd = new Map>(); + for (const provider of providers) { + const cwds = + target.kind === "environment" + ? (provider.workspaceSnapshots ?? []).map((snapshot) => snapshot.cwd) + : [target.cwd]; + for (const cwd of cwds) + scansByCwd.set(cwd, [...(scansByCwd.get(cwd) ?? []), provider.instanceId]); + } yield* Effect.forEach( target.kind === "environment" ? providers : [], (provider) => providerRegistry.refreshInstance(provider.instanceId), { concurrency: "unbounded", discard: true }, ); + // A fresh scan drops the other agents' snapshots of its folder, and a + // scan whose starting snapshot changed is discarded, so one folder's + // agents scan one after another. Different folders scan in parallel. yield* Effect.forEach( - scans, - (scan) => providerRegistry.refreshWorkspaceSnapshot({ ...scan, fresh: true }), + scansByCwd, + ([cwd, instanceIds]) => + Effect.forEach( + instanceIds, + (instanceId) => + providerRegistry.refreshWorkspaceSnapshot({ instanceId, cwd, fresh: true }), + { discard: true }, + ), { concurrency: "unbounded", discard: true }, ); }); diff --git a/packages/shared/src/agentTools.test.ts b/packages/shared/src/agentTools.test.ts index 592b2067788f..6c92ead66e1e 100644 --- a/packages/shared/src/agentTools.test.ts +++ b/packages/shared/src/agentTools.test.ts @@ -10,7 +10,7 @@ import { import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { skillsDisabledPatch } from "./agentTools.ts"; +import { mcpServerEnabledPatch, skillsDisabledPatch } from "./agentTools.ts"; import { resolveProjectSettings } from "./projectSettings.ts"; import { applyServerSettingsPatch } from "./serverSettings.ts"; @@ -121,6 +121,15 @@ describe("skill switch names", () => { }); }); +describe("server switch names", () => { + it("refuses a server the settings only inherit from Object", () => { + expect( + mcpServerEnabledPatch(DEFAULT_SERVER_SETTINGS, projectId, "constructor", false), + ).toBeNull(); + expect(mcpServerEnabledPatch(DEFAULT_SERVER_SETTINGS, null, "constructor", false)).toBeNull(); + }); +}); + describe("mcpServers patches", () => { it("replaces one server at a time and removes it with null", () => { const current = { ...DEFAULT_SERVER_SETTINGS, mcpServers: { linear, supabase } }; diff --git a/packages/shared/src/agentTools.ts b/packages/shared/src/agentTools.ts index b041b3362abf..a707f1c9d297 100644 --- a/packages/shared/src/agentTools.ts +++ b/packages/shared/src/agentTools.ts @@ -101,14 +101,17 @@ export function mcpServerEnabledPatch( name: string, enabled: boolean, ): ServerSettingsPatch | null { - const inherited = settings.mcpServers[name]; + // Own keys only: `constructor` must not read as a server the object inherits. + const inherited = Object.hasOwn(settings.mcpServers, name) + ? settings.mcpServers[name] + : undefined; if (projectId === null) { return inherited ? { mcpServers: { [name]: { ...inherited, enabled } } } : null; } const entries: Record = { ...settings.projectSettingsOverrides[projectId]?.mcpServers, }; - const own = entries[name]; + const own = Object.hasOwn(entries, name) ? entries[name] : undefined; if (own?.transport !== undefined) { entries[name] = { ...own, enabled }; } else if (inherited === undefined) { From bd9994d354561199ffcfd52c5d457ed24439f719 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 20:16:49 -0700 Subject: [PATCH 07/26] feat(web): Skills list and skill page in the style of #17513 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the Skills UI from n0mahd's skills series (#17513) onto the Tools page: - dense one-line rows, sections with counts and a hint ('This project · 2 · Lives in this repo'), ✦ when every agent loads a skill; - installed packs as collapsible 'From owner/repo' groups with one switch and the first three skills shown; - a Needs attention filter (conflicts, and project or global skills an enabled agent doesn't load); - a skill page replacing the View files dialog: Used by chips, the source, Includes scripts with the script files, Copy path, Update and Remove, a file tree with script labels, SKILL.md rendered or as source, each copy of a conflicting name, and Escape back to the list. The server marks each file as a script, so the list and the tree agree. After an install, one agent rescans a project fresh and the rest follow without dropping its result, so every agent's list shows the new skill. Co-Authored-By: n0mahd <39080654+n0mahd@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- apps/server/src/skills/SkillLibrary.test.ts | 26 +- apps/server/src/skills/SkillLibrary.ts | 15 +- .../src/components/settings/SkillAgents.tsx | 61 +++ .../src/components/settings/SkillDetail.tsx | 252 ++++++++++ .../src/components/settings/SkillFiles.tsx | 193 +++++++ .../components/settings/SkillFilesDialog.tsx | 95 ---- .../web/src/components/settings/SkillList.tsx | 238 +++++++++ .../src/components/settings/ToolsSettings.tsx | 470 ++++++++---------- .../settings/toolsSettings.logic.test.ts | 53 ++ .../settings/toolsSettings.logic.ts | 159 +++++- apps/web/src/hooks/useAfterDelay.ts | 18 + packages/contracts/src/skills.ts | 2 + 12 files changed, 1196 insertions(+), 386 deletions(-) create mode 100644 apps/web/src/components/settings/SkillAgents.tsx create mode 100644 apps/web/src/components/settings/SkillDetail.tsx create mode 100644 apps/web/src/components/settings/SkillFiles.tsx delete mode 100644 apps/web/src/components/settings/SkillFilesDialog.tsx create mode 100644 apps/web/src/components/settings/SkillList.tsx create mode 100644 apps/web/src/hooks/useAfterDelay.ts diff --git a/apps/server/src/skills/SkillLibrary.test.ts b/apps/server/src/skills/SkillLibrary.test.ts index aac1b96891a9..5d6b8f91607b 100644 --- a/apps/server/src/skills/SkillLibrary.test.ts +++ b/apps/server/src/skills/SkillLibrary.test.ts @@ -105,7 +105,10 @@ const withLibrary = ( > = { getProviders: Ref.get(providers), refreshInstance: (instanceId) => record(`instance:${instanceId}`), - refreshWorkspaceSnapshot: ({ instanceId, cwd }) => scan(instanceId, cwd), + refreshWorkspaceSnapshot: ({ instanceId, cwd, fresh }) => + record(`fresh:${instanceId}:${cwd}:${fresh === true}`).pipe( + Effect.andThen(scan(instanceId, cwd)), + ), }; // SkillLibrary reads provider snapshots and asks for rescans, nothing else. const registry = stub as ProviderRegistry.ProviderRegistry["Service"]; @@ -158,10 +161,10 @@ it.effect( ], ); assert.deepEqual( - preview.skills[1]?.files.map((file) => [file.path, file.executable]), + preview.skills[1]?.files.map((file) => [file.path, file.executable, file.script]), [ - ["SKILL.md", false], - ["bin/run", true], + ["SKILL.md", false, false], + ["bin/run", true, true], ], ); assert.isFalse(yield* fileSystem.exists(`${home}/.agents`)); @@ -187,7 +190,10 @@ it.effect( const claudeLink = path.join(project, ".claude", "skills", "review", "SKILL.md"); assert.isTrue(yield* fileSystem.exists(claudeLink)); assert.isFalse(yield* fileSystem.exists(path.join(project, ".agents/skills/notes"))); - assert.deepEqual(yield* Ref.get(refreshed), [`workspace:claude:${project}`]); + assert.deepEqual( + (yield* Ref.get(refreshed)).filter((entry) => entry.startsWith("workspace:")), + [`workspace:claude:${project}`], + ); yield* Ref.set(providers, [ { @@ -307,8 +313,16 @@ it.effect( const refreshes = yield* Ref.get(refreshed); expect(refreshes).toContain(`workspace:claude:${project}`); expect(refreshes).toContain(`workspace:codex:${project}`); - // A fresh scan drops other agents' snapshots of the folder, so they take turns. + // A fresh scan drops other agents' snapshots of the folder, so they take turns, + // and only the first is fresh: a later one would drop the first one's result. expect(refreshes.filter((entry) => entry.startsWith("overlap:"))).toEqual([]); + // Each of the two installs rescans the project once, Claude fresh and Codex after it. + expect(refreshes.filter((entry) => entry.startsWith("fresh:"))).toEqual([ + `fresh:claude:${project}:true`, + `fresh:codex:${project}:false`, + `fresh:claude:${project}:true`, + `fresh:codex:${project}:false`, + ]); }).pipe(Effect.provide(NodeServices.layer)), ), 120_000, diff --git a/apps/server/src/skills/SkillLibrary.ts b/apps/server/src/skills/SkillLibrary.ts index ed5268894fb7..96355cbad6c6 100644 --- a/apps/server/src/skills/SkillLibrary.ts +++ b/apps/server/src/skills/SkillLibrary.ts @@ -224,7 +224,9 @@ const make = Effect.gen(function* () { (provider) => providerRegistry.refreshInstance(provider.instanceId), { concurrency: "unbounded", discard: true }, ); - // A fresh scan drops the other agents' snapshots of its folder, and a + // A fresh scan drops every other agent's snapshot of its folder. So one + // agent scans fresh, which drops the rest's stale lists (and clears its + // own caches), then the others rescan without dropping it in turn. A // scan whose starting snapshot changed is discarded, so one folder's // agents scan one after another. Different folders scan in parallel. yield* Effect.forEach( @@ -232,8 +234,8 @@ const make = Effect.gen(function* () { ([cwd, instanceIds]) => Effect.forEach( instanceIds, - (instanceId) => - providerRegistry.refreshWorkspaceSnapshot({ instanceId, cwd, fresh: true }), + (instanceId, index) => + providerRegistry.refreshWorkspaceSnapshot({ instanceId, cwd, fresh: index === 0 }), { discard: true }, ), { concurrency: "unbounded", discard: true }, @@ -270,18 +272,19 @@ const make = Effect.gen(function* () { } if (!link && info?.type !== "File") continue; if (files.length >= MAX_FILES) return { files, truncated: true }; + const executable = info !== undefined && (info.mode & 0o111) !== 0; files.push({ path: childPath, size: info === undefined ? 0 : Number(info.size), - executable: info !== undefined && (info.mode & 0o111) !== 0, + executable, + script: executable || childPath.startsWith("bin/") || SCRIPT_EXTENSION.test(childPath), }); } } return { files, truncated }; }); - const hasScripts = (files: ReadonlyArray) => - files.some((file) => file.executable || SCRIPT_EXTENSION.test(file.path)); + const hasScripts = (files: ReadonlyArray) => files.some((file) => file.script); const sha256 = (text: string) => crypto.digest("SHA-256", new TextEncoder().encode(text)).pipe( diff --git a/apps/web/src/components/settings/SkillAgents.tsx b/apps/web/src/components/settings/SkillAgents.tsx new file mode 100644 index 000000000000..1258e9b59ebe --- /dev/null +++ b/apps/web/src/components/settings/SkillAgents.tsx @@ -0,0 +1,61 @@ +import { SparklesIcon } from "lucide-react"; + +import { shouldShowInstanceBadge } from "../../providerInstances"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { availabilityNote, type SkillAgent, type SkillAvailability } from "./toolsSettings.logic"; + +/** An agent's icon. Instances that share a driver carry a badge, so they can be told apart. */ +export function SkillAgentIcon({ + agent, + agents, + active = true, +}: { + agent: SkillAgent; + /** Every agent on the page, to know whether this one shares its driver with another. */ + agents: ReadonlyArray; + active?: boolean; +}) { + return ( + + + + ); +} + +/** + * Who loads a skill: one mark when every enabled agent does, otherwise the + * agents that do. A tight run of icons, so a row stays on one line. + */ +export function SkillAgents({ + value, + agents, +}: { + value: SkillAvailability; + agents: ReadonlyArray; +}) { + const label = availabilityNote(value); + if (!value.everyone && value.agents.length === 0) return null; + return ( + + } + > + {value.everyone ? ( + + ) : ( + value.agents.map((agent) => ( + + )) + )} + + {label} + + ); +} diff --git a/apps/web/src/components/settings/SkillDetail.tsx b/apps/web/src/components/settings/SkillDetail.tsx new file mode 100644 index 000000000000..a923b80c6963 --- /dev/null +++ b/apps/web/src/components/settings/SkillDetail.tsx @@ -0,0 +1,252 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { AlertTriangleIcon, ArrowLeftIcon, MoreHorizontalIcon } from "lucide-react"; +import { lazy, Suspense, useEffect, useEffectEvent, useState } from "react"; + +import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; +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 { Toggle, ToggleGroup } from "../ui/toggle-group"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SkillAgentIcon } from "./SkillAgents"; +import { + skillAttention, + type SkillAgent, + type SkillRow, + type SkillRowDetails, +} from "./toolsSettings.logic"; + +// The tree and the viewer pull in the file-tree and highlighter code. +const SkillFiles = lazy(() => import("./SkillFiles")); + +/** + * 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. 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 }); + }, []); +} + +/** + * One skill: what it says, which agents load it, and its files. Adapted from + * the skill page in #17513. + */ +export function SkillDetail({ + row, + details, + agents, + groupLabel, + environmentId, + onBack, + onUpdate, + onRemove, +}: { + row: SkillRow; + details: SkillRowDetails; + agents: ReadonlyArray; + /** The list section the skill came from, for the breadcrumb. */ + groupLabel: string; + environmentId: EnvironmentId; + onBack: () => void; + onUpdate: (() => void) | null; + onRemove: (() => void) | null; +}) { + useEscapeToList(onBack); + const loads = new Set(row.providers.map((provider) => provider.instanceId)); + const warning = skillAttention(row, details, agents); + // A name can be several folders (a conflict, or a project copy over a global one). + const [folderPath, setFolderPath] = useState(details.folder?.folder ?? null); + const folder = + details.copies.find((copy) => copy.folder === folderPath) ?? details.folder ?? null; + const scripts = folder?.files.filter((file) => file.script).map((file) => file.path) ?? []; + + const copyPath = (path: string) => { + void writeTextToClipboard(path, "skill path").then( + (copied) => { + if (copied) 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 ( +
+ +
+
+

{row.name}

+

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

+
+
+ Used by + {agents.length === 0 ? ( + No agents are enabled. + ) : null} + {agents.map((agent) => { + const on = loads.has(agent.instanceId); + return ( + + + } + > + + {agent.displayName} + + + {on + ? `${agent.displayName} loads this skill.` + : `${agent.displayName} doesn't load this skill.`} + + + ); + })} + + {folder !== null || onUpdate !== null || onRemove !== null ? ( + + } + > + + + + {folder !== null ? ( + copyPath(folder.folder)}>Copy path + ) : null} + {onUpdate !== null ? Update : null} + {onRemove !== null ? ( + + Remove + + ) : null} + + + ) : null} +
+ {warning !== null || scripts.length > 0 || details.installed !== null ? ( +
+ {details.installed !== null ? ( + + From {details.installed.source} + + ) : null} + {warning !== null ? ( + {warning.detail} + ) : null} + {scripts.length > 0 ? ( + + + } + > + + Includes scripts + + + {scripts.slice(0, 8).join(", ")} + {scripts.length > 8 ? ` and ${scripts.length - 8} more` : ""} + + + ) : null} +
+ ) : null} +
+ +
+ {folder !== null ? ( +
+ {details.copies.length > 1 ? ( + { + if (next[0] !== undefined) setFolderPath(next[0]); + }} + > + {details.copies.map((copy, index) => ( + + Copy {index + 1} + + ))} + + ) : null} + + {folder.folder} + +
+ ) : null} + {folder === null ? ( +

+ This agent doesn't say where the skill's files are, so they can't be shown. +

+ ) : ( + + + +
+ } + > + + + )} + {folder?.filesTruncated ? ( +

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

+ ) : null} +
+ + ); +} diff --git a/apps/web/src/components/settings/SkillFiles.tsx b/apps/web/src/components/settings/SkillFiles.tsx new file mode 100644 index 000000000000..09860d98d7b1 --- /dev/null +++ b/apps/web/src/components/settings/SkillFiles.tsx @@ -0,0 +1,193 @@ +import { FileTree, useFileTree } from "@pierre/trees/react"; +import type { EnvironmentId, SkillFileEntry } 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 { useProjectFileQuery } from "../files/projectFilesQueryState"; +import ReadOnlySourcePreview from "../files/ReadOnlySourcePreview"; +import { Button } from "../ui/button"; +import { compareSkillFiles, skillBody } from "./toolsSettings.logic"; + +const SKILL_FILE = "SKILL.md"; +/** The tree and the viewer share one height, so the page doesn't jump between files. */ +const PANE_HEIGHT = "h-[26rem]"; + +/** + * A skill's files as a tree beside a read-only viewer. Loaded when a skill + * opens, since the tree and the highlighter are large. Adapted from the + * skills page in #17513. + */ +export default function SkillFiles({ + environmentId, + folder, + files, +}: { + environmentId: EnvironmentId; + /** The skill's folder after following links. */ + folder: string; + files: ReadonlyArray; +}) { + const { resolvedTheme } = useTheme(); + const fileSet = useMemo(() => new Set(files.map((file) => file.path)), [files]); + const scripts = useMemo( + () => new Set(files.filter((file) => file.script).map((file) => file.path)), + [files], + ); + const [current, setCurrent] = useState( + fileSet.has(SKILL_FILE) ? SKILL_FILE : (files[0]?.path ?? ""), + ); + const [treeOpen, setTreeOpen] = useState(false); + 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" above. + 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 !== undefined && fileSet.has(path)) { + setCurrent(path); + setTreeOpen(false); + } + }, + }); + + return ( +
+
+
+ +
+
+ +
+
+ +
+ ); +} + +/** One file, read when it is picked. SKILL.md also renders as text. */ +function FilePane({ + environmentId, + folder, + path, +}: { + environmentId: EnvironmentId; + folder: string; + path: string; +}) { + const file = useProjectFileQuery(environmentId, folder, path === "" ? null : path); + const [source, setSource] = useState(false); + const isSkillFile = path === SKILL_FILE; + return ( +
+
+ {path} + + {isSkillFile ? ( + <> + + + + ) : ( + Read-only + )} +
+
+ {file.data !== null ? ( + <> + {file.data.truncated ? ( +

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

+ ) : null} + {isSkillFile && !source ? ( +
+ + {skillBody(file.data.contents)} + +
+ ) : ( + + )} + + ) : file.isPending ? ( +

+ Loading {path}… +

+ ) : ( +

+ {file.readError?.failure === "binary_file" + ? "This file isn't text." + : (file.error ?? "This file can't be previewed here.")} +

+ )} +
+
+ ); +} + +/** + * SKILL.md's body as text. 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/SkillFilesDialog.tsx b/apps/web/src/components/settings/SkillFilesDialog.tsx deleted file mode 100644 index 38f111531012..000000000000 --- a/apps/web/src/components/settings/SkillFilesDialog.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type { EnvironmentId, SkillFolderInfo } from "@t3tools/contracts"; -import { FileIcon, TriangleAlertIcon } from "lucide-react"; -import { useState } from "react"; - -import { cn } from "~/lib/utils"; -import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview"; -import { - FileSurfaceFailure, - FileSurfaceLoading, - FileSurfaceNotice, -} from "../files/fileSurfaceChrome"; -import { useProjectFileQuery } from "../files/projectFilesQueryState"; -import { Badge } from "../ui/badge"; -import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog"; - -/** A skill's files and one file's text, read-only, to check what a skill does. */ -export function SkillFilesDialog({ - open, - onOpenChange, - environmentId, - name, - folder, -}: { - readonly open: boolean; - readonly onOpenChange: (open: boolean) => void; - readonly environmentId: EnvironmentId; - readonly name: string; - readonly folder: SkillFolderInfo; -}) { - const [selected, setSelected] = useState("SKILL.md"); - const file = useProjectFileQuery(environmentId, folder.folder, selected, open); - - return ( - - - -
- - {name} - - {folder.scripts ? ( - - - Includes scripts - - ) : null} -
-

{folder.folder}

-
-
-
    - {folder.files.map((entry) => ( -
  • - -
  • - ))} - {folder.filesTruncated ? ( -
  • More files not shown
  • - ) : null} -
-
- {file.data?.truncated ? ( - Showing the first 1 MB. - ) : null} - {file.data !== null ? ( -
- -
- ) : file.readError?.failure === "binary_file" ? ( - - ) : file.error !== null ? ( - - ) : ( - - )} -
-
-
-
- ); -} diff --git a/apps/web/src/components/settings/SkillList.tsx b/apps/web/src/components/settings/SkillList.tsx new file mode 100644 index 000000000000..8ddc6748b8fe --- /dev/null +++ b/apps/web/src/components/settings/SkillList.tsx @@ -0,0 +1,238 @@ +import { ChevronRightIcon } from "lucide-react"; +import { memo, useState, type MouseEvent } from "react"; + +import { cn } from "../../lib/utils"; +import { GitHubIcon } from "../Icons"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Switch } from "../ui/switch"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SettingsGroup } from "./SettingsGroup"; +import { SkillAgents } from "./SkillAgents"; +import { + SOURCE_GROUP_PREVIEW, + skillAttention, + skillAvailability, + type SkillAgent, + type SkillRow, + type SkillRowDetails, +} from "./toolsSettings.logic"; + +/* + * The Skills list: dense one-line rows grouped by section and by the source a + * pack was installed from. Adapted from the skills list in #17513. + */ + +/** Keeps a click on a control inside a clickable row from also opening the row. */ +const stopRowClick = (event: MouseEvent) => event.stopPropagation(); + +export interface SkillListRow { + readonly row: SkillRow; + readonly details: SkillRowDetails; + /** The skill is off here, by this scope's settings. */ + readonly disabled: boolean; + /** The project switch differs from the environment's. */ + readonly overridden: boolean; +} + +interface RowHandlers { + readonly agents: ReadonlyArray; + readonly canWrite: boolean; + readonly onToggle: (rows: ReadonlyArray, enabled: boolean) => void; + readonly onOpen: (name: string) => void; +} + +const SkillListItem = memo(function SkillListItem({ + item, + nested, + agents, + canWrite, + onToggle, + onOpen, +}: RowHandlers & { item: SkillListRow; nested: boolean }) { + const { row, details, disabled, overridden } = item; + const lockedOff = row.disabledByProvider; + const warning = skillAttention(row, details, agents); + return ( +
  • +
    onOpen(row.name)} + className={cn( + "flex cursor-pointer items-center gap-3 py-2 pr-3 hover:bg-muted/40 sm:pr-4", + nested ? "pl-9 sm:pl-10" : "pl-3 sm:pl-4", + )} + > + + + {overridden ? ( + + This project + + ) : null} + {warning?.kind === "conflict" ? ( + + }> + + Conflict + + + + {warning.detail} + + + ) : null} + + + + + onToggle([row], enabled)} + /> + + + +
    +
  • + ); +}); + +/** A pack's heading row and its skills: the first few, and a row that reveals the rest. */ +function SourceGroup({ + source, + items, + ...handlers +}: RowHandlers & { source: string; items: ReadonlyArray }) { + const [open, setOpen] = useState(true); + const [showAll, setShowAll] = useState(false); + const allOff = items.every((item) => item.disabled || item.row.disabledByProvider); + const shown = showAll ? items : items.slice(0, SOURCE_GROUP_PREVIEW); + const hidden = items.length - shown.length; + const github = /^[\w.-]+\/[\w.-]+$/.test(source) || source.includes("github.com"); + return ( + <> +
  • +
    setOpen((value) => !value)} + className="flex cursor-pointer items-center gap-2 px-3 py-2 hover:bg-muted/40 sm:px-4" + > + + + + + handlers.onToggle( + items.map((item) => item.row), + enabled, + ) + } + /> + + {/* The width of a row's chevron, so this switch sits over the rows' switches. */} + +
    +
  • + {open + ? shown.map((item) => ( + + )) + : null} + {open && items.length > SOURCE_GROUP_PREVIEW ? ( +
  • + +
  • + ) : null} + + ); +} + +/** One section: its name and count, then its packs and loose skills. */ +export function SkillListSection({ + title, + hint, + total, + groups, + emptyText, + ...handlers +}: RowHandlers & { + title: string; + /** A short muted phrase beside the name, in plain words. */ + hint: string; + /** Every skill in the section before the search narrows it. */ + total: number; + groups: ReadonlyArray<{ + readonly source: string | null; + readonly rows: ReadonlyArray; + }>; + emptyText: string; +}) { + return ( +
    +

    + + {title} · {total} + + {hint} +

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

    {emptyText}

    + ) : ( +
      + {groups.map(({ source, rows }) => + source === null ? ( + rows.map((item) => ( + + )) + ) : ( + + ), + )} +
    + )} +
    +
    + ); +} diff --git a/apps/web/src/components/settings/ToolsSettings.tsx b/apps/web/src/components/settings/ToolsSettings.tsx index 01c5a85dc888..41e1ade358d0 100644 --- a/apps/web/src/components/settings/ToolsSettings.tsx +++ b/apps/web/src/components/settings/ToolsSettings.tsx @@ -8,7 +8,6 @@ import { type ProjectId, type ServerProvider, type ServerSettingsPatch, - type SkillFolderInfo, type SkillInstallTarget, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; @@ -19,14 +18,14 @@ import { skillsDisabledPatch, } from "@t3tools/shared/agentTools"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; -import { MoreHorizontalIcon, PlusIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; +import { MoreHorizontalIcon, PlusIcon, SearchIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAfterDelay } from "../../hooks/useAfterDelay"; import { formatEnvironmentQueryError, useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; import { useEnvironmentsWithScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; -import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { AlertDialog, AlertDialogClose, @@ -40,12 +39,16 @@ import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { RefreshIcon } from "../ui/refresh-icon"; +import { Skeleton } from "../ui/skeleton"; import { Switch } from "../ui/switch"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AddSkillsDialog } from "./AddSkillsDialog"; import { McpServerDialog } from "./McpServerDialog"; -import { SkillFilesDialog } from "./SkillFilesDialog"; +import { SettingsGroup } from "./SettingsGroup"; +import { SkillDetail } from "./SkillDetail"; +import { SkillListSection, type SkillListRow } from "./SkillList"; import { useSettingsScope } from "./SettingsScopeContext"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -55,10 +58,12 @@ import { groupSkillRows, listMcpServerRows, type McpServerRow, + SKILL_GROUP_HINTS, SKILL_GROUP_LABELS, - skillReachesSomeProviders, + skillAgents, + skillAttention, + type SkillGroupKind, type SkillRow, - type SkillRowDetails, skillRowDetails, splitRowsBySource, type ToolsTab, @@ -66,6 +71,8 @@ import { import { useScopedSettings, useScopedSettingsWriteAllowed } from "./useScopedSettings"; const EMPTY_PROVIDERS: ReadonlyArray = []; +/** A scan that finishes sooner than this shows no placeholder at all. */ +const SKELETON_DELAY_MS = 150; interface ToolsTarget { readonly environmentId: EnvironmentId; @@ -212,6 +219,9 @@ function SkillsPanel() { reportFailure: false, }); const [query, setQuery] = useState(""); + const [onlyAttention, setOnlyAttention] = useState(false); + // The skill whose page is open in place of the list. + const [openName, setOpenName] = useState(null); const [refreshing, setRefreshing] = useState(false); const enabledProviders = useMemo( () => providers.filter((provider) => provider.enabled), @@ -231,10 +241,6 @@ function SkillsPanel() { const updateSkill = useAtomCommand(serverEnvironment.updateSkill, { reportFailure: false }); const removeSkill = useAtomCommand(serverEnvironment.removeSkill, { reportFailure: false }); const [adding, setAdding] = useState(false); - const [viewing, setViewing] = useState<{ - readonly name: string; - readonly folder: SkillFolderInfo; - } | null>(null); const [removingSkill, setRemovingSkill] = useState<{ readonly name: string; readonly target: SkillInstallTarget; @@ -321,13 +327,36 @@ function SkillsPanel() { }, [cwd, refresh, scanKey]); const rows = useMemo(() => collectSkillRows(providers, cwd), [cwd, providers]); - const groups = useMemo(() => groupSkillRows(filterSkillRows(rows, query)), [query, rows]); + const agents = useMemo(() => skillAgents(providers), [providers]); const detailsByName = useMemo( () => new Map(rows.map((row) => [row.name, skillRowDetails(row, foldersByPath)])), [foldersByPath, rows], ); const detailsOf = (row: SkillRow) => detailsByName.get(row.name) ?? skillRowDetails(row, foldersByPath); + const attentionNames = useMemo( + () => + new Set( + rows + .filter((row) => { + const details = detailsByName.get(row.name); + return details !== undefined && skillAttention(row, details, agents) !== null; + }) + .map((row) => row.name), + ), + [agents, detailsByName, rows], + ); + const visibleRows = useMemo( + () => + filterSkillRows(rows, query).filter((row) => !onlyAttention || attentionNames.has(row.name)), + [attentionNames, onlyAttention, query, rows], + ); + const groups = useMemo(() => groupSkillRows(visibleRows), [visibleRows]); + const totals = useMemo(() => { + const counts = new Map(); + for (const row of rows) counts.set(row.group, (counts.get(row.group) ?? 0) + 1); + return counts; + }, [rows]); const setSkillsDisabled = (skillRows: ReadonlyArray, disabled: boolean) => persist(({ settings, projectId }) => @@ -338,152 +367,153 @@ function SkillsPanel() { disabled, ), ); - const setSkillDisabled = (name: string, disabled: boolean) => { - const row = rows.find((candidate) => candidate.name === name); - if (row !== undefined) setSkillsDisabled([row], disabled); + const projectOverrides = (name: string) => + isProjectScope && + targets.some( + (candidate) => + candidate.projectId !== null && + Object.hasOwn( + target?.settings.projectSettingsOverrides[candidate.projectId]?.disabledSkills ?? {}, + name, + ), + ); + const listItem = (row: SkillRow): SkillListRow => ({ + row, + details: detailsOf(row), + disabled: isSkillDisabled(disabledSkills, row.name), + overridden: projectOverrides(row.name), + }); + const updateOf = (row: SkillRow) => { + const installed = detailsOf(row).installed; + return installed === null || !canInstall + ? null + : () => void runSkillAction("update", { name: row.name, target: installed.target }); }; + const removeOf = (row: SkillRow) => { + const installed = detailsOf(row).installed; + return installed === null || !canInstall + ? null + : () => setRemovingSkill({ name: row.name, target: installed.target }); + }; + + const opened = openName === null ? null : (rows.find((row) => row.name === openName) ?? null); + const showSkeleton = useAfterDelay(rows.length === 0 && refreshing, SKELETON_DELAY_MS); return ( <> - -
    - - - - - setQuery(event.target.value)} - aria-label="Filter skills" - /> - - - void refresh(true)} - /> - } - > - - - Rescan skill folders - - {installTarget !== null && canInstall ? ( - - ) : null} -
    - {skillActionError !== null ? ( -

    {skillActionError}

    - ) : null} -
    - {rows.length === 0 ? ( - - - - ) : groups.length === 0 ? ( - - - + {opened !== null && environmentId !== null ? ( + setOpenName(null)} + onUpdate={updateOf(opened)} + onRemove={removeOf(opened)} + /> ) : ( - groups.map(({ group, rows: groupRows }) => ( - - {splitRowsBySource(groupRows, (row) => detailsOf(row).installed?.source ?? null).map( - ({ source, rows: sourceRows }) => { - const renderRow = (row: SkillRow) => { - const details = detailsOf(row); - const { folder, installed } = details; - return ( - - candidate.projectId !== null && - Object.hasOwn( - target?.settings.projectSettingsOverrides[candidate.projectId] - ?.disabledSkills ?? {}, - row.name, - ), - ) - } - showProviders={skillReachesSomeProviders(row, enabledProviders.length)} - canWrite={canWrite} - onChange={(enabled) => setSkillDisabled(row.name, !enabled)} - onView={folder === null ? null : () => setViewing({ name: row.name, folder })} - onUpdate={ - installed === null || !canInstall - ? null - : () => - void runSkillAction("update", { - name: row.name, - target: installed.target, - }) - } - onRemove={ - installed === null || !canInstall - ? null - : () => setRemovingSkill({ name: row.name, target: installed.target }) - } - /> - ); - }; - if (source === null) return sourceRows.map(renderRow); - const allOff = sourceRows.every((row) => isSkillDisabled(disabledSkills, row.name)); - return ( -
    - - From {source} - - } - control={ - setSkillsDisabled(sourceRows, !enabled)} - /> - } + <> + +
    + + + + + setQuery(event.target.value)} + aria-label="Search skills" + /> + + {rows.length > 0 ? ( + + ) : null} + + void refresh(true)} /> -
    {sourceRows.map(renderRow)}
    -
    - ); - }, - )} + } + > + + + Rescan skill folders + + {installTarget !== null && canInstall ? ( + + ) : null} +
    + {skillActionError !== null ? ( +

    {skillActionError}

    + ) : null}
    - )) + {showSkeleton ? : null} + {rows.length === 0 && !refreshing ? ( + + + + ) : rows.length > 0 && groups.length === 0 ? ( + + + + ) : ( + groups.map(({ group, rows: groupRows }) => ( + item.details.installed?.source ?? null, + )} + emptyText="No matching skills." + agents={agents} + canWrite={canWrite} + onToggle={(skillRows, enabled) => setSkillsDisabled(skillRows, !enabled)} + onOpen={setOpenName} + /> + )) + )} +

    + Turning a skill off hides it from Claude, Codex and OpenCode, and from the composer's + skill menu. Other agents may still load it on their own. Changes apply to new sessions. +

    + )} -

    - Turning a skill off hides it from Claude, Codex and OpenCode, and from the composer's skill - menu. Other agents may still load it on their own. Changes apply to new sessions. -

    {adding && environmentId !== null && installTarget !== null ? ( ) : null} - {viewing !== null && environmentId !== null ? ( - { - if (!open) setViewing(null); - }} - environmentId={environmentId} - name={viewing.name} - folder={viewing.folder} - /> - ) : null} !open && setRemovingSkill(null)} @@ -522,7 +541,10 @@ function SkillsPanel() { - / {groupLabel} -

    {row.name}

    diff --git a/apps/web/src/components/settings/ToolsSettings.tsx b/apps/web/src/components/settings/ToolsSettings.tsx index 41e1ade358d0..71f696f227f9 100644 --- a/apps/web/src/components/settings/ToolsSettings.tsx +++ b/apps/web/src/components/settings/ToolsSettings.tsx @@ -169,16 +169,18 @@ function usePersistToolsPatch() { ); } -/** - * Replace one key of a project's override entry, dropping the key (or the entry) when empty. - * The rest of the entry is resent as stored, since the patch replaces the whole entry. - */ +/** Settings → Tools: the skills and MCP servers agents get, as two tabs. */ export function ToolsSettings({ tab = "skills", + skill, onTabChange, + onOpenSkill, }: { readonly tab?: ToolsTab; + /** The skill whose page is open in place of the list; the header names it. */ + readonly skill?: string; readonly onTabChange: (tab: ToolsTab) => void; + readonly onOpenSkill: (name: string | null) => void; }) { const selectTab = onTabChange; return ( @@ -197,14 +199,24 @@ export function ToolsSettings({ MCP servers
    - {tab === "mcp" ? : } + {tab === "mcp" ? ( + + ) : ( + + )} ); } // ── Skills ─────────────────────────────────────────────────────────── -function SkillsPanel() { +function SkillsPanel({ + openName, + onOpen, +}: { + openName: string | null; + onOpen: (name: string | null) => void; +}) { const { scope, target } = useSettingsScope(); const environmentId = target?.environmentId ?? null; const targets = useToolsTargets(); @@ -220,8 +232,6 @@ function SkillsPanel() { }); const [query, setQuery] = useState(""); const [onlyAttention, setOnlyAttention] = useState(false); - // The skill whose page is open in place of the list. - const [openName, setOpenName] = useState(null); const [refreshing, setRefreshing] = useState(false); const enabledProviders = useMemo( () => providers.filter((provider) => provider.enabled), @@ -407,12 +417,22 @@ function SkillsPanel() { row={opened} details={detailsOf(opened)} agents={agents} - groupLabel={SKILL_GROUP_LABELS[opened.group]} environmentId={environmentId} - onBack={() => setOpenName(null)} + onBack={() => onOpen(null)} onUpdate={updateOf(opened)} onRemove={removeOf(opened)} /> + ) : openName !== null ? ( + // A link or Back to a skill that isn't here (yet, or any more). + + + ) : ( <> setSkillsDisabled(skillRows, !enabled)} - onOpen={setOpenName} + onOpen={onOpen} /> )) )} @@ -543,7 +563,7 @@ function SkillsPanel() { onClick={() => { if (removingSkill) { void runSkillAction("remove", removingSkill); - if (removingSkill.name === openName) setOpenName(null); + if (removingSkill.name === openName) onOpen(null); } setRemovingSkill(null); }} diff --git a/apps/web/src/components/settings/toolsSettings.logic.ts b/apps/web/src/components/settings/toolsSettings.logic.ts index 09312fa3269b..0105c60d9f3c 100644 --- a/apps/web/src/components/settings/toolsSettings.logic.ts +++ b/apps/web/src/components/settings/toolsSettings.logic.ts @@ -17,8 +17,18 @@ import { export type ToolsTab = "skills" | "mcp"; -export function validateToolsSearch(raw: Record): { tab?: ToolsTab } { - return raw.tab === "mcp" || raw.tab === "skills" ? { tab: raw.tab } : {}; +/** `tab` picks the tab; `skill` is the skill whose page is open on the Skills tab. */ +export function validateToolsSearch(raw: Record): { + tab?: ToolsTab; + skill?: string; +} { + const tab = raw.tab === "mcp" || raw.tab === "skills" ? raw.tab : undefined; + const skill = + tab !== "mcp" && typeof raw.skill === "string" && raw.skill.length > 0 ? raw.skill : undefined; + return { + ...(tab === undefined ? {} : { tab }), + ...(skill === undefined ? {} : { skill }), + }; } /** Where a skill lives, the groups the Skills tab shows in order. */ diff --git a/apps/web/src/routes/settings.tools.tsx b/apps/web/src/routes/settings.tools.tsx index ddcf66562afd..7654a6fc2a47 100644 --- a/apps/web/src/routes/settings.tools.tsx +++ b/apps/web/src/routes/settings.tools.tsx @@ -4,7 +4,7 @@ import { ToolsSettings } from "../components/settings/ToolsSettings"; import { validateToolsSearch } from "../components/settings/toolsSettings.logic"; function SettingsToolsRoute() { - const { tab } = Route.useSearch(); + const { tab, skill } = Route.useSearch(); const navigate = Route.useNavigate(); // A settings search result links by hash, so it opens the tab it points at. const hash = useLocation({ select: (location) => location.hash }); @@ -12,6 +12,7 @@ function SettingsToolsRoute() { return ( void navigate({ // The settings route keeps the scope; Skills is the default tab. @@ -20,6 +21,15 @@ function SettingsToolsRoute() { resetScroll: false, }) } + // Like a tab, a skill's page replaces the entry: Escape from the list + // then still leaves Settings rather than reopening the skill. + onOpenSkill={(name) => + void navigate({ + search: name === null ? {} : { skill: name }, + replace: true, + resetScroll: false, + }) + } /> ); } From efe7aa6a102865ca0e499eced5fd116048b95fea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 21:28:27 -0700 Subject: [PATCH 12/26] fix(web): show project skills only for the project picked, and line up pack rows At All projects, Codex's own skill list (read in the folder T3 was launched from) filled a This project section that belonged to no picked project. Project skills now come only from a scan of the picked checkout. Skills inside a From pack, and its N more row, now start where the pack's name does instead of under its icon. Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/settings/SkillList.tsx | 22 ++-- .../settings/toolsSettings.logic.test.ts | 120 ++++++++++++------ .../settings/toolsSettings.logic.ts | 18 +-- 3 files changed, 104 insertions(+), 56 deletions(-) diff --git a/apps/web/src/components/settings/SkillList.tsx b/apps/web/src/components/settings/SkillList.tsx index 8ddc6748b8fe..bd7a454bb6df 100644 --- a/apps/web/src/components/settings/SkillList.tsx +++ b/apps/web/src/components/settings/SkillList.tsx @@ -1,10 +1,9 @@ -import { ChevronRightIcon } from "lucide-react"; +import { ChevronRightIcon, PackageIcon } from "lucide-react"; import { memo, useState, type MouseEvent } from "react"; import { cn } from "../../lib/utils"; import { GitHubIcon } from "../Icons"; import { Badge } from "../ui/badge"; -import { Button } from "../ui/button"; import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SettingsGroup } from "./SettingsGroup"; @@ -59,7 +58,8 @@ const SkillListItem = memo(function SkillListItem({ onClick={() => onOpen(row.name)} className={cn( "flex cursor-pointer items-center gap-3 py-2 pr-3 hover:bg-muted/40 sm:pr-4", - nested ? "pl-9 sm:pl-10" : "pl-3 sm:pl-4", + // Nested rows start where their pack's name does: past its chevron and icon. + nested ? "pl-15 sm:pl-16" : "pl-3 sm:pl-4", )} > + ) : null} diff --git a/apps/web/src/components/settings/toolsSettings.logic.test.ts b/apps/web/src/components/settings/toolsSettings.logic.test.ts index 45040dec8c60..8143e52cdf73 100644 --- a/apps/web/src/components/settings/toolsSettings.logic.test.ts +++ b/apps/web/src/components/settings/toolsSettings.logic.test.ts @@ -37,50 +37,77 @@ const provider = ( }) as ServerProvider; describe("collectSkillRows", () => { - it("merges one skill across providers and folders, grouped by its most specific location", () => { - const rows = collectSkillRows( + const codexSkills: ServerProvider["skills"] = [ + { + name: "frontend-design", + path: "/home/me/.agents/skills/frontend-design/SKILL.md", + scope: "user", + enabled: true, + }, + { + name: "test-t3-app", + path: "/work/t3/.agents/skills/test-t3-app/SKILL.md", + scope: "repo", + enabled: true, + }, + // A repository folder that happens to be called `plugins`. + { + name: "release", + path: "/work/t3/packages/plugins/.agents/skills/release/SKILL.md", + scope: "repo", + enabled: true, + }, + // A plugin skill the provider reports with no scope. + { + name: "docs", + path: "/home/me/.claude/plugins/cache/acme/docs/skills/docs/SKILL.md", + enabled: true, + }, + ]; + const providers = [ + provider("claudeAgent", [ + { + name: "frontend-design", + path: "/home/me/.claude/skills/frontend-design/SKILL.md", + scope: "user", + enabled: true, + }, + ]), + // Codex was launched in /work/other, whose repository skills it lists + // until a scan of the picked checkout arrives. + provider( + "codex", [ - provider("claudeAgent", [ - { - name: "frontend-design", - path: "/home/me/.claude/skills/frontend-design/SKILL.md", - scope: "user", - enabled: true, - }, - ]), - provider("codex", [ - { - name: "frontend-design", - path: "/home/me/.agents/skills/frontend-design/SKILL.md", - scope: "user", - enabled: true, - }, - { - name: "test-t3-app", - path: "/work/t3/.agents/skills/test-t3-app/SKILL.md", - scope: "repo", - enabled: true, - }, - // A repository folder that happens to be called `plugins`. - { - name: "release", - path: "/work/t3/packages/plugins/.agents/skills/release/SKILL.md", - scope: "repo", - enabled: true, - }, - // A plugin skill the provider reports with no scope. + ...codexSkills.filter((skill) => skill.scope !== "repo"), + { + name: "launch-folder-skill", + path: "/work/other/.agents/skills/launch-folder-skill/SKILL.md", + scope: "repo", + enabled: true, + }, + ], + { + workspaceSnapshots: [ { - name: "docs", - path: "/home/me/.claude/plugins/cache/acme/docs/skills/docs/SKILL.md", - enabled: true, + cwd: "/work/t3", + checkedAt: "2026-10-10T00:00:00.000Z", + slashCommands: [], + skills: codexSkills, }, - ]), - provider("cursor", [], { enabled: false }), - ], - null, - ); + ], + }, + ), + provider("cursor", [], { enabled: false }), + ]; + + it("merges one skill across providers and folders, grouped by its most specific location", () => { expect( - rows.map((row) => [row.name, row.group, row.paths.length, row.providers.length]), + collectSkillRows(providers, "/work/t3").map((row) => [ + row.name, + row.group, + row.paths.length, + row.providers.length, + ]), ).toEqual([ ["release", "project", 1, 1], ["test-t3-app", "project", 1, 1], @@ -89,6 +116,19 @@ describe("collectSkillRows", () => { ]); }); + it("shows project skills only from a scan of the picked checkout", () => { + // No project picked: the launch folder's skills belong to no project here. + expect(collectSkillRows(providers, null).map((row) => row.name)).toEqual([ + "frontend-design", + "docs", + ]); + // Before the checkout's scan arrives, nothing stands in for it. + expect(collectSkillRows(providers, "/work/elsewhere").map((row) => row.name)).toEqual([ + "frontend-design", + "docs", + ]); + }); + it("shows a skill as switched off by the provider only when every provider has it off", () => { const skill = (enabled: boolean) => ({ name: "grill-me", diff --git a/apps/web/src/components/settings/toolsSettings.logic.ts b/apps/web/src/components/settings/toolsSettings.logic.ts index 0105c60d9f3c..abc1ed05b99f 100644 --- a/apps/web/src/components/settings/toolsSettings.logic.ts +++ b/apps/web/src/components/settings/toolsSettings.logic.ts @@ -10,10 +10,7 @@ import { type ServerProviderSkill, type SkillFolderInfo, } from "@t3tools/contracts"; -import { - resolveProviderSkillSourceKind, - resolveProviderSkillsForCwd, -} from "@t3tools/client-runtime/providerSkills"; +import { resolveProviderSkillSourceKind } from "@t3tools/client-runtime/providerSkills"; export type ToolsTab = "skills" | "mcp"; @@ -96,8 +93,9 @@ const isFilePath = (path: string) => path.startsWith("/") || /^[A-Za-z]:[\\/]/.t /** * The skills every enabled provider instance on one environment discovers, - * for the checkout at `cwd` when one is given (falling back to each - * provider's own launch directory until its snapshot for `cwd` arrives). + * for the checkout at `cwd` when one is given. A provider's own list is read + * in the folder it was launched from, so its project skills belong to no + * project picked here: they show only from a scan of `cwd`. * A skill reached from a project folder by any provider counts as a project * skill, so the group follows the most specific location. */ @@ -118,11 +116,15 @@ export function collectSkillRows( >(); for (const provider of providers) { if (!provider.enabled) continue; - const skills = cwd === null ? provider.skills : resolveProviderSkillsForCwd(provider, cwd); - for (const skill of skills) { + const snapshot = + cwd === null + ? undefined + : provider.workspaceSnapshots?.find((candidate) => candidate.cwd === cwd); + for (const skill of snapshot?.skills ?? provider.skills) { const key = skill.name.trim().toLowerCase(); if (key.length === 0) continue; const group = skillGroup(skill); + if (group === "project" && snapshot === undefined) continue; const ref: SkillProviderRef = { instanceId: provider.instanceId, driver: provider.driver, From 17163abaf8195041f2189e185970c012badfecfe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 21:33:51 -0700 Subject: [PATCH 13/26] fix(web): mark skill packs with a heading band instead of an indent A pack's skills now line up with every other row. Its From heading is a slim tinted band whose switch and chevron sit in the rows' columns. Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/settings/SkillList.tsx | 53 +++++++++---------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/settings/SkillList.tsx b/apps/web/src/components/settings/SkillList.tsx index bd7a454bb6df..2b6ae82d3a0d 100644 --- a/apps/web/src/components/settings/SkillList.tsx +++ b/apps/web/src/components/settings/SkillList.tsx @@ -1,4 +1,4 @@ -import { ChevronRightIcon, PackageIcon } from "lucide-react"; +import { ChevronDownIcon, ChevronRightIcon, PackageIcon } from "lucide-react"; import { memo, useState, type MouseEvent } from "react"; import { cn } from "../../lib/utils"; @@ -43,12 +43,11 @@ interface RowHandlers { const SkillListItem = memo(function SkillListItem({ item, - nested, agents, canWrite, onToggle, onOpen, -}: RowHandlers & { item: SkillListRow; nested: boolean }) { +}: RowHandlers & { item: SkillListRow }) { const { row, details, disabled, overridden } = item; const lockedOff = row.disabledByProvider; const warning = skillAttention(row, details, agents); @@ -56,11 +55,7 @@ const SkillListItem = memo(function SkillListItem({
  • onOpen(row.name)} - className={cn( - "flex cursor-pointer items-center gap-3 py-2 pr-3 hover:bg-muted/40 sm:pr-4", - // Nested rows start where their pack's name does: past its chevron and icon. - nested ? "pl-15 sm:pl-16" : "pl-3 sm:pl-4", - )} + className="flex cursor-pointer items-center gap-3 px-3 py-2 hover:bg-muted/40 sm:px-4" >
  • {open - ? shown.map((item) => ( - - )) + ? shown.map((item) => ) : null} {open && items.length > SOURCE_GROUP_PREVIEW ? (
  • @@ -229,9 +226,7 @@ export function SkillListSection({
      {groups.map(({ source, rows }) => source === null ? ( - rows.map((item) => ( - - )) + rows.map((item) => ) ) : ( ), From 2ea06517565d689243b10c6e7229f4aa3cf98549 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 21:43:40 -0700 Subject: [PATCH 14/26] fix(web): put the Tools tabs on one row with each tab's controls The tabs, search and buttons took three rows above the list. Each tab now puts the tabs at the start of its own toolbar: search, the attention filter, rescan and Add skills on Skills; Add server on MCP servers. Needs attention becomes a warning icon with its count, shown only when something needs a look, and its tooltip says what that means. Co-Authored-By: Claude Opus 5.5 --- .../src/components/settings/ToolsSettings.tsx | 95 +++++++++++-------- .../settings/toolsSettings.logic.ts | 2 +- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/settings/ToolsSettings.tsx b/apps/web/src/components/settings/ToolsSettings.tsx index 71f696f227f9..0b8d203e0523 100644 --- a/apps/web/src/components/settings/ToolsSettings.tsx +++ b/apps/web/src/components/settings/ToolsSettings.tsx @@ -18,8 +18,8 @@ import { skillsDisabledPatch, } from "@t3tools/shared/agentTools"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; -import { MoreHorizontalIcon, PlusIcon, SearchIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AlertTriangleIcon, MoreHorizontalIcon, PlusIcon, SearchIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useAfterDelay } from "../../hooks/useAfterDelay"; import { formatEnvironmentQueryError, useEnvironmentQuery } from "../../state/query"; @@ -182,27 +182,27 @@ export function ToolsSettings({ readonly onTabChange: (tab: ToolsTab) => void; readonly onOpenSkill: (name: string | null) => void; }) { - const selectTab = onTabChange; + // Each tab puts these at the start of its own toolbar row. + const tabs = ( + { + const value = next[0]; + if (value === "skills" || value === "mcp") onTabChange(value); + }} + > + Skills + MCP servers + + ); return ( -
      - { - const value = next[0]; - if (value === "skills" || value === "mcp") selectTab(value); - }} - > - Skills - MCP servers - -
      {tab === "mcp" ? ( - + ) : ( - + )}
      ); @@ -211,9 +211,11 @@ export function ToolsSettings({ // ── Skills ─────────────────────────────────────────────────────────── function SkillsPanel({ + tabs, openName, onOpen, }: { + tabs: ReactNode; openName: string | null; onOpen: (name: string | null) => void; }) { @@ -442,7 +444,8 @@ function SkillsPanel({ className="px-3 sm:px-4" >
      - + {tabs} + @@ -454,15 +457,28 @@ function SkillsPanel({ aria-label="Search skills" /> - {rows.length > 0 ? ( - + {attentionNames.size > 0 || onlyAttention ? ( + + setOnlyAttention((value) => !value)} + /> + } + > + + {attentionNames.size} + + + {onlyAttention + ? "Show all skills." + : "Show only skills that need a look: one name used by different skills, or a skill some of your agents don't load."} + + ) : null} +
      + {tabs} + {canEditServers ? ( + + ) : null} +
      setEditor({ mode: "add" })}> - - Add server - - ) : null - } + hideTitle={!isProjectScope} > {ownRows.length === 0 ? ( Date: Sat, 10 Oct 2026 21:48:46 -0700 Subject: [PATCH 15/26] fix(web): flag only skills that share a name, not ones some agents skip Not available to Claude gave nothing to act on: the agent chips already show who loads a skill, and nothing in T3 makes another agent load it. Only a name used by different skills needs a look now, and its message says what to do. Co-Authored-By: Claude Opus 5.5 --- .../src/components/settings/SkillDetail.tsx | 10 +++--- .../web/src/components/settings/SkillList.tsx | 8 ++--- .../src/components/settings/ToolsSettings.tsx | 12 +++---- .../settings/toolsSettings.logic.test.ts | 18 +++++----- .../settings/toolsSettings.logic.ts | 34 ++++--------------- docs/user/tools.md | 3 +- 6 files changed, 33 insertions(+), 52 deletions(-) diff --git a/apps/web/src/components/settings/SkillDetail.tsx b/apps/web/src/components/settings/SkillDetail.tsx index e9fe0b0cd081..9cfcf83e5226 100644 --- a/apps/web/src/components/settings/SkillDetail.tsx +++ b/apps/web/src/components/settings/SkillDetail.tsx @@ -12,7 +12,7 @@ import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SkillAgentIcon } from "./SkillAgents"; import { - skillAttention, + skillConflict, type SkillAgent, type SkillRow, type SkillRowDetails, @@ -72,7 +72,7 @@ export function SkillDetail({ }) { useEscapeToList(onBack); const loads = new Set(row.providers.map((provider) => provider.instanceId)); - const warning = skillAttention(row, details, agents); + const conflict = skillConflict(row, details); // A name can be several folders (a conflict, or a project copy over a global one). const [folderPath, setFolderPath] = useState(details.folder?.folder ?? null); const folder = @@ -150,16 +150,14 @@ export function SkillDetail({ ) : null}
      - {warning !== null || scripts.length > 0 || details.installed !== null ? ( + {conflict !== null || scripts.length > 0 || details.installed !== null ? (
      {details.installed !== null ? ( From {details.installed.source} ) : null} - {warning !== null ? ( - {warning.detail} - ) : null} + {conflict !== null ? {conflict} : null} {scripts.length > 0 ? (
      ) : null} - {warning?.kind === "conflict" ? ( + {conflict !== null ? ( }> @@ -89,7 +89,7 @@ const SkillListItem = memo(function SkillListItem({ - {warning.detail} + {conflict} ) : null} diff --git a/apps/web/src/components/settings/ToolsSettings.tsx b/apps/web/src/components/settings/ToolsSettings.tsx index 0b8d203e0523..ac466840e817 100644 --- a/apps/web/src/components/settings/ToolsSettings.tsx +++ b/apps/web/src/components/settings/ToolsSettings.tsx @@ -61,7 +61,7 @@ import { SKILL_GROUP_HINTS, SKILL_GROUP_LABELS, skillAgents, - skillAttention, + skillConflict, type SkillGroupKind, type SkillRow, skillRowDetails, @@ -352,11 +352,11 @@ function SkillsPanel({ rows .filter((row) => { const details = detailsByName.get(row.name); - return details !== undefined && skillAttention(row, details, agents) !== null; + return details !== undefined && skillConflict(row, details) !== null; }) .map((row) => row.name), ), - [agents, detailsByName, rows], + [detailsByName, rows], ); const visibleRows = useMemo( () => @@ -465,7 +465,7 @@ function SkillsPanel({ size="sm" variant={onlyAttention ? "secondary" : "outline"} aria-pressed={onlyAttention} - aria-label={`Show only skills that need a look (${attentionNames.size})`} + aria-label={`Show only skills whose name is used more than once (${attentionNames.size})`} onClick={() => setOnlyAttention((value) => !value)} /> } @@ -476,7 +476,7 @@ function SkillsPanel({ {onlyAttention ? "Show all skills." - : "Show only skills that need a look: one name used by different skills, or a skill some of your agents don't load."} + : "Show only skills whose name is used by different skills. One switch turns all of them on or off."} ) : null} @@ -520,7 +520,7 @@ function SkillsPanel({ diff --git a/apps/web/src/components/settings/toolsSettings.logic.test.ts b/apps/web/src/components/settings/toolsSettings.logic.test.ts index 8143e52cdf73..f0ca84016c62 100644 --- a/apps/web/src/components/settings/toolsSettings.logic.test.ts +++ b/apps/web/src/components/settings/toolsSettings.logic.test.ts @@ -15,8 +15,8 @@ import { parseMcpServerJson, formatArgs, skillAgents, - skillAttention, skillAvailability, + skillConflict, skillBody, skillRowDetails, splitRowsBySource, @@ -256,13 +256,15 @@ describe("skill availability and attention", () => { ]); }); - it("flags a skill some agents miss, but not a plugin or built-in one", () => { - expect(skillAttention(row("personal", ["codex"]), noDetails, agents)?.detail).toBe( - "Not available to Claude.", - ); - expect(skillAttention(row("plugin", ["codex"]), noDetails, agents)).toBeNull(); - expect(skillAttention(row("system", ["codex"]), noDetails, agents)).toBeNull(); - expect(skillAttention(row("project", ["claudeAgent", "codex"]), noDetails, agents)).toBeNull(); + it("flags only a name used by different skills, not one some agents don't load", () => { + expect(skillConflict(row("personal", ["codex"]), noDetails)).toBeNull(); + expect( + skillConflict(row("personal", ["codex"]), { + ...noDetails, + copies: [noDetails.folder, noDetails.folder].filter((copy) => copy !== null), + conflict: true, + }), + ).toMatch(/^Different skills are named “review”/); }); it("strips SKILL.md's header for the rendered view", () => { diff --git a/apps/web/src/components/settings/toolsSettings.logic.ts b/apps/web/src/components/settings/toolsSettings.logic.ts index fdcd5c29aef9..d8b23a26135a 100644 --- a/apps/web/src/components/settings/toolsSettings.logic.ts +++ b/apps/web/src/components/settings/toolsSettings.logic.ts @@ -308,34 +308,14 @@ export function availabilityNote(value: SkillAvailability): string { } /** - * Why a skill needs a look, in one plain sentence, or null. Only a conflict - * marks the row itself; the rest show in the list's attention filter and on - * the skill's page. + * What to do about a skill that needs a look, or null. Only a name used by + * different skills does: its one switch can't tell them apart, and removing + * or renaming a copy fixes it. Which agents load a skill is shown, not flagged. */ -export interface SkillAttention { - readonly kind: "conflict" | "missing"; - readonly detail: string; -} - -export function skillAttention( - row: SkillRow, - details: SkillRowDetails, - agents: ReadonlyArray, -): SkillAttention | null { - if (details.conflict) { - return { - kind: "conflict", - detail: `Different skills are named “${row.name}”. Its switch turns all of them on or off.`, - }; - } - // A plugin or built-in skill belongs to its agent; others never load it. - if (row.group !== "project" && row.group !== "personal") return null; - const availability = skillAvailability(row, agents); - if (availability.missing.length === 0) return null; - return { - kind: "missing", - detail: `Not available to ${joinNames(availability.missing.map((agent) => agent.displayName))}.`, - }; +export function skillConflict(row: SkillRow, details: SkillRowDetails): string | null { + return details.conflict + ? `Different skills are named “${row.name}”, and its switch turns them all on or off. Remove or rename the copy you don't want.` + : null; } /** Rows with no recorded source show first; a pack collapses past this many rows. */ diff --git a/docs/user/tools.md b/docs/user/tools.md index 83384521a7aa..e1f22bdfff30 100644 --- a/docs/user/tools.md +++ b/docs/user/tools.md @@ -20,7 +20,8 @@ With a project selected, a switch overrides the environment for that project onl skill off for one project, or back on where the environment has it off. A **Conflict** mark means two different skills share a name. The switch turns both of them on or -off. **View files** in a skill's menu shows what is in its folder; a skill marked **Includes +off, so remove or rename the copy you don't want; the warning button beside the search lists every +conflict. **View files** in a skill's menu shows what is in its folder; a skill marked **Includes scripts** has files an agent could run. ### Adding skills From 88b1ea3305da6be4892f9fbc4f8f543ab99ddf53 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 21:53:04 -0700 Subject: [PATCH 16/26] fix(web): pick skills to add from dense rows, with select all and a filter Each skill in the Add skills picker took three lines, so a big source showed three at a time. Rows now match the Skills list: name and one-line description, a script mark, and the file count on the right. A header box selects or clears every skill shown, and a source with many skills gets a filter. Co-Authored-By: Claude Opus 5.5 --- .../components/settings/AddSkillsDialog.tsx | 152 +++++++++++++----- 1 file changed, 113 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/settings/AddSkillsDialog.tsx b/apps/web/src/components/settings/AddSkillsDialog.tsx index f51e3b3f1f33..9b41e1159534 100644 --- a/apps/web/src/components/settings/AddSkillsDialog.tsx +++ b/apps/web/src/components/settings/AddSkillsDialog.tsx @@ -4,12 +4,12 @@ import { type SkillInstallTarget, type SkillPreviewItem, } from "@t3tools/contracts"; -import { useId, useState } from "react"; +import { AlertTriangleIcon, SearchIcon } from "lucide-react"; +import { useId, useMemo, useState } from "react"; import { serverEnvironment } from "../../state/server"; import { formatEnvironmentQueryError } from "../../state/query"; import { useAtomCommand } from "../../state/use-atom-command"; -import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; import { @@ -22,12 +22,18 @@ import { DialogTitle, } from "../ui/dialog"; import { Input } from "../ui/input"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; import { Label } from "../ui/label"; import { Spinner } from "../ui/spinner"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +/** A source this big gets a filter above its list. */ +const FILTER_FROM = 8; /** * Install skills from a source, as `npx skills add` does: paste the source, * pick from the skills it holds, install for every agent at the current scope. + * The picker uses the Skills list's dense one-line rows. */ export function AddSkillsDialog({ open, @@ -54,6 +60,7 @@ export function AddSkillsDialog({ readonly skills: ReadonlyArray; } | null>(null); const [selected, setSelected] = useState>(new Set()); + const [filter, setFilter] = useState(""); const [busy, setBusy] = useState<"finding" | "installing" | null>(null); const [error, setError] = useState(null); @@ -70,6 +77,7 @@ export function AddSkillsDialog({ return; } setFound({ source: trimmed, skills: result.value.skills }); + setFilter(""); // Everything is ticked, up to what one install takes. setSelected( new Set(result.value.skills.slice(0, MAX_SKILLS_PER_INSTALL).map((skill) => skill.name)), @@ -111,6 +119,28 @@ export function AddSkillsDialog({ return next; }); const full = selected.size >= MAX_SKILLS_PER_INSTALL; + const shown = useMemo(() => { + const needle = filter.trim().toLowerCase(); + if (found === null || needle.length === 0) return found?.skills ?? []; + return found.skills.filter( + (skill) => + skill.folder.toLowerCase().includes(needle) || + skill.name.toLowerCase().includes(needle) || + (skill.description ?? "").toLowerCase().includes(needle), + ); + }, [filter, found]); + // The header box acts on the rows shown, so a filter can pick a subset. + const shownSelected = shown.filter((skill) => selected.has(skill.name)).length; + const allShown = shown.length > 0 && shownSelected === shown.length; + const selectShown = (checked: boolean) => + setSelected((current) => { + const next = new Set(current); + for (const skill of shown) { + if (!checked) next.delete(skill.name); + else if (next.size < MAX_SKILLS_PER_INSTALL) next.add(skill.name); + } + return next; + }); return ( - + Add skills @@ -166,43 +196,82 @@ export function AddSkillsDialog({

      {found !== null ? ( -
      - - {found.skills.length === 1 ? "1 skill" : `${found.skills.length} skills`} - -
      - {found.skills.map((skill) => ( -
      ) : null} {folder === null ? ( From 93548e8821a39fc416fc9dbd94c07d5c67b83ac5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 22:43:24 -0700 Subject: [PATCH 20/26] fix(web): show a skill's Update and Remove as buttons, not a menu With Copy path beside the path, the skill's menu held one or two items. They are now buttons on the skill's page: Update for a skill installed from a source, and Remove. Co-Authored-By: Claude Opus 5.5 --- .../src/components/settings/SkillDetail.tsx | 28 +++++++------------ docs/user/tools.md | 4 +-- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/settings/SkillDetail.tsx b/apps/web/src/components/settings/SkillDetail.tsx index 92b9b586f37b..7847d89c7cc8 100644 --- a/apps/web/src/components/settings/SkillDetail.tsx +++ b/apps/web/src/components/settings/SkillDetail.tsx @@ -1,13 +1,12 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { Check, Copy } from "lucide"; -import { AlertTriangleIcon, MoreHorizontalIcon } from "lucide-react"; +import { AlertTriangleIcon } from "lucide-react"; import { lazy, Suspense, useEffect, useEffectEvent, useState } from "react"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { MorphIcon } from "../MorphIcon"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Skeleton } from "../ui/skeleton"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -117,22 +116,15 @@ export function SkillDetail({ ); })} - {onUpdate !== null || onRemove !== null ? ( - - } - > - - - - {onUpdate !== null ? Update : null} - {onRemove !== null ? ( - - Remove - - ) : null} - - + {onUpdate !== null ? ( + + ) : null} + {onRemove !== null ? ( + ) : null}
      {conflict !== null || scripts.length > 0 || details.installed !== null ? ( diff --git a/docs/user/tools.md b/docs/user/tools.md index d82673ee7a82..6e755ef53d2a 100644 --- a/docs/user/tools.md +++ b/docs/user/tools.md @@ -35,9 +35,9 @@ With a project selected, skills go into the project's `.agents/skills` folder an for every project. Agents that only read their own folder, such as Claude, get a link to the skill. Skills installed this way, here or with `npx skills` in a terminal, are grouped by where they came -from, with one switch for the group. Their menu has **Update**, which installs the latest version +from, with one switch for the group. Their page has **Update**, which installs the latest version from the same source, and **Remove**. Other skills in your own skill folders, such as -`~/.claude/skills` or a project's `.agents/skills`, can be removed from a skill's menu too; skills +`~/.claude/skills` or a project's `.agents/skills`, can be removed from a skill's page too; skills from plugins and the ones agents ship with can only be switched off. Installing runs the open-source [`skills`](https://github.com/vercel-labs/skills) installer built into T3 Code, with its telemetry off. Installing, updating and removing skills needs permission to manage providers. From b6fe2fa677859f94e7e773e094d401cbe0406da4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 22:43:38 -0700 Subject: [PATCH 21/26] docs: a skill's page shows its files, there is no View files menu item Co-Authored-By: Claude Opus 5.5 --- docs/user/tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/tools.md b/docs/user/tools.md index 6e755ef53d2a..726f4dc07dff 100644 --- a/docs/user/tools.md +++ b/docs/user/tools.md @@ -21,7 +21,7 @@ skill off for one project, or back on where the environment has it off. A **Conflict** mark means two different skills share a name. The switch turns both of them on or off, so remove or rename the copy you don't want; the warning button beside the search lists every -conflict. **View files** in a skill's menu shows what is in its folder; a skill marked **Includes +conflict. Opening a skill shows the files in its folder; a skill marked **Includes scripts** has files an agent could run. ### Adding skills From 57ce0cfff39cfe21afcc26e2a9c6a90afc7c518f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 22:58:38 -0700 Subject: [PATCH 22/26] fix(web): list T3 Code's own MCP server, always on Every agent session gets the t3-code server, but the MCP servers tab only listed servers added there, so it looked like T3's tools weren't given at all. It now heads the list as a built-in server with a switch that is on and locked, since nothing turns it off. Co-Authored-By: Claude Opus 5.5 --- .../src/components/settings/ToolsSettings.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/web/src/components/settings/ToolsSettings.tsx b/apps/web/src/components/settings/ToolsSettings.tsx index 687b762d1070..43cabbf35384 100644 --- a/apps/web/src/components/settings/ToolsSettings.tsx +++ b/apps/web/src/components/settings/ToolsSettings.tsx @@ -754,6 +754,7 @@ function McpServersPanel({ tabs }: { tabs: ReactNode }) { title={isProjectScope ? "This project" : "MCP servers"} hideTitle={!isProjectScope} > + {isProjectScope ? null : } {ownRows.length === 0 ? ( + t3-code + + Built in + + + } + description="T3 Code's own tools: threads, tasks, the browser and devices." + control={ + + }> + + + Every agent session gets this server. + + } + /> + ); +} + function McpServerSettingsRow({ row, canSwitch, From d2758ad7f49a62d7bd9916a76cdd2ea66f725555 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 23:35:59 -0700 Subject: [PATCH 23/26] feat: show which environments have each skill, and copy one to the rest With several environments in scope, the Skills tab read only the primary environment, so a skill on one machine but not another looked the same. It now reads every environment in scope and merges their skills by name. A row shows the machines that have the skill, or one fleet mark when they all do; its page lists them and can copy the skill to the ones missing it. A copy goes through two new RPCs. skills.export packs the folder an agent reports for the skill (bounded at 4 MiB and 500 files). skills.import reinstalls from the skill's remote source when the CLI recorded one, so the copy can be updated, and otherwise writes the files into .agents/skills with links for agents that read their own folder. Update and Remove on a skill's page now reach every environment in scope that has it. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/auth/RpcAuthorization.ts | 2 + .../src/observability/RpcInstrumentation.ts | 2 + apps/server/src/skills/SkillLibrary.test.ts | 84 +++++ apps/server/src/skills/SkillLibrary.ts | 193 +++++++++- apps/server/src/ws.ts | 2 + .../src/components/settings/SkillDetail.tsx | 62 ++- .../components/settings/SkillEnvironments.tsx | 144 +++++++ .../web/src/components/settings/SkillList.tsx | 13 +- .../src/components/settings/ToolsSettings.tsx | 356 ++++++++++++++---- .../settings/toolsSettings.logic.test.ts | 30 ++ .../settings/toolsSettings.logic.ts | 56 +++ docs/user/tools.md | 5 + packages/client-runtime/src/state/server.ts | 9 + .../contracts/src/clientRpcPermissions.ts | 1 + packages/contracts/src/rpc.ts | 19 + packages/contracts/src/skills.ts | 52 +++ .../src/server/AgentSkillFolders.ts | 26 ++ 17 files changed, 971 insertions(+), 85 deletions(-) create mode 100644 apps/web/src/components/settings/SkillEnvironments.tsx diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 9d32a456c8d4..1446b240d52c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -146,6 +146,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.skillsInspect]: AuthFilesystemReadScope, // A preview unpacks a source into a temp folder and deletes it. [WS_METHODS.skillsPreview]: AuthFilesystemReadScope, + // Packs a folder an agent reports for that skill, nothing a client names. + [WS_METHODS.skillsExport]: AuthFilesystemReadScope, [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope, [WS_METHODS.projectsReadFile]: AuthFilesystemReadScope, [WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 0447400994fa..f5b637eecc1c 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -38,6 +38,8 @@ const RPC_AGGREGATES = { [WS_METHODS.skillsInstall]: "skills", [WS_METHODS.skillsUpdate]: "skills", [WS_METHODS.skillsRemove]: "skills", + [WS_METHODS.skillsExport]: "skills", + [WS_METHODS.skillsImport]: "skills", [WS_METHODS.serverUpdateProvider]: "server", [WS_METHODS.providerAuthStart]: "provider", [WS_METHODS.providerConsumeResetCredit]: "provider", diff --git a/apps/server/src/skills/SkillLibrary.test.ts b/apps/server/src/skills/SkillLibrary.test.ts index 5294f3ccd703..b6c9d2c3faa8 100644 --- a/apps/server/src/skills/SkillLibrary.test.ts +++ b/apps/server/src/skills/SkillLibrary.test.ts @@ -328,6 +328,90 @@ it.effect( 60_000, ); +it.effect( + "copies a skill to another environment as files, with links for agents that need them", + () => + withLibrary(({ library, home, project, providers }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // On the first environment: a project skill with a script. + const folder = path.join(project, ".agents", "skills", "lint"); + yield* fileSystem.makeDirectory(path.join(folder, "bin"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(folder, "SKILL.md"), SKILL("lint", "Lint.")); + yield* fileSystem.writeFileString(path.join(folder, "bin", "run"), "#!/bin/sh\n"); + yield* fileSystem.chmod(path.join(folder, "bin", "run"), 0o755); + yield* Ref.set(providers, [ + { + ...claude, + workspaceSnapshots: [ + { + cwd: project, + checkedAt: "2026-10-10T00:00:00Z", + slashCommands: [], + skills: [ + { + name: "lint", + path: path.join(folder, "SKILL.md"), + scope: "project", + enabled: true, + }, + ], + }, + ], + }, + ]); + const bundle = yield* library.exportSkill({ + name: "lint", + target: { kind: "project", cwd: project }, + }); + assert.deepEqual( + bundle.files.map((file) => [file.path, file.executable]), + [ + ["SKILL.md", false], + ["bin/run", true], + ], + ); + + // On the second environment (the same service here): a global copy. + yield* library.importSkill({ bundle, target: { kind: "environment" } }); + const copied = path.join(home, ".agents", "skills", "lint"); + assert.equal( + yield* fileSystem.readFileString(path.join(copied, "SKILL.md")), + SKILL("lint", "Lint."), + ); + assert.equal( + ((yield* fileSystem.stat(path.join(copied, "bin", "run"))).mode & 0o111) !== 0, + true, + ); + // Claude reads only `.claude/skills`, so it gets a link, as an install gives it. + assert.equal( + yield* fileSystem.readLink(path.join(home, ".claude", "skills", "lint")), + copied, + ); + + const again = yield* library + .importSkill({ bundle, target: { kind: "environment" } }) + .pipe(Effect.flip); + assert.equal(again.reason, "exists"); + + const escape = yield* library + .importSkill({ + bundle: { + folder: "evil", + files: [{ path: "../../outside", contents: "", executable: false }], + }, + target: { kind: "environment" }, + }) + .pipe(Effect.flip); + assert.equal(escape.reason, "cliFailed"); + assert.isFalse(yield* fileSystem.exists(path.join(home, ".agents", "outside"))); + assert.isFalse(yield* fileSystem.exists(path.join(home, ".agents", "skills", "evil"))); + }).pipe(Effect.provide(NodeServices.layer)), + ), + 60_000, +); + it.effect( "fails a removal the CLI couldn't finish, which it reports with a zero exit", () => diff --git a/apps/server/src/skills/SkillLibrary.ts b/apps/server/src/skills/SkillLibrary.ts index 088f9c7cad9b..7eab8bb5bfab 100644 --- a/apps/server/src/skills/SkillLibrary.ts +++ b/apps/server/src/skills/SkillLibrary.ts @@ -15,10 +15,15 @@ */ import { type InstalledSkillInput, + MAX_SKILL_BUNDLE_BYTES, + MAX_SKILL_BUNDLE_FILES, type ProviderDriverKind, type ServerProvider, type SkillFileEntry, + type SkillBundle, + type SkillExportInput, type SkillFolderInfo, + type SkillImportInput, type SkillInspectInput, type SkillInspectResult, type SkillInstallInput, @@ -31,6 +36,7 @@ import { import { STANDARD_SKILL_FOLDER, agentSkillFolderRoots, + skillLinkFolders, skillsCliInstallAgents, } from "@t3tools/provider-core/server/AgentSkillFolders"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; @@ -38,6 +44,7 @@ import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Base64 from "effect/encoding/Base64"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -72,6 +79,17 @@ export class SkillLibrary extends Context.Service< * CLI when it installed the skill, else each folder's copy of that name. */ readonly remove: (input: InstalledSkillInput) => Effect.Effect; + /** Pack the folder an agent reports for a skill, to copy it to another environment. */ + readonly exportSkill: ( + input: SkillExportInput, + ) => Effect.Effect; + /** + * Put a copied skill in place. One the CLI installed from a remote source + * is installed from there again, so it can be updated; otherwise, or when + * that fails, its files go into `.agents/skills` with links for the agents + * that read their own folder, as an install would make. + */ + readonly importSkill: (input: SkillImportInput) => Effect.Effect; } >()("t3/skills/SkillLibrary") {} @@ -662,7 +680,180 @@ const make = Effect.gen(function* () { yield* refreshAgents(target); }); - return SkillLibrary.of({ inspect, preview, install, update, remove }); + /** + * The folder an enabled agent reports for a skill in one of the level's + * agent skill folders, after links. Plugin and built-in skills aren't there. + */ + const reportedFolder = (target: SkillInstallTarget, name: string) => + Effect.gen(function* () { + const wanted = name.trim().toLowerCase(); + const roots = yield* skillRoots(target); + for (const provider of yield* enabledProviders) { + const skills = + target.kind === "environment" + ? provider.skills + : (provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === target.cwd) + ?.skills ?? []); + for (const skill of skills) { + if (skill.name.trim().toLowerCase() !== wanted) continue; + if (!path.isAbsolute(skill.path) || path.basename(skill.path) !== "SKILL.md") continue; + const folder = path.dirname(skill.path); + const real = yield* realPath(folder); + if (roots.includes(path.dirname(folder)) || roots.includes(path.dirname(real))) + return real; + } + } + return yield* new SkillLibraryError({ + reason: "notInstalled", + message: `No agent here reports a skill named ${name}.`, + }); + }); + + const tooLarge = (message: string) => new SkillLibraryError({ reason: "tooLarge", message }); + + const exportSkill: SkillLibrary["Service"]["exportSkill"] = Effect.fn("SkillLibrary.exportSkill")( + function* (input) { + const target = yield* resolveTarget(input.target); + const folder = yield* reportedFolder(target, input.name); + const { files, truncated } = yield* listFiles(folder); + if (truncated || files.length > MAX_SKILL_BUNDLE_FILES) { + return yield* tooLarge(`${input.name} has too many files to copy.`); + } + let bytes = 0; + const bundled = yield* Effect.forEach(files, (file) => + Effect.gen(function* () { + bytes += file.size; + if (bytes > MAX_SKILL_BUNDLE_BYTES) { + return yield* tooLarge(`${input.name} is too large to copy between environments.`); + } + const contents = yield* fileSystem + .readFile(path.join(folder, file.path)) + .pipe(Effect.mapError(() => tooLarge(`Couldn't read ${file.path} in ${input.name}.`))); + return { + path: file.path, + contents: Base64.encode(contents), + executable: file.executable, + }; + }), + ); + // Links aren't followed by the walk, so a folder of only links has nothing to copy. + if (!bundled.some((file) => file.path === "SKILL.md")) { + return yield* tooLarge(`${input.name} has no SKILL.md to copy.`); + } + // A remote source the CLI recorded here lets the target install it properly. + const paths = targetPaths(target); + const locked = + path.dirname(folder) === (yield* realPath(paths.folder)) + ? lockEntryFor(yield* readLock(paths.lock), path.basename(folder)) + : undefined; + const remote = + locked !== undefined && + locked.entry.sourceType !== "local" && + locked.entry.sourceType !== "node_modules" + ? lockedInstallSource(locked.entry, target.kind === "environment" ? "home" : "project") + : null; + return { + folder: skillFolderName(path.basename(folder)), + files: bundled, + ...(remote === null || locked === undefined + ? {} + : { install: { source: remote.source, skill: locked.key, fullDepth: remote.fullDepth } }), + }; + }, + ); + + const importSkill: SkillLibrary["Service"]["importSkill"] = Effect.fn("SkillLibrary.importSkill")( + function* (input) { + const target = yield* resolveTarget(input.target); + yield* importLocked(target, input.bundle).pipe(changes.withPermits(1)); + }, + ); + + const importLocked = (target: SkillInstallTarget, bundle: SkillBundle) => + Effect.gen(function* () { + const paths = targetPaths(target); + const folderName = skillFolderName(bundle.folder); + const destination = path.join(paths.folder, folderName); + const drivers = (yield* enabledProviders).map((provider) => provider.driver); + const links = skillLinkFolders( + drivers, + target.kind === "environment" ? "home" : "project", + ).map((folder) => path.join(paths.root, folder, folderName)); + const taken = yield* Effect.filter([destination, ...links], (candidate) => + fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)), + ); + if (taken.length > 0) { + return yield* new SkillLibraryError({ + reason: "exists", + message: `A skill named ${bundle.folder} is already at ${taken[0]}.`, + }); + } + if (bundle.install !== undefined) { + const installed = yield* installInto( + target, + bundle.install.source, + [bundle.install.skill], + bundle.install.fullDepth, + ).pipe( + Effect.map( + (result) => + result.outcomes.length > 0 && + result.outcomes.every((outcome) => outcome.status === "installed"), + ), + Effect.orElseSucceed(() => false), + ); + if (installed) return; + // The source may be private or offline from here: the files still work. + yield* fileSystem.remove(destination, { recursive: true }).pipe(Effect.ignore); + } + const failed = (message: string) => cliFailed(message); + const write = Effect.gen(function* () { + let bytes = 0; + for (const file of bundle.files) { + // Bundles come from another environment: keep every path inside the folder. + const relative = path.normalize(file.path); + const absolute = path.resolve(destination, relative); + if (path.isAbsolute(relative) || !absolute.startsWith(destination + path.sep)) { + return yield* failed(`${file.path} isn't a path inside the skill.`); + } + const contents = Base64.decode(file.contents); + if (contents._tag === "Failure") return yield* failed(`${file.path} isn't valid base64.`); + bytes += contents.success.byteLength; + if (bytes > MAX_SKILL_BUNDLE_BYTES) + return yield* tooLarge(`${bundle.folder} is too large.`); + yield* fileSystem.makeDirectory(path.dirname(absolute), { recursive: true }); + yield* fileSystem.writeFile(absolute, contents.success); + if (file.executable) yield* fileSystem.chmod(absolute, 0o755); + } + for (const link of links) { + yield* fileSystem.makeDirectory(path.dirname(link), { recursive: true }); + yield* fileSystem.symlink(destination, link); + } + }).pipe( + Effect.catchTags({ + PlatformError: (error) => + Effect.fail(failed(`Couldn't write ${bundle.folder}: ${error.message}`)), + }), + // A half-written skill is worse than none. + Effect.onError(() => + Effect.forEach([...links, destination], (leftover) => + fileSystem.remove(leftover, { recursive: true }).pipe(Effect.ignore), + ), + ), + ); + yield* write; + yield* refreshAgents(target); + }); + + return SkillLibrary.of({ + inspect, + preview, + install, + update, + remove, + exportSkill, + importSkill, + }); }); export const layer = Layer.effect(SkillLibrary, make).pipe(Layer.provide(SkillsCli.layer)); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5619ed6a8c09..5dc69c38fb1c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2649,6 +2649,8 @@ const layerWsRpc = ( [WS_METHODS.skillsInstall]: (input) => skillLibrary.install(input), [WS_METHODS.skillsUpdate]: (input) => skillLibrary.update(input), [WS_METHODS.skillsRemove]: (input) => skillLibrary.remove(input).pipe(Effect.as({})), + [WS_METHODS.skillsExport]: (input) => skillLibrary.exportSkill(input), + [WS_METHODS.skillsImport]: (input) => skillLibrary.importSkill(input).pipe(Effect.as({})), [WS_METHODS.projectsReadFile]: (input) => workspaceFileSystem.readFile(input).pipe( Effect.mapError( diff --git a/apps/web/src/components/settings/SkillDetail.tsx b/apps/web/src/components/settings/SkillDetail.tsx index 7847d89c7cc8..50f8f6f9ac41 100644 --- a/apps/web/src/components/settings/SkillDetail.tsx +++ b/apps/web/src/components/settings/SkillDetail.tsx @@ -1,16 +1,19 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { Check, Copy } from "lucide"; import { AlertTriangleIcon } from "lucide-react"; import { lazy, Suspense, useEffect, useEffectEvent, useState } from "react"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { MorphIcon } from "../MorphIcon"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Skeleton } from "../ui/skeleton"; +import { Spinner } from "../ui/spinner"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SkillAgentIcon } from "./SkillAgents"; +import type { SkillEnvironment } from "./SkillEnvironments"; import { skillConflict, type SkillAgent, @@ -58,17 +61,30 @@ export function SkillDetail({ details, agents, environmentId, + fleet, onBack, onUpdate, onRemove, + onSync, + syncing, }: { row: SkillRow; details: SkillRowDetails; + /** The agents on the environment whose copy is shown. */ agents: ReadonlyArray; environmentId: EnvironmentId; + /** With several environments in scope: which have the skill, and whose copy shows. */ + fleet?: { + readonly on: ReadonlyArray; + readonly environments: ReadonlyArray; + readonly shownFrom: EnvironmentId; + }; onBack: () => void; onUpdate: (() => void) | null; onRemove: (() => void) | null; + /** Copy the skill to the environments in scope that lack it. */ + onSync: (() => void) | null; + syncing: boolean; }) { useEscapeToList(onBack); const loads = new Set(row.providers.map((provider) => provider.instanceId)); @@ -127,6 +143,50 @@ export function SkillDetail({ ) : null}
  • + {fleet !== undefined ? ( +
    + On + {fleet.environments.map((environment) => { + const has = fleet.on.includes(environment.environmentId); + return ( + + + } + > + + + + {environment.label} + + + {!has + ? `${environment.label} doesn't have this skill.` + : environment.environmentId === fleet.shownFrom + ? `Showing ${environment.label}'s copy.` + : `${environment.label} has this skill.`} + + + ); + })} + + {onSync !== null || syncing ? ( + + ) : null} +
    + ) : null} {conflict !== null || scripts.length > 0 || details.installed !== null ? (
    {details.installed !== null ? ( diff --git a/apps/web/src/components/settings/SkillEnvironments.tsx b/apps/web/src/components/settings/SkillEnvironments.tsx new file mode 100644 index 000000000000..0ae024defb53 --- /dev/null +++ b/apps/web/src/components/settings/SkillEnvironments.tsx @@ -0,0 +1,144 @@ +import { + type EnvironmentId, + resolveEnvironmentMachineKind, + type ServerConfig, + type SkillFolderInfo, +} from "@t3tools/contracts"; +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import { NetworkIcon } from "lucide-react"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { useCallback, useContext, useMemo } from "react"; + +import { serverEnvironment } from "../../state/server"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +/** A connected environment the Skills tab reads, in the order rows prefer them. */ +export interface SkillEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly serverConfig: ServerConfig | null; + /** The project's checkout there, at project scope. */ + readonly cwd: string | null; +} + +const EMPTY: ReadonlyArray = []; + +/** + * Each environment's provider list, read together. The lists already stream + * to the client with each environment's config, so this adds no requests. + * Keyed by the ids alone: they decide which atoms are read. + */ +const providersByEnvironment = Atom.family((key: string) => + Atom.make((get) => + (key === "" ? [] : (key.split(",") as EnvironmentId[])).map((environmentId) => ({ + environmentId, + providers: get(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY, + })), + ).pipe(Atom.withLabel(`web:skill-environment-providers:${key}`)), +); + +export function useEnvironmentProviders(environments: ReadonlyArray) { + return useAtomValue( + providersByEnvironment(environments.map((environment) => environment.environmentId).join(",")), + ); +} + +/** An environment and the checkout to inspect there, as a family key. */ +const folderKey = (environment: SkillEnvironment) => + JSON.stringify([environment.environmentId, environment.cwd]); + +const inspectAtom = (key: string) => { + const [environmentId, cwd] = JSON.parse(key) as [EnvironmentId, string | null]; + return serverEnvironment.inspectSkills({ + environmentId, + input: cwd === null ? {} : { cwd }, + }); +}; + +const foldersByEnvironment = Atom.family((keys: string) => + Atom.make((get) => + (JSON.parse(keys) as string[]).map((key) => ({ + environmentId: (JSON.parse(key) as [EnvironmentId])[0], + result: get(inspectAtom(key)), + })), + ).pipe(Atom.withLabel(`web:skill-environment-folders:${keys}`)), +); + +/** + * Each environment's inspected skill folders, by the SKILL.md path its agents + * reported. Paths are per machine (two Macs share `/Users/me`), so they stay + * keyed by environment. + */ +export function useEnvironmentSkillFolders(environments: ReadonlyArray) { + const keys = JSON.stringify(environments.map(folderKey)); + const results = useAtomValue(foldersByEnvironment(keys)); + const registry = useContext(RegistryContext); + const refresh = useCallback(() => { + for (const key of JSON.parse(keys) as string[]) registry.refresh(inspectAtom(key)); + }, [keys, registry]); + const folders = useMemo( + () => + new Map( + results.map(({ environmentId, result }) => [ + environmentId, + new Map( + (Option.getOrNull(AsyncResult.value(result))?.folders ?? []).map((folder) => [ + folder.path, + folder, + ]), + ), + ]), + ), + [results], + ); + return { folders, refresh }; +} + +/** + * Where a skill is, at a glance: a fleet mark when every environment listed + * has it, otherwise the machines that do. + */ +export function SkillEnvironmentMarks({ + on, + environments, +}: { + on: ReadonlyArray; + environments: ReadonlyArray; +}) { + const present = environments.filter((environment) => on.includes(environment.environmentId)); + const missing = environments.filter((environment) => !on.includes(environment.environmentId)); + const label = + missing.length === 0 + ? `On all ${environments.length} environments` + : `On ${present.map((environment) => environment.label).join(", ")}; not on ${missing + .map((environment) => environment.label) + .join(", ")}`; + return ( + + + } + > + {missing.length === 0 ? ( + + ) : ( + present.map((environment) => ( + + )) + )} + + {label} + + ); +} diff --git a/apps/web/src/components/settings/SkillList.tsx b/apps/web/src/components/settings/SkillList.tsx index 69c900075a98..fdfc1e661a4d 100644 --- a/apps/web/src/components/settings/SkillList.tsx +++ b/apps/web/src/components/settings/SkillList.tsx @@ -8,7 +8,9 @@ import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SettingsGroup } from "./SettingsGroup"; import { SkillAgents } from "./SkillAgents"; +import { type SkillEnvironment, SkillEnvironmentMarks } from "./SkillEnvironments"; import { TruncatedText } from "./TruncatedText"; +import type { EnvironmentId } from "@t3tools/contracts"; import { SOURCE_GROUP_PREVIEW, skillAvailability, @@ -33,10 +35,14 @@ export interface SkillListRow { readonly disabled: boolean; /** The project switch differs from the environment's. */ readonly overridden: boolean; + /** With several environments in scope, the ones that have this skill. */ + readonly on?: ReadonlyArray; } interface RowHandlers { readonly agents: ReadonlyArray; + /** The environments in scope, when there are several. */ + readonly environments: ReadonlyArray; readonly canWrite: boolean; readonly onToggle: (rows: ReadonlyArray, enabled: boolean) => void; readonly onOpen: (name: string) => void; @@ -45,6 +51,7 @@ interface RowHandlers { const SkillListItem = memo(function SkillListItem({ item, agents, + environments, canWrite, onToggle, onOpen, @@ -98,7 +105,11 @@ const SkillListItem = memo(function SkillListItem({ ) : null} - + {item.on !== undefined && environments.length > 1 ? ( + + ) : ( + + )} = []; +const EMPTY_FOLDERS: ReadonlyMap = new Map(); /** A scan that finishes sooner than this shows no placeholder at all. */ const SKELETON_DELAY_MS = 150; @@ -219,12 +228,37 @@ function SkillsPanel({ openName: string | null; onOpen: (name: string | null) => void; }) { - const { scope, target } = useSettingsScope(); + const { scope, target, connectedEnvironments } = useSettingsScope(); const environmentId = target?.environmentId ?? null; const targets = useToolsTargets(); const cwd = targets.find((candidate) => candidate.environmentId === environmentId)?.cwd ?? null; + // Every environment the scope covers, the representative one first: rows + // show its copy of a skill when it has one. + const environments = useMemo((): ReadonlyArray => { + const ordered = [ + ...targets.filter((candidate) => candidate.environmentId === environmentId), + ...targets.filter((candidate) => candidate.environmentId !== environmentId), + ]; + return ordered.flatMap((candidate) => { + const connected = connectedEnvironments.find( + (environment) => environment.environmentId === candidate.environmentId, + ); + return connected === undefined + ? [] + : [ + { + environmentId: candidate.environmentId, + label: connected.label, + serverConfig: connected.serverConfig, + cwd: candidate.cwd, + }, + ]; + }); + }, [connectedEnvironments, environmentId, targets]); + const fleet = environments.length > 1; + const environmentProviders = useEnvironmentProviders(environments); const providers = - useAtomValue(serverEnvironment.providersValueAtom(environmentId ?? ("" as EnvironmentId))) ?? + environmentProviders.find((entry) => entry.environmentId === environmentId)?.providers ?? EMPTY_PROVIDERS; const disabledSkills = useScopedSettings((settings) => settings.disabledSkills); const canWrite = useScopedSettingsWriteAllowed(); @@ -235,10 +269,6 @@ function SkillsPanel({ const [query, setQuery] = useState(""); const [onlyAttention, setOnlyAttention] = useState(false); const [refreshing, setRefreshing] = useState(false); - const enabledProviders = useMemo( - () => providers.filter((provider) => provider.enabled), - [providers], - ); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; // Installs go to the checkout shown, or to the environment's home folders. const installTarget: SkillInstallTarget | null = @@ -250,28 +280,27 @@ function SkillsPanel({ : { kind: "project", cwd } : { kind: "environment" }; const canInstall = useAtomValue(serverEnvironment.installSkills.permissionAtom(environmentId)); + const installers = useEnvironmentsWithScope(environments, AuthProvidersManageScope); + const exportSkill = useAtomCommand(serverEnvironment.exportSkill, { reportFailure: false }); + const importSkill = useAtomCommand(serverEnvironment.importSkill, { reportFailure: false }); + const [syncing, setSyncing] = useState>(new Set()); const updateSkill = useAtomCommand(serverEnvironment.updateSkill, { reportFailure: false }); const removeSkill = useAtomCommand(serverEnvironment.removeSkill, { reportFailure: false }); const [adding, setAdding] = useState(false); const [removingSkill, setRemovingSkill] = useState<{ readonly name: string; - readonly target: SkillInstallTarget; - /** The `skills` CLI installed it, so its lock entry goes too. */ + /** Each environment's copy to remove. */ + readonly inputs: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly name: string; + readonly target: SkillInstallTarget; + }>; + /** The `skills` CLI installed the shown copy, so its lock entry goes too. */ readonly installed: boolean; } | null>(null); const [skillActionError, setSkillActionError] = useState(null); - const inspected = useEnvironmentQuery( - environmentId === null - ? null - : serverEnvironment.inspectSkills({ - environmentId, - input: cwd === null ? {} : { cwd }, - }), - ); - const foldersByPath = useMemo( - () => new Map((inspected.data?.folders ?? []).map((folder) => [folder.path, folder])), - [inspected.data], - ); + const inspected = useEnvironmentSkillFolders(environments); + const foldersOf = (id: EnvironmentId) => inspected.folders.get(id) ?? EMPTY_FOLDERS; const scopeLabel = isProjectScope ? scope.kind === "project" || scope.kind === "checkout" ? scope.group.displayName @@ -280,20 +309,29 @@ function SkillsPanel({ ? scope.label : "this environment"; + // Rescans every environment in scope; a project's checkout is only scanned + // when asked, as the composer does before showing its skill menu. const refresh = async (fresh: boolean) => { - if (environmentId === null) return; + if (environments.length === 0) return; setRefreshing(true); try { await Promise.all( - enabledProviders.map((provider) => - refreshProviders({ - environmentId, - input: { - instanceId: provider.instanceId, - ...(cwd === null ? {} : { cwd }), - ...(fresh ? { fresh: true } : {}), - }, - }), + environments.flatMap((environment) => + ( + environmentProviders.find((entry) => entry.environmentId === environment.environmentId) + ?.providers ?? EMPTY_PROVIDERS + ) + .filter((provider) => provider.enabled) + .map((provider) => + refreshProviders({ + environmentId: environment.environmentId, + input: { + instanceId: provider.instanceId, + ...(environment.cwd === null ? {} : { cwd: environment.cwd }), + ...(fresh ? { fresh: true } : {}), + }, + }), + ), ), ); } finally { @@ -306,48 +344,106 @@ function SkillsPanel({ inspected.refresh(); }; + /** A skill's level on one environment: where it lives there, to update or remove it. */ + type SkillAction = { + readonly environmentId: EnvironmentId; + readonly name: string; + readonly target: SkillInstallTarget; + }; + const runSkillAction = async ( action: "update" | "remove", - input: { readonly name: string; readonly target: SkillInstallTarget }, + inputs: ReadonlyArray, ) => { - if (environmentId === null) return; setSkillActionError(null); - const failure = async () => { - if (action === "remove") { - const result = await removeSkill({ environmentId, input }); - return result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null; - } - const result = await updateSkill({ environmentId, input }); - if (result._tag === "Failure") return formatEnvironmentQueryError(result.cause); - // The CLI reports a skill it couldn't reinstall as a result, not an error. - const failed = result.value.outcomes.find((outcome) => outcome.status !== "installed"); - return failed === undefined ? null : (failed.error ?? failed.status); - }; - const message = await failure(); + const failures = await Promise.all( + inputs.map(async ({ environmentId: id, ...input }) => { + const label = environmentLabel(id); + if (action === "remove") { + const result = await removeSkill({ environmentId: id, input }); + return result._tag === "Failure" + ? `${label}: ${formatEnvironmentQueryError(result.cause)}` + : null; + } + const result = await updateSkill({ environmentId: id, input }); + if (result._tag === "Failure") + return `${label}: ${formatEnvironmentQueryError(result.cause)}`; + // The CLI reports a skill it couldn't reinstall as a result, not an error. + const failed = result.value.outcomes.find((outcome) => outcome.status !== "installed"); + return failed === undefined ? null : `${label}: ${failed.error ?? failed.status}`; + }), + ); afterSkillChange(); - if (message !== null) setSkillActionError(`Couldn't ${action} ${input.name}: ${message}`); + const messages = failures.filter((message) => message !== null); + if (messages.length > 0) { + setSkillActionError( + `Couldn't ${action} ${inputs[0]?.name ?? "the skill"}. ${messages.join(" ")}`, + ); + } }; - // A project's skills live in its checkout, which each provider only scans - // when asked; the composer does the same before showing its skill menu. - // Keyed on the checkout and provider set, not on every status push, which - // would rescan each time the scan's own result arrives. - const scanKey = `${environmentId}:${cwd}:${enabledProviders.map((provider) => provider.instanceId).join(",")}`; + const scanKey = `${environments.map((environment) => `${environment.environmentId}:${environment.cwd ?? ""}`).join(",")}:${environmentProviders + .flatMap((entry) => entry.providers.filter((provider) => provider.enabled)) + .map((provider) => provider.instanceId) + .join(",")}`; const scannedKeyRef = useRef(null); useEffect(() => { - if (cwd === null || scannedKeyRef.current === scanKey) return; + if (!environments.some((environment) => environment.cwd !== null)) return; + if (scannedKeyRef.current === scanKey) return; scannedKeyRef.current = scanKey; void refresh(false); - }, [cwd, refresh, scanKey]); + }, [environments, refresh, scanKey]); + + const environmentLabel = (id: EnvironmentId) => + environments.find((environment) => environment.environmentId === id)?.label ?? "Environment"; - const rows = useMemo(() => collectSkillRows(providers, cwd), [cwd, providers]); + // Each environment's rows, merged by name: a row shows the first copy found. + const fleetRows = useMemo( + () => + mergeEnvironmentSkillRows( + environments.map((environment) => ({ + environmentId: environment.environmentId, + rows: collectSkillRows( + environmentProviders.find((entry) => entry.environmentId === environment.environmentId) + ?.providers ?? EMPTY_PROVIDERS, + environment.cwd, + ), + })), + ), + [environmentProviders, environments], + ); + const fleetByName = useMemo( + () => new Map(fleetRows.map((entry) => [entry.row.name, entry])), + [fleetRows], + ); + const rows = useMemo(() => fleetRows.map((entry) => entry.row), [fleetRows]); + // Agents on the environment whose copy a row shows. + const agentsByEnvironment = useMemo( + () => + new Map( + environmentProviders.map((entry) => [entry.environmentId, skillAgents(entry.providers)]), + ), + [environmentProviders], + ); const agents = useMemo(() => skillAgents(providers), [providers]); + const fleetOf = (row: SkillRow): FleetSkillRow => + fleetByName.get(row.name) ?? { + row, + environmentId: environmentId ?? ("" as EnvironmentId), + on: [], + }; const detailsByName = useMemo( - () => new Map(rows.map((row) => [row.name, skillRowDetails(row, foldersByPath)])), - [foldersByPath, rows], + () => + new Map( + fleetRows.map((entry) => [ + entry.row.name, + skillRowDetails(entry.row, inspected.folders.get(entry.environmentId) ?? EMPTY_FOLDERS), + ]), + ), + [fleetRows, inspected.folders], ); const detailsOf = (row: SkillRow) => - detailsByName.get(row.name) ?? skillRowDetails(row, foldersByPath); + detailsByName.get(row.name) ?? skillRowDetails(row, foldersOf(fleetOf(row).environmentId)); const attentionNames = useMemo( () => new Set( @@ -391,25 +487,108 @@ function SkillsPanel({ name, ), ); + + /** + * The environments in scope that lack a skill and can take a copy of it: + * the user's own skills only, onto environments that let this client + * manage providers. + */ + const syncTargetsOf = (row: SkillRow) => { + if (!fleet || (row.group !== "project" && row.group !== "personal")) return []; + const { on } = fleetOf(row); + return environments.filter( + (environment) => + !on.includes(environment.environmentId) && + installers.has(environment.environmentId) && + (row.group === "personal" || environment.cwd !== null), + ); + }; + const syncOf = (row: SkillRow) => { + const missing = syncTargetsOf(row); + const { environmentId: from } = fleetOf(row); + const removable = detailsOf(row).removable; + if (missing.length === 0 || removable === null || syncing.has(row.name)) return null; + return () => void syncSkill(row.name, from, removable, missing); + }; + const syncSkill = async ( + name: string, + from: EnvironmentId, + level: SkillInstallTarget, + to: ReadonlyArray, + ) => { + setSkillActionError(null); + setSyncing((current) => new Set([...current, name])); + try { + const bundle = await exportSkill({ environmentId: from, input: { name, target: level } }); + if (bundle._tag === "Failure") { + setSkillActionError( + `Couldn't copy ${name} from ${environmentLabel(from)}: ${formatEnvironmentQueryError(bundle.cause)}`, + ); + return; + } + const failures = await Promise.all( + to.map(async (environment) => { + const result = await importSkill({ + environmentId: environment.environmentId, + input: { + bundle: bundle.value, + // Project skills go into the project's checkout on each environment. + target: + level.kind === "project" && environment.cwd !== null + ? { kind: "project", cwd: environment.cwd } + : { kind: "environment" }, + }, + }); + return result._tag === "Failure" + ? `${environment.label}: ${formatEnvironmentQueryError(result.cause)}` + : null; + }), + ); + const messages = failures.filter((message) => message !== null); + if (messages.length > 0) setSkillActionError(`Couldn't copy ${name}. ${messages.join(" ")}`); + } finally { + setSyncing((current) => { + const next = new Set(current); + next.delete(name); + return next; + }); + afterSkillChange(); + } + }; + + /** Update and remove reach every environment in scope that has the skill. */ + const actionTargetsOf = (row: SkillRow, kind: "update" | "remove"): ReadonlyArray => + fleetOf(row).on.flatMap((id) => { + if (!installers.has(id)) return []; + const environmentRow = collectSkillRows( + environmentProviders.find((entry) => entry.environmentId === id)?.providers ?? + EMPTY_PROVIDERS, + environments.find((environment) => environment.environmentId === id)?.cwd ?? null, + ).find((candidate) => candidate.name.toLowerCase() === row.name.toLowerCase()); + if (environmentRow === undefined) return []; + const details = skillRowDetails(environmentRow, foldersOf(id)); + const level = kind === "update" ? details.installed?.target : details.removable; + return level === undefined || level === null + ? [] + : [{ environmentId: id, name: environmentRow.name, target: level }]; + }); + const updateOf = (row: SkillRow) => { + const inputs = actionTargetsOf(row, "update"); + return inputs.length === 0 ? null : () => void runSkillAction("update", inputs); + }; + const removeOf = (row: SkillRow) => { + const inputs = actionTargetsOf(row, "remove"); + if (inputs.length === 0) return null; + const installed = detailsOf(row).installed !== null; + return () => setRemovingSkill({ name: row.name, inputs, installed }); + }; const listItem = (row: SkillRow): SkillListRow => ({ row, details: detailsOf(row), disabled: isSkillDisabled(disabledSkills, row.name), overridden: projectOverrides(row.name), + ...(fleet ? { on: fleetOf(row).on } : {}), }); - const updateOf = (row: SkillRow) => { - const installed = detailsOf(row).installed; - return installed === null || !canInstall - ? null - : () => void runSkillAction("update", { name: row.name, target: installed.target }); - }; - const removeOf = (row: SkillRow) => { - const { installed, removable } = detailsOf(row); - return removable === null || !canInstall - ? null - : () => - setRemovingSkill({ name: row.name, target: removable, installed: installed !== null }); - }; const opened = openName === null ? null : (rows.find((row) => row.name === openName) ?? null); const showSkeleton = useAfterDelay(rows.length === 0 && refreshing, SKELETON_DELAY_MS); @@ -421,11 +600,22 @@ function SkillsPanel({ key={opened.name} row={opened} details={detailsOf(opened)} - agents={agents} - environmentId={environmentId} + agents={agentsByEnvironment.get(fleetOf(opened).environmentId) ?? agents} + environmentId={fleetOf(opened).environmentId} + {...(fleet + ? { + fleet: { + on: fleetOf(opened).on, + environments, + shownFrom: fleetOf(opened).environmentId, + }, + } + : {})} onBack={() => onOpen(null)} onUpdate={updateOf(opened)} onRemove={removeOf(opened)} + onSync={syncOf(opened)} + syncing={syncing.has(opened.name)} /> ) : openName !== null ? ( // A link or Back to a skill that isn't here (yet, or any more). @@ -541,6 +731,7 @@ function SkillsPanel({ )} emptyText="No matching skills." agents={agents} + environments={environments} canWrite={canWrite} onToggle={(skillRows, enabled) => setSkillsDisabled(skillRows, !enabled)} onOpen={onOpen} @@ -574,15 +765,19 @@ function SkillsPanel({ {removingSkill?.installed ? ( <> Deletes the skill's folder and every agent's link to it, as{" "} - npx skills remove does. + npx skills remove does ) : ( - `Deletes ${removingSkill?.name ?? "the skill"} from every agent's skill folder ${ - removingSkill?.target.kind === "project" - ? "in this project" - : "on this environment" - }. It wasn't installed from a source, so it can't be added back from here.` + `Deletes ${removingSkill?.name ?? "the skill"} from every agent's skill folder` )} + {removingSkill !== null && removingSkill.inputs.length > 1 + ? ` on ${removingSkill.inputs.map((input) => environmentLabel(input.environmentId)).join(", ")}.` + : removingSkill?.inputs[0]?.target.kind === "project" + ? " in this project." + : " on this environment."} + {removingSkill?.installed + ? "" + : " It wasn't installed from a source, so it can't be added back from here."} @@ -591,10 +786,7 @@ function SkillsPanel({ variant="destructive" onClick={() => { if (removingSkill) { - void runSkillAction("remove", { - name: removingSkill.name, - target: removingSkill.target, - }); + void runSkillAction("remove", removingSkill.inputs); if (removingSkill.name === openName) onOpen(null); } setRemovingSkill(null); diff --git a/apps/web/src/components/settings/toolsSettings.logic.test.ts b/apps/web/src/components/settings/toolsSettings.logic.test.ts index 03644a2557f0..ba8a32a18bf4 100644 --- a/apps/web/src/components/settings/toolsSettings.logic.test.ts +++ b/apps/web/src/components/settings/toolsSettings.logic.test.ts @@ -1,4 +1,5 @@ import { + EnvironmentId, ProviderDriverKind, ProviderInstanceId, type McpServerConfig, @@ -9,6 +10,7 @@ import { describe, expect, it } from "vite-plus/test"; import { collectSkillRows, listMcpServerRows, + mergeEnvironmentSkillRows, mcpServerDraftFrom, mcpServerFromDraft, parseArgs, @@ -146,6 +148,34 @@ describe("collectSkillRows", () => { }); }); +describe("mergeEnvironmentSkillRows", () => { + const skillRow = (name: string, group: SkillRow["group"] = "personal"): SkillRow => ({ + name, + description: undefined, + group, + paths: [`/h/.agents/skills/${name}/SKILL.md`], + providers: [], + disabledByProvider: false, + }); + const mac = EnvironmentId.make("mac"); + const nucbox = EnvironmentId.make("nucbox"); + + it("lists each skill once, shown from the first environment that has it", () => { + const merged = mergeEnvironmentSkillRows([ + { environmentId: mac, rows: [skillRow("review"), skillRow("Notes")] }, + { + environmentId: nucbox, + rows: [skillRow("notes"), skillRow("deploy"), skillRow("docs", "plugin")], + }, + ]); + expect(merged.map((entry) => [entry.row.name, entry.environmentId, entry.on])).toEqual([ + ["deploy", nucbox, [nucbox]], + ["Notes", mac, [mac, nucbox]], + ["review", mac, [mac]], + ]); + }); +}); + describe("skill row details", () => { const row = (paths: ReadonlyArray): SkillRow => ({ name: "review", diff --git a/apps/web/src/components/settings/toolsSettings.logic.ts b/apps/web/src/components/settings/toolsSettings.logic.ts index 8b302f3a7875..1d6dab1fbc08 100644 --- a/apps/web/src/components/settings/toolsSettings.logic.ts +++ b/apps/web/src/components/settings/toolsSettings.logic.ts @@ -1,4 +1,5 @@ import { + type EnvironmentId, isValidMcpServerName, type McpServerConfig, type McpServerProjectOverride, @@ -170,6 +171,61 @@ export function collectSkillRows( ); } +/** One environment's skill rows, from that environment's providers and checkout. */ +export interface EnvironmentSkillRows { + readonly environmentId: EnvironmentId; + readonly rows: ReadonlyArray; +} + +/** A skill row across environments: shown from one of them, present on some. */ +export interface FleetSkillRow { + readonly row: SkillRow; + /** The environment whose copy the row, its details and its page show. */ + readonly environmentId: EnvironmentId; + /** Every listed environment that has a skill by this name, in list order. */ + readonly on: ReadonlyArray; +} + +/** + * Merge each environment's rows by skill name. A row shows the copy on the + * first environment listed that has it, so the primary environment goes + * first; `on` says where else the name is. Plugin and built-in skills belong + * to their agents, so only the first environment's are kept. + */ +export function mergeEnvironmentSkillRows( + environments: ReadonlyArray, +): ReadonlyArray { + const byName = new Map< + string, + { row: SkillRow; environmentId: EnvironmentId; on: EnvironmentId[] } + >(); + for (const [index, { environmentId, rows }] of environments.entries()) { + for (const row of rows) { + const own = row.group === "project" || row.group === "personal"; + if (!own && index > 0) continue; + const key = row.name.toLowerCase(); + const existing = byName.get(key); + if (existing === undefined) { + byName.set(key, { row, environmentId, on: [environmentId] }); + } else if (!existing.on.includes(environmentId)) { + existing.on.push(environmentId); + } + } + } + const order = environments.map((environment) => environment.environmentId); + return [...byName.values()] + .map(({ row, environmentId, on }) => ({ + row, + environmentId, + on: on.toSorted((left, right) => order.indexOf(left) - order.indexOf(right)), + })) + .toSorted( + (left, right) => + GROUP_ORDER.indexOf(left.row.group) - GROUP_ORDER.indexOf(right.row.group) || + left.row.name.localeCompare(right.row.name), + ); +} + export function groupSkillRows( rows: ReadonlyArray, ): ReadonlyArray<{ readonly group: SkillGroupKind; readonly rows: ReadonlyArray }> { diff --git a/docs/user/tools.md b/docs/user/tools.md index 726f4dc07dff..c779cdc22a57 100644 --- a/docs/user/tools.md +++ b/docs/user/tools.md @@ -34,6 +34,11 @@ With a project selected, skills go into the project's `.agents/skills` folder an `skills-lock.json`, which you can commit for your team. Otherwise they go into `~/.agents/skills` for every project. Agents that only read their own folder, such as Claude, get a link to the skill. +With **All environments** selected, the list shows the skills on every connected environment. Each +row shows the machines that have the skill, or one mark when they all do, and a skill's page can +**Copy to** the environments missing it. A copy reinstalls from the skill's source when it has one +the other machine can reach, so it can be updated there too, and otherwise copies the files. + Skills installed this way, here or with `npx skills` in a terminal, are grouped by where they came from, with one switch for the group. Their page has **Update**, which installs the latest version from the same source, and **Remove**. Other skills in your own skill folders, such as diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index aabc46cf1be1..ac7fa8b78148 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1202,6 +1202,15 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.skillsRemove, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }), + exportSkill: createEnvironmentRpcCommand(runtime, { + label: "environment-data:skills:export", + tag: WS_METHODS.skillsExport, + }), + importSkill: createEnvironmentRpcCommand(runtime, { + label: "environment-data:skills:import", + tag: WS_METHODS.skillsImport, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 90fb22d6b1e2..cf9b6c01c403 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -40,6 +40,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.skillsInstall]: AuthProvidersManageScope, [WS_METHODS.skillsUpdate]: AuthProvidersManageScope, [WS_METHODS.skillsRemove]: AuthProvidersManageScope, + [WS_METHODS.skillsImport]: AuthProvidersManageScope, [WS_METHODS.scheduledTasksUpsert]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksSetEnabled]: AuthOrchestrationOperateScope, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6002cebbdcaf..b8b598965966 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -150,6 +150,9 @@ import { import { KeybindingsConfigError } from "./keybindings.ts"; import { InstalledSkillInput, + SkillBundle, + SkillExportInput, + SkillImportInput, SkillInspectInput, SkillInspectResult, SkillInstallInput, @@ -477,6 +480,8 @@ export const WS_METHODS = { skillsInstall: "skills.install", skillsUpdate: "skills.update", skillsRemove: "skills.remove", + skillsExport: "skills.export", + skillsImport: "skills.import", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", @@ -630,6 +635,18 @@ const WsSkillsRemoveRpc = Rpc.make(WS_METHODS.skillsRemove, { error: Schema.Union([SkillLibraryError, EnvironmentAuthorizationError]), }); +const WsSkillsExportRpc = Rpc.make(WS_METHODS.skillsExport, { + payload: SkillExportInput, + success: SkillBundle, + error: Schema.Union([SkillLibraryError, EnvironmentAuthorizationError]), +}); + +const WsSkillsImportRpc = Rpc.make(WS_METHODS.skillsImport, { + payload: SkillImportInput, + success: Schema.Struct({}), + error: Schema.Union([SkillLibraryError, EnvironmentAuthorizationError]), +}); + const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { payload: Schema.Struct({}), success: ServerConfig, @@ -1862,6 +1879,8 @@ export const WsRpcGroup = RpcGroup.make( WsSkillsInstallRpc, WsSkillsUpdateRpc, WsSkillsRemoveRpc, + WsSkillsExportRpc, + WsSkillsImportRpc, WsServerUpdateProviderRpc, WsProviderConsumeResetCreditRpc, WsProviderAuthStartRpc, diff --git a/packages/contracts/src/skills.ts b/packages/contracts/src/skills.ts index d7b8f4db8bde..657b979634f1 100644 --- a/packages/contracts/src/skills.ts +++ b/packages/contracts/src/skills.ts @@ -104,6 +104,54 @@ export type SkillInstallOutcome = typeof SkillInstallOutcome.Type; export const SkillInstallResult = Schema.Struct({ outcomes: Schema.Array(SkillInstallOutcome) }); export type SkillInstallResult = typeof SkillInstallResult.Type; +/** + * Copying a skill to another environment: the source environment packs the + * folder an agent found it in, the client hands the bundle to each target. + * Bounded, since the bundle crosses the WebSocket whole. + */ +export const MAX_SKILL_BUNDLE_BYTES = 4 * 1024 * 1024; +export const MAX_SKILL_BUNDLE_FILES = 500; + +export const SkillBundleFile = Schema.Struct({ + /** Relative to the skill's folder, with `/` separators. */ + path: TrimmedNonEmptyString.check(Schema.isMaxLength(1024)), + /** The file's bytes as base64. */ + contents: Schema.String, + executable: Schema.Boolean, +}); +export type SkillBundleFile = typeof SkillBundleFile.Type; + +export const SkillBundle = Schema.Struct({ + /** The folder name the skill is invoked by. */ + folder: InstalledSkillName, + files: Schema.Array(SkillBundleFile).check( + Schema.isMinLength(1), + Schema.isMaxLength(MAX_SKILL_BUNDLE_FILES), + ), + /** + * Set when the `skills` CLI installed the skill from a remote source: the + * target reinstalls from there, so the copy can be updated too, and falls + * back to the files when that fails. + */ + install: Schema.optional( + Schema.Struct({ source: SkillSource, skill: InstalledSkillName, fullDepth: Schema.Boolean }), + ), +}); +export type SkillBundle = typeof SkillBundle.Type; + +/** A skill an agent reports on this environment, by name, at the level it lives. */ +export const SkillExportInput = Schema.Struct({ + name: InstalledSkillName, + target: SkillInstallTarget, +}); +export type SkillExportInput = typeof SkillExportInput.Type; + +export const SkillImportInput = Schema.Struct({ + bundle: SkillBundle, + target: SkillInstallTarget, +}); +export type SkillImportInput = typeof SkillImportInput.Type; + export const SkillInspectInput = Schema.Struct({ /** A project checkout whose skills are inspected besides the environment's. */ cwd: Schema.optional(TrimmedNonEmptyString), @@ -160,6 +208,10 @@ export class SkillLibraryError extends Schema.TaggedError()( * entry), and remove a skill in the agents' skill folders at that level. */ "notInstalled", + /** A skill to copy is bigger than a bundle may be, or its folder can't be read. */ + "tooLarge", + /** A skill with that name is already at the target. */ + "exists", ]), message: Schema.String, }, diff --git a/packages/provider-core/src/server/AgentSkillFolders.ts b/packages/provider-core/src/server/AgentSkillFolders.ts index d315ea26ad63..26a350d10040 100644 --- a/packages/provider-core/src/server/AgentSkillFolders.ts +++ b/packages/provider-core/src/server/AgentSkillFolders.ts @@ -88,6 +88,32 @@ export function agentSkillFolderRoots(level: "home" | "project"): ReadonlyArray< ]; } +/** + * Where each `skills` CLI agent links an install, by level, as its + * `src/agents.ts` does; `universal` writes `.agents/skills` itself. + */ +const CLI_AGENT_LINK_FOLDERS: Readonly< + Record +> = { + "claude-code": { home: ".claude/skills", project: ".claude/skills" }, + grok: { project: ".grok/skills" }, + "antigravity-cli": { home: ".gemini/antigravity-cli/skills" }, +}; + +/** + * The folders a skill copied into `.agents/skills` by hand needs links in, so + * every listed driver sees it: the ones a `skills` CLI install would link. + */ +export function skillLinkFolders( + drivers: ReadonlyArray, + level: "home" | "project", +): ReadonlyArray { + return skillsCliInstallAgents(drivers, level).flatMap((agent) => { + const folder = CLI_AGENT_LINK_FOLDERS[agent]?.[level]; + return folder === undefined ? [] : [folder]; + }); +} + /** * The `skills` CLI agents an install names so every listed driver can use the * skill: `universal` writes `.agents/skills`, and each driver that doesn't read From 6a482469bed0071fcb949008fb83ad1be2828b28 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 11 Oct 2026 02:04:32 -0700 Subject: [PATCH 24/26] =?UTF-8?q?feat(server):=20list=20an=20MCP=20server'?= =?UTF-8?q?s=20tools=20from=20Settings=20=E2=86=92=20Tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Tools listed MCP servers but couldn't show what each one offers. `mcpServers.listTools` starts the named server the way an agent session would, with the project's overrides and the stored secrets, asks it for `tools/list` (following cursors), and stops it again, within 30 seconds. Command servers run in the project's checkout with the host environment under their own variables; URL servers fall back to SSE on a 404/405. Failures come back as a typed McpServerToolsError with a plain message. T3's own `t3-code` server is described from the toolkits `/mcp` registers, without starting anything; a registration test keeps that list in step. Listing runs the server's command with its secrets, so it needs `providers:manage`, the scope that gates adding that command. Co-Authored-By: Claude Opus 5.5 --- apps/server/package.json | 2 +- apps/server/src/auth/RpcAuthorization.ts | 4 + apps/server/src/mcp/McpHttpServer.ts | 22 ++ .../toolkits/worktree/registration.test.ts | 8 + .../src/mcpServers/McpServerTools.test.ts | 203 ++++++++++ apps/server/src/mcpServers/McpServerTools.ts | 354 ++++++++++++++++++ .../testFixtures/two-tool-mcp-server.mjs | 72 ++++ .../src/observability/RpcInstrumentation.ts | 1 + apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 3 + packages/client-runtime/src/state/server.ts | 7 + packages/contracts/src/agentTools.ts | 91 ++++- packages/contracts/src/rpc.ts | 9 + pnpm-lock.yaml | 6 +- 14 files changed, 778 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/mcpServers/McpServerTools.test.ts create mode 100644 apps/server/src/mcpServers/McpServerTools.ts create mode 100644 apps/server/src/mcpServers/testFixtures/two-tool-mcp-server.mjs diff --git a/apps/server/package.json b/apps/server/package.json index b3dbc1f9a157..7e8f6c5d337c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,6 +34,7 @@ "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@ff-labs/fff-node": "0.9.4", + "@modelcontextprotocol/sdk": "1.29.0", "@muse-code/sdk": "1.4.2", "@napi-rs/keyring": "^1.3.0", "@opencode-ai/sdk": "^1.3.15", @@ -59,7 +60,6 @@ }, "devDependencies": { "@effect/vitest": "catalog:", - "@modelcontextprotocol/sdk": "1.29.0", "@t3tools/contracts": "workspace:*", "@t3tools/provider-testing": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 1446b240d52c..139041c006cc 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -148,6 +148,10 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.skillsPreview]: AuthFilesystemReadScope, // Packs a folder an agent reports for that skill, nothing a client names. [WS_METHODS.skillsExport]: AuthFilesystemReadScope, + // Listing a server's tools runs its command with its stored secrets, which + // only a connection allowed to add that command may do. A read the client + // caches as a query, so the client checks the scope itself (no guard entry). + [WS_METHODS.mcpServersListTools]: AuthProvidersManageScope, [WS_METHODS.projectsListEntries]: AuthFilesystemReadScope, [WS_METHODS.projectsReadFile]: AuthFilesystemReadScope, [WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index d22a48d0e6b6..3a4223b82756 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -854,6 +854,28 @@ export const layerMcpTransport = McpServer.layerHttp({ protocols: [McpProtocol.v2025_06_18], }).pipe(Layer.provide(layerMcpAuthMiddleware)); +/** + * Every tool `layer` registers, for describing `t3-code` without a session + * (Settings → Tools). Add a toolkit here when you register one below. + */ +export const T3_MCP_TOOLKITS: ReadonlyArray = [ + PreviewStandardToolkit, + PreviewSnapshotToolkit, + OrchestratorToolkit, + ThreadToolkit, + AttachmentToolkit, + ProjectToolkit, + EnvironmentToolkit, + ToolsToolkit, + PreviewControlsToolkit, + WorktreeToolkit, + PullRequestsToolkit, + DeviceStandardToolkit, + DeviceScreenshotToolkit, + HtmlRenderToolkit, + HtmlPreviewToolkit, +]; + export const layer = Layer.mergeAll( layerPreviewToolkit, layerOrchestratorToolkit, diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 588d11d9c715..0bdea8c4755b 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -127,6 +127,14 @@ it.effect("production mcp layer lists worktree tools over http", () => // than replacing them. expect(toolNames).toContain("preview_status"); expect(toolNames).toContain("delegate_task"); + // Settings → Tools describes t3-code from this list without a session. + expect( + new Set( + McpHttpServer.T3_MCP_TOOLKITS.flatMap((toolkit) => + Object.values(toolkit.tools).map((tool) => tool.name), + ), + ), + ).toEqual(new Set(toolNames)); // The handoff tool mutates thread state, reaches the network (origin // fetch), and runs project setup scripts, so its MCP hints must not diff --git a/apps/server/src/mcpServers/McpServerTools.test.ts b/apps/server/src/mcpServers/McpServerTools.test.ts new file mode 100644 index 000000000000..eb428d720c49 --- /dev/null +++ b/apps/server/src/mcpServers/McpServerTools.test.ts @@ -0,0 +1,203 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it } from "@effect/vitest"; +import { type McpServerConfig, ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import * as ProjectService from "../project/ProjectService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as McpServerTools from "./McpServerTools.ts"; + +const fixture = new URL("./testFixtures/two-tool-mcp-server.mjs", import.meta.url).pathname; + +const stdioServer = ( + args: ReadonlyArray, + env: ReadonlyArray<{ readonly name: string; readonly value: string }> = [], +): McpServerConfig => ({ + enabled: true, + transport: { + type: "stdio", + command: process.execPath, + args: [...args], + env: env.map((variable) => ({ ...variable, sensitive: false })), + }, +}); + +/** One registered project, checked out at `projectRoot`. */ +const projects = (projectRoot: string | null) => + Layer.mock(ProjectService.ProjectService)({ + getByWorkspaceRoot: (root: string) => + Effect.succeed( + root === projectRoot + ? Option.some({ + id: ProjectId.make("project"), + title: "Project", + workspaceRoot: projectRoot, + } as never) + : Option.none(), + ), + }); + +const listTools = ( + settings: Parameters[0], + input: Parameters[0], + projectRoot: string | null = null, +) => + McpServerTools.McpServerTools.pipe( + Effect.flatMap((service) => service.listTools(input)), + Effect.provide( + McpServerTools.layer.pipe( + Layer.provide(ServerSettings.layerTest(settings)), + Layer.provide(projects(projectRoot)), + ), + ), + ); + +/** Whether a process with this pid is still running. */ +const isRunning = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +it.live("lists a stdio server's tools across pages, then stops it", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-tools-" }); + const pidFile = path.join(directory, "pid"); + + const result = yield* listTools( + { + mcpServers: { + fixture: stdioServer( + [fixture], + [ + { name: "MCP_FIXTURE_PID_FILE", value: pidFile }, + { name: "MCP_FIXTURE_GREETING", value: "Hello from env." }, + ], + ), + }, + }, + { name: "fixture" }, + ); + + expect(result.serverInfo).toEqual({ name: "two-tool-fixture", version: "1.2.3" }); + expect(result.tools).toEqual([ + { + name: "echo", + title: "Echo", + description: "Repeats text back. Hello from env.", + parameters: [ + { name: "text", type: "string", description: "What to repeat", required: true }, + { name: "times", type: "integer", description: "How many times", required: false }, + ], + }, + { + name: "pick", + description: "Picks a color.", + parameters: [ + { name: "color", type: '"red" | "green"', required: true }, + { name: "tags", type: "string[]", required: false }, + ], + }, + ]); + + const pid = Number(yield* fileSystem.readFileString(pidFile)); + expect(isRunning(pid)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("lists a project's server that is switched off, started in the checkout", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const projectRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-project-" }); + // A relative path, which only resolves from the project's checkout. + yield* fileSystem.copyFile(fixture, `${projectRoot}/server.mjs`); + const result = yield* listTools( + { + projectSettingsOverrides: { + [ProjectId.make("project")]: { + mcpServers: { local: { ...stdioServer(["server.mjs"]), enabled: false } }, + }, + }, + }, + { name: "local", cwd: projectRoot }, + projectRoot, + ); + expect(result.tools.map((tool) => tool.name)).toEqual(["echo", "pick"]); + + // The environment doesn't have the project's server. + const outside = yield* listTools({}, { name: "local" }, projectRoot).pipe(Effect.flip); + assert(outside._tag === "McpServerToolsError"); + expect(outside.reason).toBe("not-found"); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("fails with a typed error when the command can't run or exits", () => + Effect.gen(function* () { + const missing = yield* listTools( + { + mcpServers: { + broken: { + enabled: true, + transport: { + type: "stdio", + command: "/definitely/not/a/command", + args: [], + env: [], + }, + }, + }, + }, + { name: "broken" }, + ).pipe(Effect.flip); + assert(missing._tag === "McpServerToolsError"); + expect(missing.reason).toBe("spawn-failed"); + + const exits = yield* listTools( + { mcpServers: { exits: stdioServer(["-e", "process.exit(3)"]) } }, + { name: "exits" }, + ).pipe(Effect.flip); + assert(exits._tag === "McpServerToolsError"); + expect(exits.reason).toBe("exited"); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("reports a server or project that isn't there", () => + Effect.gen(function* () { + const unknown = yield* listTools({}, { name: "nope" }).pipe(Effect.flip); + assert(unknown._tag === "McpServerToolsError"); + expect(unknown.reason).toBe("not-found"); + + const project = yield* listTools({}, { name: "nope", cwd: "/elsewhere" }).pipe(Effect.flip); + assert(project._tag === "McpServerToolsError"); + expect(project.reason).toBe("unknown-project"); + }), +); + +it.effect("lists T3 Code's own tools without starting anything", () => + Effect.gen(function* () { + const result = yield* listTools({}, { name: "t3-code" }); + expect(result.serverInfo?.name).toBe("T3 Code"); + const names = result.tools.map((tool) => tool.name); + expect(names).toEqual( + expect.arrayContaining([ + "t3_thread_launch", + "delegate_task", + "preview_snapshot", + "html_render", + ]), + ); + expect(new Set(names).size).toBe(names.length); + const launch = result.tools.find((tool) => tool.name === "t3_thread_launch"); + expect(launch?.description).toBeTruthy(); + expect(launch?.parameters.some((parameter) => parameter.name === "title")).toBe(true); + }), +); diff --git a/apps/server/src/mcpServers/McpServerTools.ts b/apps/server/src/mcpServers/McpServerTools.ts new file mode 100644 index 000000000000..12ac73bf0917 --- /dev/null +++ b/apps/server/src/mcpServers/McpServerTools.ts @@ -0,0 +1,354 @@ +/** + * A Settings → Tools server's tools, read by starting the server the way an + * agent session would: the same settings, project overrides and secrets, then + * `initialize` and `tools/list`, then shut down. T3's own `t3-code` server is + * read from its toolkits without starting anything. + */ +import { + type McpServerTool, + type McpServerToolParameter, + McpServerToolsError, + type McpServerToolsInput, + type McpServerToolsResult, + type McpServerTransport, + mcpServerVariableRecord, + type ServerSettingsError, + T3_MCP_SERVER_NAME, +} from "@t3tools/contracts"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { Tool } from "effect/ai"; + +import { T3_MCP_TOOLKITS } from "../mcp/McpHttpServer.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ServerSettings from "../serverSettings.ts"; + +/** Long enough for `npx -y` to fetch a package on a first run. */ +const LIST_TOOLS_TIMEOUT = Duration.seconds(30); +/** A server that pages forever stops being read here. */ +const MAX_TOOL_PAGES = 50; + +export class McpServerTools extends Context.Service< + McpServerTools, + { + /** The named server's tools, as an agent in the project at `cwd` (or on the environment) gets it. */ + readonly listTools: ( + input: McpServerToolsInput, + ) => Effect.Effect; + } +>()("t3/mcpServers/McpServerTools") {} + +// ── Tool descriptions ──────────────────────────────────────────────── + +type JsonObject = Readonly>; + +const isObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +const nonEmpty = (value: unknown): string | undefined => + typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; + +/** A short reading of a JSON Schema's type: `string`, `number[]`, `"a" | "b"`, `object`. */ +function schemaTypeLabel(schema: unknown, depth = 0): string | undefined { + if (!isObject(schema) || depth > 3) return undefined; + if ("const" in schema) return JSON.stringify(schema.const); + if (Array.isArray(schema.enum) && schema.enum.length > 0) { + const shown = schema.enum.slice(0, 6).map((value) => JSON.stringify(value)); + return schema.enum.length > shown.length ? `${shown.join(" | ")} | …` : shown.join(" | "); + } + const type = schema.type; + if (Array.isArray(type)) { + const names = type.filter((entry): entry is string => typeof entry === "string"); + return names.length === 0 ? undefined : names.join(" | "); + } + if (type === "array") { + const item = schemaTypeLabel(schema.items, depth + 1); + return item === undefined ? "array" : item.includes(" ") ? `(${item})[]` : `${item}[]`; + } + if (typeof type === "string") return type; + for (const key of ["anyOf", "oneOf"] as const) { + const options = schema[key]; + if (!Array.isArray(options)) continue; + const labels = [ + ...new Set( + options + .map((option) => schemaTypeLabel(option, depth + 1)) + .filter((label): label is string => label !== undefined), + ), + ]; + if (labels.length > 0) return labels.join(" | "); + } + return isObject(schema.properties) ? "object" : undefined; +} + +/** The top-level properties of a tool's input schema, required ones first. */ +function summarizeToolParameters(inputSchema: unknown): ReadonlyArray { + if (!isObject(inputSchema) || !isObject(inputSchema.properties)) return []; + const required = new Set( + Array.isArray(inputSchema.required) + ? inputSchema.required.filter((name): name is string => typeof name === "string") + : [], + ); + const parameters = Object.entries(inputSchema.properties).map(([name, schema]) => { + const type = schemaTypeLabel(schema); + const description = isObject(schema) ? nonEmpty(schema.description) : undefined; + return { + name, + required: required.has(name), + ...(type === undefined ? {} : { type }), + ...(description === undefined ? {} : { description }), + } satisfies McpServerToolParameter; + }); + // A stable sort, so each half keeps the server's order. + return parameters.toSorted((left, right) => Number(right.required) - Number(left.required)); +} + +function describeTool(tool: { + readonly name: string; + readonly title?: string | undefined; + readonly description?: string | undefined; + readonly annotations?: { readonly title?: string | undefined } | undefined; + readonly inputSchema?: unknown; +}): McpServerTool { + const title = nonEmpty(tool.title) ?? nonEmpty(tool.annotations?.title); + const description = nonEmpty(tool.description); + return { + name: tool.name, + ...(title === undefined ? {} : { title }), + ...(description === undefined ? {} : { description }), + parameters: summarizeToolParameters(tool.inputSchema), + }; +} + +/** + * T3's own tools, from the toolkits `/mcp` registers. A session only gets the + * ones its access allows (browser and device tools follow their settings), but + * every one is listed here. + */ +function t3CodeTools(): McpServerToolsResult { + const tools = new Map(); + for (const toolkit of T3_MCP_TOOLKITS) { + for (const tool of Object.values(toolkit.tools)) { + if (tools.has(tool.name)) continue; + tools.set( + tool.name, + describeTool({ + name: tool.name, + title: Option.getOrUndefined(Context.getOption(tool.annotations, Tool.Title)), + description: Tool.getDescription(tool), + inputSchema: Tool.getJsonSchema(tool), + }), + ); + } + } + return { + serverInfo: { name: "T3 Code" }, + tools: [...tools.values()].toSorted((left, right) => left.name.localeCompare(right.name)), + }; +} + +// ── Talking to a server ────────────────────────────────────────────── + +/** Loaded on first use, so the server doesn't load the MCP client and its validators at startup. */ +const loadSdk = () => + Promise.all([ + import("@modelcontextprotocol/sdk/client/index.js"), + import("@modelcontextprotocol/sdk/client/stdio.js"), + import("@modelcontextprotocol/sdk/client/streamableHttp.js"), + import("@modelcontextprotocol/sdk/client/sse.js"), + import("@modelcontextprotocol/sdk/client/auth.js"), + import("@modelcontextprotocol/sdk/types.js"), + ]).then(([client, stdio, streamableHttp, sse, auth, types]) => ({ + Client: client.Client, + StdioClientTransport: stdio.StdioClientTransport, + StreamableHTTPClientTransport: streamableHttp.StreamableHTTPClientTransport, + StreamableHTTPError: streamableHttp.StreamableHTTPError, + SSEClientTransport: sse.SSEClientTransport, + SseError: sse.SseError, + UnauthorizedError: auth.UnauthorizedError, + McpError: types.McpError, + connectionClosed: types.ErrorCode.ConnectionClosed, + })); +type Sdk = Awaited>; + +/** Why a server couldn't be listed, from what the SDK and Node throw. */ +function failureReason( + sdk: Sdk, + transport: McpServerTransport, + cause: unknown, + stage: "connect" | "list", +): McpServerToolsError["reason"] { + const gone = transport.type === "stdio" ? "exited" : "unreachable"; + if (cause instanceof sdk.UnauthorizedError) return "unauthorized"; + if (cause instanceof sdk.McpError) { + // The SDK's own code for a process or socket that went away. + return cause.code === sdk.connectionClosed ? gone : "request-failed"; + } + if (cause instanceof sdk.StreamableHTTPError || cause instanceof sdk.SseError) { + const status = cause.code; + if (status === 401 || status === 403) return "unauthorized"; + return typeof status === "number" && status >= 400 && status < 500 ? "request-failed" : gone; + } + // Node's spawn error (ENOENT, EACCES) rejects the transport's start. + if (transport.type === "stdio" && stage === "connect") return "spawn-failed"; + return gone; +} + +/** A 404 or 405 for the first POST: an older server that only speaks SSE. */ +const wantsSseFallback = (sdk: Sdk, cause: unknown) => + cause instanceof sdk.StreamableHTTPError && (cause.code === 404 || cause.code === 405); + +const make = Effect.gen(function* () { + const settingsService = yield* ServerSettings.ServerSettingsService; + const projects = yield* ProjectService.ProjectService; + let t3CodeListing: McpServerToolsResult | undefined; + + /** The server as agents in this scope get it: project overrides applied, secrets filled in. */ + const resolveServer = Effect.fn("McpServerTools.resolveServer")(function* ( + input: McpServerToolsInput, + ) { + let projectId = null; + let cwd: string | undefined; + if (input.cwd !== undefined) { + const project = yield* projects.getByWorkspaceRoot(input.cwd).pipe( + Effect.map(Option.getOrUndefined), + Effect.orElseSucceed(() => undefined), + ); + if (project === undefined) { + return yield* new McpServerToolsError({ server: input.name, reason: "unknown-project" }); + } + projectId = project.id; + cwd = project.workspaceRoot; + } + const settings = yield* settingsService.getSettings; + const servers = resolveProjectSettings(settings, projectId).settings.mcpServers; + const server = Object.hasOwn(servers, input.name) ? servers[input.name] : undefined; + if (server === undefined) { + return yield* new McpServerToolsError({ server: input.name, reason: "not-found" }); + } + // A server switched off is still inspected: its definition is what's asked about. + return { transport: server.transport, cwd }; + }); + + const listServerTools = Effect.fn("McpServerTools.listServerTools")(function* ( + name: string, + transport: McpServerTransport, + cwd: string | undefined, + ) { + yield* Effect.annotateCurrentSpan({ "mcp.server.transport": transport.type }); + const hostEnvironment = yield* HostProcessEnvironment; + const sdk = yield* Effect.promise(loadSdk); + const fail = (reason: McpServerToolsError["reason"], cause?: unknown) => + new McpServerToolsError({ server: name, reason, ...(cause === undefined ? {} : { cause }) }); + + const makeTransport = (kind: "streamable" | "sse") => { + if (transport.type === "stdio") { + // The host environment with the server's variables over it, as providers + // start their servers; the SDK's default would keep only a few names. + const env: Record = {}; + for (const [key, value] of Object.entries(hostEnvironment)) { + if (value !== undefined) env[key] = value; + } + return new sdk.StdioClientTransport({ + command: transport.command, + args: [...transport.args], + env: { ...env, ...mcpServerVariableRecord(transport.env) }, + // A project's agents run in its checkout, where a relative path resolves. + ...(cwd === undefined ? {} : { cwd }), + stderr: "ignore", + }); + } + const url = new URL(transport.url); + const requestInit = { headers: mcpServerVariableRecord(transport.headers) }; + return kind === "sse" + ? new sdk.SSEClientTransport(url, { requestInit }) + : new sdk.StreamableHTTPClientTransport(url, { requestInit }); + }; + + /** Connects and initializes; the client and its process close with the scope. */ + const connect = (kind: "streamable" | "sse") => + Effect.acquireRelease( + Effect.sync(() => ({ + client: new sdk.Client({ name: "t3-code", version: "1.0.0" }, { capabilities: {} }), + connection: makeTransport(kind), + })), + // Closing a stdio transport ends stdin, then sends SIGTERM and SIGKILL + // until the process has exited, and resolves only once it has. + ({ client, connection }) => + Effect.promise(() => client.close().then(() => connection.close())).pipe(Effect.ignore), + ).pipe( + Effect.tap(({ client, connection }) => + Effect.tryPromise({ + // The SDK declares `sessionId?: string`, which its own HTTP transport's + // `string | undefined` getter misses under exactOptionalPropertyTypes. + try: () => client.connect(connection as Transport), + catch: (cause) => fail(failureReason(sdk, transport, cause, "connect"), cause), + }), + ), + ); + + const listPages = (client: InstanceType) => + Effect.gen(function* () { + const tools: McpServerTool[] = []; + let cursor: string | undefined; + for (let page = 0; page < MAX_TOOL_PAGES; page += 1) { + const result = yield* Effect.tryPromise({ + try: () => client.listTools(cursor === undefined ? undefined : { cursor }), + catch: (cause) => fail(failureReason(sdk, transport, cause, "list"), cause), + }); + tools.push(...result.tools.map(describeTool)); + cursor = result.nextCursor; + if (cursor === undefined) break; + } + return tools; + }); + + return yield* Effect.scoped( + Effect.gen(function* () { + const { client } = yield* connect("streamable").pipe( + Effect.catchIf( + (error) => transport.type === "http" && wantsSseFallback(sdk, error.cause), + () => connect("sse"), + ), + ); + const tools = yield* listPages(client); + const info = client.getServerVersion(); + const serverName = nonEmpty(info?.name); + const version = nonEmpty(info?.version); + return { + ...(serverName === undefined + ? {} + : { serverInfo: { name: serverName, ...(version === undefined ? {} : { version }) } }), + tools, + } satisfies McpServerToolsResult; + }), + ).pipe( + // The timeout interrupts the scope, which still waits for the process to exit. + Effect.timeoutOrElse({ + duration: LIST_TOOLS_TIMEOUT, + orElse: () => Effect.fail(fail("timed-out")), + }), + ); + }); + + const listTools: McpServerTools["Service"]["listTools"] = Effect.fn("McpServerTools.listTools")( + function* (input) { + if (input.name === T3_MCP_SERVER_NAME) { + t3CodeListing ??= t3CodeTools(); + return t3CodeListing; + } + const server = yield* resolveServer(input); + return yield* listServerTools(input.name, server.transport, server.cwd); + }, + ); + + return McpServerTools.of({ listTools }); +}); + +export const layer = Layer.effect(McpServerTools, make); diff --git a/apps/server/src/mcpServers/testFixtures/two-tool-mcp-server.mjs b/apps/server/src/mcpServers/testFixtures/two-tool-mcp-server.mjs new file mode 100644 index 000000000000..2ccc66f1b798 --- /dev/null +++ b/apps/server/src/mcpServers/testFixtures/two-tool-mcp-server.mjs @@ -0,0 +1,72 @@ +// A stdio MCP server with two tools, one per `tools/list` page, for +// McpServerTools.test.ts. It writes its pid to MCP_FIXTURE_PID_FILE so the +// test can check that it was stopped, and puts MCP_FIXTURE_GREETING in a tool +// description so the test can check that the server's variables reached it. +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; + +if (process.env.MCP_FIXTURE_PID_FILE) { + NodeFS.writeFileSync(process.env.MCP_FIXTURE_PID_FILE, String(process.pid)); +} + +const echo = { + name: "echo", + title: "Echo", + description: `Repeats text back. ${process.env.MCP_FIXTURE_GREETING ?? ""}`.trim(), + inputSchema: { + type: "object", + properties: { + times: { type: "integer", description: "How many times" }, + text: { type: "string", description: "What to repeat" }, + }, + required: ["text"], + }, +}; +const pick = { + name: "pick", + description: "Picks a color.", + inputSchema: { + type: "object", + properties: { + color: { type: "string", enum: ["red", "green"] }, + tags: { type: "array", items: { type: "string" } }, + }, + required: ["color"], + }, +}; + +const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); + +for await (const line of NodeReadline.createInterface({ input: process.stdin })) { + const request = JSON.parse(line); + if (request.id === undefined) continue; + switch (request.method) { + case "initialize": + write({ + jsonrpc: "2.0", + id: request.id, + result: { + protocolVersion: request.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: "two-tool-fixture", version: "1.2.3" }, + }, + }); + break; + case "tools/list": + write({ + jsonrpc: "2.0", + id: request.id, + result: + request.params?.cursor === "page-2" + ? { tools: [pick] } + : { tools: [echo], nextCursor: "page-2" }, + }); + break; + default: + write({ + jsonrpc: "2.0", + id: request.id, + error: { code: -32601, message: "Method not found" }, + }); + } +} diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index f5b637eecc1c..7c3db517b22e 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -40,6 +40,7 @@ const RPC_AGGREGATES = { [WS_METHODS.skillsRemove]: "skills", [WS_METHODS.skillsExport]: "skills", [WS_METHODS.skillsImport]: "skills", + [WS_METHODS.mcpServersListTools]: "mcpServers", [WS_METHODS.serverUpdateProvider]: "server", [WS_METHODS.providerAuthStart]: "provider", [WS_METHODS.providerConsumeResetCredit]: "provider", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f032fa74ff85..1cc46f0c80db 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -62,6 +62,7 @@ import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceR import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; +import * as McpServerTools from "./mcpServers/McpServerTools.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; import * as DeviceHubProxy from "./device/DeviceHubProxy.ts"; @@ -591,6 +592,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( layerProviderInstallationRefresh, ReplayMarkers.layer, SkillLibrary.layer.pipe(Layer.provide(ProcessRunner.layer)), + McpServerTools.layer, ).pipe( // Core Services Layer.provideMerge(layerOrchestrationApplication), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5dc69c38fb1c..426520461806 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -190,6 +190,7 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import * as McpServerTools from "./mcpServers/McpServerTools.ts"; import * as SkillLibrary from "./skills/SkillLibrary.ts"; import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; @@ -1288,6 +1289,7 @@ const layerWsRpc = ( const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const skillLibrary = yield* SkillLibrary.SkillLibrary; + const mcpServerTools = yield* McpServerTools.McpServerTools; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); @@ -2651,6 +2653,7 @@ const layerWsRpc = ( [WS_METHODS.skillsRemove]: (input) => skillLibrary.remove(input).pipe(Effect.as({})), [WS_METHODS.skillsExport]: (input) => skillLibrary.exportSkill(input), [WS_METHODS.skillsImport]: (input) => skillLibrary.importSkill(input).pipe(Effect.as({})), + [WS_METHODS.mcpServersListTools]: (input) => mcpServerTools.listTools(input), [WS_METHODS.projectsReadFile]: (input) => workspaceFileSystem.readFile(input).pipe( Effect.mapError( diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index ac7fa8b78148..f84f5b302093 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1183,6 +1183,13 @@ export function createServerEnvironmentAtoms( staleTimeMs: 30_000, idleTtlMs: 5 * 60_000, }), + // An MCP server's tools, read by starting it; needs `providers:manage`. + listMcpServerTools: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:mcp-servers:list-tools", + tag: WS_METHODS.mcpServersListTools, + staleTimeMs: 5 * 60_000, + idleTtlMs: 5 * 60_000, + }), previewSkills: createEnvironmentRpcCommand(runtime, { label: "environment-data:skills:preview", tag: WS_METHODS.skillsPreview, diff --git a/packages/contracts/src/agentTools.ts b/packages/contracts/src/agentTools.ts index aa478cbf05e5..6abdf211affa 100644 --- a/packages/contracts/src/agentTools.ts +++ b/packages/contracts/src/agentTools.ts @@ -18,8 +18,9 @@ import { ForwardCompatibleArray, TrimmedNonEmptyString } from "./baseSchemas.ts" */ const MCP_SERVER_NAME_PATTERN = /^[a-z][a-z0-9_-]*$/; const MCP_SERVER_NAME_MAX_CHARS = 48; -/** Reserved for T3's own server, which every session already gets. */ -const RESERVED_MCP_SERVER_NAMES: ReadonlySet = new Set(["t3-code"]); +/** T3's own server, which every session already gets. */ +export const T3_MCP_SERVER_NAME = "t3-code"; +const RESERVED_MCP_SERVER_NAMES: ReadonlySet = new Set([T3_MCP_SERVER_NAME]); export const McpServerName = TrimmedNonEmptyString.check( Schema.isMaxLength(MCP_SERVER_NAME_MAX_CHARS), @@ -133,3 +134,89 @@ export function mcpServerTransportSummary(transport: McpServerTransport): string ? [transport.command, ...transport.args].join(" ") : transport.url; } + +// ── A server's tools ───────────────────────────────────────────────── + +export const McpServerToolsInput = Schema.Struct({ + /** A server from Settings → Tools, or `t3-code` for T3's own. */ + name: TrimmedNonEmptyString.check(Schema.isMaxLength(MCP_SERVER_NAME_MAX_CHARS)), + /** A project's checkout: the server as that project's agents get it. */ + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type McpServerToolsInput = typeof McpServerToolsInput.Type; + +/** One top-level property of a tool's input schema. */ +export const McpServerToolParameter = Schema.Struct({ + name: Schema.String, + /** A short reading of its JSON Schema type, such as `string`, `number[]` or `"a" | "b"`. */ + type: Schema.optionalKey(Schema.String), + description: Schema.optionalKey(Schema.String), + required: Schema.Boolean, +}); +export type McpServerToolParameter = typeof McpServerToolParameter.Type; + +export const McpServerTool = Schema.Struct({ + name: Schema.String, + title: Schema.optionalKey(Schema.String), + description: Schema.optionalKey(Schema.String), + parameters: Schema.Array(McpServerToolParameter), +}); +export type McpServerTool = typeof McpServerTool.Type; + +export const McpServerToolsResult = Schema.Struct({ + /** What the server called itself when it started, if it said. */ + serverInfo: Schema.optionalKey( + Schema.Struct({ name: Schema.String, version: Schema.optionalKey(Schema.String) }), + ), + tools: Schema.Array(McpServerTool), +}); +export type McpServerToolsResult = typeof McpServerToolsResult.Type; + +export const McpServerToolsErrorReason = Schema.Literals([ + /** No server of that name at this scope. */ + "not-found", + /** The folder isn't one of this environment's projects. */ + "unknown-project", + /** The command couldn't be run at all. */ + "spawn-failed", + /** The command ran but stopped before it listed its tools. */ + "exited", + /** The URL couldn't be reached. */ + "unreachable", + /** The URL answered 401 or 403: it wants a sign-in T3 can't do here. */ + "unauthorized", + /** The server answered, with an error. */ + "request-failed", + "timed-out", +]); +export type McpServerToolsErrorReason = typeof McpServerToolsErrorReason.Type; + +export class McpServerToolsError extends Schema.TaggedError()( + "McpServerToolsError", + { + server: Schema.String, + reason: McpServerToolsErrorReason, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.reason) { + case "not-found": + return `There's no MCP server named "${this.server}" here.`; + case "unknown-project": + return "That folder isn't a project on this environment."; + case "spawn-failed": + return "The server's command couldn't be run. Check that it's installed on this environment."; + case "exited": + return "The server stopped before it listed its tools. Check its command, arguments and environment variables."; + case "unreachable": + return "The server's URL couldn't be reached."; + case "unauthorized": + return "The server wants a sign-in, which listing its tools can't do here. Add an access token as a header, or sign in from an agent."; + case "request-failed": + return "The server returned an error when asked for its tools."; + case "timed-out": + return "The server didn't list its tools within 30 seconds."; + } + } +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b8b598965966..ae83136adc2f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -161,6 +161,7 @@ import { SkillPreviewInput, SkillPreviewResult, } from "./skills.ts"; +import { McpServerToolsError, McpServerToolsInput, McpServerToolsResult } from "./agentTools.ts"; import { OrchestrationSearchThreadsError, OrchestrationSearchThreadsInput, @@ -482,6 +483,7 @@ export const WS_METHODS = { skillsRemove: "skills.remove", skillsExport: "skills.export", skillsImport: "skills.import", + mcpServersListTools: "mcpServers.listTools", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", @@ -647,6 +649,12 @@ const WsSkillsImportRpc = Rpc.make(WS_METHODS.skillsImport, { error: Schema.Union([SkillLibraryError, EnvironmentAuthorizationError]), }); +const WsMcpServersListToolsRpc = Rpc.make(WS_METHODS.mcpServersListTools, { + payload: McpServerToolsInput, + success: McpServerToolsResult, + error: Schema.Union([McpServerToolsError, ServerSettingsError, EnvironmentAuthorizationError]), +}); + const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { payload: Schema.Struct({}), success: ServerConfig, @@ -1881,6 +1889,7 @@ export const WsRpcGroup = RpcGroup.make( WsSkillsRemoveRpc, WsSkillsExportRpc, WsSkillsImportRpc, + WsMcpServersListToolsRpc, WsServerUpdateProviderRpc, WsProviderConsumeResetCreditRpc, WsProviderAuthStartRpc, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13ae026c4e7d..8cd0cd141f96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -594,6 +594,9 @@ importers: '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=c4e3cc2420ceb9dc650f9d189e9c24baf7e83f342998bacda5f459a4ce7927a8) + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0(zod@4.6.5) '@muse-code/sdk': specifier: 1.4.2 version: 1.4.2(@types/node@24.12.4) @@ -664,9 +667,6 @@ importers: '@effect/vitest': specifier: 4.0.1 version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) - '@modelcontextprotocol/sdk': - specifier: 1.29.0 - version: 1.29.0(zod@4.6.5) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts From 25a7af8aeb46365e0b05e64c6c8fdbd6e6850c8f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 11 Oct 2026 02:13:53 -0700 Subject: [PATCH 25/26] =?UTF-8?q?feat(web):=20open=20an=20MCP=20server=20i?= =?UTF-8?q?n=20Settings=20=E2=86=92=20Tools=20to=20see=20its=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a server on the MCP servers tab, including the built-in t3-code row, opens its page in place of the list, the way a skill opens: the header reads Settings / Tools / , and the Tools crumb or Escape goes back. The page shows the transport and command or URL, the switch, Edit and Remove as buttons, then the server's tools in dense rows that open to their parameters (name, type, required, description). Tools load through `mcpServers.listTools`, with a delayed skeleton, an error with Retry, and a refresh; an edit while the page is open lists them again. A server that is switched off can still be inspected. The row's switch, Reset and menu keep their clicks to themselves. Co-Authored-By: Claude Opus 5.5 --- .../components/settings/McpServerDetail.tsx | 331 ++++++++++++++++++ .../settings/SettingsBreadcrumb.tsx | 12 +- .../src/components/settings/SkillDetail.tsx | 30 +- .../web/src/components/settings/SkillList.tsx | 2 +- .../src/components/settings/ToolsSettings.tsx | 265 +++++++++----- .../settings/toolsSettings.logic.test.ts | 15 + .../settings/toolsSettings.logic.ts | 11 +- .../components/settings/useEscapeToList.ts | 29 ++ apps/web/src/routes/settings.tools.tsx | 14 +- docs/user/tools.md | 5 + 10 files changed, 597 insertions(+), 117 deletions(-) create mode 100644 apps/web/src/components/settings/McpServerDetail.tsx create mode 100644 apps/web/src/components/settings/useEscapeToList.ts diff --git a/apps/web/src/components/settings/McpServerDetail.tsx b/apps/web/src/components/settings/McpServerDetail.tsx new file mode 100644 index 000000000000..191eefa432fa --- /dev/null +++ b/apps/web/src/components/settings/McpServerDetail.tsx @@ -0,0 +1,331 @@ +import { + AuthProvidersManageScope, + type EnvironmentId, + mcpServerTransportSummary, + type McpServerTool, +} from "@t3tools/contracts"; +import { ChevronDownIcon } from "lucide-react"; +import { memo, useEffect, useRef, useState } from "react"; + +import { useAfterDelay } from "../../hooks/useAfterDelay"; +import { cn } from "../../lib/utils"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; +import { useEnvironmentScope } from "../../state/session"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { RefreshIcon } from "../ui/refresh-icon"; +import { Skeleton } from "../ui/skeleton"; +import { Switch } from "../ui/switch"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SettingsGroup } from "./SettingsGroup"; +import { TruncatedText } from "./TruncatedText"; +import type { McpServerRow } from "./toolsSettings.logic"; +import { useEscapeToList } from "./useEscapeToList"; + +/** A start that finishes sooner than this shows no placeholder at all. */ +const SKELETON_DELAY_MS = 150; + +/** + * One MCP server: what it is, its switch and actions, then the tools it + * offers, read by starting it as an agent would. The settings header names + * it and leads back to the list. `row` is null for T3 Code's own server. + */ +export function McpServerDetail({ + name, + row, + environmentId, + cwd, + canSwitch, + onEnabledChange, + onEdit, + onRemove, + onReset, + onBack, +}: { + name: string; + row: McpServerRow | null; + environmentId: EnvironmentId; + /** The project checkout whose settings apply, or null for the environment's. */ + cwd: string | null; + canSwitch: boolean; + onEnabledChange: (enabled: boolean) => void; + onEdit: (() => void) | null; + onRemove: (() => void) | null; + onReset: (() => void) | null; + onBack: () => void; +}) { + useEscapeToList(onBack); + const canList = useEnvironmentScope(environmentId, AuthProvidersManageScope); + const query = useEnvironmentQuery( + canList + ? serverEnvironment.listMcpServerTools({ + environmentId, + input: { name, ...(cwd === null ? {} : { cwd }) }, + }) + : null, + ); + // An edit while the page is open lists the server again as it now starts. + const transportKey = row === null ? "" : JSON.stringify(row.config.transport); + const listedFor = useRef(transportKey); + const { refresh } = query; + useEffect(() => { + if (listedFor.current === transportKey) return; + listedFor.current = transportKey; + refresh(); + }, [refresh, transportKey]); + + const tools = query.data?.tools ?? null; + const showSkeleton = useAfterDelay( + canList && tools === null && query.error === null, + SKELETON_DELAY_MS, + ); + const serverInfo = query.data?.serverInfo; + const enabled = row?.config.enabled ?? true; + + return ( +
    +
    +
    +
    +

    + {name} + + {row === null + ? "Built in" + : row.config.transport.type === "stdio" + ? "Command" + : "URL"} + + {row?.replacesEnvironment ? ( + + Replaces environment's + + ) : null} + {row?.origin === "project-switch" ? ( + + {enabled ? "On" : "Off"} for this project + + ) : null} +

    + {row === null ? ( +

    + T3 Code's own tools: threads, tasks, the browser and devices. Every agent session + gets this server; browser and device tools follow their settings. +

    + ) : ( +

    + {mcpServerTransportSummary(row.config.transport)} +

    + )} +
    + {row === null ? ( + + }> + + + Every agent session gets this server. + + ) : ( + + )} +
    + {onReset !== null || onEdit !== null || onRemove !== null ? ( +
    + {onReset !== null ? ( + + ) : null} + {onEdit !== null ? ( + + ) : null} + {onRemove !== null ? ( + + ) : null} +
    + ) : null} +
    + +
    +
    +

    + Tools{tools === null ? "" : ` · ${tools.length}`} +

    + {serverInfo !== undefined ? ( + + {serverInfo.name} + {serverInfo.version === undefined ? "" : ` ${serverInfo.version}`} + + ) : null} + + {row !== null && canList ? ( + + + } + > + + + Start the server again and list its tools + + ) : null} +
    + {!canList ? ( + +

    + Listing a server's tools starts it, which needs permission to manage providers on this + environment. +

    +
    + ) : query.error !== null ? ( + +
    +

    + {tools === null + ? "Couldn't list this server's tools. " + : "Couldn't list them again. "} + {query.error} +

    + +
    +
    + ) : null} + {tools !== null ? ( + + {tools.length === 0 ? ( +

    + This server offers no tools. +

    + ) : ( +
      + {tools.map((tool) => ( + + ))} +
    + )} +
    + ) : showSkeleton ? ( + + ) : null} +
    +
    + ); +} + +/** A tool on one dense line; it opens to its full description and parameters. */ +const McpToolItem = memo(function McpToolItem({ tool }: { tool: McpServerTool }) { + const [open, setOpen] = useState(false); + const count = tool.parameters.length; + return ( +
  • + + {open ? ( +
    +

    + {tool.description ?? "No description."} +

    + {count === 0 ? ( +

    Takes no parameters.

    + ) : ( +
    + {tool.parameters.map((parameter) => ( +
    +
    + + {parameter.name} + + {parameter.required ? ( + + Required + + ) : null} +
    +
    + {parameter.type !== undefined ? ( + {parameter.type} + ) : null} + {parameter.type !== undefined && parameter.description !== undefined + ? " · " + : null} + {parameter.description} +
    +
    + ))} +
    + )} +
    + ) : null} +
  • + ); +}); + +/** Placeholder rows while the server starts, laid out like the list they stand in for. */ +function ToolsSkeleton() { + return ( + +
      + {[0, 1, 2, 3].map((row) => ( +
    • + + + + + +
    • + ))} +
    +
    + ); +} diff --git a/apps/web/src/components/settings/SettingsBreadcrumb.tsx b/apps/web/src/components/settings/SettingsBreadcrumb.tsx index dd68e3ae92d1..e1f2bee351b0 100644 --- a/apps/web/src/components/settings/SettingsBreadcrumb.tsx +++ b/apps/web/src/components/settings/SettingsBreadcrumb.tsx @@ -19,14 +19,17 @@ function settingsBreadcrumbLabel(pathname: string): string | null { } /** - * `Settings / Section`, or `Settings / Tools / skill` on a skill's page, where - * Tools leads back to the list. The scope a change applies to lives at the top - * of the page content, see `SettingsScopeSentence`. + * `Settings / Section`, or `Settings / Tools / name` on a skill's or MCP + * server's page, where Tools leads back to the list it came from. The scope a + * change applies to lives at the top of the page content, see + * `SettingsScopeSentence`. */ export function SettingsBreadcrumb({ pathname }: { pathname: string }) { const sectionLabel = settingsBreadcrumbLabel(pathname); const skill = useSearch({ strict: false, select: (search) => search.skill }); - const page = pathname === "/settings/tools" ? skill : undefined; + const server = useSearch({ strict: false, select: (search) => search.server }); + const onTools = pathname === "/settings/tools"; + const page = onTools ? (skill ?? server) : undefined; return ( @@ -42,6 +45,7 @@ export function SettingsBreadcrumb({ pathname }: { pathname: string }) { {/* The settings route keeps the scope on a link without search. */} diff --git a/apps/web/src/components/settings/SkillDetail.tsx b/apps/web/src/components/settings/SkillDetail.tsx index 50f8f6f9ac41..cf096e9c348a 100644 --- a/apps/web/src/components/settings/SkillDetail.tsx +++ b/apps/web/src/components/settings/SkillDetail.tsx @@ -1,7 +1,7 @@ import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { Check, Copy } from "lucide"; import { AlertTriangleIcon } from "lucide-react"; -import { lazy, Suspense, useEffect, useEffectEvent, useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; @@ -14,6 +14,7 @@ import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { SkillAgentIcon } from "./SkillAgents"; import type { SkillEnvironment } from "./SkillEnvironments"; +import { useEscapeToList } from "./useEscapeToList"; import { skillConflict, type SkillAgent, @@ -24,33 +25,6 @@ import { // The tree and the viewer pull in the file-tree and highlighter code. const SkillFiles = lazy(() => import("./SkillFiles")); -/** - * 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. 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 }); - }, []); -} - /** * One skill: what it says, which agents load it, and its files. The settings * header names it and leads back to the list. Adapted from the skill page in diff --git a/apps/web/src/components/settings/SkillList.tsx b/apps/web/src/components/settings/SkillList.tsx index fdfc1e661a4d..6e68cfe1e641 100644 --- a/apps/web/src/components/settings/SkillList.tsx +++ b/apps/web/src/components/settings/SkillList.tsx @@ -26,7 +26,7 @@ import { */ /** Keeps a click on a control inside a clickable row from also opening the row. */ -const stopRowClick = (event: MouseEvent) => event.stopPropagation(); +export const stopRowClick = (event: MouseEvent) => event.stopPropagation(); export interface SkillListRow { readonly row: SkillRow; diff --git a/apps/web/src/components/settings/ToolsSettings.tsx b/apps/web/src/components/settings/ToolsSettings.tsx index a984f612a235..2620c3a3351e 100644 --- a/apps/web/src/components/settings/ToolsSettings.tsx +++ b/apps/web/src/components/settings/ToolsSettings.tsx @@ -10,6 +10,7 @@ import { type ServerSettingsPatch, type SkillFolderInfo, type SkillInstallTarget, + T3_MCP_SERVER_NAME, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { @@ -19,11 +20,18 @@ import { skillsDisabledPatch, } from "@t3tools/shared/agentTools"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; -import { AlertTriangleIcon, MoreHorizontalIcon, PlusIcon, SearchIcon } from "lucide-react"; +import { + AlertTriangleIcon, + ChevronRightIcon, + MoreHorizontalIcon, + PlusIcon, + SearchIcon, +} from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useAfterDelay } from "../../hooks/useAfterDelay"; -import { formatEnvironmentQueryError } from "../../state/query"; +import { cn } from "../../lib/utils"; +import { formatEnvironmentQueryError, useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; import { useEnvironmentsWithScope } from "../../state/session"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -46,6 +54,7 @@ import { Switch } from "../ui/switch"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AddSkillsDialog } from "./AddSkillsDialog"; +import { McpServerDetail } from "./McpServerDetail"; import { McpServerDialog } from "./McpServerDialog"; import { SettingsGroup } from "./SettingsGroup"; import { SkillDetail } from "./SkillDetail"; @@ -54,7 +63,7 @@ import { useEnvironmentProviders, useEnvironmentSkillFolders, } from "./SkillEnvironments"; -import { SkillListSection, type SkillListRow } from "./SkillList"; +import { SkillListSection, type SkillListRow, stopRowClick } from "./SkillList"; import { useSettingsScope } from "./SettingsScopeContext"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -182,14 +191,19 @@ function usePersistToolsPatch() { export function ToolsSettings({ tab = "skills", skill, + server, onTabChange, onOpenSkill, + onOpenServer, }: { readonly tab?: ToolsTab; /** The skill whose page is open in place of the list; the header names it. */ readonly skill?: string; + /** The MCP server whose page is open in place of the list; the header names it. */ + readonly server?: string; readonly onTabChange: (tab: ToolsTab) => void; readonly onOpenSkill: (name: string | null) => void; + readonly onOpenServer: (name: string | null) => void; }) { // Each tab puts these at the start of its own toolbar row. const tabs = ( @@ -209,7 +223,7 @@ export function ToolsSettings({ return ( {tab === "mcp" ? ( - + ) : ( )} @@ -831,7 +845,15 @@ type ServerEditor = | { readonly mode: "add" } | { readonly mode: "edit"; readonly row: McpServerRow }; -function McpServersPanel({ tabs }: { tabs: ReactNode }) { +function McpServersPanel({ + tabs, + openName, + onOpen, +}: { + tabs: ReactNode; + openName: string | null; + onOpen: (name: string | null) => void; +}) { const { scope, target, connectedEnvironments } = useSettingsScope(); const targets = useToolsTargets(); const persist = usePersistToolsPatch(); @@ -928,73 +950,123 @@ function McpServersPanel({ tabs }: { tabs: ReactNode }) { const ownRows = isProjectScope ? rows.filter((row) => row.origin === "project") : rows; const inheritedRows = isProjectScope ? rows.filter((row) => row.origin !== "project") : []; const canEditServers = canWriteSettings && canManageServers && targets.length > 0; + // The environment's own switch is part of the server definition. + const canSwitchServers = isProjectScope ? canWriteSettings : canEditServers; + // Inherited servers are edited where they're defined, on the environment. + const editable = (row: McpServerRow) => + canEditServers && (!isProjectScope || row.origin === "project"); + + const environmentId = target?.environmentId ?? null; + const cwd = targets.find((candidate) => candidate.environmentId === environmentId)?.cwd ?? null; + const isBuiltIn = openName === T3_MCP_SERVER_NAME; + const opened = + openName === null || isBuiltIn ? null : (rows.find((row) => row.name === openName) ?? null); return ( <> -
    - {tabs} - {canEditServers ? ( - - ) : null} -
    - - {isProjectScope ? null : } - {ownRows.length === 0 ? ( + {openName !== null && environmentId !== null && (isBuiltIn || opened !== null) ? ( + { + if (opened !== null) setEnabled(opened, enabled); + }} + onEdit={ + opened !== null && editable(opened) + ? () => setEditor({ mode: "edit", row: opened }) + : null + } + onRemove={opened !== null && editable(opened) ? () => setRemoving(opened) : null} + onReset={ + opened !== null && opened.origin === "project-switch" ? () => resetSwitch(opened) : null + } + onBack={() => onOpen(null)} + /> + ) : openName !== null ? ( + // A link or Back to a server that isn't here (yet, or any more). + - ) : ( - ownRows.map((row) => ( - setEnabled(row, enabled)} - onEdit={() => setEditor({ mode: "edit", row })} - onRemove={() => setRemoving(row)} - /> - )) - )} - - {isProjectScope && inheritedRows.length > 0 ? ( - - {inheritedRows.map((row) => ( - setEnabled(row, enabled)} - {...(row.origin === "project-switch" ? { onReset: () => resetSwitch(row) } : {})} - /> - ))} - ) : null} - {!canManageServers && connectedEnvironments.length > 0 ? ( -

    - Adding or editing servers needs permission to manage providers on this environment. -

    ) : ( -

    - Servers you add here are given to Claude, Codex, Cursor, OpenCode, Grok and other ACP - agents. Pi isn't supported yet. Changes apply to new sessions; use Restart agent session - to pick them up in an open thread. -

    + <> +
    + {tabs} + {canEditServers ? ( + + ) : null} +
    + + {isProjectScope ? null : onOpen(T3_MCP_SERVER_NAME)} />} + {ownRows.length === 0 ? ( + + ) : ( + ownRows.map((row) => ( + onOpen(row.name)} + onEnabledChange={(enabled) => setEnabled(row, enabled)} + onEdit={() => setEditor({ mode: "edit", row })} + onRemove={() => setRemoving(row)} + /> + )) + )} + + {isProjectScope && inheritedRows.length > 0 ? ( + + {inheritedRows.map((row) => ( + onOpen(row.name)} + onEnabledChange={(enabled) => setEnabled(row, enabled)} + {...(row.origin === "project-switch" ? { onReset: () => resetSwitch(row) } : {})} + /> + ))} + + ) : null} + {!canManageServers && connectedEnvironments.length > 0 ? ( +

    + Adding, editing or inspecting servers needs permission to manage providers on this + environment. +

    + ) : ( +

    + Servers you add here are given to Claude, Codex, Cursor, OpenCode, Grok and other ACP + agents. Pi isn't supported yet. Changes apply to new sessions; use Restart agent + session to pick them up in an open thread. Open a server to see its tools. +

    + )} + )} {editor ? ( @@ -1053,7 +1129,10 @@ function McpServersPanel({ tabs }: { tabs: ReactNode }) { + ); +} + +/** A server row opens the server's page; its controls keep their clicks to themselves. */ +const OPEN_ROW_CLASS = "cursor-pointer hover:bg-muted/40"; + /** * T3 Code's own server, which every session gets so agents can reach threads, * the browser and other T3 tools. It isn't a setting, so it can't be switched * off; its name is reserved (see `isValidMcpServerName`). */ -function T3McpServerRow() { +function T3McpServerRow({ onOpen }: { onOpen: () => void }) { return ( - t3-code + + {T3_MCP_SERVER_NAME} Built in -
    + } description="T3 Code's own tools: threads, tasks, the browser and devices." control={ - - }> - - - Every agent session gets this server. - + + + }> + + + Every agent session gets this server. + + + } /> ); @@ -1099,6 +1199,7 @@ function McpServerSettingsRow({ row, canSwitch, canEdit, + onOpen, onEnabledChange, onEdit, onRemove, @@ -1107,6 +1208,7 @@ function McpServerSettingsRow({ readonly row: McpServerRow; readonly canSwitch: boolean; readonly canEdit: boolean; + readonly onOpen: () => void; readonly onEnabledChange: (enabled: boolean) => void; readonly onEdit?: () => void; readonly onRemove?: () => void; @@ -1115,8 +1217,9 @@ function McpServerSettingsRow({ const transport = row.config.transport; return ( + {row.name} {transport.type === "stdio" ? "Command" : "URL"} @@ -1131,14 +1234,15 @@ function McpServerSettingsRow({ {row.config.enabled ? "On" : "Off"} for this project ) : null} - + } description={ {mcpServerTransportSummary(transport)} } - className={row.config.enabled ? undefined : "[&_h3]:text-muted-foreground"} + className={cn(OPEN_ROW_CLASS, !row.config.enabled && "[&_h3]:text-muted-foreground")} control={ - <> + // Menu items render in a portal, but React still bubbles their clicks through here. + {onReset ? (