diff --git a/docs/adapters/creating-an-adapter.md b/docs/adapters/creating-an-adapter.md index ae5e4ccb..c81274f7 100644 --- a/docs/adapters/creating-an-adapter.md +++ b/docs/adapters/creating-an-adapter.md @@ -203,6 +203,43 @@ export const sessionCodec: AdapterSessionCodec = { }; ``` +## Capability Flags + +Adapters can declare what "local" capabilities they support by setting optional fields on the `ServerAdapterModule`. The server and UI use these flags to decide which features to enable for agents using the adapter (instructions bundle editor, skills sync, JWT auth, etc.). + +| Flag | Type | Default | What it controls | +|------|------|---------|------------------| +| `supportsLocalAgentJwt` | `boolean` | `false` | Whether heartbeat generates a local JWT for the agent | +| `supportsInstructionsBundle` | `boolean` | `false` | Managed instructions bundle (AGENTS.md) — server-side resolution + UI editor | +| `instructionsPathKey` | `string` | `"instructionsFilePath"` | The `adapterConfig` key that holds the instructions file path | +| `requiresMaterializedRuntimeSkills` | `boolean` | `false` | Whether runtime skill entries must be written to disk before execution | + +These flags are exposed via `GET /api/adapters` in a `capabilities` object, along with a derived `supportsSkills` flag (true when `listSkills` or `syncSkills` is defined). + +### Example + +```ts +export function createServerAdapter(): ServerAdapterModule { + return { + type: "my_k8s_adapter", + execute: myExecute, + testEnvironment: myTestEnvironment, + listSkills: myListSkills, + syncSkills: mySyncSkills, + + // Capability flags + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, + }; +} +``` + +With these flags set, the Paperclip UI will automatically show the instructions bundle editor, skills management tab, and working directory field for agents using this adapter — no Paperclip source changes required. + +If capability flags are not set, the server falls back to legacy hardcoded lists for built-in adapter types. External adapters that omit the flags will default to `false` for all capabilities. + ## Skills Injection Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory: diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 6f7b0973..4e473df5 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -95,6 +95,51 @@ describe("server adapter registry", () => { ]); }); + it("exposes capability flags from registered adapters", () => { + const adapterWithCaps: ServerAdapterModule = { + type: "external_test", + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: async () => ({ + adapterType: "external_test", + status: "pass" as const, + checks: [], + testedAt: new Date(0).toISOString(), + }), + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "customPathKey", + requiresMaterializedRuntimeSkills: true, + }; + + registerServerAdapter(adapterWithCaps); + + const resolved = findActiveServerAdapter("external_test"); + expect(resolved).not.toBeNull(); + expect(resolved!.supportsInstructionsBundle).toBe(true); + expect(resolved!.instructionsPathKey).toBe("customPathKey"); + expect(resolved!.requiresMaterializedRuntimeSkills).toBe(true); + expect(resolved!.supportsLocalAgentJwt).toBe(true); + }); + + it("returns undefined for capability flags on adapters that do not set them", () => { + registerServerAdapter(externalAdapter); + + const resolved = findActiveServerAdapter("external_test"); + expect(resolved).not.toBeNull(); + expect(resolved!.supportsInstructionsBundle).toBeUndefined(); + expect(resolved!.instructionsPathKey).toBeUndefined(); + expect(resolved!.requiresMaterializedRuntimeSkills).toBeUndefined(); + }); + + it("built-in claude_local adapter declares capability flags", () => { + const adapter = findActiveServerAdapter("claude_local"); + expect(adapter).not.toBeNull(); + expect(adapter!.supportsInstructionsBundle).toBe(true); + expect(adapter!.instructionsPathKey).toBe("instructionsFilePath"); + expect(adapter!.requiresMaterializedRuntimeSkills).toBe(false); + expect(adapter!.supportsLocalAgentJwt).toBe(true); + }); + it("switches active adapter behavior back to the builtin when an override is paused", async () => { const builtIn = findServerAdapter("claude_local"); expect(builtIn).not.toBeNull(); diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index c1ce6c3a..eddf3817 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -57,6 +57,75 @@ describe("adapter routes", () => { unregisterServerAdapter("claude_local"); }); + it("GET /api/adapters includes capabilities object for each adapter", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + + // Every adapter should have a capabilities object + for (const adapter of res.body) { + expect(adapter.capabilities).toBeDefined(); + expect(typeof adapter.capabilities.supportsInstructionsBundle).toBe("boolean"); + expect(typeof adapter.capabilities.supportsSkills).toBe("boolean"); + expect(typeof adapter.capabilities.supportsLocalAgentJwt).toBe("boolean"); + expect(typeof adapter.capabilities.requiresMaterializedRuntimeSkills).toBe("boolean"); + } + }); + + it("GET /api/adapters returns correct capabilities for built-in adapters", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + + // codex_local has instructions bundle + skills + jwt, no materialized skills + // (claude_local is overridden by beforeEach, so check codex_local instead) + const codexLocal = res.body.find((a: any) => a.type === "codex_local"); + expect(codexLocal).toBeDefined(); + expect(codexLocal.capabilities).toMatchObject({ + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: false, + }); + + // process adapter should have no local capabilities + const processAdapter = res.body.find((a: any) => a.type === "process"); + expect(processAdapter).toBeDefined(); + expect(processAdapter.capabilities).toMatchObject({ + supportsInstructionsBundle: false, + supportsSkills: false, + supportsLocalAgentJwt: false, + requiresMaterializedRuntimeSkills: false, + }); + + // cursor adapter should require materialized runtime skills + const cursorAdapter = res.body.find((a: any) => a.type === "cursor"); + expect(cursorAdapter).toBeDefined(); + expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true); + expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true); + }); + + it("GET /api/adapters derives supportsSkills from listSkills/syncSkills presence", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + + // http adapter has no listSkills/syncSkills + const httpAdapter = res.body.find((a: any) => a.type === "http"); + expect(httpAdapter).toBeDefined(); + expect(httpAdapter.capabilities.supportsSkills).toBe(false); + + // codex_local has listSkills/syncSkills + const codexLocal = res.body.find((a: any) => a.type === "codex_local"); + expect(codexLocal).toBeDefined(); + expect(codexLocal.capabilities.supportsSkills).toBe(true); + }); + it("uses the active adapter when resolving config schema for a paused builtin override", async () => { const app = createApp();