Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .caplets/config.json

This file was deleted.

6 changes: 6 additions & 0 deletions .changeset/tame-cups-attach.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion .codex/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ 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"
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"
8 changes: 7 additions & 1 deletion packages/core/src/attach/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &
Expand All @@ -11,7 +12,9 @@ export type RawAttachServeOptions = RemoteSelectionInput &
};

export type AttachServeOptions = ServeOptions & {
configPath: string;
projectRoot: string;
projectConfigPath: string;
selection: ResolvedRemoteSelection;
};

Expand All @@ -21,9 +24,12 @@ export async function resolveAttachServeOptions(
): Promise<AttachServeOptions> {
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,
};
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/attach/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/native/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
ian-pascoe marked this conversation as resolved.
const remoteHasCaplet = serviceHasCaplet(this.remote, capletId);
if (localHasCaplet && !remoteHasCaplet) {
return await this.local.execute(capletId, request);
Expand Down Expand Up @@ -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;
});
}

Expand Down
59 changes: 59 additions & 0 deletions packages/core/test/attach-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"], {
Expand Down
112 changes: 112 additions & 0 deletions packages/core/test/native-remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "../src/native/remote";
import {
createNativeCapletsService,
type NativeCapletsService,
resetNativeProjectBindingFallbackWarningForTests,
} from "../src/native/service";
import { createHttpServeApp } from "../src/serve/http";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down