Repository navigation
feat: install skills from a source, view their files, and let agents change Tools #17691
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
juliusmarminge
wants to merge
26
commits into
t3code/agent-tools-settings
Choose a base branch
from
t3code/skills-installer
base: t3code/agent-tools-settings
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+6,640
−379
Open
Changes from 2 commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
6396c81
feat(server): install skills from a source with the bundled skills CLI
juliusmarminge 458f630
feat: add skills from a source, view their files, and let agents chan…
juliusmarminge ec2b475
fix: address review findings on the skills installer
juliusmarminge 64303a5
fix: run skill changes one at a time, and keep the environment's spel…
juliusmarminge 28399ce
fix: rescan listed projects after a global skill change, and handle a…
juliusmarminge 58f6a82
fix: scan one folder's agents in turn, and ignore inherited server names
juliusmarminge bd9994d
feat(web): Skills list and skill page in the style of #17513
juliusmarminge b26a78d
fix(server): install and track skills by the skills CLI's own name
juliusmarminge 7425688
fix: cap a skill install at 200 skills, and read large installer resu…
juliusmarminge b0c21cb
fix(server): flush the skills installer's output by making stdout blo…
juliusmarminge 57d3b32
fix(web): name an open skill in the settings header, not above the page
juliusmarminge efe7aa6
fix(web): show project skills only for the project picked, and line u…
juliusmarminge 17163ab
fix(web): mark skill packs with a heading band instead of an indent
juliusmarminge 2ea0651
fix(web): put the Tools tabs on one row with each tab's controls
juliusmarminge 6cda7c8
fix(web): flag only skills that share a name, not ones some agents skip
juliusmarminge 88b1ea3
fix(web): pick skills to add from dense rows, with select all and a f…
juliusmarminge 4ae4726
fix(web): show a skill's whole description when its line is cut off
juliusmarminge 57758db
feat: remove any skill in the agents' own skill folders
juliusmarminge 8ddae4a
fix(web): copy a skill's path from a button beside it
juliusmarminge 93548e8
fix(web): show a skill's Update and Remove as buttons, not a menu
juliusmarminge b6fe2fa
docs: a skill's page shows its files, there is no View files menu item
juliusmarminge 57ce0cf
fix(web): list T3 Code's own MCP server, always on
juliusmarminge d2758ad
feat: show which environments have each skill, and copy one to the rest
juliusmarminge 6a48246
feat(server): list an MCP server's tools from Settings → Tools
juliusmarminge 25a7af8
feat(web): open an MCP server in Settings → Tools to see its tools
juliusmarminge 88a827b
chore(web): drop an unused import left by combining the Tools changes
juliusmarminge File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import * as Effect from "effect/Effect"; | ||
| import { Argument, Command } from "effect/cli"; | ||
|
|
||
| /** | ||
| * `t3 skills-cli <args…>` 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<string>): Promise<void> { | ||
| // 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))), | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ReadonlyArray<ServerSettingsPatch>>([]); | ||
| 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), | ||
| ), | ||
| ), | ||
| ); | ||
| }), | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = <A, E, R, CR>( | ||
| check: Effect.Effect<unknown, OrchestratorMcpFailure, CR>, | ||
| change: Effect.Effect<A, E, R>, | ||
| ) => | ||
| 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 {}; | ||
| }), | ||
| ), | ||
| ), | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.