diff --git a/.caplets/config.json b/.caplets/config.json deleted file mode 100644 index f670cef7..00000000 --- a/.caplets/config.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://caplets.dev/config.schema.json", - "mcpServers": {} -} diff --git a/.changeset/tame-cups-attach.md b/.changeset/tame-cups-attach.md new file mode 100644 index 00000000..4336710c --- /dev/null +++ b/.changeset/tame-cups-attach.md @@ -0,0 +1,6 @@ +--- +"@caplets/core": patch +"caplets": patch +--- + +Preserve the caller's Caplets config paths when running `caplets attach` so local overlay handles come from the intended `CAPLETS_CONFIG` instead of the default user config. Local overlay Code Mode handles now execute locally when attached to a remote service. diff --git a/.codex/config.toml b/.codex/config.toml index 01ce6b20..f7fa3f8f 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -6,6 +6,7 @@ args = ["./packages/cli/dist/index.js", "attach", "--remote-url", "http://localh [mcp_servers.caplets-remote.env] CAPLETS_MODE = "remote" +CAPLETS_CONFIG = "./.caplets/local.config.json" [mcp_servers.caplets-local] command = "node" @@ -13,4 +14,4 @@ args = ["./packages/cli/dist/index.js", "serve", "--transport", "stdio"] [mcp_servers.caplets-local.env] CAPLETS_MODE = "local" -CAPLETS_CONFIG = "./.caplets/config.json" +CAPLETS_CONFIG = "./.caplets/local.config.json" diff --git a/packages/core/src/attach/options.ts b/packages/core/src/attach/options.ts index a606e965..854c2048 100644 --- a/packages/core/src/attach/options.ts +++ b/packages/core/src/attach/options.ts @@ -3,6 +3,7 @@ import { type RemoteSelectionInput, type ResolvedRemoteSelection, } from "../remote/selection"; +import { resolveConfigPath, resolveProjectConfigPath } from "../config"; import { resolveServeOptions, type RawServeOptions, type ServeOptions } from "../serve/options"; export type RawAttachServeOptions = RemoteSelectionInput & @@ -11,7 +12,9 @@ export type RawAttachServeOptions = RemoteSelectionInput & }; export type AttachServeOptions = ServeOptions & { + configPath: string; projectRoot: string; + projectConfigPath: string; selection: ResolvedRemoteSelection; }; @@ -21,9 +24,12 @@ export async function resolveAttachServeOptions( ): Promise { const selection = await resolveRemoteSelection(raw, env); const serve = resolveServeOptions(attachLocalServeOptions(raw), env); + const projectRoot = raw.projectRoot ?? process.cwd(); return { ...serve, - projectRoot: raw.projectRoot ?? process.cwd(), + configPath: resolveConfigPath(env.CAPLETS_CONFIG?.trim() || undefined), + projectRoot, + projectConfigPath: env.CAPLETS_PROJECT_CONFIG?.trim() || resolveProjectConfigPath(projectRoot), selection, }; } diff --git a/packages/core/src/attach/server.ts b/packages/core/src/attach/server.ts index c6043f92..5859ec0f 100644 --- a/packages/core/src/attach/server.ts +++ b/packages/core/src/attach/server.ts @@ -37,6 +37,8 @@ export async function attachResolvedCaplets( function createAttachNativeService(options: AttachServeOptions, io: AttachServeIo) { return createNativeCapletsService({ mode: options.selection.kind === "hosted_cloud" ? "cloud" : "remote", + configPath: options.configPath, + projectConfigPath: options.projectConfigPath, server: { url: options.selection.remote.baseUrl.toString(), ...(options.selection.remote.fetch ? { fetch: options.selection.remote.fetch } : {}), diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index aa1b01ca..f6ea62e5 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -756,7 +756,7 @@ class CompositeNativeCapletsService implements NativeCapletsService { if (capletId === nativeCodeModeToolId) { return await executeCodeModeRunNative(this, request); } - const localHasCaplet = this.local.listTools().some((tool) => tool.caplet === capletId); + const localHasCaplet = serviceHasCaplet(this.local, capletId); const remoteHasCaplet = serviceHasCaplet(this.remote, capletId); if (localHasCaplet && !remoteHasCaplet) { return await this.local.execute(capletId, request); @@ -890,7 +890,7 @@ function serviceHasCaplet(service: NativeCapletsService, capletId: string): bool if (tool.codeModeRun) { return tool.codeModeCaplets?.some((caplet) => caplet.id === capletId) ?? false; } - return tool.caplet === capletId; + return tool.caplet === capletId || tool.sourceCaplet === capletId; }); } diff --git a/packages/core/test/attach-cli.test.ts b/packages/core/test/attach-cli.test.ts index 9e8c2347..a6059306 100644 --- a/packages/core/test/attach-cli.test.ts +++ b/packages/core/test/attach-cli.test.ts @@ -78,6 +78,65 @@ describe("caplets attach CLI", () => { }); }); + it("passes local overlay config paths into attach serving", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-attach-config-")); + tempDirs.push(dir); + const configPath = join(dir, "local.json"); + const projectConfigPath = join(dir, "project.json"); + const served: unknown[] = []; + + await runCli(["attach", "--remote-url", "https://caplets.example.com/caplets"], { + env: { + CAPLETS_MODE: "remote", + CAPLETS_CONFIG: configPath, + CAPLETS_PROJECT_CONFIG: projectConfigPath, + }, + attachServe: async (options: unknown) => { + served.push(options); + }, + } as never); + + expect(served).toHaveLength(1); + expect(served[0]).toMatchObject({ + configPath, + projectConfigPath, + }); + }); + + it("uses attach --project-root for the default local overlay project config", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-attach-project-root-")); + tempDirs.push(dir); + const projectRoot = join(dir, "checkout"); + const configPath = join(dir, "local.json"); + const served: unknown[] = []; + + await runCli( + [ + "attach", + "--remote-url", + "https://caplets.example.com/caplets", + "--project-root", + projectRoot, + ], + { + env: { + CAPLETS_MODE: "remote", + CAPLETS_CONFIG: configPath, + }, + attachServe: async (options: unknown) => { + served.push(options); + }, + } as never, + ); + + expect(served).toHaveLength(1); + expect(served[0]).toMatchObject({ + configPath, + projectRoot, + projectConfigPath: join(projectRoot, ".caplets", "config.json"), + }); + }); + it("rejects attach server in local mode", async () => { await expect( runCli(["attach"], { diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index f19ad74c..b8cee6e1 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -14,6 +14,7 @@ import { } from "../src/native/remote"; import { createNativeCapletsService, + type NativeCapletsService, resetNativeProjectBindingFallbackWarningForTests, } from "../src/native/service"; import { createHttpServeApp } from "../src/serve/http"; @@ -1583,6 +1584,56 @@ describe("createNativeCapletsService remote mode", () => { await service.close(); }); + it("does not execute local Code Mode handles shadowed by remote direct tools", async () => { + const fixture = client([ + { + name: "shared__ping", + sourceCapletId: "shared", + title: "Ping", + description: "Remote direct tool.", + }, + ]); + const localExecute = vi.fn(async () => ({ local: true })); + const localService = { + listTools: vi.fn(() => [ + { + caplet: "code_mode", + toolName: "caplets__code_mode", + title: "Local Code", + description: "Local Code Mode handle.", + codeModeRun: true, + codeModeCaplets: [ + { + id: "shared", + name: "Shared", + description: "Local shared Code Mode handle.", + }, + ], + promptGuidance: [], + }, + ]), + execute: localExecute, + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => undefined), + close: vi.fn(async () => undefined), + } satisfies NativeCapletsService; + const service = createNativeCapletsService({ + mode: "remote", + server: { url: "http://127.0.0.1:5387" }, + remoteClientFactory: vi.fn(() => fixture.api), + localServiceFactory: vi.fn(() => localService), + }); + + await service.reload(); + + expect(configuredCapletIds(service.listTools())).toEqual(["shared__ping"]); + await service.execute("shared", { operation: "check" }); + + expect(localExecute).not.toHaveBeenCalled(); + expect(fixture.api.callTool).toHaveBeenCalledWith("shared", { operation: "check" }); + await service.close(); + }); + it("keeps visible local Code Mode handles in composite Code Mode declarations", async () => { const fixture = client([{ name: "remote-only", title: "Remote Only" }]); const { dir, configPath, projectConfigPath } = tempConfig({ @@ -1615,6 +1666,67 @@ describe("createNativeCapletsService remote mode", () => { await service.close(); }); + it("executes local overlay Code Mode handles locally", async () => { + const fixture = client([{ name: "remote-only", title: "Remote Only" }]); + const localExecute = vi.fn(async (capletId: string, request: unknown) => ({ + capletId, + request, + status: "available", + })); + const localService = { + listTools: vi.fn(() => [ + { + caplet: "code_mode", + toolName: "caplets__code_mode", + title: "Local Code", + description: "Local Code Mode handle.", + codeModeRun: true, + codeModeCaplets: [ + { + id: "local-code", + name: "Local Code", + description: "Local Code Mode handle.", + }, + ], + promptGuidance: [], + }, + ]), + execute: localExecute, + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => undefined), + close: vi.fn(async () => undefined), + } satisfies NativeCapletsService; + const service = createNativeCapletsService({ + mode: "remote", + server: { url: "http://127.0.0.1:5387" }, + remoteClientFactory: vi.fn(() => fixture.api), + localServiceFactory: vi.fn(() => localService), + }); + + await service.reload(); + + await expect( + service.execute("code_mode", { + code: 'return await caplets["local-code"].check();', + }), + ).resolves.toMatchObject({ + ok: true, + value: { + ok: true, + data: { + capletId: "local-code", + request: { operation: "check" }, + status: "available", + }, + }, + }); + expect(localExecute).toHaveBeenCalledWith("local-code", { operation: "check" }); + expect(fixture.api.callTool).not.toHaveBeenCalledWith("local-code", { + operation: "check", + }); + await service.close(); + }); + it("does not make attach-visible remote tools callable from Code Mode when the manifest is explicit empty", async () => { const fixture = client([{ name: "remote-only", title: "Remote Only", codeModeCaplets: [] }]); const { dir, configPath, projectConfigPath } = tempConfig({