From 70b1a9109d756818fb448beafa67af27babb52de Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Wed, 3 Jun 2026 02:13:29 +0200 Subject: [PATCH] Improve CLI API parity coverage (#6626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is a control plane for AI-agent companies, with the CLI acting as a scriptable operator and agent interface to that control plane. > - The REST API surface has grown across companies, agents, issues, routines, plugins, auth, workspaces, secrets, and operational inspection commands. > - The CLI had drifted from that API surface: some commands were missing, some command shapes differed from docs/reference material, and several edge cases only failed during end-to-end local-source testing. > - The local development runbook requires these tests to be disposable and isolated from a real `~/.paperclip`, `~/.codex`, or `~/.claude` installation. > - This pull request adds broad CLI/API parity coverage, fixes the actionable bugs found during that pass, and records the reproducible test log under `doc/logs`. > - The benefit is a more complete, scriptable CLI surface with regression coverage for the command families exercised by the parity run. ## What Changed - Added or expanded CLI command coverage for access/auth, companies, agents, projects, goals, issues and subresources, routines, plugins, workspaces, activity/run/cost/dashboard inspection, assets, skills, secrets, tokens, prompt/wake flows, and local setup helpers. - Fixed CLI/API parity bugs found during the run, including context profile patching, issue interaction optional payloads, malformed tree-hold errors, environment duplicate handling, configure invalid-section exit codes, worktree pnpm invocation, token agent ID resolution, plugin tool worker lookup, and routine webhook secret cleanup. - Added missing CLI wrappers and route coverage for health/access, invite resolution URL forwarding, join status normalization, secret lifecycle commands, LLM docs routes, available-skill isolation, positive board-claim coverage, and interactive `connect` prompt-flow tests. - Added a schema-backed `/api/openapi.json` route sufficient for CLI parity and `paperclipai openapi --json` smoke coverage. - Added `doc/logs/2026-05-24-cli-api-parity-e2e-log.md` with the detailed living test/bug log and renamed the log directory from `doc/bugs` to `doc/logs`. - Added `doc/plans/2026-05-23-cli-api-parity.md` and the OpenAPI parity reference used during the pass. OpenAPI note: this PR intentionally does not try to subsume `feature/openapi-spec`. The OpenAPI implementation here is schema-backed and better than the earlier route-inventory stub, but `feature/openapi-spec` is the fuller/better OpenAPI branch because it includes exact mounted-route coverage tests and additional current route coverage. That branch should stay as its own PR and can supersede this OpenAPI route implementation. ## Verification Targeted automated checks run: - `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts` - `pnpm exec vitest run server/src/__tests__/board-claim.test.ts` - `pnpm exec vitest run cli/src/__tests__/connect.test.ts` - `pnpm exec vitest run cli/src/__tests__/agent-lifecycle.test.ts` - `pnpm exec vitest run server/src/__tests__/plugin-database.test.ts` - `pnpm exec vitest run server/src/__tests__/routines-service.test.ts` - `pnpm --dir cli typecheck` - `pnpm --dir server typecheck` Manual/local E2E verification: - Ran the full disposable local-source CLI/API parity pass with isolated `PAPERCLIP_HOME`, `PAPERCLIP_CONFIG`, `PAPERCLIP_CONTEXT`, `PAPERCLIP_AUTH_STORE`, `CODEX_HOME`, and `CLAUDE_HOME` under `tmp/cli-api-parity`. - Verified `DATABASE_URL` and `DATABASE_MIGRATION_URL` stayed unset for the scratch server. - Verified live health and schema-backed OpenAPI responses on non-default port `3197`. - Revoked created board/agent tokens and cleaned up temporary plugins, secrets, non-default environments, and project workspaces. - See `doc/logs/2026-05-24-cli-api-parity-e2e-log.md` for the full command-by-command reproduction log. Not run: - Full `pnpm test`, `pnpm test:run`, or `pnpm build` were not run after the entire branch because the branch is broad and the parity pass used focused test/typecheck verification plus live isolated CLI reruns. ## Risks - This is a broad PR and touches many CLI command modules, so review surface is high. The changes are grouped around one theme, but a split may be easier if maintainers prefer narrower PRs. - The OpenAPI route in this PR is not the final/best OpenAPI implementation. `feature/openapi-spec` has stronger exact-route coverage and should remain the source for the dedicated OpenAPI PR. - The living log is intentionally detailed and large. It is useful for reproducibility but adds documentation weight. - No UI changes are intended; screenshots are not applicable. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent in Codex desktop. Exact served model/context-window identifier was not exposed in the local app. Work used shell/Git/GitHub CLI tooling, local source inspection, targeted test execution, and live isolated Paperclip CLI/API smoke testing. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Devin Foley --- cli/src/__tests__/access-parity.test.ts | 140 + cli/src/__tests__/activity-parity.test.ts | 53 + .../admin-asset-skill-parity.test.ts | 177 + cli/src/__tests__/agent-lifecycle.test.ts | 117 + .../auth-command-registration.test.ts | 57 +- cli/src/__tests__/common.test.ts | 70 +- cli/src/__tests__/configure.test.ts | 99 + cli/src/__tests__/connect.test.ts | 197 + cli/src/__tests__/context.test.ts | 65 +- cli/src/__tests__/feedback.test.ts | 2 +- cli/src/__tests__/issue-subresources.test.ts | 223 + cli/src/__tests__/operations-parity.test.ts | 136 + cli/src/__tests__/project-goal.test.ts | 155 + cli/src/__tests__/prompt.test.ts | 102 + .../__tests__/routine-plugin-parity.test.ts | 130 + cli/src/__tests__/run.test.ts | 220 + cli/src/__tests__/secrets.test.ts | 77 +- cli/src/__tests__/token.test.ts | 132 + cli/src/__tests__/worktree.test.ts | 31 + cli/src/client/context.ts | 54 +- cli/src/client/http.ts | 7 + cli/src/commands/client/access.ts | 519 +++ cli/src/commands/client/activity.ts | 41 +- cli/src/commands/client/adapter.ts | 223 + cli/src/commands/client/agent.ts | 565 ++- cli/src/commands/client/approval.ts | 17 +- cli/src/commands/client/asset.ts | 147 + cli/src/commands/client/auth.ts | 87 + cli/src/commands/client/cloud.ts | 3 +- cli/src/commands/client/common.ts | 74 +- cli/src/commands/client/company.ts | 142 +- cli/src/commands/client/connect.ts | 265 ++ cli/src/commands/client/context.ts | 43 +- cli/src/commands/client/cost.ts | 167 + cli/src/commands/client/dashboard.ts | 3 +- cli/src/commands/client/feedback.ts | 35 +- cli/src/commands/client/goal.ts | 177 + cli/src/commands/client/issue.ts | 1045 ++++- cli/src/commands/client/plugin.ts | 282 ++ cli/src/commands/client/project.ts | 228 + cli/src/commands/client/prompt.ts | 276 ++ cli/src/commands/client/routine-api.ts | 154 + cli/src/commands/client/run.ts | 321 ++ cli/src/commands/client/secrets.ts | 249 +- cli/src/commands/client/skill.ts | 150 + cli/src/commands/client/token.ts | 244 ++ cli/src/commands/client/workspace.ts | 327 ++ cli/src/commands/configure.ts | 2 + cli/src/commands/worktree.ts | 22 +- cli/src/index.ts | 29 +- doc/CLI.md | 465 +- doc/logs/2026-05-24-cli-api-parity-e2e-log.md | 1030 +++++ ...-05-23-cli-api-parity-openapi-reference.ts | 3585 +++++++++++++++ doc/plans/2026-05-23-cli-api-parity.md | 623 +++ packages/shared/src/index.ts | 2 + packages/shared/src/validators/access.ts | 8 + packages/shared/src/validators/index.ts | 2 + server/src/__tests__/board-claim.test.ts | 128 + server/src/__tests__/cli-auth-routes.test.ts | 120 + .../src/__tests__/environment-routes.test.ts | 22 + .../issue-tree-control-routes.test.ts | 21 + server/src/__tests__/openapi-routes.test.ts | 45 + server/src/__tests__/plugin-database.test.ts | 23 +- server/src/__tests__/routines-service.test.ts | 4 + server/src/app.ts | 3 + server/src/routes/access.ts | 145 +- server/src/routes/adapters.ts | 15 + server/src/routes/environments.ts | 8 +- server/src/routes/issue-tree-control.ts | 17 +- server/src/routes/openapi.ts | 3842 +++++++++++++++++ server/src/services/board-auth.ts | 78 +- server/src/services/plugin-loader.ts | 2 +- server/src/services/plugin-tool-dispatcher.ts | 7 +- server/src/services/routines.ts | 10 + 74 files changed, 18175 insertions(+), 111 deletions(-) create mode 100644 cli/src/__tests__/access-parity.test.ts create mode 100644 cli/src/__tests__/activity-parity.test.ts create mode 100644 cli/src/__tests__/admin-asset-skill-parity.test.ts create mode 100644 cli/src/__tests__/agent-lifecycle.test.ts create mode 100644 cli/src/__tests__/configure.test.ts create mode 100644 cli/src/__tests__/connect.test.ts create mode 100644 cli/src/__tests__/issue-subresources.test.ts create mode 100644 cli/src/__tests__/operations-parity.test.ts create mode 100644 cli/src/__tests__/project-goal.test.ts create mode 100644 cli/src/__tests__/prompt.test.ts create mode 100644 cli/src/__tests__/routine-plugin-parity.test.ts create mode 100644 cli/src/__tests__/run.test.ts create mode 100644 cli/src/__tests__/token.test.ts create mode 100644 cli/src/commands/client/access.ts create mode 100644 cli/src/commands/client/adapter.ts create mode 100644 cli/src/commands/client/asset.ts create mode 100644 cli/src/commands/client/connect.ts create mode 100644 cli/src/commands/client/cost.ts create mode 100644 cli/src/commands/client/goal.ts create mode 100644 cli/src/commands/client/project.ts create mode 100644 cli/src/commands/client/prompt.ts create mode 100644 cli/src/commands/client/routine-api.ts create mode 100644 cli/src/commands/client/run.ts create mode 100644 cli/src/commands/client/skill.ts create mode 100644 cli/src/commands/client/token.ts create mode 100644 cli/src/commands/client/workspace.ts create mode 100644 doc/logs/2026-05-24-cli-api-parity-e2e-log.md create mode 100644 doc/plans/2026-05-23-cli-api-parity-openapi-reference.ts create mode 100644 doc/plans/2026-05-23-cli-api-parity.md create mode 100644 server/src/__tests__/board-claim.test.ts create mode 100644 server/src/__tests__/openapi-routes.test.ts create mode 100644 server/src/routes/openapi.ts diff --git a/cli/src/__tests__/access-parity.test.ts b/cli/src/__tests__/access-parity.test.ts new file mode 100644 index 00000000..085e8a2e --- /dev/null +++ b/cli/src/__tests__/access-parity.test.ts @@ -0,0 +1,140 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerAccessCommands } from "../commands/client/access.js"; + +const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; +const USER_ID = "33333333-3333-4333-8333-333333333333"; +const INVITE_ID = "44444444-4444-4444-8444-444444444444"; +const JOIN_ID = "55555555-5555-4555-8555-555555555555"; +const MEMBER_ID = "66666666-6666-4666-8666-666666666666"; + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerAccessCommands(program); + return program; +} + +async function run(args: string[]): Promise { + await createProgram().parseAsync([...args, "--api-base", "http://localhost:3100", "--api-key", "board-token"], { from: "user" }); +} + +describe("access parity commands", () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_API_URL; + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("wraps auth, invites, joins, members, and admin endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["health"]); + await run(["whoami"]); + await run(["access", "whoami"]); + await run(["profile", "session"]); + await run(["profile", "get"]); + await run(["profile", "update", "--payload-json", "{}"]); + await run(["invite", "list", "--company-id", COMPANY_ID]); + await run(["invite", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["invite", "revoke", INVITE_ID]); + await run(["invite", "show", "token-1"]); + await run(["invite", "test-resolution", "token-1", "--url", "http://localhost:3100/invite/token-1"]); + await run(["invite", "accept", "token-1"]); + await run(["join", "list", "--company-id", COMPANY_ID, "--status", "pending"]); + await run(["join", "approve", JOIN_ID, "--company-id", COMPANY_ID]); + await run(["join", "reject", JOIN_ID, "--company-id", COMPANY_ID]); + await run(["join", "claim-key", JOIN_ID, "--claim-secret", "secret"]); + await run(["member", "list", "--company-id", COMPANY_ID]); + await run(["member", "update", MEMBER_ID, "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["member", "archive", MEMBER_ID, "--company-id", COMPANY_ID]); + await run(["admin", "user", "list"]); + await run(["admin", "user", "promote", USER_ID]); + await run(["admin", "user", "company-access:update", USER_ID, "--payload-json", "{}"]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", "http://localhost:3100/api/health"], + ["GET", "http://localhost:3100/api/cli-auth/me"], + ["GET", "http://localhost:3100/api/cli-auth/me"], + ["GET", "http://localhost:3100/api/auth/get-session"], + ["GET", "http://localhost:3100/api/auth/profile"], + ["PATCH", "http://localhost:3100/api/auth/profile"], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/invites`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/invites`], + ["POST", `http://localhost:3100/api/invites/${INVITE_ID}/revoke`], + ["GET", "http://localhost:3100/api/invites/token-1"], + ["GET", "http://localhost:3100/api/invites/token-1/test-resolution?url=http%3A%2F%2Flocalhost%3A3100%2Finvite%2Ftoken-1"], + ["POST", "http://localhost:3100/api/invites/token-1/accept"], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/join-requests?status=pending_approval`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/join-requests/${JOIN_ID}/approve`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/join-requests/${JOIN_ID}/reject`], + ["POST", `http://localhost:3100/api/join-requests/${JOIN_ID}/claim-api-key`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/members`], + ["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}/members/${MEMBER_ID}`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/members/${MEMBER_ID}/archive`], + ["GET", "http://localhost:3100/api/admin/users"], + ["POST", `http://localhost:3100/api/admin/users/${USER_ID}/promote-instance-admin`], + ["PUT", `http://localhost:3100/api/admin/users/${USER_ID}/company-access`], + ]); + }); + + it("wraps instance, sidebar, llm, and openapi endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["openapi"]); + await run(["instance", "scheduler-heartbeats"]); + await run(["instance", "settings:general"]); + await run(["instance", "settings:general:update", "--payload-json", "{}"]); + await run(["instance", "database-backup"]); + await run(["sidebar", "preferences"]); + await run(["sidebar", "preferences:update", "--payload-json", "{}"]); + await run(["sidebar", "project-preferences", "--company-id", COMPANY_ID]); + await run(["sidebar", "project-preferences:update", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["sidebar", "badges", "--company-id", COMPANY_ID]); + await run(["inbox", "dismissals", "--company-id", COMPANY_ID]); + await run(["inbox", "dismiss", "--company-id", COMPANY_ID, "--payload-json", "{\"itemKey\":\"run:1\"}"]); + await run(["board-claim", "show", "claim-token"]); + await run(["board-claim", "claim", "claim-token", "--payload-json", "{}"]); + await run(["openclaw", "invite-prompt", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["available-skill", "list"]); + await run(["available-skill", "index"]); + await run(["available-skill", "get", "paperclip"]); + await run(["llm", "agent-configuration"]); + await run(["llm", "agent-configuration:adapter", "codex_local"]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", "http://localhost:3100/api/openapi.json"], + ["GET", "http://localhost:3100/api/instance/scheduler-heartbeats"], + ["GET", "http://localhost:3100/api/instance/settings/general"], + ["PATCH", "http://localhost:3100/api/instance/settings/general"], + ["POST", "http://localhost:3100/api/instance/database-backups"], + ["GET", "http://localhost:3100/api/sidebar-preferences/me"], + ["PUT", "http://localhost:3100/api/sidebar-preferences/me"], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/sidebar-preferences/me`], + ["PUT", `http://localhost:3100/api/companies/${COMPANY_ID}/sidebar-preferences/me`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/sidebar-badges`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/inbox-dismissals`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/inbox-dismissals`], + ["GET", "http://localhost:3100/api/board-claim/claim-token"], + ["POST", "http://localhost:3100/api/board-claim/claim-token/claim"], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/openclaw/invite-prompt`], + ["GET", "http://localhost:3100/api/skills/available"], + ["GET", "http://localhost:3100/api/skills/index"], + ["GET", "http://localhost:3100/api/skills/paperclip"], + ["GET", "http://localhost:3100/api/llms/agent-configuration.txt"], + ["GET", "http://localhost:3100/api/llms/agent-configuration/codex_local.txt"], + ]); + }); +}); + +function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response { + return new Response(JSON.stringify(body), init); +} diff --git a/cli/src/__tests__/activity-parity.test.ts b/cli/src/__tests__/activity-parity.test.ts new file mode 100644 index 00000000..ade04fb4 --- /dev/null +++ b/cli/src/__tests__/activity-parity.test.ts @@ -0,0 +1,53 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerActivityCommands } from "../commands/client/activity.js"; + +const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; +const ISSUE_ID = "33333333-3333-4333-8333-333333333333"; + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerActivityCommands(program); + return program; +} + +async function run(args: string[]): Promise { + await createProgram().parseAsync([...args, "--api-base", "http://localhost:3100", "--api-key", "board-token"], { from: "user" }); +} + +describe("activity parity commands", () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_API_URL; + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("wraps activity endpoints", async () => { + const fetchMock = vi + .fn() + .mockImplementationOnce(() => Promise.resolve(jsonResponse([]))) + .mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["activity", "list", "--company-id", COMPANY_ID, "--agent-id", "agent-1"]); + await run(["activity", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["activity", "issue", ISSUE_ID]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/activity?agentId=agent-1`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/activity`], + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/activity`], + ]); + }); +}); + +function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response { + return new Response(JSON.stringify(body), init); +} diff --git a/cli/src/__tests__/admin-asset-skill-parity.test.ts b/cli/src/__tests__/admin-asset-skill-parity.test.ts new file mode 100644 index 00000000..7fc9d81d --- /dev/null +++ b/cli/src/__tests__/admin-asset-skill-parity.test.ts @@ -0,0 +1,177 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerAdapterCommands } from "../commands/client/adapter.js"; +import { registerAssetCommands } from "../commands/client/asset.js"; +import { registerCompanyCommands, resolveExportOutputPath } from "../commands/client/company.js"; +import { registerSkillCommands } from "../commands/client/skill.js"; + +const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; +const SKILL_ID = "33333333-3333-4333-8333-333333333333"; +const ASSET_ID = "44444444-4444-4444-8444-444444444444"; + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerCompanyCommands(program); + registerAdapterCommands(program); + registerAssetCommands(program); + registerSkillCommands(program); + return program; +} + +async function run(args: string[]): Promise { + await createProgram().parseAsync([ + ...args, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); +} + +describe("admin, asset, and skill parity commands", () => { + let tempDir: string; + + beforeEach(async () => { + vi.restoreAllMocks(); + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_API_URL; + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + tempDir = await mkdtemp(path.join(tmpdir(), "paperclip-cli-parity-")); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("wraps company management and raw portability endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["company", "stats"]); + await run(["company", "create", "--payload-json", "{}"]); + await run(["company", "update", COMPANY_ID, "--payload-json", "{}"]); + await run(["company", "branding:update", COMPANY_ID, "--payload-json", "{}"]); + await run(["company", "archive", COMPANY_ID]); + await run(["company", "export:preview", COMPANY_ID, "--payload-json", "{}"]); + await run(["company", "export:api", COMPANY_ID, "--payload-json", "{}"]); + await run(["company", "import:preview", COMPANY_ID, "--payload-json", "{}"]); + await run(["company", "import:apply", COMPANY_ID, "--payload-json", "{}"]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", "http://localhost:3100/api/companies/stats"], + ["POST", "http://localhost:3100/api/companies"], + ["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}`], + ["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}/branding`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/archive`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/exports/preview`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/exports`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/imports/preview`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/imports/apply`], + ]); + }); + + it("wraps adapter management and company adapter endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["adapter", "list"]); + await run(["adapter", "install", "--payload-json", "{\"packageName\":\"adapter\"}"]); + await run(["adapter", "get", "codex_local"]); + await run(["adapter", "get", "codex/local"]); + await run(["adapter", "update", "codex_local", "--payload-json", "{\"disabled\":true}"]); + await run(["adapter", "override", "codex_local", "--payload-json", "{\"paused\":true}"]); + await run(["adapter", "reload", "codex_local"]); + await run(["adapter", "reinstall", "codex_local"]); + await run(["adapter", "config-schema", "codex_local"]); + await run(["adapter", "ui-parser", "codex_local"]); + await run(["adapter", "models", "codex_local", "--company-id", COMPANY_ID, "--refresh", "--environment-id", "env-1"]); + await run(["adapter", "model-profiles", "codex_local", "--company-id", COMPANY_ID]); + await run(["adapter", "detect-model", "codex_local", "--company-id", COMPANY_ID]); + await run(["adapter", "test-environment", "codex_local", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["adapter", "delete", "codex_local"]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", "http://localhost:3100/api/adapters"], + ["POST", "http://localhost:3100/api/adapters/install"], + ["GET", "http://localhost:3100/api/adapters/codex_local"], + ["GET", "http://localhost:3100/api/adapters/codex%2Flocal"], + ["PATCH", "http://localhost:3100/api/adapters/codex_local"], + ["PATCH", "http://localhost:3100/api/adapters/codex_local/override"], + ["POST", "http://localhost:3100/api/adapters/codex_local/reload"], + ["POST", "http://localhost:3100/api/adapters/codex_local/reinstall"], + ["GET", "http://localhost:3100/api/adapters/codex_local/config-schema"], + ["GET", "http://localhost:3100/api/adapters/codex_local/ui-parser.js"], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/adapters/codex_local/models?refresh=true&environmentId=env-1`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/adapters/codex_local/model-profiles`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/adapters/codex_local/detect-model`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/adapters/codex_local/test-environment`], + ["DELETE", "http://localhost:3100/api/adapters/codex_local"], + ]); + }); + + it("wraps asset upload/download endpoints", async () => { + const imagePath = path.join(tempDir, "logo.png"); + const outputPath = path.join(tempDir, "asset.bin"); + await writeFile(imagePath, Buffer.from("png")); + const fetchMock = vi + .fn() + .mockImplementationOnce(() => Promise.resolve(jsonResponse({ assetId: ASSET_ID }, { status: 201 }))) + .mockImplementationOnce(() => Promise.resolve(jsonResponse({ assetId: ASSET_ID }, { status: 201 }))) + .mockImplementationOnce(() => Promise.resolve(new Response("asset-bytes"))); + vi.stubGlobal("fetch", fetchMock); + + await run(["asset", "image:upload", "--company-id", COMPANY_ID, "--file", imagePath, "--namespace", "docs", "--alt", "Logo"]); + await run(["asset", "logo:upload", "--company-id", COMPANY_ID, "--file", imagePath]); + await run(["asset", "content", ASSET_ID, "--out", outputPath]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/assets/images`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/logo`], + ["GET", `http://localhost:3100/api/assets/${ASSET_ID}/content`], + ]); + const firstUpload = fetchMock.mock.calls[0]?.[1]?.body as FormData; + expect((firstUpload.get("file") as File).type).toBe("image/png"); + }); + + it("rejects portable export paths outside the output directory", async () => { + expect(() => resolveExportOutputPath(tempDir, "../outside.md")).toThrow("outside output directory"); + }); + + it("wraps company skill endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["skill", "list", "--company-id", COMPANY_ID]); + await run(["skill", "get", SKILL_ID, "--company-id", COMPANY_ID]); + await run(["skill", "file", SKILL_ID, "--company-id", COMPANY_ID, "--path", "SKILL.md"]); + await run(["skill", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["skill", "file:update", SKILL_ID, "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["skill", "import", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["skill", "scan-projects", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["skill", "update-status", SKILL_ID, "--company-id", COMPANY_ID]); + await run(["skill", "install-update", SKILL_ID, "--company-id", COMPANY_ID]); + await run(["skill", "delete", SKILL_ID, "--company-id", COMPANY_ID]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/skills`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}/files?path=SKILL.md`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/skills`], + ["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}/files`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/import`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/scan-projects`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}/update-status`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}/install-update`], + ["DELETE", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}`], + ]); + }); +}); + +function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response { + return new Response(JSON.stringify(body), init); +} diff --git a/cli/src/__tests__/agent-lifecycle.test.ts b/cli/src/__tests__/agent-lifecycle.test.ts new file mode 100644 index 00000000..4e2917ce --- /dev/null +++ b/cli/src/__tests__/agent-lifecycle.test.ts @@ -0,0 +1,117 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerAgentCommands } from "../commands/client/agent.js"; + +const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; +const AGENT_ID = "11111111-1111-4111-8111-111111111111"; +const REVISION_ID = "33333333-3333-4333-8333-333333333333"; + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + registerAgentCommands(program); + return program; +} + +async function run(args: string[]): Promise { + await createProgram().parseAsync([ + ...args, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); +} + +describe("agent lifecycle commands", () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_API_URL; + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("wraps agent lifecycle and state endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run([ + "agent", "create", + "--company-id", COMPANY_ID, + "--payload-json", JSON.stringify({ name: "Builder", adapterType: "codex_local" }), + ]); + await run(["agent", "hire", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["agent", "update", AGENT_ID, "--payload-json", JSON.stringify({ title: "Senior Builder" })]); + await run(["agent", "pause", AGENT_ID]); + await run(["agent", "resume", AGENT_ID]); + await run(["agent", "approve", AGENT_ID]); + await run(["agent", "terminate", AGENT_ID]); + await run(["agent", "heartbeat:invoke", AGENT_ID]); + await run(["agent", "claude-login", AGENT_ID]); + await run(["agent", "delete", AGENT_ID, "--yes"]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/agents`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/agent-hires`], + ["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}`], + ["POST", `http://localhost:3100/api/agents/${AGENT_ID}/pause`], + ["POST", `http://localhost:3100/api/agents/${AGENT_ID}/resume`], + ["POST", `http://localhost:3100/api/agents/${AGENT_ID}/approve`], + ["POST", `http://localhost:3100/api/agents/${AGENT_ID}/terminate`], + ["POST", `http://localhost:3100/api/agents/${AGENT_ID}/heartbeat/invoke`], + ["POST", `http://localhost:3100/api/agents/${AGENT_ID}/claude-login`], + ["DELETE", `http://localhost:3100/api/agents/${AGENT_ID}`], + ]); + }); + + it("wraps configuration, runtime, skills, and instructions endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["agent", "permissions:update", AGENT_ID, "--payload-json", JSON.stringify({ canCreateAgents: true, canAssignTasks: true })]); + await run(["agent", "configuration", AGENT_ID]); + await run(["agent", "config-revisions", AGENT_ID]); + await run(["agent", "config-revision:get", AGENT_ID, REVISION_ID]); + await run(["agent", "config-revision:rollback", AGENT_ID, REVISION_ID]); + await run(["agent", "runtime-state", AGENT_ID]); + await run(["agent", "runtime-state:reset-session", AGENT_ID, "--task-key", "task-1"]); + await run(["agent", "task-sessions", AGENT_ID]); + await run(["agent", "skills", AGENT_ID]); + await run(["agent", "skills:sync", AGENT_ID, "--desired-skills", "paperclip,github"]); + await run(["agent", "instructions-path:update", AGENT_ID, "--payload-json", JSON.stringify({ path: "/tmp/AGENTS.md" })]); + await run(["agent", "instructions-bundle", AGENT_ID]); + await run(["agent", "instructions-bundle:update", AGENT_ID, "--payload-json", JSON.stringify({ mode: "managed" })]); + await run(["agent", "instructions-file:get", AGENT_ID, "--path", "AGENTS.md"]); + await run(["agent", "instructions-file:put", AGENT_ID, "--path", "AGENTS.md", "--content", "hello"]); + await run(["agent", "instructions-file:delete", AGENT_ID, "--path", "AGENTS.md"]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/permissions`], + ["GET", `http://localhost:3100/api/agents/${AGENT_ID}/configuration`], + ["GET", `http://localhost:3100/api/agents/${AGENT_ID}/config-revisions`], + ["GET", `http://localhost:3100/api/agents/${AGENT_ID}/config-revisions/${REVISION_ID}`], + ["POST", `http://localhost:3100/api/agents/${AGENT_ID}/config-revisions/${REVISION_ID}/rollback`], + ["GET", `http://localhost:3100/api/agents/${AGENT_ID}/runtime-state`], + ["POST", `http://localhost:3100/api/agents/${AGENT_ID}/runtime-state/reset-session`], + ["GET", `http://localhost:3100/api/agents/${AGENT_ID}/task-sessions`], + ["GET", `http://localhost:3100/api/agents/${AGENT_ID}/skills`], + ["POST", `http://localhost:3100/api/agents/${AGENT_ID}/skills/sync`], + ["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-path`], + ["GET", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle`], + ["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle`], + ["GET", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle/file?path=AGENTS.md`], + ["PUT", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle/file`], + ["DELETE", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle/file?path=AGENTS.md`], + ]); + }); +}); + +function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response { + return new Response(JSON.stringify(body), init); +} diff --git a/cli/src/__tests__/auth-command-registration.test.ts b/cli/src/__tests__/auth-command-registration.test.ts index a93d8fa7..76b3c51d 100644 --- a/cli/src/__tests__/auth-command-registration.test.ts +++ b/cli/src/__tests__/auth-command-registration.test.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { registerClientAuthCommands } from "../commands/client/auth.js"; describe("registerClientAuthCommands", () => { @@ -14,3 +14,58 @@ describe("registerClientAuthCommands", () => { expect(login?.options.filter((option) => option.long === "--company-id")).toHaveLength(1); }); }); + +describe("client auth API commands", () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_API_URL; + delete process.env.PAPERCLIP_TEST_CHALLENGE_TOKEN; + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("wraps CLI auth challenge endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + async function run(args: string[]) { + const program = new Command(); + const auth = program.command("auth"); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerClientAuthCommands(auth); + await program.parseAsync([ + "auth", + ...args, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); + } + + await run(["challenge", "create", "--payload-json", "{}"]); + await run(["challenge", "get", "challenge-1", "--token", "secret"]); + await run(["challenge", "approve", "challenge-1", "--token", "secret"]); + process.env.PAPERCLIP_TEST_CHALLENGE_TOKEN = "env-secret"; + await run(["challenge", "approve", "challenge/2", "--token-env", "PAPERCLIP_TEST_CHALLENGE_TOKEN"]); + await run(["challenge", "cancel", "challenge-1", "--token", "secret"]); + await run(["revoke-current"]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["POST", "http://localhost:3100/api/cli-auth/challenges"], + ["GET", "http://localhost:3100/api/cli-auth/challenges/challenge-1?token=secret"], + ["POST", "http://localhost:3100/api/cli-auth/challenges/challenge-1/approve"], + ["POST", "http://localhost:3100/api/cli-auth/challenges/challenge%2F2/approve"], + ["POST", "http://localhost:3100/api/cli-auth/challenges/challenge-1/cancel"], + ["POST", "http://localhost:3100/api/cli-auth/revoke-current"], + ]); + expect(JSON.parse(String(fetchMock.mock.calls[3]?.[1]?.body))).toEqual({ token: "env-secret" }); + }); +}); + +function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response { + return new Response(JSON.stringify(body), init); +} diff --git a/cli/src/__tests__/common.test.ts b/cli/src/__tests__/common.test.ts index 91be4b7c..9562e47f 100644 --- a/cli/src/__tests__/common.test.ts +++ b/cli/src/__tests__/common.test.ts @@ -3,7 +3,8 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { writeContext } from "../client/context.js"; -import { resolveCommandContext } from "../commands/client/common.js"; +import { setStoredBoardCredential } from "../client/board-auth.js"; +import { resolveApiBase, resolveCommandContext } from "../commands/client/common.js"; const ORIGINAL_ENV = { ...process.env }; @@ -18,6 +19,8 @@ describe("resolveCommandContext", () => { delete process.env.PAPERCLIP_API_URL; delete process.env.PAPERCLIP_API_KEY; delete process.env.PAPERCLIP_COMPANY_ID; + delete process.env.PAPERCLIP_AUTH_STORE; + delete process.env.PAPERCLIP_SERVER_PORT; }); afterEach(() => { @@ -29,7 +32,7 @@ describe("resolveCommandContext", () => { writeContext( { - version: 1, + version: 2, currentProfile: "ops", profiles: { ops: { @@ -53,7 +56,7 @@ describe("resolveCommandContext", () => { const contextPath = createTempPath("context.json"); writeContext( { - version: 1, + version: 2, currentProfile: "default", profiles: { default: { @@ -84,7 +87,7 @@ describe("resolveCommandContext", () => { const contextPath = createTempPath("context.json"); writeContext( { - version: 1, + version: 2, currentProfile: "default", profiles: { default: {} }, }, @@ -95,4 +98,63 @@ describe("resolveCommandContext", () => { resolveCommandContext({ context: contextPath, apiBase: "http://localhost:3100" }, { requireCompany: true }), ).toThrow(/Company ID is required/); }); + + it("resolves api base by explicit, env, profile, then config/default precedence", () => { + const configPath = createTempPath("config.json"); + fs.writeFileSync(configPath, JSON.stringify({ + $meta: { version: 1, updatedAt: "2026-05-23T00:00:00.000Z", source: "onboard" }, + database: { mode: "embedded-postgres" }, + logging: { mode: "file" }, + server: { deploymentMode: "local_trusted", exposure: "private", host: "127.0.0.1", port: 4111 }, + })); + + expect(resolveApiBase({ apiBase: "http://explicit:1", config: configPath }, { apiBase: "http://profile:2" })) + .toBe("http://explicit:1"); + + process.env.PAPERCLIP_API_URL = "http://env:3/"; + expect(resolveApiBase({ config: configPath }, { apiBase: "http://profile:2" })).toBe("http://env:3"); + + delete process.env.PAPERCLIP_API_URL; + expect(resolveApiBase({ config: configPath }, { apiBase: "http://profile:2/" })).toBe("http://profile:2"); + expect(resolveApiBase({ config: configPath }, {})).toBe("http://localhost:4111"); + }); + + it("prefers explicit and env tokens over profile env and stored board auth", () => { + const contextPath = createTempPath("context.json"); + const authStorePath = createTempPath("auth.json"); + process.env.PAPERCLIP_AUTH_STORE = authStorePath; + process.env.PROFILE_KEY = "profile-token"; + setStoredBoardCredential({ + apiBase: "http://localhost:3100", + token: "stored-board-token", + userId: "user-1", + storePath: authStorePath, + }); + writeContext( + { + version: 2, + currentProfile: "default", + profiles: { + default: { + apiBase: "http://localhost:3100", + apiKeyEnvVarName: "PROFILE_KEY", + }, + }, + }, + contextPath, + ); + + const profileResolved = resolveCommandContext({ context: contextPath }); + expect(profileResolved.api.apiKey).toBe("profile-token"); + expect(profileResolved.authSource).toBe("profile_env"); + + process.env.PAPERCLIP_API_KEY = "env-token"; + const envResolved = resolveCommandContext({ context: contextPath }); + expect(envResolved.api.apiKey).toBe("env-token"); + expect(envResolved.authSource).toBe("env"); + + const explicitResolved = resolveCommandContext({ context: contextPath, apiKey: "explicit-token" }); + expect(explicitResolved.api.apiKey).toBe("explicit-token"); + expect(explicitResolved.authSource).toBe("explicit"); + }); }); diff --git a/cli/src/__tests__/configure.test.ts b/cli/src/__tests__/configure.test.ts new file mode 100644 index 00000000..74a37fc8 --- /dev/null +++ b/cli/src/__tests__/configure.test.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { configure } from "../commands/configure.js"; +import type { PaperclipConfig } from "../config/schema.js"; + +const ORIGINAL_EXIT_CODE = process.exitCode; + +afterEach(() => { + process.exitCode = ORIGINAL_EXIT_CODE; +}); + +function writeBaseConfig(configPath: string) { + const base: PaperclipConfig = { + $meta: { + version: 1, + updatedAt: "2026-01-01T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: "/tmp/paperclip-db", + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: "/tmp/paperclip-backups", + }, + }, + logging: { + mode: "file", + logDir: "/tmp/paperclip-logs", + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + bind: "loopback", + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + telemetry: { + enabled: true, + }, + storage: { + provider: "local_disk", + localDisk: { baseDir: "/tmp/paperclip-storage" }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { keyFilePath: "/tmp/paperclip-secrets/master.key" }, + }, + }; + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(base, null, 2)); +} + +describe("configure command", () => { + it("sets a failing exit code for unknown sections", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-configure-")); + const configPath = path.join(root, "config.json"); + writeBaseConfig(configPath); + + try { + await configure({ config: configPath, section: "invalid-section" }); + + expect(process.exitCode).toBe(1); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("sets a failing exit code when no config exists", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-configure-missing-")); + const configPath = path.join(root, "missing.json"); + + try { + await configure({ config: configPath, section: "server" }); + + expect(process.exitCode).toBe(1); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/src/__tests__/connect.test.ts b/cli/src/__tests__/connect.test.ts new file mode 100644 index 00000000..01c2388e --- /dev/null +++ b/cli/src/__tests__/connect.test.ts @@ -0,0 +1,197 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as prompts from "@clack/prompts"; +import { registerConnectCommand } from "../commands/client/connect.js"; +import { loginBoardCli } from "../client/board-auth.js"; + +vi.mock("@clack/prompts", () => ({ + intro: vi.fn(), + outro: vi.fn(), + cancel: vi.fn(), + isCancel: vi.fn(() => false), + text: vi.fn(), + select: vi.fn(), +})); + +vi.mock("../client/board-auth.js", () => ({ + loginBoardCli: vi.fn(), +})); + +const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; +const AGENT_ID = "33333333-3333-4333-8333-333333333333"; +const API_BASE = "http://127.0.0.1:3197"; + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerConnectCommand(program); + return program; +} + +function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response { + return new Response(JSON.stringify(body), init); +} + +function createTempContextPath() { + return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-connect-test-")), "context.json"); +} + +function readContext(filePath: string) { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as { + currentProfile: string; + profiles: Record>; + }; +} + +describe("connect command", () => { + let originalStdinIsTTY: boolean | undefined; + let originalStdoutIsTTY: boolean | undefined; + + beforeEach(() => { + originalStdinIsTTY = process.stdin.isTTY; + originalStdoutIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + vi.restoreAllMocks(); + vi.mocked(loginBoardCli).mockResolvedValue({ + token: "board-login-token", + approvalUrl: `${API_BASE}/cli-auth/challenge-1`, + userId: "user-1", + }); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + Object.defineProperty(process.stdin, "isTTY", { value: originalStdinIsTTY, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value: originalStdoutIsTTY, configurable: true }); + vi.restoreAllMocks(); + }); + + it("drives the interactive board profile flow through prompts and context writes", async () => { + const contextPath = createTempContextPath(); + vi.mocked(prompts.text).mockResolvedValue(API_BASE); + vi.mocked(prompts.select).mockResolvedValue(COMPANY_ID); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + if (url.pathname === "/api/health") return jsonResponse({ status: "ok" }); + if (url.pathname === "/api/companies") { + return jsonResponse([{ id: COMPANY_ID, name: "Connect Co" }]); + } + if (url.pathname === "/api/board-api-keys" && init?.method === "POST") { + return jsonResponse({ + id: "board-key-1", + name: "connect-board-token", + token: "pcp_board_created", + createdAt: "2026-05-24T12:00:00.000Z", + expiresAt: null, + }); + } + return jsonResponse({ error: `Unexpected ${init?.method ?? "GET"} ${url.pathname}` }, { status: 500 }); + }); + vi.stubGlobal("fetch", fetchMock); + + await createProgram().parseAsync([ + "connect", + "--persona", + "board", + "--profile", + "cli-board", + "--token-name", + "connect-board-token", + "--context", + contextPath, + "--api-base", + API_BASE, + "--json", + ], { from: "user" }); + + expect(loginBoardCli).toHaveBeenCalledWith(expect.objectContaining({ + apiBase: API_BASE, + requestedAccess: "board", + command: "paperclipai connect", + })); + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", new URL(String(call[0])).pathname])).toEqual([ + ["GET", "/api/health"], + ["GET", "/api/companies"], + ["POST", "/api/board-api-keys"], + ]); + expect(readContext(contextPath)).toMatchObject({ + currentProfile: "cli-board", + profiles: { + "cli-board": { + apiBase: API_BASE, + companyId: COMPANY_ID, + persona: "board", + tokenId: "board-key-1", + tokenName: "connect-board-token", + }, + }, + }); + }); + + it("drives the interactive agent profile flow through prompts and context writes", async () => { + const contextPath = createTempContextPath(); + vi.mocked(prompts.text).mockResolvedValue(API_BASE); + vi.mocked(prompts.select).mockResolvedValue(AGENT_ID); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + if (url.pathname === "/api/health") return jsonResponse({ status: "ok" }); + if (url.pathname === "/api/companies") { + return jsonResponse([{ id: COMPANY_ID, name: "Connect Co" }]); + } + if (url.pathname === `/api/companies/${COMPANY_ID}/agents`) { + return jsonResponse([{ id: AGENT_ID, name: "Connect Agent", role: "Operator" }]); + } + if (url.pathname === `/api/agents/${AGENT_ID}/keys` && init?.method === "POST") { + return jsonResponse({ + id: "agent-key-1", + name: "connect-agent-token", + token: "pcp_agent_created", + createdAt: "2026-05-24T12:00:00.000Z", + }); + } + return jsonResponse({ error: `Unexpected ${init?.method ?? "GET"} ${url.pathname}` }, { status: 500 }); + }); + vi.stubGlobal("fetch", fetchMock); + + await createProgram().parseAsync([ + "connect", + "--persona", + "agent", + "--profile", + "cli-agent", + "--token-name", + "connect-agent-token", + "--context", + contextPath, + "--api-base", + API_BASE, + "--json", + ], { from: "user" }); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", new URL(String(call[0])).pathname])).toEqual([ + ["GET", "/api/health"], + ["GET", "/api/companies"], + ["GET", `/api/companies/${COMPANY_ID}/agents`], + ["POST", `/api/agents/${AGENT_ID}/keys`], + ]); + expect(readContext(contextPath)).toMatchObject({ + currentProfile: "cli-agent", + profiles: { + "cli-agent": { + apiBase: API_BASE, + companyId: COMPANY_ID, + persona: "agent", + agentId: AGENT_ID, + agentName: "Connect Agent", + tokenId: "agent-key-1", + tokenName: "connect-agent-token", + }, + }, + }); + }); +}); diff --git a/cli/src/__tests__/context.test.ts b/cli/src/__tests__/context.test.ts index f9b28597..a57f24eb 100644 --- a/cli/src/__tests__/context.test.ts +++ b/cli/src/__tests__/context.test.ts @@ -30,6 +30,9 @@ describe("client context store", () => { { apiBase: "http://localhost:3100", companyId: "company-123", + persona: "agent", + agentId: "agent-123", + agentName: "Agent One", apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN", }, contextPath, @@ -42,15 +45,75 @@ describe("client context store", () => { expect(context.profiles.work).toEqual({ apiBase: "http://localhost:3100", companyId: "company-123", + persona: "agent", + agentId: "agent-123", + agentName: "Agent One", apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN", }); }); + it("preserves existing profile values when patch fields are undefined", () => { + const contextPath = createTempContextPath(); + + upsertProfile( + "default", + { + apiBase: "http://127.0.0.1:3197", + }, + contextPath, + ); + + upsertProfile( + "default", + { + apiBase: undefined, + companyId: "company-123", + persona: undefined, + }, + contextPath, + ); + + const context = readContext(contextPath); + expect(context.profiles.default).toEqual({ + apiBase: "http://127.0.0.1:3197", + companyId: "company-123", + }); + }); + + it("migrates version 1 context files to version 2 with persona metadata", () => { + const contextPath = createTempContextPath(); + fs.writeFileSync( + contextPath, + JSON.stringify({ + version: 1, + currentProfile: "legacy", + profiles: { + legacy: { + apiBase: "http://localhost:3101", + companyId: "company-legacy", + persona: "board", + apiKeyEnvVarName: "PAPERCLIP_BOARD_TOKEN", + }, + }, + }), + ); + + const context = readContext(contextPath); + + expect(context.version).toBe(2); + expect(context.profiles.legacy).toEqual({ + apiBase: "http://localhost:3101", + companyId: "company-legacy", + persona: "board", + apiKeyEnvVarName: "PAPERCLIP_BOARD_TOKEN", + }); + }); + it("normalizes invalid file content to safe defaults", () => { const contextPath = createTempContextPath(); writeContext( { - version: 1, + version: 2, currentProfile: "x", profiles: { x: { diff --git a/cli/src/__tests__/feedback.test.ts b/cli/src/__tests__/feedback.test.ts index e46b307d..35cdd0c8 100644 --- a/cli/src/__tests__/feedback.test.ts +++ b/cli/src/__tests__/feedback.test.ts @@ -69,7 +69,7 @@ describe("registerFeedbackCommands", () => { const feedback = program.commands.find((command) => command.name() === "feedback"); expect(feedback).toBeDefined(); - expect(feedback?.commands.map((command) => command.name())).toEqual(["report", "export"]); + expect(feedback?.commands.map((command) => command.name())).toEqual(["report", "export", "trace", "bundle"]); expect(feedback?.commands[0]?.options.filter((option) => option.long === "--company-id")).toHaveLength(1); }); }); diff --git a/cli/src/__tests__/issue-subresources.test.ts b/cli/src/__tests__/issue-subresources.test.ts new file mode 100644 index 00000000..afa9e411 --- /dev/null +++ b/cli/src/__tests__/issue-subresources.test.ts @@ -0,0 +1,223 @@ +import { Command } from "commander"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerIssueCommands } from "../commands/client/issue.js"; + +const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; +const ISSUE_ID = "44444444-4444-4444-8444-444444444444"; +const COMMENT_ID = "55555555-5555-4555-8555-555555555555"; +const APPROVAL_ID = "66666666-6666-4666-8666-666666666666"; +const PRODUCT_ID = "77777777-7777-4777-8777-777777777777"; +const INTERACTION_ID = "88888888-8888-4888-8888-888888888888"; +const HOLD_ID = "99999999-9999-4999-8999-999999999999"; +const ATTACHMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const LABEL_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + registerIssueCommands(program); + return program; +} + +async function run(args: string[]): Promise { + await createProgram().parseAsync([ + ...args, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); +} + +describe("issue subresource commands", () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_API_URL; + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("wraps core issue get, update, and delete endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["issue", "get", ISSUE_ID]); + await run(["issue", "update", ISSUE_ID, "--title", "New title"]); + await run(["issue", "delete", ISSUE_ID, "--yes"]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}`], + ["PATCH", `http://localhost:3100/api/issues/${ISSUE_ID}`], + ["DELETE", `http://localhost:3100/api/issues/${ISSUE_ID}`], + ]); + }); + + it("wraps comments, approvals, markers, and recovery action endpoints", async () => { + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["issue", "comments", ISSUE_ID, "--limit", "10"]); + await run(["issue", "comment:get", ISSUE_ID, COMMENT_ID]); + await run(["issue", "comment:delete", ISSUE_ID, COMMENT_ID]); + await run(["issue", "approvals", ISSUE_ID]); + await run(["issue", "approval:link", ISSUE_ID, APPROVAL_ID]); + await run(["issue", "approval:unlink", ISSUE_ID, APPROVAL_ID]); + await run(["issue", "read", ISSUE_ID]); + await run(["issue", "unread", ISSUE_ID]); + await run(["issue", "archive", ISSUE_ID]); + await run(["issue", "unarchive", ISSUE_ID]); + await run(["issue", "recovery-actions", ISSUE_ID]); + await run([ + "issue", "recovery:resolve", ISSUE_ID, + "--outcome", "restored", + "--source-issue-status", "todo", + "--action-id", APPROVAL_ID, + ]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/comments?limit=10`], + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/comments/${COMMENT_ID}`], + ["DELETE", `http://localhost:3100/api/issues/${ISSUE_ID}/comments/${COMMENT_ID}`], + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/approvals`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/approvals`], + ["DELETE", `http://localhost:3100/api/issues/${ISSUE_ID}/approvals/${APPROVAL_ID}`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/read`], + ["DELETE", `http://localhost:3100/api/issues/${ISSUE_ID}/read`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/inbox-archive`], + ["DELETE", `http://localhost:3100/api/issues/${ISSUE_ID}/inbox-archive`], + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/recovery-actions`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/recovery-actions/resolve`], + ]); + }); + + it("wraps document and work product endpoints", async () => { + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["issue", "documents", ISSUE_ID, "--include-system"]); + await run(["issue", "document:get", ISSUE_ID, "plan"]); + await run(["issue", "document:put", ISSUE_ID, "plan", "--body", "# Plan", "--title", "Plan"]); + await run(["issue", "document:lock", ISSUE_ID, "plan"]); + await run(["issue", "document:unlock", ISSUE_ID, "plan"]); + await run(["issue", "document:revisions", ISSUE_ID, "plan"]); + await run(["issue", "document:restore", ISSUE_ID, "plan", APPROVAL_ID]); + await run(["issue", "document:delete", ISSUE_ID, "plan"]); + await run(["issue", "work-products", ISSUE_ID]); + await run([ + "issue", "work-product:create", ISSUE_ID, + "--payload-json", JSON.stringify({ type: "pull_request", provider: "github", title: "PR", url: "https://example.com/pr/1" }), + ]); + await run([ + "issue", "work-product:update", PRODUCT_ID, + "--payload-json", JSON.stringify({ title: "Updated PR" }), + ]); + await run(["issue", "work-product:delete", PRODUCT_ID]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/documents?includeSystem=true`], + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan`], + ["PUT", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan/lock`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan/unlock`], + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan/revisions`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan/revisions/${APPROVAL_ID}/restore`], + ["DELETE", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan`], + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/work-products`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/work-products`], + ["PATCH", `http://localhost:3100/api/work-products/${PRODUCT_ID}`], + ["DELETE", `http://localhost:3100/api/work-products/${PRODUCT_ID}`], + ]); + }); + + it("wraps interactions, tree holds, labels, feedback votes, and attachments", async () => { + const tmp = await mkdtemp(join(tmpdir(), "paperclip-cli-test-")); + const filePath = join(tmp, "attachment.txt"); + await writeFile(filePath, "hello", "utf8"); + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + try { + await run(["issue", "interactions", ISSUE_ID]); + await run([ + "issue", "interaction:create", ISSUE_ID, + "--payload-json", JSON.stringify({ + kind: "request_confirmation", + payload: { version: 1, prompt: "Continue?" }, + }), + ]); + await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID]); + await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID, "--selected-client-keys", "yes"]); + await run(["issue", "interaction:reject", ISSUE_ID, INTERACTION_ID, "--reason", "no"]); + await run(["issue", "interaction:cancel", ISSUE_ID, INTERACTION_ID, "--reason", "stale"]); + await run([ + "issue", "interaction:respond", ISSUE_ID, INTERACTION_ID, + "--answers-json", JSON.stringify([{ questionId: "q1", optionIds: ["a1"] }]), + ]); + await run(["issue", "tree-state", ISSUE_ID]); + await run(["issue", "tree-preview", ISSUE_ID, "--payload-json", JSON.stringify({ mode: "pause" })]); + await run(["issue", "tree-holds", ISSUE_ID, "--status", "active", "--include-members"]); + await run(["issue", "tree-hold:create", ISSUE_ID, "--payload-json", JSON.stringify({ mode: "pause", reason: "test" })]); + await run(["issue", "tree-hold:get", ISSUE_ID, HOLD_ID]); + await run(["issue", "tree-hold:release", ISSUE_ID, HOLD_ID]); + await run(["issue", "attachments", ISSUE_ID]); + await run(["issue", "attachment:upload", ISSUE_ID, "--company-id", COMPANY_ID, "--file", filePath]); + await run(["issue", "attachment:download", ATTACHMENT_ID]); + await run(["issue", "attachment:delete", ATTACHMENT_ID]); + await run(["issue", "label:list", "--company-id", COMPANY_ID]); + await run(["issue", "label:create", "--company-id", COMPANY_ID, "--name", "bug", "--color", "#ff0000"]); + await run(["issue", "label:delete", LABEL_ID]); + await run(["issue", "feedback:votes", ISSUE_ID]); + await run([ + "issue", "feedback:vote", ISSUE_ID, + "--payload-json", JSON.stringify({ targetType: "issue_comment", targetId: COMMENT_ID, vote: "up" }), + ]); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/reject`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/cancel`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/respond`], + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/tree-control/state`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/tree-control/preview`], + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/tree-holds?status=active&includeMembers=true`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/tree-holds`], + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/tree-holds/${HOLD_ID}`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/tree-holds/${HOLD_ID}/release`], + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/attachments`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/issues/${ISSUE_ID}/attachments`], + ["GET", `http://localhost:3100/api/attachments/${ATTACHMENT_ID}/content`], + ["DELETE", `http://localhost:3100/api/attachments/${ATTACHMENT_ID}`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/labels`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/labels`], + ["DELETE", `http://localhost:3100/api/labels/${LABEL_ID}`], + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/feedback-votes`], + ["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/feedback-votes`], + ]); + }); +}); + +function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response { + return new Response(JSON.stringify(body), init); +} diff --git a/cli/src/__tests__/operations-parity.test.ts b/cli/src/__tests__/operations-parity.test.ts new file mode 100644 index 00000000..fc145532 --- /dev/null +++ b/cli/src/__tests__/operations-parity.test.ts @@ -0,0 +1,136 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerCostCommands } from "../commands/client/cost.js"; +import { registerWorkspaceCommands } from "../commands/client/workspace.js"; + +const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; +const AGENT_ID = "11111111-1111-4111-8111-111111111111"; +const ISSUE_ID = "44444444-4444-4444-8444-444444444444"; +const WORKSPACE_ID = "55555555-5555-4555-8555-555555555555"; +const PROJECT_ID = "66666666-6666-4666-8666-666666666666"; +const PROJECT_WORKSPACE_ID = "77777777-7777-4777-8777-777777777777"; +const ENV_ID = "88888888-8888-4888-8888-888888888888"; +const INCIDENT_ID = "99999999-9999-4999-8999-999999999999"; + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerCostCommands(program); + registerWorkspaceCommands(program); + return program; +} + +async function run(args: string[]): Promise { + await createProgram().parseAsync([ + ...args, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); +} + +describe("operations parity commands", () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_API_URL; + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("wraps cost, finance, and budget endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["cost", "summary", "--company-id", COMPANY_ID]); + await run(["cost", "by-agent", "--company-id", COMPANY_ID]); + await run(["cost", "by-project", "--company-id", COMPANY_ID]); + await run(["cost", "issue", ISSUE_ID]); + await run(["cost", "event:create", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["finance", "event:create", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["finance", "summary", "--company-id", COMPANY_ID]); + await run(["budget", "overview", "--company-id", COMPANY_ID]); + await run(["budget", "policy:upsert", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["budget", "company:update", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["budget", "agent:update", AGENT_ID, "--payload-json", "{}"]); + await run(["budget", "incident:resolve", INCIDENT_ID, "--company-id", COMPANY_ID]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/costs/summary`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/costs/by-agent`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/costs/by-project`], + ["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/cost-summary`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/cost-events`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/finance-events`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/costs/finance-summary`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/budgets/overview`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/budgets/policies`], + ["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}/budgets`], + ["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/budgets`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/budget-incidents/${INCIDENT_ID}/resolve`], + ]); + }); + + it("wraps org, execution workspace, environment, and project workspace endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["org", "get", "--company-id", COMPANY_ID]); + await run(["org", "svg", "--company-id", COMPANY_ID]); + await run(["agent-config", "list", "--company-id", COMPANY_ID]); + await run(["workspace", "list", "--company-id", COMPANY_ID]); + await run(["workspace", "get", WORKSPACE_ID]); + await run(["workspace", "close-readiness", WORKSPACE_ID]); + await run(["workspace", "operations", WORKSPACE_ID]); + await run(["workspace", "update", WORKSPACE_ID, "--payload-json", "{}"]); + await run(["workspace", "runtime-service", WORKSPACE_ID, "restart", "--payload-json", "{}"]); + await run(["environment", "list", "--company-id", COMPANY_ID]); + await run(["environment", "capabilities", "--company-id", COMPANY_ID]); + await run(["environment", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["environment", "get", ENV_ID]); + await run(["environment", "leases", ENV_ID]); + await run(["environment", "update", ENV_ID, "--payload-json", "{}"]); + await run(["environment", "delete", ENV_ID]); + await run(["environment", "probe", ENV_ID]); + await run(["environment", "probe-config", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["project-workspace", "list", PROJECT_ID]); + await run(["project-workspace", "create", PROJECT_ID, "--payload-json", "{}"]); + await run(["project-workspace", "update", PROJECT_ID, PROJECT_WORKSPACE_ID, "--payload-json", "{}"]); + await run(["project-workspace", "runtime-command", PROJECT_ID, PROJECT_WORKSPACE_ID, "run", "--payload-json", "{}"]); + await run(["project-workspace", "delete", PROJECT_ID, PROJECT_WORKSPACE_ID]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/org`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/org.svg`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/agent-configurations`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/execution-workspaces`], + ["GET", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}`], + ["GET", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}/close-readiness`], + ["GET", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}/workspace-operations`], + ["PATCH", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}`], + ["POST", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}/runtime-services/restart`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/environments`], + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/environments/capabilities`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/environments`], + ["GET", `http://localhost:3100/api/environments/${ENV_ID}`], + ["GET", `http://localhost:3100/api/environments/${ENV_ID}/leases`], + ["PATCH", `http://localhost:3100/api/environments/${ENV_ID}`], + ["DELETE", `http://localhost:3100/api/environments/${ENV_ID}`], + ["POST", `http://localhost:3100/api/environments/${ENV_ID}/probe`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/environments/probe-config`], + ["GET", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces`], + ["POST", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces`], + ["PATCH", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces/${PROJECT_WORKSPACE_ID}`], + ["POST", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces/${PROJECT_WORKSPACE_ID}/runtime-commands/run`], + ["DELETE", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces/${PROJECT_WORKSPACE_ID}`], + ]); + }); +}); + +function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response { + return new Response(JSON.stringify(body), init); +} diff --git a/cli/src/__tests__/project-goal.test.ts b/cli/src/__tests__/project-goal.test.ts new file mode 100644 index 00000000..53cec698 --- /dev/null +++ b/cli/src/__tests__/project-goal.test.ts @@ -0,0 +1,155 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerGoalCommands } from "../commands/client/goal.js"; +import { registerProjectCommands } from "../commands/client/project.js"; + +const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; +const PROJECT_ID = "33333333-3333-4333-8333-333333333333"; +const GOAL_ID = "44444444-4444-4444-8444-444444444444"; + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + registerProjectCommands(program); + registerGoalCommands(program); + return program; +} + +describe("project and goal commands", () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_API_URL; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("creates and updates projects with shared schemas", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: PROJECT_ID, + companyId: COMPANY_ID, + name: "Launch Site", + status: "planned", + goalIds: [GOAL_ID], + }), { status: 201 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: PROJECT_ID, + companyId: COMPANY_ID, + name: "Launch Site", + status: "in_progress", + goalIds: [GOAL_ID], + }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await createProgram().parseAsync([ + "project", "create", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--company-id", COMPANY_ID, + "--name", "Launch Site", + "--status", "planned", + "--goal-ids", GOAL_ID, + ], { from: "user" }); + + await createProgram().parseAsync([ + "project", "update", PROJECT_ID, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--status", "in_progress", + ], { from: "user" }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/projects`); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ + name: "Launch Site", + status: "planned", + goalIds: [GOAL_ID], + }); + expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/projects/${PROJECT_ID}`); + expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("PATCH"); + }); + + it("lists and deletes projects", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify([{ id: PROJECT_ID, companyId: COMPANY_ID, name: "Launch Site", status: "planned", goalIds: [] }]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ id: PROJECT_ID, companyId: COMPANY_ID, name: "Launch Site" }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await createProgram().parseAsync([ + "project", "list", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--company-id", COMPANY_ID, + ], { from: "user" }); + + await createProgram().parseAsync([ + "project", "delete", PROJECT_ID, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--yes", + ], { from: "user" }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/projects`); + expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/projects/${PROJECT_ID}`); + expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("DELETE"); + }); + + it("creates, updates, lists, and deletes goals", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ id: GOAL_ID, companyId: COMPANY_ID, title: "Grow", level: "company", status: "active" }), { status: 201 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ id: GOAL_ID, companyId: COMPANY_ID, title: "Grow faster", level: "company", status: "active" }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify([{ id: GOAL_ID, companyId: COMPANY_ID, title: "Grow faster", level: "company", status: "active" }]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ id: GOAL_ID, companyId: COMPANY_ID, title: "Grow faster" }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await createProgram().parseAsync([ + "goal", "create", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--company-id", COMPANY_ID, + "--title", "Grow", + "--level", "company", + "--status", "active", + ], { from: "user" }); + + await createProgram().parseAsync([ + "goal", "update", GOAL_ID, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--title", "Grow faster", + ], { from: "user" }); + + await createProgram().parseAsync([ + "goal", "list", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--company-id", COMPANY_ID, + ], { from: "user" }); + + await createProgram().parseAsync([ + "goal", "delete", GOAL_ID, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--yes", + ], { from: "user" }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/goals`); + expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/goals/${GOAL_ID}`); + expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("PATCH"); + expect(fetchMock.mock.calls[2]?.[0]).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/goals`); + expect(fetchMock.mock.calls[3]?.[0]).toBe(`http://localhost:3100/api/goals/${GOAL_ID}`); + expect(fetchMock.mock.calls[3]?.[1]?.method).toBe("DELETE"); + }); +}); diff --git a/cli/src/__tests__/prompt.test.ts b/cli/src/__tests__/prompt.test.ts new file mode 100644 index 00000000..21f3a78b --- /dev/null +++ b/cli/src/__tests__/prompt.test.ts @@ -0,0 +1,102 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { writeContext } from "../client/context.js"; +import { runAgentPrompt } from "../commands/client/prompt.js"; + +const ORIGINAL_ENV = { ...process.env }; + +function createTempContextPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-prompt-")); + return path.join(dir, "context.json"); +} + +function agent(overrides: Record = {}) { + return { + id: "11111111-1111-4111-8111-111111111111", + companyId: "22222222-2222-4222-8222-222222222222", + name: "Worker", + urlKey: "worker", + role: "Engineer", + status: "active", + ...overrides, + }; +} + +describe("prompt handoff", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.PAPERCLIP_API_KEY; + vi.restoreAllMocks(); + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.restoreAllMocks(); + }); + + it("fails when an agent prompt uses a board persona profile", async () => { + const contextPath = createTempContextPath(); + writeContext( + { + version: 2, + currentProfile: "board", + profiles: { + board: { + apiBase: "http://localhost:3100", + persona: "board", + }, + }, + }, + contextPath, + ); + + await expect(runAgentPrompt("worker", "Do the work", { context: contextPath, apiKey: "agent-token" })) + .rejects + .toThrow(/persona=board/); + }); + + it("fails when the supplied agent key belongs to a different agent", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(agent()), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(runAgentPrompt("other-agent", "Do the work", { + apiBase: "http://localhost:3100", + apiKey: "agent-token", + })).rejects.toThrow(/Agent key belongs to Worker/); + }); + + it("creates an assigned issue and wakes the authenticated agent", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(agent()), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: "issue-1", + companyId: "22222222-2222-4222-8222-222222222222", + title: "Investigate queue lag", + status: "todo", + priority: "medium", + assigneeAgentId: "11111111-1111-4111-8111-111111111111", + }), { status: 201 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ id: "run-1", status: "queued" }), { status: 202 })); + vi.stubGlobal("fetch", fetchMock); + + const result = await runAgentPrompt("worker", "Investigate queue lag", { + apiBase: "http://localhost:3100", + apiKey: "agent-token", + }); + + expect(result.mode).toBe("issue"); + expect(result.agent.id).toBe("11111111-1111-4111-8111-111111111111"); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3100/api/companies/22222222-2222-4222-8222-222222222222/issues"); + expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ + title: "Investigate queue lag", + assigneeAgentId: "11111111-1111-4111-8111-111111111111", + }); + expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3100/api/agents/11111111-1111-4111-8111-111111111111/wakeup"); + }); +}); diff --git a/cli/src/__tests__/routine-plugin-parity.test.ts b/cli/src/__tests__/routine-plugin-parity.test.ts new file mode 100644 index 00000000..e3086b0c --- /dev/null +++ b/cli/src/__tests__/routine-plugin-parity.test.ts @@ -0,0 +1,130 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerPluginCommands } from "../commands/client/plugin.js"; +import { registerRoutineApiCommands } from "../commands/client/routine-api.js"; + +const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; +const ROUTINE_ID = "33333333-3333-4333-8333-333333333333"; +const REVISION_ID = "44444444-4444-4444-8444-444444444444"; +const TRIGGER_ID = "55555555-5555-4555-8555-555555555555"; + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerRoutineApiCommands(program); + registerPluginCommands(program); + return program; +} + +async function run(args: string[]): Promise { + await createProgram().parseAsync([...args, "--api-base", "http://localhost:3100", "--api-key", "board-token"], { from: "user" }); +} + +describe("routine and plugin parity commands", () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_API_URL; + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("wraps routine API endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["routine", "list", "--company-id", COMPANY_ID, "--project-id", "p1"]); + await run(["routine", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["routine", "get", ROUTINE_ID]); + await run(["routine", "update", ROUTINE_ID, "--payload-json", "{}"]); + await run(["routine", "revisions", ROUTINE_ID]); + await run(["routine", "revision:restore", ROUTINE_ID, REVISION_ID]); + await run(["routine", "runs", ROUTINE_ID, "--limit", "5"]); + await run(["routine", "run", ROUTINE_ID]); + await run(["routine", "trigger:create", ROUTINE_ID, "--payload-json", "{}"]); + await run(["routine", "trigger:update", TRIGGER_ID, "--payload-json", "{}"]); + await run(["routine", "trigger:delete", TRIGGER_ID]); + await run(["routine", "trigger:rotate-secret", TRIGGER_ID]); + await run(["routine", "trigger:fire", "public-id"]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/routines?projectId=p1`], + ["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/routines`], + ["GET", `http://localhost:3100/api/routines/${ROUTINE_ID}`], + ["PATCH", `http://localhost:3100/api/routines/${ROUTINE_ID}`], + ["GET", `http://localhost:3100/api/routines/${ROUTINE_ID}/revisions`], + ["POST", `http://localhost:3100/api/routines/${ROUTINE_ID}/revisions/${REVISION_ID}/restore`], + ["GET", `http://localhost:3100/api/routines/${ROUTINE_ID}/runs?limit=5`], + ["POST", `http://localhost:3100/api/routines/${ROUTINE_ID}/run`], + ["POST", `http://localhost:3100/api/routines/${ROUTINE_ID}/triggers`], + ["PATCH", `http://localhost:3100/api/routine-triggers/${TRIGGER_ID}`], + ["DELETE", `http://localhost:3100/api/routine-triggers/${TRIGGER_ID}`], + ["POST", `http://localhost:3100/api/routine-triggers/${TRIGGER_ID}/rotate-secret`], + ["POST", "http://localhost:3100/api/routine-triggers/public/public-id/fire"], + ]); + }); + + it("wraps deeper plugin endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await run(["plugin", "ui-contributions"]); + await run(["plugin", "tools"]); + await run(["plugin", "tool:execute", "--payload-json", "{}"]); + await run(["plugin", "health", "plug"]); + await run(["plugin", "logs", "plug"]); + await run(["plugin", "upgrade", "plug"]); + await run(["plugin", "config", "plug"]); + await run(["plugin", "config:set", "plug", "--payload-json", "{}"]); + await run(["plugin", "config:test", "plug", "--payload-json", "{}"]); + await run(["plugin", "jobs", "plug"]); + await run(["plugin", "job:runs", "plug", "job1"]); + await run(["plugin", "job:trigger", "plug", "job1"]); + await run(["plugin", "webhook", "plug", "endpoint", "--payload-json", "{}"]); + await run(["plugin", "dashboard", "plug"]); + await run(["plugin", "bridge:data", "plug", "--payload-json", "{}"]); + await run(["plugin", "bridge:action", "plug", "--payload-json", "{}"]); + await run(["plugin", "bridge:stream", "plug", "events", "--duration-ms", "1"]); + await run(["plugin", "data", "plug", "key", "--payload-json", "{}"]); + await run(["plugin", "action", "plug", "key", "--payload-json", "{}"]); + await run(["plugin", "local-folders", "plug", "--company-id", COMPANY_ID]); + await run(["plugin", "local-folder:status", "plug", "source", "--company-id", COMPANY_ID]); + await run(["plugin", "local-folder:validate", "plug", "source", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + await run(["plugin", "local-folder:set", "plug", "source", "--company-id", COMPANY_ID, "--payload-json", "{}"]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", "http://localhost:3100/api/plugins/ui-contributions"], + ["GET", "http://localhost:3100/api/plugins/tools"], + ["POST", "http://localhost:3100/api/plugins/tools/execute"], + ["GET", "http://localhost:3100/api/plugins/plug/health"], + ["GET", "http://localhost:3100/api/plugins/plug/logs"], + ["POST", "http://localhost:3100/api/plugins/plug/upgrade"], + ["GET", "http://localhost:3100/api/plugins/plug/config"], + ["POST", "http://localhost:3100/api/plugins/plug/config"], + ["POST", "http://localhost:3100/api/plugins/plug/config/test"], + ["GET", "http://localhost:3100/api/plugins/plug/jobs"], + ["GET", "http://localhost:3100/api/plugins/plug/jobs/job1/runs"], + ["POST", "http://localhost:3100/api/plugins/plug/jobs/job1/trigger"], + ["POST", "http://localhost:3100/api/plugins/plug/webhooks/endpoint"], + ["GET", "http://localhost:3100/api/plugins/plug/dashboard"], + ["POST", "http://localhost:3100/api/plugins/plug/bridge/data"], + ["POST", "http://localhost:3100/api/plugins/plug/bridge/action"], + ["GET", "http://localhost:3100/api/plugins/plug/bridge/stream/events"], + ["POST", "http://localhost:3100/api/plugins/plug/data/key"], + ["POST", "http://localhost:3100/api/plugins/plug/actions/key"], + ["GET", `http://localhost:3100/api/plugins/plug/companies/${COMPANY_ID}/local-folders`], + ["GET", `http://localhost:3100/api/plugins/plug/companies/${COMPANY_ID}/local-folders/source/status`], + ["POST", `http://localhost:3100/api/plugins/plug/companies/${COMPANY_ID}/local-folders/source/validate`], + ["PUT", `http://localhost:3100/api/plugins/plug/companies/${COMPANY_ID}/local-folders/source`], + ]); + }); +}); + +function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response { + return new Response(JSON.stringify(body), init); +} diff --git a/cli/src/__tests__/run.test.ts b/cli/src/__tests__/run.test.ts new file mode 100644 index 00000000..97aac540 --- /dev/null +++ b/cli/src/__tests__/run.test.ts @@ -0,0 +1,220 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerAgentCommands } from "../commands/client/agent.js"; +import { registerIssueCommands } from "../commands/client/issue.js"; +import { registerRunCommands } from "../commands/client/run.js"; + +const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; +const AGENT_ID = "11111111-1111-4111-8111-111111111111"; +const RUN_ID = "33333333-3333-4333-8333-333333333333"; +const ISSUE_ID = "44444444-4444-4444-8444-444444444444"; + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + const run = program.command("run").action(() => {}); + registerRunCommands(run); + registerAgentCommands(program); + registerIssueCommands(program); + return program; +} + +describe("run inspection commands", () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_API_URL; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("lists and reads heartbeat runs through run subcommands", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify([ + { id: RUN_ID, companyId: COMPANY_ID, agentId: AGENT_ID, status: "running", invocationSource: "on_demand" }, + ]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: RUN_ID, + companyId: COMPANY_ID, + agentId: AGENT_ID, + status: "running", + invocationSource: "on_demand", + }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + text: "hello", + offset: 0, + nextOffset: 5, + }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ id: RUN_ID, status: "cancelled" }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + await createProgram().parseAsync([ + "run", "list", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--company-id", COMPANY_ID, + "--agent-id", AGENT_ID, + "--limit", "25", + ], { from: "user" }); + + await createProgram().parseAsync([ + "run", "get", RUN_ID, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); + + await createProgram().parseAsync([ + "run", "log", RUN_ID, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--offset", "4", + "--limit-bytes", "100", + "--text", + ], { from: "user" }); + + await createProgram().parseAsync([ + "run", "cancel", RUN_ID, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + `http://localhost:3100/api/companies/${COMPANY_ID}/heartbeat-runs?agentId=${AGENT_ID}&limit=25`, + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}`); + expect(fetchMock.mock.calls[2]?.[0]).toBe( + `http://localhost:3100/api/heartbeat-runs/${RUN_ID}/log?offset=4&limitBytes=100`, + ); + expect(fetchMock.mock.calls[3]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/cancel`); + expect(fetchMock.mock.calls[3]?.[1]?.method).toBe("POST"); + }); + + it("supports run events, issues, workspace operations, and watchdog decisions", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify([ + { id: 1, runId: RUN_ID, agentId: AGENT_ID, seq: 1, eventType: "output", message: "hi" }, + ]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify([ + { id: ISSUE_ID, identifier: "PC-1", title: "Fix it", status: "in_progress", priority: "normal" }, + ]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify([ + { id: "55555555-5555-4555-8555-555555555555", status: "succeeded", phase: "workspace_provision" }, + ]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ text: "workspace" }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ id: "decision-1", decision: "continue" }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await createProgram().parseAsync([ + "run", "events", RUN_ID, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--after-seq", "7", + "--limit", "50", + ], { from: "user" }); + await createProgram().parseAsync([ + "run", "issues", RUN_ID, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); + await createProgram().parseAsync([ + "run", "workspace-operations", RUN_ID, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); + await createProgram().parseAsync([ + "run", "workspace-log", "55555555-5555-4555-8555-555555555555", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); + await createProgram().parseAsync([ + "run", "watchdog-decision", RUN_ID, + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--decision", "continue", + "--reason", "operator reviewed", + ], { from: "user" }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + `http://localhost:3100/api/heartbeat-runs/${RUN_ID}/events?afterSeq=7&limit=50`, + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/issues`); + expect(fetchMock.mock.calls[2]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/workspace-operations`); + expect(fetchMock.mock.calls[3]?.[0]).toBe( + "http://localhost:3100/api/workspace-operations/55555555-5555-4555-8555-555555555555/log?offset=0", + ); + expect(fetchMock.mock.calls[4]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/watchdog-decisions`); + expect(JSON.parse(String(fetchMock.mock.calls[4]?.[1]?.body))).toMatchObject({ + decision: "continue", + reason: "operator reviewed", + }); + }); + + it("wakes agents and exposes issue run helpers", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: AGENT_ID, + name: "Builder", + companyId: COMPANY_ID, + urlKey: "builder", + }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: RUN_ID, + companyId: COMPANY_ID, + agentId: AGENT_ID, + status: "queued", + }), { status: 202 })) + .mockResolvedValueOnce(new Response(JSON.stringify([{ id: RUN_ID, status: "succeeded" }]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify([{ id: RUN_ID, status: "running" }]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ id: RUN_ID, status: "running" }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await createProgram().parseAsync([ + "agent", "wake", "builder", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--company-id", COMPANY_ID, + "--reason", "manual check", + "--payload", "{\"issueId\":\"PC-1\"}", + ], { from: "user" }); + await createProgram().parseAsync([ + "issue", "runs", "PC-1", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); + await createProgram().parseAsync([ + "issue", "live-runs", "PC-1", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); + await createProgram().parseAsync([ + "issue", "active-run", "PC-1", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/agents/builder?companyId=${COMPANY_ID}`); + expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}/wakeup`); + expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ + source: "on_demand", + triggerDetail: "manual", + reason: "manual check", + payload: { issueId: "PC-1" }, + }); + expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3100/api/issues/PC-1/runs"); + expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3100/api/issues/PC-1/live-runs"); + expect(fetchMock.mock.calls[4]?.[0]).toBe("http://localhost:3100/api/issues/PC-1/active-run"); + }); +}); diff --git a/cli/src/__tests__/secrets.test.ts b/cli/src/__tests__/secrets.test.ts index a1089ae0..89ea9840 100644 --- a/cli/src/__tests__/secrets.test.ts +++ b/cli/src/__tests__/secrets.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Agent, CompanySecret } from "@paperclipai/shared"; import type { PaperclipConfig } from "../config/schema.js"; import { secretsCheck } from "../checks/secrets-check.js"; @@ -7,6 +8,7 @@ import { buildMigratedAgentEnv, collectInlineSecretMigrationCandidates, parseSecretsInclude, + registerSecretCommands, toPlainEnvValue, } from "../commands/client/secrets.js"; @@ -255,3 +257,76 @@ describe("secrets CLI helpers", () => { expect(result.message).toContain("AWS_PROFILE/shared config"); }); }); + +describe("secrets API parity commands", () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_API_URL; + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("wraps provider config and remote import endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await runSecretCommand(["secrets", "provider-configs", "--company-id", "company-1"]); + await runSecretCommand(["secrets", "provider-config:create", "--company-id", "company-1", "--payload-json", "{}"]); + await runSecretCommand(["secrets", "provider-config:discovery-preview", "--company-id", "company-1", "--payload-json", "{}"]); + await runSecretCommand(["secrets", "provider-config:get", "config-1"]); + await runSecretCommand(["secrets", "provider-config:update", "config-1", "--payload-json", "{}"]); + await runSecretCommand(["secrets", "provider-config:default", "config-1"]); + await runSecretCommand(["secrets", "provider-config:health", "config-1"]); + await runSecretCommand(["secrets", "provider-config:delete", "config-1"]); + await runSecretCommand(["secrets", "remote-import:preview", "--company-id", "company-1", "--payload-json", "{}"]); + await runSecretCommand(["secrets", "remote-import", "--company-id", "company-1", "--payload-json", "{}"]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["GET", "http://localhost:3100/api/companies/company-1/secret-provider-configs"], + ["POST", "http://localhost:3100/api/companies/company-1/secret-provider-configs"], + ["POST", "http://localhost:3100/api/companies/company-1/secret-provider-configs/discovery/preview"], + ["GET", "http://localhost:3100/api/secret-provider-configs/config-1"], + ["PATCH", "http://localhost:3100/api/secret-provider-configs/config-1"], + ["POST", "http://localhost:3100/api/secret-provider-configs/config-1/default"], + ["POST", "http://localhost:3100/api/secret-provider-configs/config-1/health"], + ["DELETE", "http://localhost:3100/api/secret-provider-configs/config-1"], + ["POST", "http://localhost:3100/api/companies/company-1/secrets/remote-import/preview"], + ["POST", "http://localhost:3100/api/companies/company-1/secrets/remote-import"], + ]); + }); + + it("wraps secret metadata, rotation, usage, access event, and delete endpoints", async () => { + const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse())); + vi.stubGlobal("fetch", fetchMock); + + await runSecretCommand(["secrets", "update", "secret-1", "--payload-json", "{\"description\":\"updated\"}"]); + await runSecretCommand(["secrets", "rotate", "secret-1", "--value", "new-value"]); + await runSecretCommand(["secrets", "usage", "secret-1"]); + await runSecretCommand(["secrets", "access-events", "secret-1"]); + await runSecretCommand(["secrets", "delete", "secret-1", "--yes", "--confirm", "secret-1"]); + + expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([ + ["PATCH", "http://localhost:3100/api/secrets/secret-1"], + ["POST", "http://localhost:3100/api/secrets/secret-1/rotate"], + ["GET", "http://localhost:3100/api/secrets/secret-1/usage"], + ["GET", "http://localhost:3100/api/secrets/secret-1/access-events"], + ["DELETE", "http://localhost:3100/api/secrets/secret-1"], + ]); + }); +}); + +async function runSecretCommand(args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerSecretCommands(program); + await program.parseAsync([...args, "--api-base", "http://localhost:3100", "--api-key", "board-token"], { from: "user" }); +} + +function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response { + return new Response(JSON.stringify(body), init); +} diff --git a/cli/src/__tests__/token.test.ts b/cli/src/__tests__/token.test.ts new file mode 100644 index 00000000..02d53fe1 --- /dev/null +++ b/cli/src/__tests__/token.test.ts @@ -0,0 +1,132 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerTokenCommands } from "../commands/client/token.js"; + +const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; +const AGENT_ID = "11111111-1111-4111-8111-111111111111"; + +function createProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + registerTokenCommands(program); + return program; +} + +function agentResponse() { + return { + id: AGENT_ID, + companyId: COMPANY_ID, + name: "Worker", + urlKey: "worker", + role: "Engineer", + status: "active", + }; +} + +describe("token commands", () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete process.env.PAPERCLIP_API_KEY; + delete process.env.PAPERCLIP_API_URL; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("creates an agent token through the generic token command", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(agentResponse()), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: "key-1", + name: "external-worker", + token: "pcp_plaintext", + createdAt: "2026-05-23T00:00:00.000Z", + }), { status: 201 })); + vi.stubGlobal("fetch", fetchMock); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await createProgram().parseAsync([ + "token", + "agent", + "create", + "--api-base", + "http://localhost:3100", + "--api-key", + "board-token", + "--company-id", + COMPANY_ID, + "--agent", + "worker", + "--name", + "external-worker", + "--json", + ], { from: "user" }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/agents/worker?companyId=${COMPANY_ID}`); + expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}/keys`); + expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({ name: "external-worker" }); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ + agentId: AGENT_ID, + companyId: COMPANY_ID, + key: { id: "key-1", token: "pcp_plaintext" }, + }); + }); + + it("lists and revokes agent tokens", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(agentResponse()), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify([{ id: "key-1", name: "external-worker", createdAt: "2026-05-23T00:00:00.000Z", revokedAt: null }]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(agentResponse()), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, keyId: "key-1" }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await createProgram().parseAsync([ + "token", "agent", "list", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--company-id", COMPANY_ID, + "--agent", "worker", + ], { from: "user" }); + + await createProgram().parseAsync([ + "token", "agent", "revoke", "key-1", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--company-id", COMPANY_ID, + "--agent", "worker", + ], { from: "user" }); + + expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}/keys`); + expect(fetchMock.mock.calls[3]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}/keys/key-1`); + expect(fetchMock.mock.calls[3]?.[1]?.method).toBe("DELETE"); + }); + + it("resolves agent token commands by agent id without the reference lookup query", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(agentResponse()), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify([{ id: "key-1", name: "external-worker", createdAt: "2026-05-23T00:00:00.000Z", revokedAt: null }]), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await createProgram().parseAsync([ + "token", "agent", "list", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + "--company-id", COMPANY_ID, + "--agent", AGENT_ID, + "--json", + ], { from: "user" }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}`); + expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}/keys`); + }); +}); diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 49c445b1..0ac73ce6 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -27,6 +27,7 @@ import { resolveWorktreeReseedSource, resolveWorktreeReseedTargetPaths, resolveGitWorktreeAddArgs, + resolvePnpmInstallInvocation, resolveWorktreeMakeTargetPath, worktreeRepairCommand, worktreeInitCommand, @@ -150,6 +151,36 @@ describe("worktree helpers", () => { ); }); + it("reuses the current pnpm executable for worktree dependency installation", () => { + expect( + resolvePnpmInstallInvocation( + { npm_execpath: "/Users/test/.pnpm/pnpm/9.15.4/bin/pnpm.cjs" }, + "/usr/local/bin/node", + ), + ).toEqual({ + command: "/usr/local/bin/node", + argsPrefix: ["/Users/test/.pnpm/pnpm/9.15.4/bin/pnpm.cjs"], + }); + expect( + resolvePnpmInstallInvocation( + { npm_execpath: "/Users/test/.pnpm/pnpm/9.15.4/bin/pnpm" }, + "/usr/local/bin/node", + ), + ).toEqual({ + command: "/Users/test/.pnpm/pnpm/9.15.4/bin/pnpm", + argsPrefix: [], + }); + expect( + resolvePnpmInstallInvocation( + { npm_execpath: "/Users/test/.npm/npm-cli.js" }, + "/usr/local/bin/node", + ), + ).toEqual({ + command: "pnpm", + argsPrefix: [], + }); + }); + it("builds git worktree add args for new and existing branches", () => { expect( resolveGitWorktreeAddArgs({ diff --git a/cli/src/client/context.ts b/cli/src/client/context.ts index 15fde597..eccaf465 100644 --- a/cli/src/client/context.ts +++ b/cli/src/client/context.ts @@ -8,11 +8,17 @@ const DEFAULT_PROFILE = "default"; export interface ClientContextProfile { apiBase?: string; companyId?: string; + persona?: "board" | "agent"; + agentId?: string; + agentName?: string; apiKeyEnvVarName?: string; + tokenName?: string; + tokenId?: string; + tokenCreatedAt?: string; } export interface ClientContext { - version: 1; + version: 2; currentProfile: string; profiles: Record; } @@ -43,7 +49,7 @@ export function resolveContextPath(overridePath?: string): string { export function defaultClientContext(): ClientContext { return { - version: 1, + version: 2, currentProfile: DEFAULT_PROFILE, profiles: { [DEFAULT_PROFILE]: {}, @@ -66,11 +72,20 @@ function toStringOrUndefined(value: unknown): string | undefined { function normalizeProfile(value: unknown): ClientContextProfile { if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; const profile = value as Record; + const persona = profile.persona === "board" || profile.persona === "agent" + ? profile.persona + : undefined; return { apiBase: toStringOrUndefined(profile.apiBase), companyId: toStringOrUndefined(profile.companyId), + persona, + agentId: toStringOrUndefined(profile.agentId), + agentName: toStringOrUndefined(profile.agentName), apiKeyEnvVarName: toStringOrUndefined(profile.apiKeyEnvVarName), + tokenName: toStringOrUndefined(profile.tokenName), + tokenId: toStringOrUndefined(profile.tokenId), + tokenCreatedAt: toStringOrUndefined(profile.tokenCreatedAt), }; } @@ -80,7 +95,7 @@ function normalizeContext(raw: unknown): ClientContext { } const record = raw as Record; - const version = record.version === 1 ? 1 : 1; + const version = 2; const currentProfile = toStringOrUndefined(record.currentProfile) ?? DEFAULT_PROFILE; const rawProfiles = record.profiles; @@ -134,10 +149,17 @@ export function upsertProfile( ): ClientContext { const context = readContext(contextPath); const existing = context.profiles[profileName] ?? {}; - const merged: ClientContextProfile = { - ...existing, - ...patch, - }; + const merged: ClientContextProfile = { ...existing }; + + if (patch.apiBase !== undefined) merged.apiBase = patch.apiBase; + if (patch.companyId !== undefined) merged.companyId = patch.companyId; + if (patch.persona !== undefined) merged.persona = patch.persona; + if (patch.agentId !== undefined) merged.agentId = patch.agentId; + if (patch.agentName !== undefined) merged.agentName = patch.agentName; + if (patch.apiKeyEnvVarName !== undefined) merged.apiKeyEnvVarName = patch.apiKeyEnvVarName; + if (patch.tokenName !== undefined) merged.tokenName = patch.tokenName; + if (patch.tokenId !== undefined) merged.tokenId = patch.tokenId; + if (patch.tokenCreatedAt !== undefined) merged.tokenCreatedAt = patch.tokenCreatedAt; if (patch.apiBase !== undefined && patch.apiBase.trim().length === 0) { delete merged.apiBase; @@ -145,9 +167,27 @@ export function upsertProfile( if (patch.companyId !== undefined && patch.companyId.trim().length === 0) { delete merged.companyId; } + if (patch.persona === undefined && "persona" in patch) { + delete merged.persona; + } + if (patch.agentId !== undefined && patch.agentId.trim().length === 0) { + delete merged.agentId; + } + if (patch.agentName !== undefined && patch.agentName.trim().length === 0) { + delete merged.agentName; + } if (patch.apiKeyEnvVarName !== undefined && patch.apiKeyEnvVarName.trim().length === 0) { delete merged.apiKeyEnvVarName; } + if (patch.tokenName !== undefined && patch.tokenName.trim().length === 0) { + delete merged.tokenName; + } + if (patch.tokenId !== undefined && patch.tokenId.trim().length === 0) { + delete merged.tokenId; + } + if (patch.tokenCreatedAt !== undefined && patch.tokenCreatedAt.trim().length === 0) { + delete merged.tokenCreatedAt; + } context.profiles[profileName] = merged; context.currentProfile = context.currentProfile || profileName; diff --git a/cli/src/client/http.ts b/cli/src/client/http.ts index 27de5eb1..fe7abb74 100644 --- a/cli/src/client/http.ts +++ b/cli/src/client/http.ts @@ -81,6 +81,13 @@ export class PaperclipApiClient { }, opts); } + put(path: string, body?: unknown, opts?: RequestOptions): Promise { + return this.request(path, { + method: "PUT", + body: body === undefined ? undefined : JSON.stringify(body), + }, opts); + } + delete(path: string, opts?: RequestOptions): Promise { return this.request(path, { method: "DELETE" }, opts); } diff --git a/cli/src/commands/client/access.ts b/cli/src/commands/client/access.ts new file mode 100644 index 00000000..55e0ef05 --- /dev/null +++ b/cli/src/commands/client/access.ts @@ -0,0 +1,519 @@ +import { Command } from "commander"; +import { + addCommonClientOptions, + apiPath, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface CompanyOptions extends BaseClientOptions { + companyId?: string; +} + +interface JsonPayloadOptions extends CompanyOptions { + payloadJson?: string; +} + +interface QueryOptions extends CompanyOptions { + query?: string; + status?: string; + requestType?: string; + url?: string; +} + +export function registerAccessCommands(program: Command): void { + addWhoamiCommand(program); + addCommonClientOptions( + program + .command("health") + .description("Check API health") + .action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get("/api/health"), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + const access = program.command("access").description("Access and auth inspection operations"); + addWhoamiCommand(access); + + addCommonClientOptions( + program + .command("openapi") + .description("Print the OpenAPI document") + .action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get("/api/openapi.json"), { json: true }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + const profile = program.command("profile").description("Current user profile operations"); + addSimpleGet(profile, "session", "Get auth session", "/api/auth/get-session"); + addSimpleGet(profile, "get", "Get current auth profile", "/api/auth/profile"); + addJsonPatch(profile, "update", "Update current auth profile", "/api/auth/profile"); + addCommonClientOptions( + profile + .command("company-user") + .description("Get a user profile within a company") + .argument("", "User slug") + .option("-C, --company-id ", "Company ID") + .action(async (userSlug: string, opts: CompanyOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.get(apiPath`/api/companies/${ctx.companyId}/users/${userSlug}/profile`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + const invite = program.command("invite").description("Invite operations"); + addCompanyList(invite, "list", "List company invites", "invites"); + addCompanyPost(invite, "create", "Create an invite", "invites"); + addCommonClientOptions( + invite + .command("revoke") + .description("Revoke an invite") + .argument("", "Invite ID") + .action(async (inviteId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(apiPath`/api/invites/${inviteId}/revoke`, {}), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + for (const [name, suffix] of [ + ["show", ""], + ["logo", "logo"], + ["onboarding", "onboarding"], + ["onboarding:text", "onboarding.txt"], + ["skills:index", "skills/index"], + ] as const) { + addCommonClientOptions( + invite + .command(name) + .description(`Get invite ${name}`) + .argument("", "Invite token") + .action(async (token: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const path = `${apiPath`/api/invites/${token}`}${suffix ? `/${suffix}` : ""}`; + printOutput(await ctx.api.get(path), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + } + addCommonClientOptions( + invite + .command("test-resolution") + .description("Test invite URL resolution") + .argument("", "Invite token") + .requiredOption("--url ", "URL to test") + .action(async (token: string, opts: QueryOptions) => { + try { + const ctx = resolveCommandContext(opts); + const query = new URLSearchParams({ url: opts.url ?? "" }); + printOutput(await ctx.api.get(`${apiPath`/api/invites/${token}/test-resolution`}?${query.toString()}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( + invite + .command("skill") + .description("Get invite skill markdown") + .argument("", "Invite token") + .argument("", "Skill name") + .action(async (token: string, skillName: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(apiPath`/api/invites/${token}/skills/${skillName}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( + invite + .command("accept") + .description("Accept an invite") + .argument("", "Invite token") + .option("--payload-json ", "Invite accept JSON payload", "{}") + .action(async (token: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(apiPath`/api/invites/${token}/accept`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + const join = program.command("join").description("Join request operations"); + addCommonClientOptions( + join + .command("list") + .description("List join requests") + .option("-C, --company-id ", "Company ID") + .option("--status ", "Filter by status (pending_approval, approved, rejected; pending alias accepted)") + .option("--request-type ", "Filter by request type") + .action(async (opts: QueryOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const params = new URLSearchParams(); + const status = normalizeJoinStatus(opts.status); + if (status) params.set("status", status); + if (opts.requestType) params.set("requestType", opts.requestType); + const query = params.toString(); + printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}/join-requests`}${query ? `?${query}` : ""}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + addJoinAction(join, "approve"); + addJoinAction(join, "reject"); + addCommonClientOptions( + join + .command("claim-key") + .description("Claim an agent API key for an approved join request") + .argument("", "Join request ID") + .requiredOption("--claim-secret ", "Claim secret") + .action(async (requestId: string, opts: BaseClientOptions & { claimSecret: string }) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(apiPath`/api/join-requests/${requestId}/claim-api-key`, { claimSecret: opts.claimSecret }), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + const member = program.command("member").description("Company member operations"); + addCompanyList(member, "list", "List company members", "members"); + addCompanyList(member, "user-directory", "List company user directory", "user-directory"); + addMemberPatch(member, "update", "members"); + addMemberPatch(member, "role-and-grants", "members", "role-and-grants"); + addMemberPatch(member, "permissions", "members", "permissions"); + addMemberPost(member, "archive", "members", "archive"); + + const admin = program.command("admin").description("Instance admin operations"); + const user = admin.command("user").description("Admin user operations"); + addCommonClientOptions( + user + .command("list") + .description("List users") + .option("--query ", "Search query") + .action(async (opts: QueryOptions) => { + try { + const ctx = resolveCommandContext(opts); + const query = opts.query ? `?${new URLSearchParams({ query: opts.query }).toString()}` : ""; + printOutput(await ctx.api.get(`/api/admin/users${query}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addAdminUserPost(user, "promote", "promote-instance-admin"); + addAdminUserPost(user, "demote", "demote-instance-admin"); + addCommonClientOptions( + user + .command("company-access") + .description("Get user company access") + .argument("", "User ID") + .action(async (userId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(apiPath`/api/admin/users/${userId}/company-access`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( + user + .command("company-access:update") + .description("Update user company access") + .argument("", "User ID") + .requiredOption("--payload-json ", "UpdateUserCompanyAccess JSON payload") + .action(async (userId: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.put(apiPath`/api/admin/users/${userId}/company-access`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + const instance = program.command("instance").description("Instance operations"); + addSimpleGet(instance, "scheduler-heartbeats", "List scheduler heartbeat agents", "/api/instance/scheduler-heartbeats"); + addSimpleGet(instance, "settings:general", "Get general instance settings", "/api/instance/settings/general"); + addJsonPatch(instance, "settings:general:update", "Update general instance settings", "/api/instance/settings/general"); + addSimpleGet(instance, "settings:experimental", "Get experimental instance settings", "/api/instance/settings/experimental"); + addJsonPatch(instance, "settings:experimental:update", "Update experimental instance settings", "/api/instance/settings/experimental"); + addCommonClientOptions( + instance + .command("database-backup") + .description("Create a database backup") + .action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post("/api/instance/database-backups", {}), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + const sidebar = program.command("sidebar").description("Sidebar preference and badge operations"); + addSimpleGet(sidebar, "preferences", "Get current sidebar preferences", "/api/sidebar-preferences/me"); + addJsonPut(sidebar, "preferences:update", "Update current sidebar preferences", "/api/sidebar-preferences/me"); + addCompanyList(sidebar, "project-preferences", "Get current project sidebar preferences", "sidebar-preferences/me"); + addCompanyPut(sidebar, "project-preferences:update", "Update current project sidebar preferences", "sidebar-preferences/me"); + addCompanyList(sidebar, "badges", "Get sidebar badges", "sidebar-badges"); + + const inbox = program.command("inbox").description("Board inbox operations"); + addCompanyList(inbox, "dismissals", "List dismissed inbox items", "inbox-dismissals"); + addCompanyPost(inbox, "dismiss", "Dismiss an inbox item", "inbox-dismissals"); + + const boardClaim = program.command("board-claim").description("Board claim token operations"); + addCommonClientOptions( + boardClaim + .command("show") + .description("Inspect a board claim token") + .argument("", "Claim token") + .action(async (token: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(apiPath`/api/board-claim/${token}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( + boardClaim + .command("claim") + .description("Claim a board claim token") + .argument("", "Claim token") + .option("--payload-json ", "Claim JSON payload", "{}") + .action(async (token: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(apiPath`/api/board-claim/${token}/claim`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + const openclaw = program.command("openclaw").description("OpenClaw integration helpers"); + addCompanyPost(openclaw, "invite-prompt", "Create an OpenClaw invite prompt", "openclaw/invite-prompt"); + + const publicSkills = program.command("available-skill").description("Public skill catalog operations"); + addSimpleGet(publicSkills, "list", "List available skills", "/api/skills/available"); + addSimpleGet(publicSkills, "index", "Get available skill index", "/api/skills/index"); + addCommonClientOptions( + publicSkills + .command("get") + .description("Get available skill markdown") + .argument("", "Skill name") + .action(async (skillName: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(apiPath`/api/skills/${skillName}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + const llm = program.command("llm").description("LLM prompt documentation"); + addSimpleGet(llm, "agent-configuration", "Get agent configuration prompt docs", "/api/llms/agent-configuration.txt"); + addSimpleGet(llm, "agent-icons", "Get agent icon prompt docs", "/api/llms/agent-icons.txt"); + addCommonClientOptions( + llm + .command("agent-configuration:adapter") + .description("Get adapter-specific agent configuration prompt docs") + .argument("", "Adapter type") + .action(async (adapterType: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(`${apiPath`/api/llms/agent-configuration/${adapterType}`}.txt`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addWhoamiCommand(parent: Command): void { + addCommonClientOptions( + parent + .command("whoami") + .description("Show current CLI auth identity") + .action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get("/api/cli-auth/me"), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function normalizeJoinStatus(status: string | undefined): string | undefined { + if (status === "pending") return "pending_approval"; + return status; +} + +function addSimpleGet(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions(parent.command(name).description(description).action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(path), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function addJsonPatch(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions(parent.command(name).description(description).requiredOption("--payload-json ", "JSON payload").action(async (opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.patch(path, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function addJsonPut(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions(parent.command(name).description(description).requiredOption("--payload-json ", "JSON payload").action(async (opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.put(path, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function addCompanyList(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions( + parent.command(name).description(description).option("-C, --company-id ", "Company ID").action(async (opts: CompanyOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addCompanyPut(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions( + parent.command(name).description(description).option("-C, --company-id ", "Company ID").requiredOption("--payload-json ", "JSON payload").action(async (opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.put(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addCompanyPost(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions( + parent.command(name).description(description).option("-C, --company-id ", "Company ID").requiredOption("--payload-json ", "JSON payload").action(async (opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addJoinAction(parent: Command, action: "approve" | "reject"): void { + addCommonClientOptions( + parent.command(action).description(`${action} a join request`).argument("", "Join request ID").option("-C, --company-id ", "Company ID").action(async (requestId: string, opts: CompanyOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}/join-requests/${requestId}`}/${action}`, {}), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addMemberPatch(parent: Command, name: string, path: string, suffix?: string): void { + addCommonClientOptions( + parent.command(name).description(`${name} a member`).argument("", "Member ID").option("-C, --company-id ", "Company ID").requiredOption("--payload-json ", "JSON payload").action(async (memberId: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const route = `${apiPath`/api/companies/${ctx.companyId}`}/${path}/${encodeURIComponent(memberId)}${suffix ? `/${suffix}` : ""}`; + printOutput(await ctx.api.patch(route, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addMemberPost(parent: Command, name: string, path: string, suffix: string): void { + addCommonClientOptions( + parent.command(name).description(`${name} a member`).argument("", "Member ID").option("-C, --company-id ", "Company ID").option("--payload-json ", "JSON payload", "{}").action(async (memberId: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}/${encodeURIComponent(memberId)}/${suffix}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addAdminUserPost(parent: Command, name: string, suffix: string): void { + addCommonClientOptions(parent.command(name).description(`${name} instance admin`).argument("", "User ID").action(async (userId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(`${apiPath`/api/admin/users/${userId}`}/${suffix}`, {}), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function parseJson(value: string): unknown { + return JSON.parse(value) as unknown; +} diff --git a/cli/src/commands/client/activity.ts b/cli/src/commands/client/activity.ts index 0ae83d95..13b08061 100644 --- a/cli/src/commands/client/activity.ts +++ b/cli/src/commands/client/activity.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import type { ActivityEvent } from "@paperclipai/shared"; import { addCommonClientOptions, + apiPath, formatInlineRecord, handleCommandError, printOutput, @@ -14,6 +15,7 @@ interface ActivityListOptions extends BaseClientOptions { agentId?: string; entityType?: string; entityId?: string; + payloadJson?: string; } export function registerActivityCommands(program: Command): void { @@ -36,7 +38,7 @@ export function registerActivityCommands(program: Command): void { if (opts.entityId) params.set("entityId", opts.entityId); const query = params.toString(); - const path = `/api/companies/${ctx.companyId}/activity${query ? `?${query}` : ""}`; + const path = `${apiPath`/api/companies/${ctx.companyId}/activity`}${query ? `?${query}` : ""}`; const rows = (await ctx.api.get(path)) ?? []; if (ctx.json) { @@ -68,4 +70,41 @@ export function registerActivityCommands(program: Command): void { }), { includeCompany: false }, ); + + addCommonClientOptions( + activity + .command("create") + .description("Create a company activity log entry") + .requiredOption("-C, --company-id ", "Company ID") + .requiredOption("--payload-json ", "CreateActivity JSON payload") + .action(async (opts: ActivityListOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const result = await ctx.api.post(apiPath`/api/companies/${ctx.companyId}/activity`, parseJson(opts.payloadJson ?? "{}")); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + activity + .command("issue") + .description("List activity for an issue") + .argument("", "Issue ID") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(apiPath`/api/issues/${issueId}/activity`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function parseJson(value: string): unknown { + return JSON.parse(value) as unknown; } diff --git a/cli/src/commands/client/adapter.ts b/cli/src/commands/client/adapter.ts new file mode 100644 index 00000000..227248d3 --- /dev/null +++ b/cli/src/commands/client/adapter.ts @@ -0,0 +1,223 @@ +import { Command } from "commander"; +import { + addCommonClientOptions, + apiPath, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface AdapterOptions extends BaseClientOptions { + companyId?: string; + payloadJson?: string; + refresh?: boolean; + environmentId?: string; +} + +export function registerAdapterCommands(program: Command): void { + const adapter = program.command("adapter").description("Adapter management operations"); + + addCommonClientOptions( + adapter + .command("list") + .description("List registered adapters") + .action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get("/api/adapters"), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addJsonPost(adapter, "install", "Install an external adapter", "/api/adapters/install"); + + addCommonClientOptions( + adapter + .command("get") + .description("Get one adapter") + .argument("", "Adapter type") + .action(async (type: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(apiPath`/api/adapters/${type}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addAdapterPatch(adapter, "update", "Update adapter settings", ""); + addAdapterPatch(adapter, "override", "Pause or resume a built-in adapter override", "/override"); + addAdapterPost(adapter, "reload", "Reload an adapter", "/reload"); + addAdapterPost(adapter, "reinstall", "Reinstall an adapter", "/reinstall"); + + addCommonClientOptions( + adapter + .command("delete") + .description("Delete an external adapter registration") + .argument("", "Adapter type") + .action(async (type: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.delete(apiPath`/api/adapters/${type}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + adapter + .command("config-schema") + .description("Get adapter config schema") + .argument("", "Adapter type") + .action(async (type: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(apiPath`/api/adapters/${type}/config-schema`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + adapter + .command("ui-parser") + .description("Get adapter UI parser JavaScript") + .argument("", "Adapter type") + .action(async (type: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(apiPath`/api/adapters/${type}/ui-parser.js`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + adapter + .command("models") + .description("List adapter models for a company") + .argument("", "Adapter type") + .option("-C, --company-id ", "Company ID") + .option("--refresh", "Refresh provider model list", false) + .option("--environment-id ", "Environment ID for environment-aware adapters") + .action(async (type: string, opts: AdapterOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const query = new URLSearchParams(); + if (opts.refresh) query.set("refresh", "true"); + if (opts.environmentId?.trim()) query.set("environmentId", opts.environmentId.trim()); + const suffix = query.size > 0 ? `?${query.toString()}` : ""; + printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}/adapters/${type}/models`}${suffix}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCompanyAdapterGet(adapter, "model-profiles", "List adapter model profiles", "model-profiles"); + addCompanyAdapterGet(adapter, "detect-model", "Detect adapter model", "detect-model"); + addCompanyAdapterPost(adapter, "test-environment", "Test adapter environment configuration", "test-environment"); +} + +function addJsonPost(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions( + parent.command(name).description(description).requiredOption("--payload-json ", "JSON payload").action(async (opts: AdapterOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(path, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addAdapterPatch(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("", "Adapter type") + .requiredOption("--payload-json ", "JSON payload") + .action(async (type: string, opts: AdapterOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.patch(`${apiPath`/api/adapters/${type}`}${suffix}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addAdapterPost(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("", "Adapter type") + .option("--payload-json ", "JSON payload", "{}") + .action(async (type: string, opts: AdapterOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(`${apiPath`/api/adapters/${type}`}${suffix}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addCompanyAdapterGet(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("", "Adapter type") + .option("-C, --company-id ", "Company ID") + .action(async (type: string, opts: AdapterOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}/adapters/${type}`}/${suffix}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addCompanyAdapterPost(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("", "Adapter type") + .option("-C, --company-id ", "Company ID") + .option("--payload-json ", "JSON payload", "{}") + .action(async (type: string, opts: AdapterOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput( + await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}/adapters/${type}`}/${suffix}`, parseJson(opts.payloadJson ?? "{}")), + { json: ctx.json }, + ); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function parseJson(value: string): unknown { + return JSON.parse(value) as unknown; +} diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 2c294628..8144352c 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -1,5 +1,18 @@ import { Command } from "commander"; -import type { Agent } from "@paperclipai/shared"; +import { + agentSkillSyncSchema, + createAgentSchema, + resetAgentSessionSchema, + updateAgentInstructionsBundleSchema, + updateAgentInstructionsPathSchema, + updateAgentPermissionsSchema, + updateAgentSchema, + upsertAgentInstructionsFileSchema, + wakeAgentSchema, + type Agent, + type AgentWakeupResponse, + type Issue, +} from "@paperclipai/shared"; import { removeMaintainerOnlySkillSymlinks, resolvePaperclipSkillsDir, @@ -10,6 +23,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { addCommonClientOptions, + apiPath, formatInlineRecord, handleCommandError, printOutput, @@ -27,6 +41,49 @@ interface AgentLocalCliOptions extends BaseClientOptions { installSkills?: boolean; } +interface AgentInboxMineOptions extends BaseClientOptions { + userId: string; + status?: string; +} + +interface AgentWakeOptions extends BaseClientOptions { + companyId?: string; + source?: string; + trigger?: string; + reason?: string; + payload?: string; + idempotencyKey?: string; + forceFreshSession?: boolean; +} + +interface AgentJsonPayloadOptions extends BaseClientOptions { + companyId?: string; + payloadJson: string; +} + +interface AgentDeleteOptions extends BaseClientOptions { + yes?: boolean; +} + +interface AgentResetSessionOptions extends BaseClientOptions { + taskKey?: string; +} + +interface AgentSkillsSyncOptions extends BaseClientOptions { + desiredSkills: string; +} + +interface AgentInstructionsFileOptions extends BaseClientOptions { + path: string; +} + +interface AgentInstructionsFilePutOptions extends BaseClientOptions { + path: string; + content?: string; + contentFile?: string; + clearLegacyPromptTemplate?: boolean; +} + interface CreatedAgentKey { id: string; name: string; @@ -159,6 +216,69 @@ function buildAgentEnvExports(input: { export function registerAgentCommands(program: Command): void { const agent = program.command("agent").description("Agent operations"); + addCommonClientOptions( + agent + .command("me") + .description("Show the current agent identity") + .action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const me = await ctx.api.get("/api/agents/me"); + printOutput(me, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("inbox") + .description("List current agent assigned inbox items") + .action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const rows = (await ctx.api.get("/api/agents/me/inbox-lite")) ?? []; + if (ctx.json) { + printOutput(rows, { json: true }); + return; + } + for (const row of rows) { + console.log(formatInlineRecord({ + identifier: row.identifier, + id: row.id, + status: row.status, + priority: row.priority, + title: row.title, + projectId: row.projectId, + })); + } + if (rows.length === 0) printOutput([], { json: false }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("inbox-mine") + .description("List current agent inbox items touched or archived by a board user") + .requiredOption("--user-id ", "Board user ID") + .option("--status ", "Comma-separated issue statuses") + .action(async (opts: AgentInboxMineOptions) => { + try { + const ctx = resolveCommandContext(opts); + const params = new URLSearchParams({ userId: opts.userId }); + if (opts.status) params.set("status", opts.status); + const rows = (await ctx.api.get(`/api/agents/me/inbox/mine?${params.toString()}`)) ?? []; + printOutput(rows, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( agent .command("list") @@ -167,7 +287,7 @@ export function registerAgentCommands(program: Command): void { .action(async (opts: AgentListOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); - const rows = (await ctx.api.get(`/api/companies/${ctx.companyId}/agents`)) ?? []; + const rows = (await ctx.api.get(apiPath`/api/companies/${ctx.companyId}/agents`)) ?? []; if (ctx.json) { printOutput(rows, { json: true }); @@ -207,7 +327,7 @@ export function registerAgentCommands(program: Command): void { .action(async (agentId: string, opts: BaseClientOptions) => { try { const ctx = resolveCommandContext(opts); - const row = await ctx.api.get(`/api/agents/${agentId}`); + const row = await ctx.api.get(apiPath`/api/agents/${agentId}`); printOutput(row, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -215,6 +335,423 @@ export function registerAgentCommands(program: Command): void { }), ); + addCommonClientOptions( + agent + .command("create") + .description("Create an agent from a JSON payload") + .option("-C, --company-id ", "Company ID") + .requiredOption("--payload-json ", "CreateAgent JSON payload") + .action(async (opts: AgentJsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const payload = createAgentSchema.parse(parseJson(opts.payloadJson)); + const created = await ctx.api.post(apiPath`/api/companies/${ctx.companyId}/agents`, payload); + printOutput(created, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + agent + .command("hire") + .description("Create an agent hire request") + .option("-C, --company-id ", "Company ID") + .requiredOption("--payload-json ", "CreateAgentHire JSON payload") + .action(async (opts: AgentJsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const result = await ctx.api.post(apiPath`/api/companies/${ctx.companyId}/agent-hires`, parseJson(opts.payloadJson)); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + agent + .command("update") + .description("Update an agent from a JSON payload") + .argument("", "Agent ID") + .requiredOption("--payload-json ", "UpdateAgent JSON payload") + .action(async (agentId: string, opts: AgentJsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = updateAgentSchema.parse(parseJson(opts.payloadJson)); + const updated = await ctx.api.patch(apiPath`/api/agents/${agentId}`, payload); + printOutput(updated, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("delete") + .description("Delete an agent") + .argument("", "Agent ID") + .option("--yes", "Confirm deletion") + .action(async (agentId: string, opts: AgentDeleteOptions) => { + try { + if (!opts.yes) throw new Error("Refusing to delete without --yes"); + const ctx = resolveCommandContext(opts); + const result = await ctx.api.delete(apiPath`/api/agents/${agentId}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + for (const [name, path, description] of [ + ["pause", "pause", "Pause an agent"], + ["resume", "resume", "Resume an agent"], + ["approve", "approve", "Approve a pending agent"], + ["terminate", "terminate", "Terminate an agent"], + ["heartbeat:invoke", "heartbeat/invoke", "Invoke an agent heartbeat"], + ["claude-login", "claude-login", "Trigger Claude login for an agent"], + ] as const) { + addCommonClientOptions( + agent + .command(name) + .description(description) + .argument("", "Agent ID") + .action(async (agentId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.post(`${apiPath`/api/agents/${agentId}`}/${path}`, {}); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + } + + addCommonClientOptions( + agent + .command("permissions:update") + .description("Update agent permissions") + .argument("", "Agent ID") + .requiredOption("--payload-json ", "UpdateAgentPermissions JSON payload") + .action(async (agentId: string, opts: AgentJsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = updateAgentPermissionsSchema.parse(parseJson(opts.payloadJson)); + const updated = await ctx.api.patch(apiPath`/api/agents/${agentId}/permissions`, payload); + printOutput(updated, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("configuration") + .description("Get redacted agent configuration") + .argument("", "Agent ID") + .action(async (agentId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get(apiPath`/api/agents/${agentId}/configuration`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("config-revisions") + .description("List agent config revisions") + .argument("", "Agent ID") + .action(async (agentId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get(apiPath`/api/agents/${agentId}/config-revisions`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("config-revision:get") + .description("Get one agent config revision") + .argument("", "Agent ID") + .argument("", "Revision ID") + .action(async (agentId: string, revisionId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get(apiPath`/api/agents/${agentId}/config-revisions/${revisionId}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("config-revision:rollback") + .description("Roll an agent back to a config revision") + .argument("", "Agent ID") + .argument("", "Revision ID") + .action(async (agentId: string, revisionId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.post(apiPath`/api/agents/${agentId}/config-revisions/${revisionId}/rollback`, {}); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("runtime-state") + .description("Get agent runtime state") + .argument("", "Agent ID") + .action(async (agentId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get(apiPath`/api/agents/${agentId}/runtime-state`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("runtime-state:reset-session") + .description("Reset an agent runtime session") + .argument("", "Agent ID") + .option("--task-key ", "Specific task session key") + .action(async (agentId: string, opts: AgentResetSessionOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = resetAgentSessionSchema.parse({ taskKey: opts.taskKey }); + const result = await ctx.api.post(apiPath`/api/agents/${agentId}/runtime-state/reset-session`, payload); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("task-sessions") + .description("List agent task sessions") + .argument("", "Agent ID") + .action(async (agentId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get(apiPath`/api/agents/${agentId}/task-sessions`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("skills") + .description("List agent skills") + .argument("", "Agent ID") + .action(async (agentId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get(apiPath`/api/agents/${agentId}/skills`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("skills:sync") + .description("Sync desired skills onto an agent") + .argument("", "Agent ID") + .requiredOption("--desired-skills ", "Desired skill names") + .action(async (agentId: string, opts: AgentSkillsSyncOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = agentSkillSyncSchema.parse({ desiredSkills: parseCsv(opts.desiredSkills) }); + const result = await ctx.api.post(apiPath`/api/agents/${agentId}/skills/sync`, payload); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("instructions-path:update") + .description("Update an agent instructions path. Process adapters require adapterConfigKey and relative paths require adapterConfig.cwd.") + .argument("", "Agent ID") + .requiredOption("--payload-json ", "UpdateAgentInstructionsPath JSON payload, for example {\"path\":\"/tmp/AGENTS.md\",\"adapterConfigKey\":\"instructionsFilePath\"}") + .action(async (agentId: string, opts: AgentJsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = updateAgentInstructionsPathSchema.parse(parseJson(opts.payloadJson)); + const result = await ctx.api.patch(apiPath`/api/agents/${agentId}/instructions-path`, payload); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("instructions-bundle") + .description("Get an agent instructions bundle") + .argument("", "Agent ID") + .action(async (agentId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get(apiPath`/api/agents/${agentId}/instructions-bundle`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("instructions-bundle:update") + .description("Update an agent instructions bundle") + .argument("", "Agent ID") + .requiredOption("--payload-json ", "UpdateAgentInstructionsBundle JSON payload") + .action(async (agentId: string, opts: AgentJsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = updateAgentInstructionsBundleSchema.parse(parseJson(opts.payloadJson)); + const result = await ctx.api.patch(apiPath`/api/agents/${agentId}/instructions-bundle`, payload); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("instructions-file:get") + .description("Get an agent instructions file") + .argument("", "Agent ID") + .requiredOption("--path ", "Bundle-relative file path") + .action(async (agentId: string, opts: AgentInstructionsFileOptions) => { + try { + const ctx = resolveCommandContext(opts); + const query = new URLSearchParams({ path: opts.path }); + const result = await ctx.api.get(`${apiPath`/api/agents/${agentId}/instructions-bundle/file`}?${query.toString()}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("instructions-file:put") + .description("Create or update an agent instructions file") + .argument("", "Agent ID") + .requiredOption("--path ", "Bundle-relative file path") + .option("--content ", "File content") + .option("--content-file ", "Read file content from disk") + .option("--clear-legacy-prompt-template", "Clear legacy prompt template") + .action(async (agentId: string, opts: AgentInstructionsFilePutOptions) => { + try { + const ctx = resolveCommandContext(opts); + const content = opts.contentFile ? await fs.readFile(opts.contentFile, "utf8") : opts.content; + const payload = upsertAgentInstructionsFileSchema.parse({ + path: opts.path, + content, + clearLegacyPromptTemplate: Boolean(opts.clearLegacyPromptTemplate), + }); + const result = await ctx.api.put(apiPath`/api/agents/${agentId}/instructions-bundle/file`, payload); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("instructions-file:delete") + .description("Delete an agent instructions file") + .argument("", "Agent ID") + .requiredOption("--path ", "Bundle-relative file path") + .action(async (agentId: string, opts: AgentInstructionsFileOptions) => { + try { + const ctx = resolveCommandContext(opts); + const query = new URLSearchParams({ path: opts.path }); + const result = await ctx.api.delete(`${apiPath`/api/agents/${agentId}/instructions-bundle/file`}?${query.toString()}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + agent + .command("wake") + .description("Request a heartbeat wakeup for an agent") + .argument("", "Agent ID or shortname/url-key") + .option("-C, --company-id ", "Company ID for shortname/url-key lookup") + .option("--source ", "Invocation source (timer, assignment, on_demand, automation)", "on_demand") + .option("--trigger ", "Trigger detail (manual, ping, callback, system)", "manual") + .option("--reason ", "Wakeup reason") + .option("--payload ", "JSON object payload") + .option("--idempotency-key ", "Wakeup idempotency key") + .option("--force-fresh-session", "Request a fresh adapter session") + .action(async (agentRef: string, opts: AgentWakeOptions) => { + try { + const ctx = resolveCommandContext(opts); + const query = opts.companyId ? `?${new URLSearchParams({ companyId: opts.companyId }).toString()}` : ""; + const agentRow = await ctx.api.get(`${apiPath`/api/agents/${agentRef}`}${query}`); + if (!agentRow) { + throw new Error(`Agent not found: ${agentRef}`); + } + const payload = wakeAgentSchema.parse({ + source: opts.source, + triggerDetail: opts.trigger, + reason: opts.reason, + payload: parseJsonObject(opts.payload), + idempotencyKey: opts.idempotencyKey, + forceFreshSession: Boolean(opts.forceFreshSession), + }); + const result = await ctx.api.post(apiPath`/api/agents/${agentRow.id}/wakeup`, payload); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + addCommonClientOptions( agent .command("local-cli") @@ -233,7 +770,7 @@ export function registerAgentCommands(program: Command): void { const ctx = resolveCommandContext(opts, { requireCompany: true }); const query = new URLSearchParams({ companyId: ctx.companyId ?? "" }); const agentRow = await ctx.api.get( - `/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`, + `${apiPath`/api/agents/${agentRef}`}?${query.toString()}`, ); if (!agentRow) { throw new Error(`Agent not found: ${agentRef}`); @@ -241,7 +778,7 @@ export function registerAgentCommands(program: Command): void { const now = new Date().toISOString().replaceAll(":", "-"); const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`; - const key = await ctx.api.post(`/api/agents/${agentRow.id}/keys`, { name: keyName }); + const key = await ctx.api.post(apiPath`/api/agents/${agentRow.id}/keys`, { name: keyName }); if (!key) { throw new Error("Failed to create API key"); } @@ -313,3 +850,21 @@ export function registerAgentCommands(program: Command): void { { includeCompany: false }, ); } + +function parseJsonObject(value: string | undefined): Record | undefined { + if (value === undefined) return undefined; + const parsed = JSON.parse(value) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("--payload must be a JSON object"); + } + return parsed as Record; +} + +function parseJson(value: string): unknown { + return JSON.parse(value) as unknown; +} + +function parseCsv(value: string | undefined): string[] { + if (!value) return []; + return value.split(",").map((part) => part.trim()).filter(Boolean); +} diff --git a/cli/src/commands/client/approval.ts b/cli/src/commands/client/approval.ts index 16d97d45..f60eb8d4 100644 --- a/cli/src/commands/client/approval.ts +++ b/cli/src/commands/client/approval.ts @@ -9,6 +9,7 @@ import { } from "@paperclipai/shared"; import { addCommonClientOptions, + apiPath, formatInlineRecord, handleCommandError, printOutput, @@ -59,7 +60,7 @@ export function registerApprovalCommands(program: Command): void { const query = params.toString(); const rows = (await ctx.api.get( - `/api/companies/${ctx.companyId}/approvals${query ? `?${query}` : ""}`, + `${apiPath`/api/companies/${ctx.companyId}/approvals`}${query ? `?${query}` : ""}`, )) ?? []; if (ctx.json) { @@ -98,7 +99,7 @@ export function registerApprovalCommands(program: Command): void { .action(async (approvalId: string, opts: BaseClientOptions) => { try { const ctx = resolveCommandContext(opts); - const row = await ctx.api.get(`/api/approvals/${approvalId}`); + const row = await ctx.api.get(apiPath`/api/approvals/${approvalId}`); printOutput(row, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -125,7 +126,7 @@ export function registerApprovalCommands(program: Command): void { requestedByAgentId: opts.requestedByAgentId, issueIds: parseCsv(opts.issueIds), }); - const created = await ctx.api.post(`/api/companies/${ctx.companyId}/approvals`, payload); + const created = await ctx.api.post(apiPath`/api/companies/${ctx.companyId}/approvals`, payload); printOutput(created, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -148,7 +149,7 @@ export function registerApprovalCommands(program: Command): void { decisionNote: opts.decisionNote, decidedByUserId: opts.decidedByUserId, }); - const updated = await ctx.api.post(`/api/approvals/${approvalId}/approve`, payload); + const updated = await ctx.api.post(apiPath`/api/approvals/${approvalId}/approve`, payload); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -170,7 +171,7 @@ export function registerApprovalCommands(program: Command): void { decisionNote: opts.decisionNote, decidedByUserId: opts.decidedByUserId, }); - const updated = await ctx.api.post(`/api/approvals/${approvalId}/reject`, payload); + const updated = await ctx.api.post(apiPath`/api/approvals/${approvalId}/reject`, payload); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -192,7 +193,7 @@ export function registerApprovalCommands(program: Command): void { decisionNote: opts.decisionNote, decidedByUserId: opts.decidedByUserId, }); - const updated = await ctx.api.post(`/api/approvals/${approvalId}/request-revision`, payload); + const updated = await ctx.api.post(apiPath`/api/approvals/${approvalId}/request-revision`, payload); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -212,7 +213,7 @@ export function registerApprovalCommands(program: Command): void { const payload = resubmitApprovalSchema.parse({ payload: opts.payload ? parseJsonObject(opts.payload, "payload") : undefined, }); - const updated = await ctx.api.post(`/api/approvals/${approvalId}/resubmit`, payload); + const updated = await ctx.api.post(apiPath`/api/approvals/${approvalId}/resubmit`, payload); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -229,7 +230,7 @@ export function registerApprovalCommands(program: Command): void { .action(async (approvalId: string, opts: ApprovalCommentOptions) => { try { const ctx = resolveCommandContext(opts); - const created = await ctx.api.post(`/api/approvals/${approvalId}/comments`, { + const created = await ctx.api.post(apiPath`/api/approvals/${approvalId}/comments`, { body: opts.body, }); printOutput(created, { json: ctx.json }); diff --git a/cli/src/commands/client/asset.ts b/cli/src/commands/client/asset.ts new file mode 100644 index 00000000..249a3ebc --- /dev/null +++ b/cli/src/commands/client/asset.ts @@ -0,0 +1,147 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { Command } from "commander"; +import { ApiRequestError } from "../../client/http.js"; +import { + addCommonClientOptions, + apiPath, + handleCommandError, + inferContentTypeFromPath, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface AssetOptions extends BaseClientOptions { + companyId?: string; + file?: string; + namespace?: string; + alt?: string; + title?: string; + out?: string; +} + +export function registerAssetCommands(program: Command): void { + const asset = program.command("asset").description("Asset operations"); + + addCommonClientOptions( + asset + .command("image:upload") + .description("Upload a company image asset") + .requiredOption("--file ", "Image file path") + .option("-C, --company-id ", "Company ID") + .option("--namespace ", "Asset namespace suffix") + .option("--alt ", "Alt text metadata") + .option("--title ", "Title metadata") + .action(async (opts: AssetOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const result = await uploadAsset(ctx.api.apiBase, ctx.api.apiKey, apiPath`/api/companies/${ctx.companyId}/assets/images`, opts); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + asset + .command("logo:upload") + .description("Upload a company logo") + .requiredOption("--file ", "Logo file path") + .option("-C, --company-id ", "Company ID") + .action(async (opts: AssetOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const result = await uploadAsset(ctx.api.apiBase, ctx.api.apiKey, apiPath`/api/companies/${ctx.companyId}/logo`, opts); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + asset + .command("content") + .description("Download asset content") + .argument("", "Asset ID") + .option("--out ", "Write content to a file instead of stdout") + .action(async (assetId: string, opts: AssetOptions) => { + try { + const ctx = resolveCommandContext(opts); + const bytes = await downloadAsset(ctx.api.apiBase, ctx.api.apiKey, assetId); + if (opts.out?.trim()) { + await writeFile(opts.out, bytes); + printOutput({ ok: true, out: opts.out, bytes: bytes.length }, { json: ctx.json }); + return; + } + process.stdout.write(bytes); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +async function uploadAsset( + apiBase: string, + apiKey: string | undefined, + path: string, + opts: AssetOptions, +): Promise { + if (!opts.file?.trim()) { + throw new Error("--file is required"); + } + const bytes = await readFile(opts.file); + const form = new FormData(); + form.set("file", new Blob([bytes], { type: inferContentTypeFromPath(opts.file) }), opts.file.split(/[\\/]/).pop() ?? "asset"); + if (opts.namespace?.trim()) form.set("namespace", opts.namespace.trim()); + if (opts.alt?.trim()) form.set("alt", opts.alt.trim()); + if (opts.title?.trim()) form.set("title", opts.title.trim()); + + const response = await fetch(buildApiUrl(apiBase, path), { + method: "POST", + headers: apiKey ? { authorization: `Bearer ${apiKey}` } : undefined, + body: form, + }); + return parseFetchResponse(response); +} + +async function downloadAsset(apiBase: string, apiKey: string | undefined, assetId: string): Promise { + const response = await fetch(buildApiUrl(apiBase, apiPath`/api/assets/${assetId}/content`), { + headers: apiKey ? { authorization: `Bearer ${apiKey}` } : undefined, + }); + if (!response.ok) { + await parseFetchResponse(response); + } + return Buffer.from(await response.arrayBuffer()); +} + +async function parseFetchResponse(response: Response): Promise { + const text = await response.text(); + const parsed = text.trim() ? safeJson(text) : null; + if (!response.ok) { + const message = + typeof parsed === "object" && parsed !== null && "error" in parsed && typeof parsed.error === "string" + ? parsed.error + : `Request failed with status ${response.status}`; + throw new ApiRequestError(response.status, message, undefined, parsed); + } + return parsed; +} + +function buildApiUrl(apiBase: string, path: string): string { + const url = new URL(apiBase); + url.pathname = `${url.pathname.replace(/\/+$/, "")}${path.startsWith("/") ? path : `/${path}`}`; + return url.toString(); +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/cli/src/commands/client/auth.ts b/cli/src/commands/client/auth.ts index 65f47610..1d1b1551 100644 --- a/cli/src/commands/client/auth.ts +++ b/cli/src/commands/client/auth.ts @@ -7,6 +7,7 @@ import { } from "../../client/board-auth.js"; import { addCommonClientOptions, + apiPath, handleCommandError, printOutput, resolveCommandContext, @@ -19,6 +20,11 @@ interface AuthLoginOptions extends BaseClientOptions { interface AuthLogoutOptions extends BaseClientOptions {} interface AuthWhoamiOptions extends BaseClientOptions {} +interface AuthChallengeOptions extends BaseClientOptions { + payloadJson?: string; + token?: string; + tokenEnv?: string; +} export function registerClientAuthCommands(auth: Command): void { addCommonClientOptions( @@ -89,6 +95,20 @@ export function registerClientAuthCommands(auth: Command): void { }), ); + addCommonClientOptions( + auth + .command("revoke-current") + .description("Revoke the current board API token") + .action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post("/api/cli-auth/revoke-current", {}), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( auth .command("whoami") @@ -110,4 +130,71 @@ export function registerClientAuthCommands(auth: Command): void { } }), ); + + const challenge = auth.command("challenge").description("CLI auth challenge operations"); + addCommonClientOptions( + challenge + .command("create") + .description("Create a CLI auth challenge") + .requiredOption("--payload-json ", "CreateCliAuthChallenge JSON payload") + .action(async (opts: AuthChallengeOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post("/api/cli-auth/challenges", parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( + challenge + .command("get") + .description("Get a CLI auth challenge") + .argument("", "Challenge ID") + .option("--token ", "Challenge secret") + .option("--token-env ", "Read the challenge secret from an environment variable") + .action(async (id: string, opts: AuthChallengeOptions) => { + try { + const ctx = resolveCommandContext(opts); + const query = new URLSearchParams({ token: resolveChallengeToken(opts) }); + printOutput(await ctx.api.get(`${apiPath`/api/cli-auth/challenges/${id}`}?${query.toString()}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + for (const action of ["approve", "cancel"] as const) { + addCommonClientOptions( + challenge + .command(action) + .description(`${action} a CLI auth challenge`) + .argument("", "Challenge ID") + .option("--token ", "Challenge secret") + .option("--token-env ", "Read the challenge secret from an environment variable") + .action(async (id: string, opts: AuthChallengeOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(`${apiPath`/api/cli-auth/challenges/${id}`}/${action}`, { token: resolveChallengeToken(opts) }), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + } +} + +function parseJson(value: string): unknown { + return JSON.parse(value) as unknown; +} + +function resolveChallengeToken(opts: AuthChallengeOptions): string { + const token = opts.token?.trim(); + if (token) return token; + const envName = opts.tokenEnv?.trim(); + if (envName) { + const envValue = process.env[envName]?.trim(); + if (envValue) return envValue; + throw new Error(`Environment variable ${envName} is empty or not set.`); + } + throw new Error("Challenge secret is required. Pass --token or --token-env."); } diff --git a/cli/src/commands/client/cloud.ts b/cli/src/commands/client/cloud.ts index f87098a0..c48a8779 100644 --- a/cli/src/commands/client/cloud.ts +++ b/cli/src/commands/client/cloud.ts @@ -12,6 +12,7 @@ import { openUrl } from "../../client/board-auth.js"; import { resolvePaperclipInstanceId } from "../../config/home.js"; import { addCommonClientOptions, + apiPath, handleCommandError, printOutput, resolveCommandContext, @@ -270,7 +271,7 @@ export async function buildBundleFromLocalCompany(input: { mode: "preview" | "apply"; }): Promise { const exported = await input.localApi.post( - `/api/companies/${input.localCompanyId}/export`, + apiPath`/api/companies/${input.localCompanyId}/export`, { include: { company: true, diff --git a/cli/src/commands/client/common.ts b/cli/src/commands/client/common.ts index db5f7dbc..6a48b4ab 100644 --- a/cli/src/commands/client/common.ts +++ b/cli/src/commands/client/common.ts @@ -23,6 +23,7 @@ export interface ResolvedClientContext { profileName: string; profile: ClientContextProfile; json: boolean; + authSource: "explicit" | "env" | "profile_env" | "stored_board" | "none"; } export function addCommonClientOptions(command: Command, opts?: { includeCompany?: boolean }): Command { @@ -49,16 +50,10 @@ export function resolveCommandContext( const context = readContext(options.context); const { name: profileName, profile } = resolveProfile(context, options.profile); - const apiBase = - options.apiBase?.trim() || - process.env.PAPERCLIP_API_URL?.trim() || - profile.apiBase || - inferApiBaseFromConfig(options.config); + const apiBase = resolveApiBase(options, profile); - const explicitApiKey = - options.apiKey?.trim() || - process.env.PAPERCLIP_API_KEY?.trim() || - readKeyFromProfileEnv(profile); + const resolvedApiKey = resolveApiKey(options, profile); + const explicitApiKey = resolvedApiKey.value; const storedBoardCredential = explicitApiKey ? null : getStoredBoardCredential(apiBase); const apiKey = explicitApiKey || storedBoardCredential?.token; @@ -100,9 +95,68 @@ export function resolveCommandContext( profileName, profile, json: Boolean(options.json), + authSource: explicitApiKey ? resolvedApiKey.source : storedBoardCredential ? "stored_board" : "none", }; } +export function resolveApiBase(options: Pick, profile: ClientContextProfile = {}): string { + return normalizeApiBase( + options.apiBase?.trim() || + process.env.PAPERCLIP_API_URL?.trim() || + profile.apiBase || + inferApiBaseFromConfig(options.config), + ); +} + +export function normalizeApiBase(apiBase: string): string { + return apiBase.trim().replace(/\/+$/, ""); +} + +export function apiPath(strings: TemplateStringsArray, ...values: Array): string { + let path = strings[0] ?? ""; + values.forEach((value, index) => { + if (value === null || value === undefined || String(value).trim() === "") { + throw new Error("Cannot build API path with an empty path segment."); + } + path += `${encodeURIComponent(String(value))}${strings[index + 1] ?? ""}`; + }); + return path; +} + +export function inferContentTypeFromPath(filePath: string): string | undefined { + const ext = filePath.split(/[\\/]/).pop()?.split(".").pop()?.toLowerCase(); + if (!ext) return undefined; + return { + avif: "image/avif", + gif: "image/gif", + jpeg: "image/jpeg", + jpg: "image/jpeg", + json: "application/json", + md: "text/markdown; charset=utf-8", + pdf: "application/pdf", + png: "image/png", + svg: "image/svg+xml", + txt: "text/plain; charset=utf-8", + webp: "image/webp", + }[ext]; +} + +function resolveApiKey( + options: Pick, + profile: ClientContextProfile, +): { value: string | undefined; source: "explicit" | "env" | "profile_env" | "none" } { + const optionValue = options.apiKey?.trim(); + if (optionValue) return { value: optionValue, source: "explicit" }; + + const envValue = process.env.PAPERCLIP_API_KEY?.trim(); + if (envValue) return { value: envValue, source: "env" }; + + const profileEnvValue = readKeyFromProfileEnv(profile); + if (profileEnvValue) return { value: profileEnvValue, source: "profile_env" }; + + return { value: undefined, source: "none" }; +} + function shouldRecoverBoardAuth(error: ApiRequestError): boolean { if (error.status === 401) return true; if (error.status !== 403) return false; @@ -183,7 +237,7 @@ function renderValue(value: unknown): string { return "[object]"; } -function inferApiBaseFromConfig(configPath?: string): string { +export function inferApiBaseFromConfig(configPath?: string): string { const envHost = process.env.PAPERCLIP_SERVER_HOST?.trim() || "localhost"; let port = Number(process.env.PAPERCLIP_SERVER_PORT || ""); diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 6e4c6688..8d0c13c6 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -18,6 +18,7 @@ import { openUrl } from "../../client/board-auth.js"; import { binaryContentTypeByExtension, readZipArchive } from "./zip.js"; import { addCommonClientOptions, + apiPath, formatInlineRecord, handleCommandError, printOutput, @@ -31,6 +32,10 @@ import { } from "./feedback.js"; interface CompanyCommandOptions extends BaseClientOptions {} +interface CompanyJsonOptions extends BaseClientOptions { + companyId?: string; + payloadJson?: string; +} type CompanyDeleteSelectorMode = "auto" | "id" | "prefix"; type CompanyImportTargetMode = "new" | "existing"; type CompanyCollisionMode = "rename" | "skip" | "replace"; @@ -745,8 +750,8 @@ export function resolveCompanyImportApiPath(input: { throw new Error("Existing-company imports require a companyId to resolve the API route."); } return input.dryRun - ? `/api/companies/${companyId}/imports/preview` - : `/api/companies/${companyId}/imports/apply`; + ? apiPath`/api/companies/${companyId}/imports/preview` + : apiPath`/api/companies/${companyId}/imports/apply`; } return input.dryRun ? "/api/companies/import/preview" : "/api/companies/import"; @@ -952,12 +957,12 @@ export async function resolveInlineSourceFromPath(inputPath: string): Promise<{ }; } -async function writeExportToFolder(outDir: string, exported: CompanyPortabilityExportResult): Promise { +export async function writeExportToFolder(outDir: string, exported: CompanyPortabilityExportResult): Promise { const root = path.resolve(outDir); await mkdir(root, { recursive: true }); for (const [relativePath, content] of Object.entries(exported.files)) { const normalized = relativePath.replace(/\\/g, "/"); - const filePath = path.join(root, normalized); + const filePath = resolveExportOutputPath(root, normalized); await mkdir(path.dirname(filePath), { recursive: true }); const writeValue = portableFileEntryToWriteValue(content); if (typeof writeValue === "string") { @@ -968,6 +973,16 @@ async function writeExportToFolder(outDir: string, exported: CompanyPortabilityE } } +export function resolveExportOutputPath(root: string, relativePath: string): string { + const resolvedRoot = path.resolve(root); + const filePath = path.resolve(resolvedRoot, relativePath); + const rootPrefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`; + if (filePath !== resolvedRoot && !filePath.startsWith(rootPrefix)) { + throw new Error(`Refusing to write export file outside output directory: ${relativePath}`); + } + return filePath; +} + async function confirmOverwriteExportDirectory(outDir: string): Promise { const root = path.resolve(outDir); const stats = await stat(root).catch(() => null); @@ -1116,7 +1131,7 @@ export function registerCompanyCommands(program: Command): void { .action(async (companyId: string, opts: CompanyCommandOptions) => { try { const ctx = resolveCommandContext(opts); - const row = await ctx.api.get(`/api/companies/${companyId}`); + const row = await ctx.api.get(apiPath`/api/companies/${companyId}`); printOutput(row, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -1124,6 +1139,87 @@ export function registerCompanyCommands(program: Command): void { }), ); + addCommonClientOptions( + company + .command("stats") + .description("Get company stats") + .action(async (opts: CompanyCommandOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get("/api/companies/stats"), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + company + .command("create") + .description("Create a company") + .requiredOption("--payload-json ", "CreateCompany JSON payload") + .action(async (opts: CompanyJsonOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post("/api/companies", parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + company + .command("update") + .description("Update a company") + .argument("", "Company ID") + .requiredOption("--payload-json ", "UpdateCompany JSON payload") + .action(async (companyId: string, opts: CompanyJsonOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.patch(apiPath`/api/companies/${companyId}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + company + .command("branding:update") + .description("Update company branding") + .argument("", "Company ID") + .requiredOption("--payload-json ", "UpdateCompanyBranding JSON payload") + .action(async (companyId: string, opts: CompanyJsonOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.patch(apiPath`/api/companies/${companyId}/branding`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + company + .command("archive") + .description("Archive a company") + .argument("", "Company ID") + .action(async (companyId: string, opts: CompanyCommandOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(apiPath`/api/companies/${companyId}/archive`, {}), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCompanyJsonPost(company, "export:preview", "Preview a portable company export", "exports/preview"); + addCompanyJsonPost(company, "export:api", "Export a company through the raw API route", "exports"); + addCompanyJsonPost(company, "import:preview", "Preview a safe company import through the raw API route", "imports/preview"); + addCompanyJsonPost(company, "import:apply", "Apply a safe company import through the raw API route", "imports/apply"); + addCommonClientOptions( company .command("feedback:list") @@ -1142,7 +1238,7 @@ export function registerCompanyCommands(program: Command): void { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); const traces = (await ctx.api.get( - `/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts)}`, + `${apiPath`/api/companies/${ctx.companyId}/feedback-traces`}${buildFeedbackTraceQuery(opts)}`, )) ?? []; if (ctx.json) { printOutput(traces, { json: true }); @@ -1186,7 +1282,7 @@ export function registerCompanyCommands(program: Command): void { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); const traces = (await ctx.api.get( - `/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`, + `${apiPath`/api/companies/${ctx.companyId}/feedback-traces`}${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`, )) ?? []; const serialized = serializeFeedbackTraces(traces, opts.format); if (opts.out?.trim()) { @@ -1226,7 +1322,7 @@ export function registerCompanyCommands(program: Command): void { const ctx = resolveCommandContext(opts); const include = parseInclude(opts.include); const exported = await ctx.api.post( - `/api/companies/${companyId}/export`, + apiPath`/api/companies/${companyId}/export`, { include, skills: parseCsvValues(opts.skills), @@ -1450,7 +1546,7 @@ export function registerCompanyCommands(program: Command): void { let companyUrl: string | undefined; if (!ctx.json) { try { - const importedCompany = await ctx.api.get(`/api/companies/${imported.company.id}`); + const importedCompany = await ctx.api.get(apiPath`/api/companies/${imported.company.id}`); const issuePrefix = importedCompany?.issuePrefix?.trim(); if (issuePrefix) { companyUrl = buildCompanyDashboardUrl(ctx.api.apiBase, issuePrefix); @@ -1520,7 +1616,7 @@ export function registerCompanyCommands(program: Command): void { let target: Company | null = null; const shouldTryIdLookup = by === "id" || (by === "auto" && isUuidLike(normalizedSelector)); if (shouldTryIdLookup) { - const byId = await ctx.api.get(`/api/companies/${normalizedSelector}`, { ignoreNotFound: true }); + const byId = await ctx.api.get(apiPath`/api/companies/${normalizedSelector}`, { ignoreNotFound: true }); if (byId) { target = byId; } else if (by === "id") { @@ -1529,7 +1625,7 @@ export function registerCompanyCommands(program: Command): void { } if (!target && ctx.companyId) { - const scoped = await ctx.api.get(`/api/companies/${ctx.companyId}`, { ignoreNotFound: true }); + const scoped = await ctx.api.get(apiPath`/api/companies/${ctx.companyId}`, { ignoreNotFound: true }); if (scoped) { try { target = resolveCompanyForDeletion([scoped], normalizedSelector, by); @@ -1559,7 +1655,7 @@ export function registerCompanyCommands(program: Command): void { assertDeleteConfirmation(target, opts); - await ctx.api.delete<{ ok: true }>(`/api/companies/${target.id}`); + await ctx.api.delete<{ ok: true }>(apiPath`/api/companies/${target.id}`); printOutput( { @@ -1576,3 +1672,25 @@ export function registerCompanyCommands(program: Command): void { }), ); } + +function addCompanyJsonPost(parent: Command, name: string, description: string, pathSuffix: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("", "Company ID") + .requiredOption("--payload-json ", "JSON payload") + .action(async (companyId: string, opts: CompanyJsonOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(`${apiPath`/api/companies/${companyId}`}/${pathSuffix}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function parseJson(value: string): unknown { + return JSON.parse(value) as unknown; +} diff --git a/cli/src/commands/client/connect.ts b/cli/src/commands/client/connect.ts new file mode 100644 index 00000000..ec389359 --- /dev/null +++ b/cli/src/commands/client/connect.ts @@ -0,0 +1,265 @@ +import { Command } from "commander"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import type { Agent, Company } from "@paperclipai/shared"; +import { createAgentKeySchema, createBoardApiKeySchema } from "@paperclipai/shared"; +import { loginBoardCli } from "../../client/board-auth.js"; +import { PaperclipApiClient } from "../../client/http.js"; +import { resolveProfile, readContext, setCurrentProfile, upsertProfile } from "../../client/context.js"; +import { + addCommonClientOptions, + apiPath, + handleCommandError, + normalizeApiBase, + printOutput, + resolveApiBase, + type BaseClientOptions, +} from "./common.js"; + +interface ConnectOptions extends BaseClientOptions { + profile?: string; + persona?: "board" | "agent"; + apiKeyEnvVarName?: string; + tokenName?: string; +} + +interface CreatedAgentKey { + id: string; + name: string; + token: string; + createdAt: string; +} + +interface CreatedBoardKey extends CreatedAgentKey { + expiresAt: string | null; +} + +export function registerConnectCommand(program: Command): void { + addCommonClientOptions( + program + .command("connect") + .description("Interactively connect the CLI as a board operator or agent") + .option("--persona ", "Persona to configure: board or agent") + .option("--api-key-env-var-name ", "Env var name to store in the profile", "PAPERCLIP_API_KEY") + .option("--token-name ", "Token label to create") + .action(async (opts: ConnectOptions) => { + try { + const result = await connectWizard(opts); + printOutput(result, { json: opts.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +async function connectWizard(opts: ConnectOptions) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error("`paperclipai connect` is interactive. For scripts, pass --api-base/--api-key or use context set/token commands."); + } + + p.intro(pc.bgCyan(pc.black(" paperclipai connect "))); + + const context = readContext(opts.context); + const resolvedProfile = resolveProfile(context, opts.profile); + const initialApiBase = resolveApiBase(opts, resolvedProfile.profile); + const apiBaseInput = await p.text({ + message: "Paperclip API base", + initialValue: initialApiBase, + placeholder: "http://localhost:3100", + }); + assertNotCancelled(apiBaseInput); + const apiBase = normalizeApiBase(String(apiBaseInput || initialApiBase)); + console.log(pc.dim(`Checking ${apiBase}/api/health ...`)); + await verifyHealth(apiBase); + + const boardLogin = await loginBoardCli({ + apiBase, + requestedAccess: "board", + requestedCompanyId: opts.companyId ?? resolvedProfile.profile.companyId ?? null, + command: "paperclipai connect", + }); + const boardApi = new PaperclipApiClient({ apiBase, apiKey: boardLogin.token }); + const companies = (await boardApi.get("/api/companies")) ?? []; + + const persona = await choosePersona(opts.persona); + const profileName = opts.profile?.trim() || await askProfileName(resolvedProfile.name); + const apiKeyEnvVarName = opts.apiKeyEnvVarName?.trim() || "PAPERCLIP_API_KEY"; + + if (persona === "board") { + const company = await chooseCompany(companies, opts.companyId ?? resolvedProfile.profile.companyId, { + optional: true, + }); + const tokenName = opts.tokenName?.trim() || `cli-board-${new Date().toISOString()}`; + const key = await boardApi.post("/api/board-api-keys", createBoardApiKeySchema.parse({ + name: tokenName, + requestedCompanyId: company?.id ?? null, + })); + if (!key) throw new Error("Failed to create board token"); + upsertProfile(profileName, { + apiBase, + companyId: company?.id, + persona: "board", + agentId: "", + agentName: "", + apiKeyEnvVarName, + tokenName: key.name, + tokenId: key.id, + tokenCreatedAt: key.createdAt, + }, opts.context); + setCurrentProfile(profileName, opts.context); + p.outro(pc.green(`Connected profile '${profileName}' as board.`)); + return { + ok: true, + profile: profileName, + persona: "board", + apiBase, + companyId: company?.id ?? null, + key: publicKeyResult(key), + exports: buildExports({ apiBase, companyId: company?.id, agentId: undefined, envName: apiKeyEnvVarName, token: key.token }), + }; + } + + const company = await chooseCompany(companies, opts.companyId ?? resolvedProfile.profile.companyId, { + optional: false, + }); + if (!company) throw new Error("Company is required for agent profiles"); + const agents = (await boardApi.get(apiPath`/api/companies/${company.id}/agents`)) ?? []; + if (agents.length === 0) throw new Error(`Company '${company.name}' has no agents to connect.`); + const agent = await chooseAgent(agents, resolvedProfile.profile.agentId); + const tokenName = opts.tokenName?.trim() || `cli-agent-${new Date().toISOString()}`; + const key = await boardApi.post(apiPath`/api/agents/${agent.id}/keys`, createAgentKeySchema.parse({ name: tokenName })); + if (!key) throw new Error("Failed to create agent token"); + upsertProfile(profileName, { + apiBase, + companyId: company.id, + persona: "agent", + agentId: agent.id, + agentName: agent.name, + apiKeyEnvVarName, + tokenName: key.name, + tokenId: key.id, + tokenCreatedAt: key.createdAt, + }, opts.context); + setCurrentProfile(profileName, opts.context); + p.outro(pc.green(`Connected profile '${profileName}' as ${agent.name}.`)); + return { + ok: true, + profile: profileName, + persona: "agent", + apiBase, + companyId: company.id, + agentId: agent.id, + agentName: agent.name, + key: publicKeyResult(key), + exports: buildExports({ apiBase, companyId: company.id, agentId: agent.id, envName: apiKeyEnvVarName, token: key.token }), + }; +} + +async function verifyHealth(apiBase: string): Promise { + const api = new PaperclipApiClient({ apiBase }); + await api.get("/api/health"); +} + +async function choosePersona(input: string | undefined): Promise<"board" | "agent"> { + if (input === "board" || input === "agent") return input; + const selected = await p.select({ + message: "Connect as", + options: [ + { value: "board", label: "Board operator" }, + { value: "agent", label: "Agent in a company" }, + ], + }); + assertNotCancelled(selected); + return selected as "board" | "agent"; +} + +async function askProfileName(defaultName: string): Promise { + const profile = await p.text({ + message: "Profile name", + initialValue: defaultName || "default", + }); + assertNotCancelled(profile); + const value = String(profile).trim(); + if (!value) throw new Error("Profile name is required"); + return value; +} + +async function chooseCompany( + companies: Company[], + preferredCompanyId: string | undefined, + opts: { optional: boolean }, +): Promise { + if (companies.length === 0) { + if (opts.optional) return null; + throw new Error("No companies are accessible with this board credential."); + } + const preferred = preferredCompanyId ? companies.find((company) => company.id === preferredCompanyId) : null; + if (companies.length === 1 && !opts.optional) return companies[0] ?? null; + const selected = await p.select({ + message: opts.optional ? "Default company for this profile" : "Agent company", + initialValue: preferred?.id ?? companies[0]?.id, + options: [ + ...(opts.optional ? [{ value: "", label: "(none)" }] : []), + ...companies.map((company) => ({ + value: company.id, + label: company.name, + hint: company.id, + })), + ], + }); + assertNotCancelled(selected); + if (!selected) return null; + return companies.find((company) => company.id === selected) ?? null; +} + +async function chooseAgent(agents: Agent[], preferredAgentId: string | undefined): Promise { + const selected = await p.select({ + message: "Agent", + initialValue: preferredAgentId && agents.some((agent) => agent.id === preferredAgentId) + ? preferredAgentId + : agents[0]?.id, + options: agents.map((agent) => ({ + value: agent.id, + label: agent.name, + hint: agent.role, + })), + }); + assertNotCancelled(selected); + const agent = agents.find((item) => item.id === selected); + if (!agent) throw new Error("Agent selection failed"); + return agent; +} + +function buildExports(input: { + apiBase: string; + companyId?: string; + agentId?: string; + envName: string; + token: string; +}): string { + const escaped = (value: string) => value.replace(/'/g, "'\"'\"'"); + return [ + `export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`, + input.companyId ? `export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'` : null, + input.agentId ? `export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'` : null, + `export ${input.envName}='${escaped(input.token)}'`, + ].filter((line): line is string => Boolean(line)).join("\n"); +} + +function publicKeyResult(key: CreatedAgentKey | CreatedBoardKey) { + return { + id: key.id, + name: key.name, + createdAt: key.createdAt, + token: key.token, + expiresAt: "expiresAt" in key ? key.expiresAt : undefined, + }; +} + +function assertNotCancelled(value: T | symbol): asserts value is T { + if (p.isCancel(value)) { + p.cancel("Cancelled."); + process.exit(0); + } +} diff --git a/cli/src/commands/client/context.ts b/cli/src/commands/client/context.ts index 8bba0ba5..46ed48b8 100644 --- a/cli/src/commands/client/context.ts +++ b/cli/src/commands/client/context.ts @@ -6,6 +6,7 @@ import { resolveProfile, setCurrentProfile, upsertProfile, + type ClientContextProfile, } from "../../client/context.js"; import { printOutput } from "./common.js"; @@ -19,6 +20,9 @@ interface ContextOptions { interface ContextSetOptions extends ContextOptions { apiBase?: string; companyId?: string; + persona?: "board" | "agent"; + agentId?: string; + agentName?: string; apiKeyEnvVarName?: string; use?: boolean; } @@ -60,6 +64,9 @@ export function registerContextCommands(program: Command): void { current: name === store.currentProfile, apiBase: profile.apiBase ?? null, companyId: profile.companyId ?? null, + persona: profile.persona ?? null, + agentId: profile.agentId ?? null, + agentName: profile.agentName ?? null, apiKeyEnvVarName: profile.apiKeyEnvVarName ?? null, })); printOutput(rows, { json: opts.json }); @@ -84,6 +91,9 @@ export function registerContextCommands(program: Command): void { .option("--profile ", "Profile name (default: current profile)") .option("--api-base ", "Default API base URL") .option("--company-id ", "Default company ID") + .option("--persona ", "Profile persona: board or agent") + .option("--agent-id ", "Default agent ID for agent persona") + .option("--agent-name ", "Default agent display name") .option("--api-key-env-var-name ", "Env var containing API key (recommended)") .option("--use", "Set this profile as active") .option("--json", "Output raw JSON") @@ -93,11 +103,7 @@ export function registerContextCommands(program: Command): void { upsertProfile( targetProfile, - { - apiBase: opts.apiBase, - companyId: opts.companyId, - apiKeyEnvVarName: opts.apiKeyEnvVarName, - }, + buildContextPatch(opts), opts.context, ); @@ -123,3 +129,30 @@ export function registerContextCommands(program: Command): void { printOutput(payload, { json: opts.json }); }); } + +function setIfProvided( + patch: Partial, + key: K, + value: ClientContextProfile[K] | undefined, +): void { + if (value !== undefined) { + patch[key] = value; + } +} + +function buildContextPatch(opts: ContextSetOptions): Partial { + const patch: Partial = {}; + setIfProvided(patch, "apiBase", opts.apiBase); + setIfProvided(patch, "companyId", opts.companyId); + setIfProvided(patch, "persona", parsePersona(opts.persona)); + setIfProvided(patch, "agentId", opts.agentId); + setIfProvided(patch, "agentName", opts.agentName); + setIfProvided(patch, "apiKeyEnvVarName", opts.apiKeyEnvVarName); + return patch; +} + +function parsePersona(value: string | undefined): "board" | "agent" | undefined { + if (value === undefined) return undefined; + if (value === "board" || value === "agent") return value; + throw new Error("Invalid --persona value. Use board or agent."); +} diff --git a/cli/src/commands/client/cost.ts b/cli/src/commands/client/cost.ts new file mode 100644 index 00000000..bd8c0ffe --- /dev/null +++ b/cli/src/commands/client/cost.ts @@ -0,0 +1,167 @@ +import { Command } from "commander"; +import { + addCommonClientOptions, + apiPath, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface CompanyOptions extends BaseClientOptions { + companyId?: string; +} + +interface JsonPayloadOptions extends CompanyOptions { + payloadJson: string; +} + +interface IncidentOptions extends CompanyOptions { + payloadJson?: string; +} + +export function registerCostCommands(program: Command): void { + const cost = program.command("cost").description("Cost and finance operations"); + + for (const [name, path] of [ + ["summary", "costs/summary"], + ["by-agent", "costs/by-agent"], + ["by-agent-model", "costs/by-agent-model"], + ["by-provider", "costs/by-provider"], + ["by-biller", "costs/by-biller"], + ["by-project", "costs/by-project"], + ["window-spend", "costs/window-spend"], + ["quota-windows", "costs/quota-windows"], + ] as const) { + addCompanyGet(cost, name, `Get ${name} cost data`, path); + } + + addCommonClientOptions( + cost + .command("issue") + .description("Get cost summary for an issue") + .argument("", "Issue ID") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get(apiPath`/api/issues/${issueId}/cost-summary`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCompanyPostJson(cost, "event:create", "Record a cost event", "cost-events"); + + const finance = program.command("finance").description("Finance event and summary operations"); + addCompanyPostJson(finance, "event:create", "Record a finance event", "finance-events"); + addCompanyGet(finance, "events", "List finance events", "costs/finance-events"); + addCompanyGet(finance, "summary", "Get finance summary", "costs/finance-summary"); + addCompanyGet(finance, "by-biller", "Get finance summary by biller", "costs/finance-by-biller"); + addCompanyGet(finance, "by-kind", "Get finance summary by kind", "costs/finance-by-kind"); + + const budget = program.command("budget").description("Budget policy and incident operations"); + addCompanyGet(budget, "overview", "Get budget overview", "budgets/overview"); + addCompanyPostJson(budget, "policy:upsert", "Create or update a budget policy", "budgets/policies"); + + addCommonClientOptions( + budget + .command("company:update") + .description("Update company budget") + .option("-C, --company-id ", "Company ID") + .requiredOption("--payload-json ", "UpdateBudget JSON payload") + .action(async (opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const result = await ctx.api.patch(apiPath`/api/companies/${ctx.companyId}/budgets`, parseJson(opts.payloadJson)); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + budget + .command("agent:update") + .description("Update agent budget") + .argument("", "Agent ID") + .requiredOption("--payload-json ", "UpdateBudget JSON payload") + .action(async (agentId: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.patch(apiPath`/api/agents/${agentId}/budgets`, parseJson(opts.payloadJson)); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + budget + .command("incident:resolve") + .description("Resolve a budget incident") + .argument("", "Budget incident ID") + .option("-C, --company-id ", "Company ID") + .option("--payload-json ", "ResolveBudgetIncident JSON payload", "{}") + .action(async (incidentId: string, opts: IncidentOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const result = await ctx.api.post( + apiPath`/api/companies/${ctx.companyId}/budget-incidents/${incidentId}/resolve`, + parseJson(opts.payloadJson ?? "{}"), + ); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addCompanyGet(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .option("-C, --company-id ", "Company ID") + .action(async (opts: CompanyOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const result = await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addCompanyPostJson(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .option("-C, --company-id ", "Company ID") + .requiredOption("--payload-json ", "JSON payload") + .action(async (opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const result = await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson)); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function parseJson(value: string): unknown { + return JSON.parse(value) as unknown; +} diff --git a/cli/src/commands/client/dashboard.ts b/cli/src/commands/client/dashboard.ts index 920ca292..01b928eb 100644 --- a/cli/src/commands/client/dashboard.ts +++ b/cli/src/commands/client/dashboard.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import type { DashboardSummary } from "@paperclipai/shared"; import { addCommonClientOptions, + apiPath, handleCommandError, printOutput, resolveCommandContext, @@ -23,7 +24,7 @@ export function registerDashboardCommands(program: Command): void { .action(async (opts: DashboardGetOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); - const row = await ctx.api.get(`/api/companies/${ctx.companyId}/dashboard`); + const row = await ctx.api.get(apiPath`/api/companies/${ctx.companyId}/dashboard`); printOutput(row, { json: ctx.json }); } catch (err) { handleCommandError(err); diff --git a/cli/src/commands/client/feedback.ts b/cli/src/commands/client/feedback.ts index 3ac247d9..c0f8390c 100644 --- a/cli/src/commands/client/feedback.ts +++ b/cli/src/commands/client/feedback.ts @@ -5,6 +5,7 @@ import { Command } from "commander"; import type { Company, FeedbackTrace, FeedbackTraceBundle } from "@paperclipai/shared"; import { addCommonClientOptions, + apiPath, handleCommandError, printOutput, resolveCommandContext, @@ -167,6 +168,36 @@ export function registerFeedbackCommands(program: Command): void { }), { includeCompany: false }, ); + + addCommonClientOptions( + feedback + .command("trace") + .description("Get a feedback trace") + .argument("", "Feedback trace ID") + .action(async (traceId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(apiPath`/api/feedback-traces/${traceId}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + feedback + .command("bundle") + .description("Get a feedback trace bundle") + .argument("", "Feedback trace ID") + .action(async (traceId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(apiPath`/api/feedback-traces/${traceId}/bundle`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); } export async function resolveFeedbackCompanyId( @@ -220,7 +251,7 @@ export async function fetchCompanyFeedbackTraces( ): Promise { return ( (await ctx.api.get( - `/api/companies/${companyId}/feedback-traces${buildFeedbackTraceQuery(opts, true)}`, + `${apiPath`/api/companies/${companyId}/feedback-traces`}${buildFeedbackTraceQuery(opts, true)}`, )) ?? [] ); } @@ -229,7 +260,7 @@ export async function fetchFeedbackTraceBundle( ctx: ResolvedClientContext, traceId: string, ): Promise { - const bundle = await ctx.api.get(`/api/feedback-traces/${traceId}/bundle`); + const bundle = await ctx.api.get(apiPath`/api/feedback-traces/${traceId}/bundle`); if (!bundle) { throw new Error(`Feedback trace bundle ${traceId} not found`); } diff --git a/cli/src/commands/client/goal.ts b/cli/src/commands/client/goal.ts new file mode 100644 index 00000000..977381db --- /dev/null +++ b/cli/src/commands/client/goal.ts @@ -0,0 +1,177 @@ +import { Command } from "commander"; +import type { Goal } from "@paperclipai/shared"; +import { createGoalSchema, updateGoalSchema } from "@paperclipai/shared"; +import { + addCommonClientOptions, + apiPath, + formatInlineRecord, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface GoalListOptions extends BaseClientOptions { + companyId?: string; +} + +interface GoalCreateOptions extends BaseClientOptions { + companyId?: string; + title: string; + description?: string; + level?: string; + status?: string; + parentId?: string; + ownerAgentId?: string; +} + +interface GoalUpdateOptions extends BaseClientOptions { + title?: string; + description?: string; + level?: string; + status?: string; + parentId?: string; + ownerAgentId?: string; +} + +interface GoalDeleteOptions extends BaseClientOptions { + yes?: boolean; +} + +export function registerGoalCommands(program: Command): void { + const goal = program.command("goal").description("Goal operations"); + + addCommonClientOptions( + goal + .command("list") + .description("List goals for a company") + .option("-C, --company-id ", "Company ID") + .action(async (opts: GoalListOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const rows = (await ctx.api.get(apiPath`/api/companies/${ctx.companyId}/goals`)) ?? []; + if (ctx.json) { + printOutput(rows, { json: true }); + return; + } + if (rows.length === 0) { + printOutput([], { json: false }); + return; + } + for (const row of rows) { + console.log(formatInlineRecord({ + id: row.id, + status: row.status, + title: row.title, + level: row.level, + parentId: row.parentId, + ownerAgentId: row.ownerAgentId, + })); + } + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + goal + .command("get") + .description("Get one goal") + .argument("", "Goal ID") + .action(async (goalId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const row = await ctx.api.get(apiPath`/api/goals/${goalId}`); + printOutput(row, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + goal + .command("create") + .description("Create a goal") + .requiredOption("-C, --company-id ", "Company ID") + .requiredOption("--title ", "Goal title") + .option("--description <text>", "Goal description") + .option("--level <level>", "Goal level") + .option("--status <status>", "Goal status") + .option("--parent-id <id>", "Parent goal ID") + .option("--owner-agent-id <id>", "Owner agent ID") + .action(async (opts: GoalCreateOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const payload = createGoalSchema.parse({ + title: opts.title, + description: opts.description, + level: opts.level, + status: opts.status, + parentId: parseNullableString(opts.parentId), + ownerAgentId: parseNullableString(opts.ownerAgentId), + }); + const created = await ctx.api.post<Goal>(apiPath`/api/companies/${ctx.companyId}/goals`, payload); + printOutput(created, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + goal + .command("update") + .description("Update a goal") + .argument("<goalId>", "Goal ID") + .option("--title <title>", "Goal title") + .option("--description <text|null>", "Goal description") + .option("--level <level>", "Goal level") + .option("--status <status>", "Goal status") + .option("--parent-id <id|null>", "Parent goal ID") + .option("--owner-agent-id <id|null>", "Owner agent ID") + .action(async (goalId: string, opts: GoalUpdateOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = updateGoalSchema.parse({ + title: opts.title, + description: parseNullableString(opts.description), + level: opts.level, + status: opts.status, + parentId: parseNullableString(opts.parentId), + ownerAgentId: parseNullableString(opts.ownerAgentId), + }); + const updated = await ctx.api.patch<Goal>(apiPath`/api/goals/${goalId}`, payload); + printOutput(updated, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + goal + .command("delete") + .description("Delete a goal") + .argument("<goalId>", "Goal ID") + .option("--yes", "Confirm deletion") + .action(async (goalId: string, opts: GoalDeleteOptions) => { + try { + if (!opts.yes) throw new Error("Deletion requires --yes."); + const ctx = resolveCommandContext(opts); + const deleted = await ctx.api.delete<Goal>(apiPath`/api/goals/${goalId}`); + printOutput(deleted, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function parseNullableString(value: string | undefined): string | null | undefined { + if (value === undefined) return undefined; + return value.trim().toLowerCase() === "null" ? null : value; +} diff --git a/cli/src/commands/client/issue.ts b/cli/src/commands/client/issue.ts index afef1923..a7bdf52f 100644 --- a/cli/src/commands/client/issue.ts +++ b/cli/src/commands/client/issue.ts @@ -1,18 +1,38 @@ import { Command } from "commander"; -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { addIssueCommentSchema, + acceptIssueThreadInteractionSchema, + cancelIssueThreadInteractionSchema, checkoutIssueSchema, + createChildIssueSchema, + createIssueLabelSchema, createIssueSchema, + createIssueThreadInteractionSchema, + createIssueTreeHoldSchema, + createIssueWorkProductSchema, type FeedbackTrace, + type HeartbeatRun, + linkIssueApprovalSchema, + previewIssueTreeControlSchema, + rejectIssueThreadInteractionSchema, + releaseIssueTreeHoldSchema, + respondIssueThreadInteractionSchema, + resolveIssueRecoveryActionSchema, + restoreIssueDocumentRevisionSchema, updateIssueSchema, + updateIssueWorkProductSchema, type Issue, type IssueComment, + upsertIssueDocumentSchema, + upsertIssueFeedbackVoteSchema, } from "@paperclipai/shared"; import { addCommonClientOptions, + apiPath, formatInlineRecord, handleCommandError, + inferContentTypeFromPath, printOutput, resolveCommandContext, type BaseClientOptions, @@ -64,6 +84,12 @@ interface IssueCommentOptions extends BaseClientOptions { resume?: boolean; } +interface IssueCommentListOptions extends BaseClientOptions { + afterCommentId?: string; + order?: string; + limit?: string; +} + interface IssueCheckoutOptions extends BaseClientOptions { agentId: string; expectedStatuses?: string; @@ -81,6 +107,65 @@ interface IssueFeedbackOptions extends BaseClientOptions { format?: string; } +interface IssueDeleteOptions extends BaseClientOptions { + yes?: boolean; +} + +interface JsonPayloadOptions extends BaseClientOptions { + payloadJson: string; +} + +interface IssueDocumentPutOptions extends BaseClientOptions { + title?: string; + format?: string; + body?: string; + bodyFile?: string; + changeSummary?: string; + baseRevisionId?: string; +} + +interface IssueAttachmentUploadOptions extends BaseClientOptions { + companyId?: string; + file: string; + commentId?: string; +} + +interface IssueAttachmentDownloadOptions extends BaseClientOptions { + out?: string; +} + +interface IssueLabelCreateOptions extends BaseClientOptions { + companyId?: string; + name: string; + color: string; +} + +interface IssueRecoveryResolveOptions extends BaseClientOptions { + actionId?: string; + outcome: string; + sourceIssueStatus: string; + resolutionNote?: string; +} + +interface InteractionAcceptOptions extends BaseClientOptions { + selectedClientKeys?: string; +} + +interface InteractionReasonOptions extends BaseClientOptions { + reason?: string; +} + +interface InteractionRespondOptions extends BaseClientOptions { + answersJson: string; + summaryMarkdown?: string; +} + +interface TreeHoldListOptions extends BaseClientOptions { + status?: string; + mode?: string; + includeMembers?: boolean; +} + export function registerIssueCommands(program: Command): void { const issue = program.command("issue").description("Issue operations"); @@ -102,7 +187,7 @@ export function registerIssueCommands(program: Command): void { if (opts.projectId) params.set("projectId", opts.projectId); const query = params.toString(); - const path = `/api/companies/${ctx.companyId}/issues${query ? `?${query}` : ""}`; + const path = `${apiPath`/api/companies/${ctx.companyId}/issues`}${query ? `?${query}` : ""}`; const rows = (await ctx.api.get<Issue[]>(path)) ?? []; const filtered = filterIssueRows(rows, opts.match); @@ -144,7 +229,7 @@ export function registerIssueCommands(program: Command): void { .action(async (idOrIdentifier: string, opts: BaseClientOptions) => { try { const ctx = resolveCommandContext(opts); - const row = await ctx.api.get<Issue>(`/api/issues/${idOrIdentifier}`); + const row = await ctx.api.get<Issue>(apiPath`/api/issues/${idOrIdentifier}`); printOutput(row, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -152,6 +237,40 @@ export function registerIssueCommands(program: Command): void { }), ); + addCommonClientOptions( + issue + .command("delete") + .description("Delete an issue") + .argument("<issueId>", "Issue ID") + .option("--yes", "Confirm deletion") + .action(async (issueId: string, opts: IssueDeleteOptions) => { + try { + if (!opts.yes) throw new Error("Refusing to delete without --yes"); + const ctx = resolveCommandContext(opts); + const deleted = await ctx.api.delete<Issue>(apiPath`/api/issues/${issueId}`); + printOutput(deleted, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("heartbeat-context") + .description("Get heartbeat context for an issue") + .argument("<issueId>", "Issue ID") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const context = await ctx.api.get(apiPath`/api/issues/${issueId}/heartbeat-context`); + printOutput(context, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( issue .command("create") @@ -183,7 +302,7 @@ export function registerIssueCommands(program: Command): void { billingCode: opts.billingCode, }); - const created = await ctx.api.post<Issue>(`/api/companies/${ctx.companyId}/issues`, payload); + const created = await ctx.api.post<Issue>(apiPath`/api/companies/${ctx.companyId}/issues`, payload); printOutput(created, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -227,7 +346,7 @@ export function registerIssueCommands(program: Command): void { hiddenAt: parseHiddenAt(opts.hiddenAt), }); - const updated = await ctx.api.patch<Issue & { comment?: IssueComment | null }>(`/api/issues/${issueId}`, payload); + const updated = await ctx.api.patch<Issue & { comment?: IssueComment | null }>(apiPath`/api/issues/${issueId}`, payload); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -251,7 +370,7 @@ export function registerIssueCommands(program: Command): void { reopen: opts.reopen, resume: opts.resume, }); - const comment = await ctx.api.post<IssueComment>(`/api/issues/${issueId}/comments`, payload); + const comment = await ctx.api.post<IssueComment>(apiPath`/api/issues/${issueId}/comments`, payload); printOutput(comment, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -259,6 +378,776 @@ export function registerIssueCommands(program: Command): void { }), ); + addCommonClientOptions( + issue + .command("comments") + .description("List issue comments") + .argument("<issueId>", "Issue ID") + .option("--after-comment-id <id>", "Only return comments after this comment ID") + .option("--order <order>", "asc or desc") + .option("--limit <n>", "Maximum comments to return") + .action(async (issueId: string, opts: IssueCommentListOptions) => { + try { + const ctx = resolveCommandContext(opts); + const params = new URLSearchParams(); + if (opts.afterCommentId) params.set("afterCommentId", opts.afterCommentId); + if (opts.order) params.set("order", opts.order); + if (opts.limit) params.set("limit", opts.limit); + const query = params.toString(); + const comments = (await ctx.api.get<IssueComment[]>( + `${apiPath`/api/issues/${issueId}/comments`}${query ? `?${query}` : ""}`, + )) ?? []; + printOutput(comments, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("comment:get") + .description("Get one issue comment") + .argument("<issueId>", "Issue ID") + .argument("<commentId>", "Comment ID") + .action(async (issueId: string, commentId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const comment = await ctx.api.get<IssueComment>(apiPath`/api/issues/${issueId}/comments/${commentId}`); + printOutput(comment, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("comment:delete") + .description("Delete or cancel one issue comment") + .argument("<issueId>", "Issue ID") + .argument("<commentId>", "Comment ID") + .action(async (issueId: string, commentId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const deleted = await ctx.api.delete<IssueComment>(apiPath`/api/issues/${issueId}/comments/${commentId}`); + printOutput(deleted, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("approvals") + .description("List approvals linked to an issue") + .argument("<issueId>", "Issue ID") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const approvals = await ctx.api.get(apiPath`/api/issues/${issueId}/approvals`); + printOutput(approvals, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("approval:link") + .description("Link an approval to an issue") + .argument("<issueId>", "Issue ID") + .argument("<approvalId>", "Approval ID") + .action(async (issueId: string, approvalId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = linkIssueApprovalSchema.parse({ approvalId }); + const approvals = await ctx.api.post(apiPath`/api/issues/${issueId}/approvals`, payload); + printOutput(approvals, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("approval:unlink") + .description("Unlink an approval from an issue") + .argument("<issueId>", "Issue ID") + .argument("<approvalId>", "Approval ID") + .action(async (issueId: string, approvalId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.delete(apiPath`/api/issues/${issueId}/approvals/${approvalId}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addIssuePostDeleteMarkerCommand(issue, "read", "Mark an issue as read", "post", "/read"); + addIssuePostDeleteMarkerCommand(issue, "unread", "Mark an issue as unread", "delete", "/read"); + addIssuePostDeleteMarkerCommand(issue, "archive", "Archive an issue from the inbox", "post", "/inbox-archive"); + addIssuePostDeleteMarkerCommand(issue, "unarchive", "Unarchive an issue from the inbox", "delete", "/inbox-archive"); + + addCommonClientOptions( + issue + .command("recovery-actions") + .description("List active recovery actions for an issue") + .argument("<issueId>", "Issue ID") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get(apiPath`/api/issues/${issueId}/recovery-actions`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("recovery:resolve") + .description("Resolve an issue recovery action") + .argument("<issueId>", "Issue ID") + .requiredOption("--outcome <outcome>", "restored, false_positive, blocked, or cancelled") + .requiredOption("--source-issue-status <status>", "todo, done, or in_review for restored outcomes; blocked is only valid for blocked outcomes") + .option("--action-id <id>", "Specific recovery action ID") + .option("--resolution-note <text>", "Resolution note") + .action(async (issueId: string, opts: IssueRecoveryResolveOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = resolveIssueRecoveryActionSchema.parse({ + actionId: opts.actionId, + outcome: opts.outcome, + sourceIssueStatus: opts.sourceIssueStatus, + resolutionNote: opts.resolutionNote, + }); + const result = await ctx.api.post(apiPath`/api/issues/${issueId}/recovery-actions/resolve`, payload); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("child:create") + .description("Create a child issue from a JSON payload") + .argument("<issueId>", "Parent issue ID") + .requiredOption("--payload-json <json>", "CreateChildIssue JSON payload") + .action(async (issueId: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = createChildIssueSchema.parse(parseJson(opts.payloadJson)); + const child = await ctx.api.post<Issue>(apiPath`/api/issues/${issueId}/children`, payload); + printOutput(child, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("force-release") + .description("Force-release an issue from an agent checkout") + .argument("<issueId>", "Issue ID") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.post(apiPath`/api/issues/${issueId}/admin/force-release`, {}); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("work-products") + .description("List issue work products") + .argument("<issueId>", "Issue ID") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const rows = await ctx.api.get(apiPath`/api/issues/${issueId}/work-products`); + printOutput(rows, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("work-product:create") + .description("Create an issue work product from JSON") + .argument("<issueId>", "Issue ID") + .requiredOption("--payload-json <json>", "CreateIssueWorkProduct JSON payload") + .action(async (issueId: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = createIssueWorkProductSchema.parse(parseJson(opts.payloadJson)); + const product = await ctx.api.post(apiPath`/api/issues/${issueId}/work-products`, payload); + printOutput(product, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("work-product:update") + .description("Update a work product from JSON") + .argument("<workProductId>", "Work product ID") + .requiredOption("--payload-json <json>", "UpdateIssueWorkProduct JSON payload") + .action(async (workProductId: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = updateIssueWorkProductSchema.parse(parseJson(opts.payloadJson)); + const product = await ctx.api.patch(apiPath`/api/work-products/${workProductId}`, payload); + printOutput(product, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("work-product:delete") + .description("Delete a work product") + .argument("<workProductId>", "Work product ID") + .action(async (workProductId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const product = await ctx.api.delete(apiPath`/api/work-products/${workProductId}`); + printOutput(product, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("documents") + .description("List issue documents") + .argument("<issueId>", "Issue ID") + .option("--include-system", "Include system documents") + .action(async (issueId: string, opts: BaseClientOptions & { includeSystem?: boolean }) => { + try { + const ctx = resolveCommandContext(opts); + const query = opts.includeSystem ? "?includeSystem=true" : ""; + const docs = await ctx.api.get(`${apiPath`/api/issues/${issueId}/documents`}${query}`); + printOutput(docs, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("document:get") + .description("Get an issue document") + .argument("<issueId>", "Issue ID") + .argument("<key>", "Document key") + .action(async (issueId: string, key: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const doc = await ctx.api.get(apiPath`/api/issues/${issueId}/documents/${key}`); + printOutput(doc, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("document:put") + .description("Create or update an issue document") + .argument("<issueId>", "Issue ID") + .argument("<key>", "Document key") + .option("--title <title>", "Document title") + .option("--format <format>", "Document format", "markdown") + .option("--body <markdown>", "Document body") + .option("--body-file <path>", "Read document body from a file") + .option("--change-summary <text>", "Change summary") + .option("--base-revision-id <id>", "Expected base revision ID") + .action(async (issueId: string, key: string, opts: IssueDocumentPutOptions) => { + try { + const ctx = resolveCommandContext(opts); + const body = opts.bodyFile ? await readFile(opts.bodyFile, "utf8") : opts.body; + const payload = upsertIssueDocumentSchema.parse({ + title: opts.title, + format: opts.format, + body, + changeSummary: opts.changeSummary, + baseRevisionId: opts.baseRevisionId, + }); + const doc = await ctx.api.put(apiPath`/api/issues/${issueId}/documents/${key}`, payload); + printOutput(doc, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("interactions") + .description("List issue thread interactions") + .argument("<issueId>", "Issue ID") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const interactions = await ctx.api.get(apiPath`/api/issues/${issueId}/interactions`); + printOutput(interactions, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("interaction:create") + .description("Create an issue thread interaction from JSON") + .argument("<issueId>", "Issue ID") + .requiredOption("--payload-json <json>", "CreateIssueThreadInteraction JSON payload") + .action(async (issueId: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = createIssueThreadInteractionSchema.parse(parseJson(opts.payloadJson)); + const interaction = await ctx.api.post(apiPath`/api/issues/${issueId}/interactions`, payload); + printOutput(interaction, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("interaction:accept") + .description("Accept an issue thread interaction") + .argument("<issueId>", "Issue ID") + .argument("<interactionId>", "Interaction ID") + .option("--selected-client-keys <csv>", "Client keys to accept") + .action(async (issueId: string, interactionId: string, opts: InteractionAcceptOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = acceptIssueThreadInteractionSchema.parse({ + selectedClientKeys: opts.selectedClientKeys === undefined ? undefined : parseCsv(opts.selectedClientKeys), + }); + const interaction = await ctx.api.post(apiPath`/api/issues/${issueId}/interactions/${interactionId}/accept`, payload); + printOutput(interaction, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + for (const [name, action, schema, description] of [ + ["interaction:reject", "reject", rejectIssueThreadInteractionSchema, "Reject an issue thread interaction"], + ["interaction:cancel", "cancel", cancelIssueThreadInteractionSchema, "Cancel an ask_user_questions issue thread interaction"], + ] as const) { + addCommonClientOptions( + issue + .command(name) + .description(description) + .argument("<issueId>", "Issue ID") + .argument("<interactionId>", "Interaction ID") + .option("--reason <text>", "Reason") + .action(async (issueId: string, interactionId: string, opts: InteractionReasonOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = schema.parse({ reason: opts.reason }); + const interaction = await ctx.api.post(`${apiPath`/api/issues/${issueId}/interactions/${interactionId}`}/${action}`, payload); + printOutput(interaction, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + } + + addCommonClientOptions( + issue + .command("interaction:respond") + .description("Respond to an issue question interaction") + .argument("<issueId>", "Issue ID") + .argument("<interactionId>", "Interaction ID") + .requiredOption("--answers-json <json>", "Answers array JSON") + .option("--summary-markdown <markdown>", "Optional response summary") + .action(async (issueId: string, interactionId: string, opts: InteractionRespondOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = respondIssueThreadInteractionSchema.parse({ + answers: parseJson(opts.answersJson), + summaryMarkdown: opts.summaryMarkdown, + }); + const interaction = await ctx.api.post(apiPath`/api/issues/${issueId}/interactions/${interactionId}/respond`, payload); + printOutput(interaction, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("tree-state") + .description("Get issue tree control state") + .argument("<issueId>", "Root issue ID") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const state = await ctx.api.get(apiPath`/api/issues/${issueId}/tree-control/state`); + printOutput(state, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("tree-preview") + .description("Preview issue tree control changes") + .argument("<issueId>", "Root issue ID") + .requiredOption("--payload-json <json>", "PreviewIssueTreeControl JSON payload") + .action(async (issueId: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = previewIssueTreeControlSchema.parse(parseJson(opts.payloadJson)); + const preview = await ctx.api.post(apiPath`/api/issues/${issueId}/tree-control/preview`, payload); + printOutput(preview, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("tree-holds") + .description("List issue tree holds") + .argument("<issueId>", "Root issue ID") + .option("--status <status>", "active or released") + .option("--mode <mode>", "pause, resume, cancel, or restore") + .option("--include-members", "Include hold members") + .action(async (issueId: string, opts: TreeHoldListOptions) => { + try { + const ctx = resolveCommandContext(opts); + const params = new URLSearchParams(); + if (opts.status) params.set("status", opts.status); + if (opts.mode) params.set("mode", opts.mode); + if (opts.includeMembers) params.set("includeMembers", "true"); + const query = params.toString(); + const holds = await ctx.api.get(`${apiPath`/api/issues/${issueId}/tree-holds`}${query ? `?${query}` : ""}`); + printOutput(holds, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("tree-hold:create") + .description("Create an issue tree hold from JSON") + .argument("<issueId>", "Root issue ID") + .requiredOption("--payload-json <json>", "CreateIssueTreeHold JSON payload") + .action(async (issueId: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = createIssueTreeHoldSchema.parse(parseJson(opts.payloadJson)); + const hold = await ctx.api.post(apiPath`/api/issues/${issueId}/tree-holds`, payload); + printOutput(hold, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("tree-hold:get") + .description("Get an issue tree hold") + .argument("<issueId>", "Root issue ID") + .argument("<holdId>", "Hold ID") + .action(async (issueId: string, holdId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const hold = await ctx.api.get(apiPath`/api/issues/${issueId}/tree-holds/${holdId}`); + printOutput(hold, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("tree-hold:release") + .description("Release an issue tree hold") + .argument("<issueId>", "Root issue ID") + .argument("<holdId>", "Hold ID") + .option("--payload-json <json>", "ReleaseIssueTreeHold JSON payload", "{}") + .action(async (issueId: string, holdId: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = releaseIssueTreeHoldSchema.parse(parseJson(opts.payloadJson)); + const hold = await ctx.api.post(apiPath`/api/issues/${issueId}/tree-holds/${holdId}/release`, payload); + printOutput(hold, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("attachments") + .description("List issue attachments") + .argument("<issueId>", "Issue ID") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const attachments = await ctx.api.get(apiPath`/api/issues/${issueId}/attachments`); + printOutput(attachments, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("attachment:upload") + .description("Upload an issue attachment") + .argument("<issueId>", "Issue ID") + .option("-C, --company-id <id>", "Company ID") + .requiredOption("--file <path>", "File to upload") + .option("--comment-id <id>", "Attach to an issue comment") + .action(async (issueId: string, opts: IssueAttachmentUploadOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const attachment = await uploadAttachment(ctx.api.apiBase, ctx.api.apiKey, { + companyId: ctx.companyId ?? "", + issueId, + filePath: opts.file, + commentId: opts.commentId, + }); + printOutput(attachment, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + issue + .command("attachment:download") + .description("Download an attachment") + .argument("<attachmentId>", "Attachment ID") + .option("--out <path>", "Output file path; prints to stdout when omitted") + .action(async (attachmentId: string, opts: IssueAttachmentDownloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const bytes = await downloadAttachment(ctx.api.apiBase, ctx.api.apiKey, attachmentId); + if (opts.out) { + await writeFile(opts.out, bytes); + if (ctx.json) printOutput({ out: opts.out, bytes: bytes.byteLength }, { json: true }); + else console.log(`Wrote ${bytes.byteLength} byte(s) to ${opts.out}`); + return; + } + process.stdout.write(bytes); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("attachment:delete") + .description("Delete an attachment") + .argument("<attachmentId>", "Attachment ID") + .action(async (attachmentId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.delete(apiPath`/api/attachments/${attachmentId}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("label:list") + .description("List issue labels in a company") + .option("-C, --company-id <id>", "Company ID") + .action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const labels = await ctx.api.get(apiPath`/api/companies/${ctx.companyId}/labels`); + printOutput(labels, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + issue + .command("label:create") + .description("Create an issue label") + .option("-C, --company-id <id>", "Company ID") + .requiredOption("--name <name>", "Label name") + .requiredOption("--color <hex>", "Label color, e.g. #4f46e5") + .action(async (opts: IssueLabelCreateOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const payload = createIssueLabelSchema.parse({ name: opts.name, color: opts.color }); + const label = await ctx.api.post(apiPath`/api/companies/${ctx.companyId}/labels`, payload); + printOutput(label, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + issue + .command("label:delete") + .description("Delete an issue label") + .argument("<labelId>", "Label ID") + .action(async (labelId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.delete(apiPath`/api/labels/${labelId}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("feedback:votes") + .description("List feedback votes for an issue") + .argument("<issueId>", "Issue ID") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const votes = await ctx.api.get(apiPath`/api/issues/${issueId}/feedback-votes`); + printOutput(votes, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("feedback:vote") + .description("Create or update a feedback vote") + .argument("<issueId>", "Issue ID") + .requiredOption("--payload-json <json>", "UpsertIssueFeedbackVote JSON payload") + .action(async (issueId: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = upsertIssueFeedbackVoteSchema.parse(parseJson(opts.payloadJson)); + const vote = await ctx.api.post(apiPath`/api/issues/${issueId}/feedback-votes`, payload); + printOutput(vote, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + for (const [name, pathSuffix, description] of [ + ["document:delete", "", "Delete an issue document"], + ["document:lock", "/lock", "Lock an issue document"], + ["document:unlock", "/unlock", "Unlock an issue document"], + ] as const) { + addCommonClientOptions( + issue + .command(name) + .description(description) + .argument("<issueId>", "Issue ID") + .argument("<key>", "Document key") + .action(async (issueId: string, key: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const path = `${apiPath`/api/issues/${issueId}/documents/${key}`}${pathSuffix}`; + const result = name === "document:delete" ? await ctx.api.delete(path) : await ctx.api.post(path, {}); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + } + + addCommonClientOptions( + issue + .command("document:revisions") + .description("List issue document revisions") + .argument("<issueId>", "Issue ID") + .argument("<key>", "Document key") + .action(async (issueId: string, key: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const revisions = await ctx.api.get(apiPath`/api/issues/${issueId}/documents/${key}/revisions`); + printOutput(revisions, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("document:restore") + .description("Restore an issue document revision") + .argument("<issueId>", "Issue ID") + .argument("<key>", "Document key") + .argument("<revisionId>", "Revision ID") + .action(async (issueId: string, key: string, revisionId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = restoreIssueDocumentRevisionSchema.parse({}); + const doc = await ctx.api.post( + apiPath`/api/issues/${issueId}/documents/${key}/revisions/${revisionId}/restore`, + payload, + ); + printOutput(doc, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( issue .command("feedback:list") @@ -275,7 +1164,7 @@ export function registerIssueCommands(program: Command): void { try { const ctx = resolveCommandContext(opts); const traces = (await ctx.api.get<FeedbackTrace[]>( - `/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts)}`, + `${apiPath`/api/issues/${issueId}/feedback-traces`}${buildFeedbackTraceQuery(opts)}`, )) ?? []; if (ctx.json) { printOutput(traces, { json: true }); @@ -298,6 +1187,54 @@ export function registerIssueCommands(program: Command): void { }), ); + addCommonClientOptions( + issue + .command("runs") + .description("List heartbeat runs associated with an issue") + .argument("<issueId>", "Issue ID or identifier") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const rows = (await ctx.api.get<unknown[]>(apiPath`/api/issues/${issueId}/runs`)) ?? []; + printOutput(rows, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("live-runs") + .description("List queued and running heartbeat runs associated with an issue") + .argument("<issueId>", "Issue ID or identifier") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const rows = (await ctx.api.get<HeartbeatRun[]>(apiPath`/api/issues/${issueId}/live-runs`)) ?? []; + printOutput(rows, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("active-run") + .description("Show the active heartbeat run associated with an issue") + .argument("<issueId>", "Issue ID or identifier") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const run = await ctx.api.get<HeartbeatRun | null>(apiPath`/api/issues/${issueId}/active-run`); + printOutput(run, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( issue .command("feedback:export") @@ -316,7 +1253,7 @@ export function registerIssueCommands(program: Command): void { try { const ctx = resolveCommandContext(opts); const traces = (await ctx.api.get<FeedbackTrace[]>( - `/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`, + `${apiPath`/api/issues/${issueId}/feedback-traces`}${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`, )) ?? []; const serialized = serializeFeedbackTraces(traces, opts.format); if (opts.out?.trim()) { @@ -356,7 +1293,7 @@ export function registerIssueCommands(program: Command): void { agentId: opts.agentId, expectedStatuses: parseCsv(opts.expectedStatuses), }); - const updated = await ctx.api.post<Issue>(`/api/issues/${issueId}/checkout`, payload); + const updated = await ctx.api.post<Issue>(apiPath`/api/issues/${issueId}/checkout`, payload); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -372,7 +1309,7 @@ export function registerIssueCommands(program: Command): void { .action(async (issueId: string, opts: BaseClientOptions) => { try { const ctx = resolveCommandContext(opts); - const updated = await ctx.api.post<Issue>(`/api/issues/${issueId}/release`, {}); + const updated = await ctx.api.post<Issue>(apiPath`/api/issues/${issueId}/release`, {}); printOutput(updated, { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -386,6 +1323,36 @@ function parseCsv(value: string | undefined): string[] { return value.split(",").map((v) => v.trim()).filter(Boolean); } +function addIssuePostDeleteMarkerCommand( + issue: Command, + name: string, + description: string, + method: "post" | "delete", + pathSuffix: string, +): void { + addCommonClientOptions( + issue + .command(name) + .description(description) + .argument("<issueId>", "Issue ID") + .action(async (issueId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = method === "post" + ? await ctx.api.post(`${apiPath`/api/issues/${issueId}`}${pathSuffix}`, {}) + : await ctx.api.delete(`${apiPath`/api/issues/${issueId}`}${pathSuffix}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function parseJson(value: string): unknown { + return JSON.parse(value) as unknown; +} + function parseOptionalInt(value: string | undefined): number | undefined { if (value === undefined) return undefined; const parsed = Number.parseInt(value, 10); @@ -412,3 +1379,61 @@ function filterIssueRows(rows: Issue[], match: string | undefined): Issue[] { return text.includes(needle); }); } + +function buildApiUrl(apiBase: string, path: string): string { + const url = new URL(apiBase); + url.pathname = `${url.pathname.replace(/\/+$/, "")}${path.startsWith("/") ? path : `/${path}`}`; + return url.toString(); +} + +async function uploadAttachment( + apiBase: string, + apiKey: string | undefined, + input: { companyId: string; issueId: string; filePath: string; commentId?: string }, +): Promise<unknown> { + const bytes = await readFile(input.filePath); + const form = new FormData(); + form.set("file", new Blob([bytes], { type: inferContentTypeFromPath(input.filePath) }), input.filePath.split(/[\\/]/).pop() ?? "attachment"); + if (input.commentId) form.set("issueCommentId", input.commentId); + const response = await fetch(buildApiUrl(apiBase, apiPath`/api/companies/${input.companyId}/issues/${input.issueId}/attachments`), { + method: "POST", + headers: apiKey ? { authorization: `Bearer ${apiKey}` } : undefined, + body: form, + }); + return parseFetchResponse(response); +} + +async function downloadAttachment( + apiBase: string, + apiKey: string | undefined, + attachmentId: string, +): Promise<Buffer> { + const response = await fetch(buildApiUrl(apiBase, apiPath`/api/attachments/${attachmentId}/content`), { + headers: apiKey ? { authorization: `Bearer ${apiKey}` } : undefined, + }); + if (!response.ok) { + await parseFetchResponse(response); + } + return Buffer.from(await response.arrayBuffer()); +} + +async function parseFetchResponse(response: Response): Promise<unknown> { + const text = await response.text(); + const parsed = text.trim() ? safeJson(text) : null; + if (!response.ok) { + const message = + typeof parsed === "object" && parsed !== null && "error" in parsed && typeof parsed.error === "string" + ? parsed.error + : `Request failed with status ${response.status}`; + throw new Error(`API error ${response.status}: ${message}`); + } + return parsed; +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } +} diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts index 933671e3..ec27f64d 100644 --- a/cli/src/commands/client/plugin.ts +++ b/cli/src/commands/client/plugin.ts @@ -65,6 +65,18 @@ interface PluginInitOptions extends BaseClientOptions { sdkPath?: string; } +interface PluginJsonOptions extends BaseClientOptions { + payloadJson?: string; +} + +interface PluginStreamOptions extends BaseClientOptions { + durationMs?: string; +} + +interface PluginCompanyOptions extends PluginJsonOptions { + companyId?: string; +} + interface PluginInitResult { outputDir: string; nextCommands: string[]; @@ -531,4 +543,274 @@ export function registerPluginCommands(program: Command): void { } }), ); + + addPluginGet(plugin, "ui-contributions", "List plugin UI contributions", "/api/plugins/ui-contributions"); + addPluginGet(plugin, "tools", "List plugin tools", "/api/plugins/tools"); + addPluginPost(plugin, "tool:execute", "Execute a plugin tool", "/api/plugins/tools/execute"); + addPluginSubGet(plugin, "health", "Get plugin health", "health"); + addPluginSubGet(plugin, "logs", "Get plugin logs", "logs"); + addPluginSubPost(plugin, "upgrade", "Upgrade a plugin", "upgrade"); + addPluginSubGet(plugin, "config", "Get plugin config", "config"); + addPluginSubPost(plugin, "config:set", "Set plugin config", "config"); + addPluginSubPost(plugin, "config:test", "Test plugin config", "config/test"); + addPluginSubGet(plugin, "jobs", "List plugin jobs", "jobs"); + addPluginJobGet(plugin, "job:runs", "List plugin job runs", "runs"); + addPluginJobPost(plugin, "job:trigger", "Trigger a plugin job", "trigger"); + addPluginKeyPost(plugin, "webhook", "Deliver a plugin webhook", "webhooks"); + addPluginSubGet(plugin, "dashboard", "Get plugin dashboard data", "dashboard"); + addPluginSubPost(plugin, "bridge:data", "Send plugin bridge data", "bridge/data"); + addPluginSubPost(plugin, "bridge:action", "Send plugin bridge action", "bridge/action"); + addCommonClientOptions( + plugin + .command("bridge:stream") + .description("Stream a plugin bridge channel") + .argument("<pluginId>", "Plugin ID or key") + .argument("<channel>", "Stream channel") + .option("--duration-ms <ms>", "Stop streaming after this many milliseconds") + .action(async (pluginId: string, channel: string, opts: PluginStreamOptions) => { + try { + const ctx = resolveCommandContext(opts); + await streamPluginBridge(ctx.api.apiBase, ctx.api.apiKey, pluginId, channel, parseOptionalInt(opts.durationMs)); + } catch (err) { + handleCommandError(err); + } + }), + ); + addPluginKeyPost(plugin, "data", "Get plugin URL-keyed data", "data"); + addPluginKeyPost(plugin, "action", "Invoke plugin URL-keyed action", "actions"); + addPluginLocalFolderGet(plugin, "local-folders", "List plugin local folder bindings"); + addPluginLocalFolderKeyGet(plugin, "local-folder:status", "Get plugin local folder status", "status"); + addPluginLocalFolderKeyPost(plugin, "local-folder:validate", "Validate plugin local folder binding", "validate"); + addPluginLocalFolderKeyPut(plugin, "local-folder:set", "Set plugin local folder binding"); +} + +function addPluginGet(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions(parent.command(name).description(description).action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(path), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function addPluginPost(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions(parent.command(name).description(description).option("--payload-json <json>", "JSON payload", "{}").action(async (opts: PluginJsonOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(path, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function addPluginSubGet(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions(parent.command(name).description(description).argument("<pluginId>", "Plugin ID or key").action(async (pluginId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(`/api/plugins/${encodeURIComponent(pluginId)}/${suffix}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function addPluginSubPost(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions(parent.command(name).description(description).argument("<pluginId>", "Plugin ID or key").option("--payload-json <json>", "JSON payload", "{}").action(async (pluginId: string, opts: PluginJsonOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(`/api/plugins/${encodeURIComponent(pluginId)}/${suffix}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function addPluginJobGet(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions(parent.command(name).description(description).argument("<pluginId>", "Plugin ID or key").argument("<jobId>", "Job ID").action(async (pluginId: string, jobId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(`/api/plugins/${encodeURIComponent(pluginId)}/jobs/${encodeURIComponent(jobId)}/${suffix}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function addPluginJobPost(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions(parent.command(name).description(description).argument("<pluginId>", "Plugin ID or key").argument("<jobId>", "Job ID").option("--payload-json <json>", "JSON payload", "{}").action(async (pluginId: string, jobId: string, opts: PluginJsonOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(`/api/plugins/${encodeURIComponent(pluginId)}/jobs/${encodeURIComponent(jobId)}/${suffix}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function addPluginKeyPost(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions(parent.command(name).description(description).argument("<pluginId>", "Plugin ID or key").argument("<key>", "Endpoint or data/action key").option("--payload-json <json>", "JSON payload", "{}").action(async (pluginId: string, key: string, opts: PluginJsonOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(`/api/plugins/${encodeURIComponent(pluginId)}/${suffix}/${encodeURIComponent(key)}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function addPluginLocalFolderGet(parent: Command, name: string, description: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<pluginId>", "Plugin ID or key") + .requiredOption("-C, --company-id <id>", "Company ID") + .action(async (pluginId: string, opts: PluginCompanyOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.get(`/api/plugins/${encodeURIComponent(pluginId)}/companies/${ctx.companyId}/local-folders`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addPluginLocalFolderKeyGet(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<pluginId>", "Plugin ID or key") + .argument("<folderKey>", "Local folder key") + .requiredOption("-C, --company-id <id>", "Company ID") + .action(async (pluginId: string, folderKey: string, opts: PluginCompanyOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput( + await ctx.api.get(`/api/plugins/${encodeURIComponent(pluginId)}/companies/${ctx.companyId}/local-folders/${encodeURIComponent(folderKey)}/${suffix}`), + { json: ctx.json }, + ); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addPluginLocalFolderKeyPost(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<pluginId>", "Plugin ID or key") + .argument("<folderKey>", "Local folder key") + .requiredOption("-C, --company-id <id>", "Company ID") + .option("--payload-json <json>", "JSON payload", "{}") + .action(async (pluginId: string, folderKey: string, opts: PluginCompanyOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput( + await ctx.api.post( + `/api/plugins/${encodeURIComponent(pluginId)}/companies/${ctx.companyId}/local-folders/${encodeURIComponent(folderKey)}/${suffix}`, + parseJson(opts.payloadJson ?? "{}"), + ), + { json: ctx.json }, + ); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addPluginLocalFolderKeyPut(parent: Command, name: string, description: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<pluginId>", "Plugin ID or key") + .argument("<folderKey>", "Local folder key") + .requiredOption("-C, --company-id <id>", "Company ID") + .requiredOption("--payload-json <json>", "JSON payload") + .action(async (pluginId: string, folderKey: string, opts: PluginCompanyOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput( + await ctx.api.put( + `/api/plugins/${encodeURIComponent(pluginId)}/companies/${ctx.companyId}/local-folders/${encodeURIComponent(folderKey)}`, + parseJson(opts.payloadJson ?? "{}"), + ), + { json: ctx.json }, + ); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function parseJson(value: string): unknown { + return JSON.parse(value) as unknown; +} + +function parseOptionalInt(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid integer value: ${value}`); + } + return parsed; +} + +async function streamPluginBridge( + apiBase: string, + apiKey: string | undefined, + pluginId: string, + channel: string, + durationMs: number | undefined, +): Promise<void> { + const controller = new AbortController(); + const timer = durationMs === undefined ? null : setTimeout(() => controller.abort(), durationMs); + try { + const response = await fetch(buildApiUrl( + apiBase, + `/api/plugins/${encodeURIComponent(pluginId)}/bridge/stream/${encodeURIComponent(channel)}`, + ), { + headers: apiKey ? { authorization: `Bearer ${apiKey}` } : undefined, + signal: controller.signal, + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(text.trim() || `Request failed with status ${response.status}`); + } + if (!response.body) return; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) process.stdout.write(decoder.decode(value, { stream: true })); + } + const trailing = decoder.decode(); + if (trailing) process.stdout.write(trailing); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") return; + throw error; + } finally { + if (timer) clearTimeout(timer); + } +} + +function buildApiUrl(apiBase: string, path: string): string { + const url = new URL(apiBase); + url.pathname = `${url.pathname.replace(/\/+$/, "")}${path.startsWith("/") ? path : `/${path}`}`; + return url.toString(); } diff --git a/cli/src/commands/client/project.ts b/cli/src/commands/client/project.ts new file mode 100644 index 00000000..4ddc883e --- /dev/null +++ b/cli/src/commands/client/project.ts @@ -0,0 +1,228 @@ +import { Command } from "commander"; +import type { Project } from "@paperclipai/shared"; +import { createProjectSchema, updateProjectSchema } from "@paperclipai/shared"; +import { + addCommonClientOptions, + apiPath, + formatInlineRecord, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface ProjectListOptions extends BaseClientOptions { + companyId?: string; +} + +interface ProjectCreateOptions extends BaseClientOptions { + companyId?: string; + name: string; + description?: string; + status?: string; + goalId?: string; + goalIds?: string; + leadAgentId?: string; + targetDate?: string; + color?: string; + envJson?: string; + executionWorkspacePolicyJson?: string; +} + +interface ProjectUpdateOptions extends BaseClientOptions { + name?: string; + description?: string; + status?: string; + goalId?: string; + goalIds?: string; + leadAgentId?: string; + targetDate?: string; + color?: string; + envJson?: string; + executionWorkspacePolicyJson?: string; + archivedAt?: string; +} + +interface ProjectDeleteOptions extends BaseClientOptions { + yes?: boolean; +} + +export function registerProjectCommands(program: Command): void { + const project = program.command("project").description("Project operations"); + + addCommonClientOptions( + project + .command("list") + .description("List projects for a company") + .option("-C, --company-id <id>", "Company ID") + .action(async (opts: ProjectListOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const rows = (await ctx.api.get<Project[]>(apiPath`/api/companies/${ctx.companyId}/projects`)) ?? []; + if (ctx.json) { + printOutput(rows, { json: true }); + return; + } + if (rows.length === 0) { + printOutput([], { json: false }); + return; + } + for (const row of rows) { + console.log(formatInlineRecord({ + id: row.id, + name: row.name, + status: row.status, + urlKey: row.urlKey, + goalIds: row.goalIds?.join(",") ?? "", + leadAgentId: row.leadAgentId, + })); + } + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + project + .command("get") + .description("Get one project by ID or shortname") + .argument("<project>", "Project ID or shortname") + .option("-C, --company-id <id>", "Company ID for shortname lookup") + .action(async (projectRef: string, opts: ProjectListOptions) => { + try { + const ctx = resolveCommandContext(opts); + const query = ctx.companyId ? `?${new URLSearchParams({ companyId: ctx.companyId }).toString()}` : ""; + const row = await ctx.api.get<Project>(`${apiPath`/api/projects/${projectRef}`}${query}`); + printOutput(row, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + project + .command("create") + .description("Create a project") + .requiredOption("-C, --company-id <id>", "Company ID") + .requiredOption("--name <name>", "Project name") + .option("--description <text>", "Project description") + .option("--status <status>", "Project status") + .option("--goal-id <id>", "Deprecated single goal ID") + .option("--goal-ids <csv>", "Comma-separated goal IDs") + .option("--lead-agent-id <id>", "Lead agent ID") + .option("--target-date <date>", "Target date") + .option("--color <value>", "Project color") + .option("--env-json <json>", "Project env binding JSON") + .option("--execution-workspace-policy-json <json>", "Execution workspace policy JSON") + .action(async (opts: ProjectCreateOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const payload = createProjectSchema.parse({ + name: opts.name, + description: opts.description, + status: opts.status, + goalId: parseNullableString(opts.goalId), + goalIds: parseCsv(opts.goalIds), + leadAgentId: parseNullableString(opts.leadAgentId), + targetDate: parseNullableString(opts.targetDate), + color: parseNullableString(opts.color), + env: parseOptionalJson(opts.envJson), + executionWorkspacePolicy: parseOptionalJson(opts.executionWorkspacePolicyJson), + }); + const created = await ctx.api.post<Project>(apiPath`/api/companies/${ctx.companyId}/projects`, payload); + printOutput(created, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + project + .command("update") + .description("Update a project") + .argument("<project>", "Project ID or shortname") + .option("-C, --company-id <id>", "Company ID for shortname lookup") + .option("--name <name>", "Project name") + .option("--description <text|null>", "Project description") + .option("--status <status>", "Project status") + .option("--goal-id <id|null>", "Deprecated single goal ID") + .option("--goal-ids <csv>", "Comma-separated goal IDs") + .option("--lead-agent-id <id|null>", "Lead agent ID") + .option("--target-date <date|null>", "Target date") + .option("--color <value|null>", "Project color") + .option("--env-json <json|null>", "Project env binding JSON") + .option("--execution-workspace-policy-json <json|null>", "Execution workspace policy JSON") + .option("--archived-at <iso8601|null>", "Archive timestamp or null") + .action(async (projectRef: string, opts: ProjectUpdateOptions) => { + try { + const ctx = resolveCommandContext(opts); + const payload = updateProjectSchema.parse({ + name: opts.name, + description: parseNullableString(opts.description), + status: opts.status, + goalId: parseNullableString(opts.goalId), + goalIds: opts.goalIds === undefined ? undefined : parseCsv(opts.goalIds), + leadAgentId: parseNullableString(opts.leadAgentId), + targetDate: parseNullableString(opts.targetDate), + color: parseNullableString(opts.color), + env: parseOptionalJson(opts.envJson), + executionWorkspacePolicy: parseOptionalJson(opts.executionWorkspacePolicyJson), + archivedAt: parseNullableString(opts.archivedAt), + }); + const query = ctx.companyId ? `?${new URLSearchParams({ companyId: ctx.companyId }).toString()}` : ""; + const updated = await ctx.api.patch<Project>(`${apiPath`/api/projects/${projectRef}`}${query}`, payload); + printOutput(updated, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + project + .command("delete") + .description("Delete a project") + .argument("<project>", "Project ID or shortname") + .option("-C, --company-id <id>", "Company ID for shortname lookup") + .option("--yes", "Confirm deletion") + .action(async (projectRef: string, opts: ProjectDeleteOptions) => { + try { + if (!opts.yes) throw new Error("Deletion requires --yes."); + const ctx = resolveCommandContext(opts); + const query = ctx.companyId ? `?${new URLSearchParams({ companyId: ctx.companyId }).toString()}` : ""; + const deleted = await ctx.api.delete<Project>(`${apiPath`/api/projects/${projectRef}`}${query}`); + printOutput(deleted, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function parseCsv(value: string | undefined): string[] | undefined { + if (value === undefined) return undefined; + return value.split(",").map((part) => part.trim()).filter(Boolean); +} + +function parseNullableString(value: string | undefined): string | null | undefined { + if (value === undefined) return undefined; + return value.trim().toLowerCase() === "null" ? null : value; +} + +function parseOptionalJson(value: string | undefined): unknown { + if (value === undefined) return undefined; + if (value.trim().toLowerCase() === "null") return null; + try { + return JSON.parse(value); + } catch (err) { + throw new Error(`Invalid JSON: ${err instanceof Error ? err.message : String(err)}`); + } +} diff --git a/cli/src/commands/client/prompt.ts b/cli/src/commands/client/prompt.ts new file mode 100644 index 00000000..d7a43d11 --- /dev/null +++ b/cli/src/commands/client/prompt.ts @@ -0,0 +1,276 @@ +import { Command } from "commander"; +import type { Agent, Issue, IssueComment } from "@paperclipai/shared"; +import { addIssueCommentSchema, createIssueSchema } from "@paperclipai/shared"; +import { + addCommonClientOptions, + apiPath, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface PromptOptions extends BaseClientOptions { + agent?: string; + apiKeyEnv?: string; + issue?: string; + title?: string; + wake?: boolean; + companyId?: string; +} + +interface PromptResult { + ok: true; + mode: "issue" | "comment"; + actor: "agent" | "board"; + apiBase: string; + companyId: string; + agent: { + id: string; + name: string; + urlKey?: string | null; + }; + issue?: Issue | null; + comment?: IssueComment | null; + wakeup?: unknown; +} + +export function registerPromptCommands(program: Command): void { + addCommonClientOptions( + program + .command("agent-prompt") + .description("Create/update Paperclip work for an agent using an agent API key") + .argument("<agent>", "Agent ID, shortname, or name") + .argument("<agentApiKey>", "Agent API key") + .argument("<prompt...>", "Prompt text") + .option("--issue <issueId>", "Append as a comment to an existing issue") + .option("--title <title>", "Issue title when creating a new issue") + .option("--no-wake", "Do not wake the agent after creating/updating work") + .action(async (agent: string, agentApiKey: string, promptParts: string[], opts: PromptOptions) => { + try { + const result = await runAgentPrompt(agent, promptParts.join(" "), { + ...opts, + apiKey: agentApiKey, + wake: opts.wake, + }); + printOutput(result, { json: opts.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + const agent = program.commands.find((cmd) => cmd.name() === "agent") ?? program.command("agent"); + addCommonClientOptions( + agent + .command("prompt") + .description("Create/update Paperclip work using an agent persona") + .argument("<prompt...>", "Prompt text") + .option("--agent <agent>", "Agent ID, shortname, or name; defaults to profile/identity agent") + .option("--api-key-env <name>", "Read the agent API key from this environment variable") + .option("--issue <issueId>", "Append as a comment to an existing issue") + .option("--title <title>", "Issue title when creating a new issue") + .option("--no-wake", "Do not wake the agent after creating/updating work") + .action(async (promptParts: string[], opts: PromptOptions) => { + try { + const apiKey = readApiKeyEnvOption(opts); + const result = await runAgentPrompt(opts.agent, promptParts.join(" "), { + ...opts, + apiKey: apiKey ?? opts.apiKey, + wake: opts.wake, + }); + printOutput(result, { json: opts.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + const board = program.command("board").description("Board operator operations"); + addCommonClientOptions( + board + .command("prompt") + .description("Create/update Paperclip work for an agent using board auth") + .requiredOption("--agent <agent>", "Target agent ID, shortname, or name") + .option("-C, --company-id <id>", "Company ID") + .option("--issue <issueId>", "Append as a comment to an existing issue") + .option("--title <title>", "Issue title when creating a new issue") + .option("--no-wake", "Do not wake the agent after creating/updating work") + .argument("<prompt...>", "Prompt text") + .action(async (promptParts: string[], opts: PromptOptions) => { + try { + const result = await runBoardPrompt(opts.agent ?? "", promptParts.join(" "), opts); + printOutput(result, { json: opts.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +export async function runAgentPrompt( + agentRef: string | undefined, + prompt: string, + opts: PromptOptions, +): Promise<PromptResult> { + const ctx = resolveCommandContext(opts); + if (ctx.profile.persona && ctx.profile.persona !== "agent") { + throw new Error(`Profile '${ctx.profileName}' is persona=${ctx.profile.persona}; use an agent profile or board prompt.`); + } + const body = normalizePrompt(prompt); + const me = await ctx.api.get<Agent>("/api/agents/me"); + if (!me) throw new Error("Agent authentication failed"); + const expectedRef = agentRef?.trim() || ctx.profile.agentId || me.id; + assertAgentMatchesReference(me, expectedRef); + + const result = await createOrCommentForAgent({ + api: ctx.api, + actor: "agent", + agent: me, + companyId: me.companyId, + prompt: body, + issueId: opts.issue, + title: opts.title, + wake: opts.wake !== false, + }); + return result; +} + +export async function runBoardPrompt( + agentRef: string, + prompt: string, + opts: PromptOptions, +): Promise<PromptResult> { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + if (ctx.profile.persona && ctx.profile.persona !== "board") { + throw new Error(`Profile '${ctx.profileName}' is persona=${ctx.profile.persona}; use an agent prompt command or a board profile.`); + } + const body = normalizePrompt(prompt); + const query = new URLSearchParams({ companyId: ctx.companyId ?? "" }); + const agent = await ctx.api.get<Agent>(`${apiPath`/api/agents/${agentRef}`}?${query.toString()}`); + if (!agent) throw new Error(`Agent not found: ${agentRef}`); + + return createOrCommentForAgent({ + api: ctx.api, + actor: "board", + agent, + companyId: ctx.companyId ?? agent.companyId, + prompt: body, + issueId: opts.issue, + title: opts.title, + wake: opts.wake !== false, + }); +} + +async function createOrCommentForAgent(input: { + api: { + apiBase: string; + post<T>(path: string, body?: unknown): Promise<T | null>; + }; + actor: "agent" | "board"; + agent: Agent; + companyId: string; + prompt: string; + issueId?: string; + title?: string; + wake: boolean; +}): Promise<PromptResult> { + if (input.issueId?.trim()) { + const payload = addIssueCommentSchema.parse({ + body: input.prompt, + resume: input.wake, + }); + const comment = await input.api.post<IssueComment>(apiPath`/api/issues/${input.issueId.trim()}/comments`, payload); + const wakeup = input.wake + ? await wakeAgent(input.api, input.agent.id, input.issueId.trim(), "Prompt comment handoff") + : null; + return { + ok: true, + mode: "comment", + actor: input.actor, + apiBase: input.api.apiBase, + companyId: input.companyId, + agent: agentSummary(input.agent), + comment, + wakeup, + }; + } + + const payload = createIssueSchema.parse({ + title: input.title?.trim() || defaultPromptTitle(input.prompt), + description: input.prompt, + status: "todo", + priority: "medium", + assigneeAgentId: input.agent.id, + }); + const issue = await input.api.post<Issue>(apiPath`/api/companies/${input.companyId}/issues`, payload); + const wakeup = input.wake && issue?.id + ? await wakeAgent(input.api, input.agent.id, issue.id, "Prompt issue handoff") + : null; + return { + ok: true, + mode: "issue", + actor: input.actor, + apiBase: input.api.apiBase, + companyId: input.companyId, + agent: agentSummary(input.agent), + issue, + wakeup, + }; +} + +function wakeAgent( + api: { post<T>(path: string, body?: unknown): Promise<T | null> }, + agentId: string, + issueId: string, + reason: string, +): Promise<unknown> { + return api.post(apiPath`/api/agents/${agentId}/wakeup`, { + source: "on_demand", + triggerDetail: "manual", + reason, + payload: { issueId }, + }); +} + +function normalizePrompt(prompt: string): string { + const normalized = prompt.trim(); + if (!normalized) throw new Error("Prompt text is required"); + return normalized; +} + +function defaultPromptTitle(prompt: string): string { + const firstLine = prompt.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "Prompt handoff"; + return firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine; +} + +function assertAgentMatchesReference(agent: Agent, reference: string): void { + const normalized = reference.trim().toLowerCase(); + if (!normalized) throw new Error("Agent reference is required"); + const matches = [ + agent.id, + agent.name, + typeof agent.urlKey === "string" ? agent.urlKey : null, + ].some((value) => value?.toLowerCase() === normalized); + if (!matches) { + throw new Error( + `Agent key belongs to ${agent.name} (${agent.id}), not '${reference}'. Use the matching agent or a board prompt.`, + ); + } +} + +function agentSummary(agent: Agent): PromptResult["agent"] { + return { + id: agent.id, + name: agent.name, + urlKey: typeof agent.urlKey === "string" ? agent.urlKey : null, + }; +} + +function readApiKeyEnvOption(opts: PromptOptions): string | undefined { + if (!opts.apiKeyEnv?.trim()) return undefined; + const value = process.env[opts.apiKeyEnv.trim()]?.trim(); + if (!value) throw new Error(`Environment variable ${opts.apiKeyEnv.trim()} is not set`); + return value; +} diff --git a/cli/src/commands/client/routine-api.ts b/cli/src/commands/client/routine-api.ts new file mode 100644 index 00000000..0f207687 --- /dev/null +++ b/cli/src/commands/client/routine-api.ts @@ -0,0 +1,154 @@ +import { Command } from "commander"; +import { + addCommonClientOptions, + apiPath, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface CompanyOptions extends BaseClientOptions { + companyId?: string; + projectId?: string; +} + +interface JsonOptions extends CompanyOptions { + payloadJson?: string; + limit?: string; +} + +export function registerRoutineApiCommands(program: Command): void { + const routine = program.command("routine").description("Routine API operations"); + addCommonClientOptions( + routine + .command("list") + .description("List routines") + .option("-C, --company-id <id>", "Company ID") + .option("--project-id <id>", "Filter by project ID") + .action(async (opts: CompanyOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const query = opts.projectId ? `?${new URLSearchParams({ projectId: opts.projectId }).toString()}` : ""; + printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}/routines`}${query}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + addCompanyPost(routine, "create", "Create a routine", "routines"); + addIdGet(routine, "get", "Get a routine", "routines"); + addIdPatch(routine, "update", "Update a routine", "routines"); + addIdGet(routine, "revisions", "List routine revisions", "routines", "revisions"); + addCommonClientOptions( + routine + .command("revision:restore") + .description("Restore a routine revision") + .argument("<routineId>", "Routine ID") + .argument("<revisionId>", "Revision ID") + .action(async (routineId: string, revisionId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(apiPath`/api/routines/${routineId}/revisions/${revisionId}/restore`, {}), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( + routine + .command("runs") + .description("List routine runs") + .argument("<routineId>", "Routine ID") + .option("--limit <n>", "Maximum runs to return") + .action(async (routineId: string, opts: JsonOptions) => { + try { + const ctx = resolveCommandContext(opts); + const query = opts.limit ? `?${new URLSearchParams({ limit: opts.limit }).toString()}` : ""; + printOutput(await ctx.api.get(`${apiPath`/api/routines/${routineId}/runs`}${query}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addIdPost(routine, "run", "Run a routine", "routines", "run"); + addIdPost(routine, "trigger:create", "Create a routine trigger", "routines", "triggers"); + addIdPatch(routine, "trigger:update", "Update a routine trigger", "routine-triggers"); + addIdDelete(routine, "trigger:delete", "Delete a routine trigger", "routine-triggers"); + addIdPost(routine, "trigger:rotate-secret", "Rotate a routine trigger secret", "routine-triggers", "rotate-secret"); + addCommonClientOptions( + routine + .command("trigger:fire") + .description("Fire a public routine trigger") + .argument("<publicId>", "Public trigger ID") + .option("--payload-json <json>", "Public trigger payload", "{}") + .action(async (publicId: string, opts: JsonOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(apiPath`/api/routine-triggers/public/${publicId}/fire`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addCompanyPost(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions(parent.command(name).description(description).option("-C, --company-id <id>", "Company ID").requiredOption("--payload-json <json>", "JSON payload").action(async (opts: JsonOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), { includeCompany: false }); +} + +function addIdGet(parent: Command, name: string, description: string, resource: string, suffix?: string): void { + addCommonClientOptions(parent.command(name).description(description).argument("<id>", "ID").action(async (id: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(`/api/${resource}/${encodeURIComponent(id)}${suffix ? `/${suffix}` : ""}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function addIdPatch(parent: Command, name: string, description: string, resource: string): void { + addCommonClientOptions(parent.command(name).description(description).argument("<id>", "ID").requiredOption("--payload-json <json>", "JSON payload").action(async (id: string, opts: JsonOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.patch(`/api/${resource}/${encodeURIComponent(id)}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function addIdPost(parent: Command, name: string, description: string, resource: string, suffix: string): void { + addCommonClientOptions(parent.command(name).description(description).argument("<id>", "ID").option("--payload-json <json>", "JSON payload", "{}").action(async (id: string, opts: JsonOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(`/api/${resource}/${encodeURIComponent(id)}/${suffix}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function addIdDelete(parent: Command, name: string, description: string, resource: string): void { + addCommonClientOptions(parent.command(name).description(description).argument("<id>", "ID").action(async (id: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.delete(`/api/${resource}/${encodeURIComponent(id)}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + })); +} + +function parseJson(value: string): unknown { + return JSON.parse(value) as unknown; +} diff --git a/cli/src/commands/client/run.ts b/cli/src/commands/client/run.ts new file mode 100644 index 00000000..1f0776b9 --- /dev/null +++ b/cli/src/commands/client/run.ts @@ -0,0 +1,321 @@ +import { Command } from "commander"; +import type { HeartbeatRun, HeartbeatRunEvent, Issue, WorkspaceOperation } from "@paperclipai/shared"; +import { + addCommonClientOptions, + apiPath, + formatInlineRecord, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface RunListOptions extends BaseClientOptions { + agentId?: string; + limit?: string; +} + +interface RunLiveOptions extends BaseClientOptions { + limit?: string; + minCount?: string; +} + +interface RunEventsOptions extends BaseClientOptions { + afterSeq?: string; + limit?: string; +} + +interface RunLogOptions extends BaseClientOptions { + offset?: string; + limitBytes?: string; + text?: boolean; +} + +interface RunWatchdogOptions extends BaseClientOptions { + decision: string; + reason?: string; + snoozedUntil?: string; + evaluationIssueId?: string; +} + +interface RunIssueSummary extends Issue { + runId?: string; + runStatus?: string; +} + +export function registerRunCommands(command: Command): void { + addCommonClientOptions( + command + .command("list") + .description("List heartbeat runs for a company") + .option("-C, --company-id <id>", "Company ID") + .option("--agent-id <id>", "Filter by agent ID") + .option("--limit <n>", "Maximum runs to return") + .action(async (opts: RunListOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const params = new URLSearchParams(); + if (opts.agentId) params.set("agentId", opts.agentId); + if (opts.limit) params.set("limit", opts.limit); + const query = params.toString(); + const rows = (await ctx.api.get<HeartbeatRun[]>( + `${apiPath`/api/companies/${ctx.companyId}/heartbeat-runs`}${query ? `?${query}` : ""}`, + )) ?? []; + printRuns(rows, ctx.json); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + command + .command("live") + .description("List queued and running heartbeat runs for a company") + .option("-C, --company-id <id>", "Company ID") + .option("--limit <n>", "Maximum runs to return") + .option("--min-count <n>", "Pad with recent completed runs up to this count") + .action(async (opts: RunLiveOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const params = new URLSearchParams(); + if (opts.limit) params.set("limit", opts.limit); + if (opts.minCount) params.set("minCount", opts.minCount); + const query = params.toString(); + const rows = (await ctx.api.get<HeartbeatRun[]>( + `${apiPath`/api/companies/${ctx.companyId}/live-runs`}${query ? `?${query}` : ""}`, + )) ?? []; + printRuns(rows, ctx.json); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + command + .command("get") + .description("Get a heartbeat run") + .argument("<runId>", "Heartbeat run ID") + .action(async (runId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const run = await ctx.api.get<HeartbeatRun>(apiPath`/api/heartbeat-runs/${runId}`); + printOutput(run, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + command + .command("cancel") + .description("Cancel a queued or running heartbeat run") + .argument("<runId>", "Heartbeat run ID") + .action(async (runId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const run = await ctx.api.post<HeartbeatRun | null>(apiPath`/api/heartbeat-runs/${runId}/cancel`, {}); + printOutput(run, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + command + .command("events") + .description("List heartbeat run events") + .argument("<runId>", "Heartbeat run ID") + .option("--after-seq <n>", "Only return events after this sequence", "0") + .option("--limit <n>", "Maximum events to return", "200") + .action(async (runId: string, opts: RunEventsOptions) => { + try { + const ctx = resolveCommandContext(opts); + const params = new URLSearchParams(); + if (opts.afterSeq) params.set("afterSeq", opts.afterSeq); + if (opts.limit) params.set("limit", opts.limit); + const events = (await ctx.api.get<HeartbeatRunEvent[]>( + `${apiPath`/api/heartbeat-runs/${runId}/events`}?${params.toString()}`, + )) ?? []; + if (ctx.json) { + printOutput(events, { json: true }); + return; + } + for (const event of events) { + console.log(formatInlineRecord({ + seq: event.seq, + eventType: event.eventType, + stream: event.stream, + level: event.level, + message: event.message, + })); + } + if (events.length === 0) printOutput([], { json: false }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + command + .command("log") + .description("Read heartbeat run log bytes") + .argument("<runId>", "Heartbeat run ID") + .option("--offset <bytes>", "Byte offset", "0") + .option("--limit-bytes <bytes>", "Maximum bytes to read") + .option("--text", "Print only the log text when the API returns a text field") + .action(async (runId: string, opts: RunLogOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await fetchLog(ctx.api, apiPath`/api/heartbeat-runs/${runId}/log`, opts); + printLogResult(result, { json: ctx.json, text: opts.text }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + command + .command("issues") + .description("List issues associated with a heartbeat run") + .argument("<runId>", "Heartbeat run ID") + .action(async (runId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const rows = (await ctx.api.get<RunIssueSummary[]>(apiPath`/api/heartbeat-runs/${runId}/issues`)) ?? []; + printOutput(rows.map((row) => ({ + identifier: row.identifier, + id: row.id, + status: row.status, + priority: row.priority, + title: row.title, + runStatus: row.runStatus, + })), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + command + .command("workspace-operations") + .description("List workspace operations for a heartbeat run") + .argument("<runId>", "Heartbeat run ID") + .action(async (runId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const rows = (await ctx.api.get<WorkspaceOperation[]>( + apiPath`/api/heartbeat-runs/${runId}/workspace-operations`, + )) ?? []; + printOutput(rows.map((row) => ({ + id: row.id, + status: row.status, + phase: row.phase, + command: row.command, + cwd: row.cwd, + logBytes: row.logBytes, + })), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + command + .command("workspace-log") + .description("Read a workspace operation log") + .argument("<operationId>", "Workspace operation ID") + .option("--offset <bytes>", "Byte offset", "0") + .option("--limit-bytes <bytes>", "Maximum bytes to read") + .option("--text", "Print only the log text when the API returns a text field") + .action(async (operationId: string, opts: RunLogOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await fetchLog(ctx.api, apiPath`/api/workspace-operations/${operationId}/log`, opts); + printLogResult(result, { json: ctx.json, text: opts.text }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + command + .command("watchdog-decision") + .description("Record a watchdog decision for a heartbeat run") + .argument("<runId>", "Heartbeat run ID") + .requiredOption("--decision <decision>", "snooze, continue, or dismissed_false_positive") + .option("--reason <text>", "Decision reason") + .option("--snoozed-until <iso8601>", "Required for snooze decisions") + .option("--evaluation-issue-id <id>", "Related watchdog evaluation issue ID") + .action(async (runId: string, opts: RunWatchdogOptions) => { + try { + const ctx = resolveCommandContext(opts); + const decision = await ctx.api.post(apiPath`/api/heartbeat-runs/${runId}/watchdog-decisions`, { + decision: opts.decision, + reason: opts.reason, + snoozedUntil: opts.snoozedUntil, + evaluationIssueId: opts.evaluationIssueId, + }); + printOutput(decision, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +async function fetchLog( + api: { get<T>(path: string): Promise<T | null> }, + path: string, + opts: RunLogOptions, +): Promise<unknown> { + const params = new URLSearchParams(); + if (opts.offset) params.set("offset", opts.offset); + if (opts.limitBytes) params.set("limitBytes", opts.limitBytes); + return api.get(`${path}?${params.toString()}`); +} + +function printRuns(rows: HeartbeatRun[], json: boolean): void { + if (json) { + printOutput(rows, { json: true }); + return; + } + for (const row of rows) { + console.log(formatInlineRecord({ + id: row.id, + status: row.status, + agentId: row.agentId, + invocationSource: row.invocationSource, + triggerDetail: row.triggerDetail, + startedAt: row.startedAt, + finishedAt: row.finishedAt, + logBytes: row.logBytes, + })); + } + if (rows.length === 0) printOutput([], { json: false }); +} + +function printLogResult(result: unknown, opts: { json: boolean; text?: boolean }): void { + if (opts.json) { + printOutput(result, { json: true }); + return; + } + + if (opts.text && typeof result === "object" && result !== null && "text" in result) { + const text = (result as { text?: unknown }).text; + process.stdout.write(typeof text === "string" ? text : String(text ?? "")); + return; + } + + printOutput(result, { json: false }); +} diff --git a/cli/src/commands/client/secrets.ts b/cli/src/commands/client/secrets.ts index 98fb025d..d9dc894b 100644 --- a/cli/src/commands/client/secrets.ts +++ b/cli/src/commands/client/secrets.ts @@ -13,6 +13,7 @@ import type { } from "@paperclipai/shared"; import { addCommonClientOptions, + apiPath, formatInlineRecord, handleCommandError, printOutput, @@ -40,6 +41,20 @@ interface SecretCreateOptions extends BaseClientOptions { description?: string; } +interface SecretUpdateOptions extends BaseClientOptions { + payloadJson?: string; +} + +interface SecretRotateOptions extends BaseClientOptions { + value?: string; + valueEnv?: string; +} + +interface SecretDeleteOptions extends BaseClientOptions { + yes?: boolean; + confirm?: string; +} + interface SecretLinkOptions extends BaseClientOptions { companyId?: string; name?: string; @@ -59,6 +74,11 @@ interface SecretMigrateInlineEnvOptions extends BaseClientOptions { apply?: boolean; } +interface SecretJsonOptions extends BaseClientOptions { + companyId?: string; + payloadJson?: string; +} + interface SecretProviderHealth { provider: SecretProvider; status: "ok" | "warn" | "error"; @@ -171,7 +191,7 @@ function asRecord(value: unknown): Record<string, unknown> | null { return value as Record<string, unknown>; } -function readValueFromOptions(opts: SecretCreateOptions): string { +function readValueFromOptions(opts: { value?: string; valueEnv?: string }): string { if (opts.value !== undefined && opts.valueEnv !== undefined) { throw new Error("Use only one of --value or --value-env."); } @@ -263,8 +283,8 @@ function asStringArray(value: unknown): string[] { async function migrateInlineEnv(opts: SecretMigrateInlineEnvOptions): Promise<void> { const ctx = resolveCommandContext(opts, { requireCompany: true }); const companyId = ctx.companyId!; - const agents = (await ctx.api.get<Agent[]>(`/api/companies/${companyId}/agents`)) ?? []; - const secrets = (await ctx.api.get<CompanySecret[]>(`/api/companies/${companyId}/secrets`)) ?? []; + const agents = (await ctx.api.get<Agent[]>(apiPath`/api/companies/${companyId}/agents`)) ?? []; + const secrets = (await ctx.api.get<CompanySecret[]>(apiPath`/api/companies/${companyId}/secrets`)) ?? []; const candidates = collectInlineSecretMigrationCandidates(agents, secrets); if (!opts.apply) { @@ -295,13 +315,13 @@ async function migrateInlineEnv(opts: SecretMigrateInlineEnvOptions): Promise<vo if (!value) continue; if (candidate.existingSecretId) { - await ctx.api.post(`/api/secrets/${candidate.existingSecretId}/rotate`, { value }); + await ctx.api.post(apiPath`/api/secrets/${candidate.existingSecretId}/rotate`, { value }); createdOrRotated.set(`${candidate.agentId}:${candidate.envKey}`, candidate.existingSecretId); rotatedSecrets += 1; continue; } - const created = await ctx.api.post<CompanySecret>(`/api/companies/${companyId}/secrets`, { + const created = await ctx.api.post<CompanySecret>(apiPath`/api/companies/${companyId}/secrets`, { name: candidate.secretName, provider: "local_encrypted", value, @@ -326,7 +346,7 @@ async function migrateInlineEnv(opts: SecretMigrateInlineEnvOptions): Promise<vo ...agent.adapterConfig, env: buildMigratedAgentEnv(env, secretIdByEnvKey), }; - await ctx.api.patch(`/api/agents/${agent.id}`, { + await ctx.api.patch(apiPath`/api/agents/${agent.id}`, { adapterConfig, replaceAdapterConfig: true, }); @@ -355,7 +375,7 @@ export function registerSecretCommands(program: Command): void { .action(async (opts: SecretListOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); - const rows = (await ctx.api.get<CompanySecret[]>(`/api/companies/${ctx.companyId}/secrets`)) ?? []; + const rows = (await ctx.api.get<CompanySecret[]>(apiPath`/api/companies/${ctx.companyId}/secrets`)) ?? []; printOutput(ctx.json ? rows : rows.map(renderSecret), { json: ctx.json }); } catch (err) { handleCommandError(err); @@ -378,7 +398,7 @@ export function registerSecretCommands(program: Command): void { throw new Error("Invalid --kind value. Use: all, secret, plain"); } const preview = await ctx.api.post<CompanyPortabilityExportPreviewResult>( - `/api/companies/${ctx.companyId}/exports/preview`, + apiPath`/api/companies/${ctx.companyId}/exports/preview`, { include: parseSecretsInclude(opts.include) }, ); const declarations = (preview?.manifest.envInputs ?? []) @@ -404,7 +424,7 @@ export function registerSecretCommands(program: Command): void { .action(async (opts: SecretCreateOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); - const created = await ctx.api.post<CompanySecret>(`/api/companies/${ctx.companyId}/secrets`, { + const created = await ctx.api.post<CompanySecret>(apiPath`/api/companies/${ctx.companyId}/secrets`, { name: opts.name, key: opts.key, provider: opts.provider, @@ -432,7 +452,7 @@ export function registerSecretCommands(program: Command): void { .action(async (opts: SecretLinkOptions) => { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); - const created = await ctx.api.post<CompanySecret>(`/api/companies/${ctx.companyId}/secrets`, { + const created = await ctx.api.post<CompanySecret>(apiPath`/api/companies/${ctx.companyId}/secrets`, { name: opts.name, key: opts.key, provider: opts.provider, @@ -448,6 +468,90 @@ export function registerSecretCommands(program: Command): void { }), ); + addCommonClientOptions( + secrets + .command("update") + .description("Update secret metadata") + .argument("<secretId>", "Secret ID") + .requiredOption("--payload-json <json>", "UpdateSecret JSON payload") + .action(async (secretId: string, opts: SecretUpdateOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.patch(apiPath`/api/secrets/${secretId}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + secrets + .command("rotate") + .description("Rotate a Paperclip-managed secret value") + .argument("<secretId>", "Secret ID") + .option("--value <value>", "New secret value") + .option("--value-env <name>", "Read new secret value from an environment variable") + .action(async (secretId: string, opts: SecretRotateOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(apiPath`/api/secrets/${secretId}/rotate`, { value: readValueFromOptions(opts) }), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + secrets + .command("usage") + .description("Show where a secret is referenced") + .argument("<secretId>", "Secret ID") + .action(async (secretId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(apiPath`/api/secrets/${secretId}/usage`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + secrets + .command("access-events") + .description("List secret access events") + .argument("<secretId>", "Secret ID") + .action(async (secretId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(apiPath`/api/secrets/${secretId}/access-events`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + secrets + .command("delete") + .description("Delete a secret") + .argument("<secretId>", "Secret ID") + .option("--yes", "Required safety flag to confirm destructive action", false) + .option("--confirm <secretId>", "Repeat the secret ID to confirm deletion") + .action(async (secretId: string, opts: SecretDeleteOptions) => { + try { + if (!opts.yes) throw new Error("Deletion requires --yes."); + if (opts.confirm !== secretId) { + throw new Error("Deletion requires --confirm <secretId> matching the secret ID."); + } + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.delete(apiPath`/api/secrets/${secretId}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( secrets .command("doctor") @@ -457,7 +561,7 @@ export function registerSecretCommands(program: Command): void { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); const health = await ctx.api.get<SecretProviderHealthResponse>( - `/api/companies/${ctx.companyId}/secret-providers/health`, + apiPath`/api/companies/${ctx.companyId}/secret-providers/health`, ); printProviderHealth(health?.providers ?? [], ctx.json); } catch (err) { @@ -475,7 +579,7 @@ export function registerSecretCommands(program: Command): void { try { const ctx = resolveCommandContext(opts, { requireCompany: true }); const rows = (await ctx.api.get<SecretProviderDescriptor[]>( - `/api/companies/${ctx.companyId}/secret-providers`, + apiPath`/api/companies/${ctx.companyId}/secret-providers`, )) ?? []; printOutput(rows, { json: ctx.json }); } catch (err) { @@ -484,6 +588,36 @@ export function registerSecretCommands(program: Command): void { }), ); + addCommonClientOptions( + secrets + .command("provider-configs") + .description("List company secret provider vault configs") + .requiredOption("-C, --company-id <id>", "Company ID") + .action(async (opts: SecretDoctorOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.get(apiPath`/api/companies/${ctx.companyId}/secret-provider-configs`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCompanySecretJsonPost(secrets, "provider-config:create", "Create a secret provider vault config", "secret-provider-configs"); + addCompanySecretJsonPost( + secrets, + "provider-config:discovery-preview", + "Preview provider vault secret discovery", + "secret-provider-configs/discovery/preview", + ); + addSecretProviderConfigGet(secrets, "provider-config:get", "Get a secret provider vault config", ""); + addSecretProviderConfigPatch(secrets, "provider-config:update", "Update a secret provider vault config", ""); + addSecretProviderConfigPost(secrets, "provider-config:default", "Set the default provider vault config", "default"); + addSecretProviderConfigPost(secrets, "provider-config:health", "Check provider vault health", "health"); + addSecretProviderConfigDelete(secrets, "provider-config:delete", "Delete a secret provider vault config"); + addCompanySecretJsonPost(secrets, "remote-import:preview", "Preview remote secret import", "secrets/remote-import/preview"); + addCompanySecretJsonPost(secrets, "remote-import", "Import selected remote secrets", "secrets/remote-import"); + addCommonClientOptions( secrets .command("migrate-inline-env") @@ -499,3 +633,94 @@ export function registerSecretCommands(program: Command): void { }), ); } + +function addCompanySecretJsonPost(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .requiredOption("-C, --company-id <id>", "Company ID") + .requiredOption("--payload-json <json>", "JSON payload") + .action(async (opts: SecretJsonOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addSecretProviderConfigGet(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<configId>", "Provider config ID") + .action(async (configId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.get(`${apiPath`/api/secret-provider-configs/${configId}`}${suffix ? `/${suffix}` : ""}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addSecretProviderConfigPatch(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<configId>", "Provider config ID") + .requiredOption("--payload-json <json>", "JSON payload") + .action(async (configId: string, opts: SecretJsonOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.patch(`${apiPath`/api/secret-provider-configs/${configId}`}${suffix ? `/${suffix}` : ""}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addSecretProviderConfigPost(parent: Command, name: string, description: string, suffix: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<configId>", "Provider config ID") + .action(async (configId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.post(`${apiPath`/api/secret-provider-configs/${configId}`}/${suffix}`, {}), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addSecretProviderConfigDelete(parent: Command, name: string, description: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<configId>", "Provider config ID") + .action(async (configId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + printOutput(await ctx.api.delete(apiPath`/api/secret-provider-configs/${configId}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function parseJson(value: string): unknown { + return JSON.parse(value) as unknown; +} diff --git a/cli/src/commands/client/skill.ts b/cli/src/commands/client/skill.ts new file mode 100644 index 00000000..18a4dfa2 --- /dev/null +++ b/cli/src/commands/client/skill.ts @@ -0,0 +1,150 @@ +import { Command } from "commander"; +import { + addCommonClientOptions, + apiPath, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface SkillOptions extends BaseClientOptions { + companyId?: string; + payloadJson?: string; + path?: string; +} + +export function registerSkillCommands(program: Command): void { + const skill = program.command("skill").description("Company skill operations"); + + addCompanyGet(skill, "list", "List company skills", "skills"); + + addCommonClientOptions( + skill + .command("get") + .description("Get company skill details") + .argument("<skillId>", "Skill ID") + .option("-C, --company-id <id>", "Company ID") + .action(async (skillId: string, opts: SkillOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.get(apiPath`/api/companies/${ctx.companyId}/skills/${skillId}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + skill + .command("file") + .description("Read a company skill file") + .argument("<skillId>", "Skill ID") + .option("-C, --company-id <id>", "Company ID") + .option("--path <path>", "Skill-relative file path", "SKILL.md") + .action(async (skillId: string, opts: SkillOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const query = new URLSearchParams({ path: opts.path ?? "SKILL.md" }); + printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}/skills/${skillId}/files`}?${query.toString()}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCompanyPost(skill, "create", "Create a local company skill", "skills", true); + addCompanyPost(skill, "import", "Import company skills from a source", "skills/import", true); + addCompanyPost(skill, "scan-projects", "Scan project workspaces for company skills", "skills/scan-projects", true); + + addCommonClientOptions( + skill + .command("file:update") + .description("Update a company skill file") + .argument("<skillId>", "Skill ID") + .option("-C, --company-id <id>", "Company ID") + .requiredOption("--payload-json <json>", "CompanySkillFileUpdate JSON payload") + .action(async (skillId: string, opts: SkillOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput( + await ctx.api.patch(apiPath`/api/companies/${ctx.companyId}/skills/${skillId}/files`, parseJson(opts.payloadJson ?? "{}")), + { json: ctx.json }, + ); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addSkillAction(skill, "update-status", "Get company skill update status", "update-status", "GET"); + addSkillAction(skill, "install-update", "Install available company skill update", "install-update", "POST"); + addSkillAction(skill, "delete", "Delete a company skill", "", "DELETE"); +} + +function addCompanyGet(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions( + parent.command(name).description(description).option("-C, --company-id <id>", "Company ID").action(async (opts: SkillOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addCompanyPost(parent: Command, name: string, description: string, path: string, requirePayload = false): void { + const command = parent.command(name).description(description).option("-C, --company-id <id>", "Company ID"); + if (requirePayload) { + command.requiredOption("--payload-json <json>", "JSON payload"); + } else { + command.option("--payload-json <json>", "JSON payload", "{}"); + } + addCommonClientOptions( + command.action(async (opts: SkillOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addSkillAction(parent: Command, name: string, description: string, suffix: string, method: "GET" | "POST" | "DELETE"): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<skillId>", "Skill ID") + .option("-C, --company-id <id>", "Company ID") + .action(async (skillId: string, opts: SkillOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const path = `${apiPath`/api/companies/${ctx.companyId}/skills/${skillId}`}${suffix ? `/${suffix}` : ""}`; + const result = + method === "GET" + ? await ctx.api.get(path) + : method === "POST" + ? await ctx.api.post(path, {}) + : await ctx.api.delete(path); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function parseJson(value: string): unknown { + return JSON.parse(value) as unknown; +} diff --git a/cli/src/commands/client/token.ts b/cli/src/commands/client/token.ts new file mode 100644 index 00000000..92472a3a --- /dev/null +++ b/cli/src/commands/client/token.ts @@ -0,0 +1,244 @@ +import { Command } from "commander"; +import { createAgentKeySchema, createBoardApiKeySchema, type Agent } from "@paperclipai/shared"; +import { + addCommonClientOptions, + apiPath, + formatInlineRecord, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface AgentTokenOptions extends BaseClientOptions { + companyId?: string; + agent?: string; + name?: string; +} + +interface BoardTokenOptions extends BaseClientOptions { + companyId?: string; + name?: string; + expiresAt?: string; + ttlDays?: string; + neverExpires?: boolean; +} + +interface CreatedAgentKey { + id: string; + name: string; + token: string; + createdAt: string; +} + +interface AgentKeyRow { + id: string; + name: string; + createdAt: string; + lastUsedAt?: string | null; + revokedAt?: string | null; +} + +interface CreatedBoardKey { + id: string; + name: string; + token: string; + createdAt: string; + lastUsedAt: string | null; + revokedAt: string | null; + expiresAt: string | null; +} + +interface BoardKeyRow { + id: string; + name: string; + createdAt: string; + lastUsedAt: string | null; + revokedAt: string | null; + expiresAt: string | null; +} + +export function registerTokenCommands(program: Command): void { + const token = program.command("token").description("Manage Paperclip API tokens"); + const agent = token.command("agent").description("Manage agent API keys"); + + addCommonClientOptions( + agent + .command("create") + .description("Create an agent API key") + .requiredOption("-C, --company-id <id>", "Company ID") + .requiredOption("--agent <agent>", "Agent ID, shortname, or unambiguous name") + .option("--name <name>", "API key label", "cli-agent") + .action(async (opts: AgentTokenOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const agentRow = await resolveAgent(ctx.api, ctx.companyId ?? "", opts.agent ?? ""); + const payload = createAgentKeySchema.parse({ name: opts.name }); + const key = await ctx.api.post<CreatedAgentKey>(apiPath`/api/agents/${agentRow.id}/keys`, payload); + if (!key) throw new Error("Failed to create agent API key"); + printOutput( + { + agentId: agentRow.id, + agentName: agentRow.name, + companyId: agentRow.companyId, + key, + }, + { json: ctx.json }, + ); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + agent + .command("list") + .description("List agent API keys") + .requiredOption("-C, --company-id <id>", "Company ID") + .requiredOption("--agent <agent>", "Agent ID, shortname, or unambiguous name") + .action(async (opts: AgentTokenOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const agentRow = await resolveAgent(ctx.api, ctx.companyId ?? "", opts.agent ?? ""); + const keys = (await ctx.api.get<AgentKeyRow[]>(apiPath`/api/agents/${agentRow.id}/keys`)) ?? []; + if (ctx.json) { + printOutput({ agentId: agentRow.id, companyId: agentRow.companyId, keys }, { json: true }); + return; + } + for (const key of keys) { + console.log(formatInlineRecord({ id: key.id, name: key.name, createdAt: key.createdAt, revokedAt: key.revokedAt ?? null })); + } + if (keys.length === 0) printOutput([], { json: false }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + agent + .command("revoke") + .description("Revoke an agent API key") + .argument("<keyId>", "Agent API key ID") + .requiredOption("-C, --company-id <id>", "Company ID") + .requiredOption("--agent <agent>", "Agent ID, shortname, or unambiguous name") + .action(async (keyId: string, opts: AgentTokenOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const agentRow = await resolveAgent(ctx.api, ctx.companyId ?? "", opts.agent ?? ""); + const result = await ctx.api.delete<{ ok: true; keyId?: string }>(apiPath`/api/agents/${agentRow.id}/keys/${keyId}`); + printOutput({ ok: true, agentId: agentRow.id, companyId: agentRow.companyId, ...(result ?? {}) }, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + const board = token.command("board").description("Manage board API keys"); + + addCommonClientOptions( + board + .command("create") + .description("Create a named board API key") + .option("-C, --company-id <id>", "Company ID used for audit context") + .option("--name <name>", "API key label", "cli-board") + .option("--expires-at <iso8601>", "Expiration timestamp") + .option("--ttl-days <days>", "Expiration in days from now") + .option("--never-expires", "Create a non-expiring key") + .action(async (opts: BoardTokenOptions) => { + try { + const ctx = resolveCommandContext(opts); + const expiresAt = resolveBoardKeyExpiresAt(opts); + const payload = createBoardApiKeySchema.parse({ + name: opts.name, + requestedCompanyId: opts.companyId ?? ctx.companyId ?? null, + expiresAt, + }); + const key = await ctx.api.post<CreatedBoardKey>("/api/board-api-keys", payload); + if (!key) throw new Error("Failed to create board API key"); + printOutput({ key }, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + board + .command("list") + .description("List board API keys for the current board user") + .action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const keys = (await ctx.api.get<BoardKeyRow[]>("/api/board-api-keys")) ?? []; + if (ctx.json) { + printOutput(keys, { json: true }); + return; + } + for (const key of keys) { + console.log(formatInlineRecord({ + id: key.id, + name: key.name, + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + expiresAt: key.expiresAt, + revokedAt: key.revokedAt, + })); + } + if (keys.length === 0) printOutput([], { json: false }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + board + .command("revoke") + .description("Revoke a board API key") + .argument("<keyId>", "Board API key ID") + .action(async (keyId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.delete<{ ok: true; keyId: string }>(apiPath`/api/board-api-keys/${keyId}`); + printOutput(result ?? { ok: true, keyId }, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +async function resolveAgent(api: { get<T>(path: string): Promise<T | null> }, companyId: string, agentRef: string): Promise<Agent> { + const trimmed = agentRef.trim(); + if (!trimmed) throw new Error("Agent reference is required"); + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(trimmed)) { + const agent = await api.get<Agent>(apiPath`/api/agents/${trimmed}`); + if (!agent || agent.companyId !== companyId) throw new Error(`Agent not found: ${agentRef}`); + return agent; + } + const query = new URLSearchParams({ companyId }); + const agent = await api.get<Agent>(`${apiPath`/api/agents/${trimmed}`}?${query.toString()}`); + if (!agent || agent.companyId !== companyId) throw new Error(`Agent not found: ${agentRef}`); + return agent; +} + +function resolveBoardKeyExpiresAt(opts: BoardTokenOptions): Date | null | undefined { + if (opts.neverExpires) return null; + if (opts.expiresAt?.trim()) { + const date = new Date(opts.expiresAt.trim()); + if (!Number.isFinite(date.getTime())) throw new Error(`Invalid --expires-at value: ${opts.expiresAt}`); + return date; + } + if (opts.ttlDays?.trim()) { + const days = Number(opts.ttlDays); + if (!Number.isFinite(days) || days <= 0) throw new Error(`Invalid --ttl-days value: ${opts.ttlDays}`); + return new Date(Date.now() + Math.floor(days * 24 * 60 * 60 * 1000)); + } + return undefined; +} diff --git a/cli/src/commands/client/workspace.ts b/cli/src/commands/client/workspace.ts new file mode 100644 index 00000000..4bbaf3b5 --- /dev/null +++ b/cli/src/commands/client/workspace.ts @@ -0,0 +1,327 @@ +import { Command } from "commander"; +import { + addCommonClientOptions, + apiPath, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface CompanyOptions extends BaseClientOptions { + companyId?: string; +} + +interface JsonPayloadOptions extends CompanyOptions { + payloadJson: string; +} + +interface RuntimeActionOptions extends BaseClientOptions { + payloadJson?: string; +} + +interface OrgOutputOptions extends CompanyOptions { + out?: string; +} + +export function registerWorkspaceCommands(program: Command): void { + const org = program.command("org").description("Organization chart operations"); + addCompanyGet(org, "get", "Get org chart data", "org"); + addBinaryCompanyGet(org, "svg", "Download org chart SVG", "org.svg"); + addBinaryCompanyGet(org, "png", "Download org chart PNG", "org.png"); + addCompanyGet(program.command("agent-config").description("Agent configuration summaries"), "list", "List agent configurations", "agent-configurations"); + + const workspace = program.command("workspace").description("Execution workspace operations"); + addCompanyGet(workspace, "list", "List execution workspaces", "execution-workspaces"); + addIdGet(workspace, "get", "Get an execution workspace", "execution-workspaces"); + addIdGet(workspace, "close-readiness", "Check execution workspace close readiness", "execution-workspaces", "close-readiness"); + addIdGet(workspace, "operations", "List execution workspace operations", "execution-workspaces", "workspace-operations"); + addPatchJson(workspace, "update", "Update an execution workspace", "execution-workspaces"); + addRuntimeAction(workspace, "runtime-service", "Control an execution workspace runtime service", "execution-workspaces", "runtime-services"); + addRuntimeAction(workspace, "runtime-command", "Run an execution workspace runtime command", "execution-workspaces", "runtime-commands"); + + const environment = program.command("environment").description("Environment operations"); + addCompanyGet(environment, "list", "List environments", "environments"); + addCompanyGet(environment, "capabilities", "Get environment capabilities", "environments/capabilities"); + addCompanyPostJson(environment, "create", "Create an environment", "environments"); + addIdGet(environment, "get", "Get an environment", "environments"); + addIdGet(environment, "leases", "List environment leases", "environments", "leases"); + addCommonClientOptions( + environment + .command("lease") + .description("Get an environment lease") + .argument("<leaseId>", "Lease ID") + .action(async (leaseId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get(apiPath`/api/environment-leases/${leaseId}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addPatchJson(environment, "update", "Update an environment", "environments"); + addDelete(environment, "delete", "Delete an environment", "environments"); + addPostEmpty(environment, "probe", "Probe an environment", "environments", "probe"); + addCompanyPostJson(environment, "probe-config", "Probe an environment config", "environments/probe-config"); + + const projectWorkspace = program.command("project-workspace").description("Project workspace operations"); + addCommonClientOptions( + projectWorkspace + .command("list") + .description("List project workspaces") + .argument("<projectId>", "Project ID") + .action(async (projectId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get(apiPath`/api/projects/${projectId}/workspaces`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addProjectWorkspaceJson(projectWorkspace, "create", "Create a project workspace", "post"); + addProjectWorkspaceJson(projectWorkspace, "update", "Update a project workspace", "patch"); + addCommonClientOptions( + projectWorkspace + .command("delete") + .description("Delete a project workspace") + .argument("<projectId>", "Project ID") + .argument("<workspaceId>", "Workspace ID") + .action(async (projectId: string, workspaceId: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.delete(apiPath`/api/projects/${projectId}/workspaces/${workspaceId}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + addProjectRuntimeAction(projectWorkspace, "runtime-service", "Control a project workspace runtime service", "runtime-services"); + addProjectRuntimeAction(projectWorkspace, "runtime-command", "Run a project workspace runtime command", "runtime-commands"); +} + +function addCompanyGet(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .option("-C, --company-id <id>", "Company ID") + .action(async (opts: CompanyOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const result = await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addBinaryCompanyGet(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .option("-C, --company-id <id>", "Company ID") + .option("--out <path>", "Write output to file") + .action(async (opts: OrgOutputOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const response = await fetch(buildApiUrl(ctx.api.apiBase, `${apiPath`/api/companies/${ctx.companyId}`}/${path}`), { + headers: ctx.api.apiKey ? { authorization: `Bearer ${ctx.api.apiKey}` } : undefined, + }); + const bytes = Buffer.from(await response.arrayBuffer()); + if (!response.ok) throw new Error(`API error ${response.status}: ${bytes.toString("utf8")}`); + if (opts.out) { + const { writeFile } = await import("node:fs/promises"); + await writeFile(opts.out, bytes); + printOutput({ out: opts.out, bytes: bytes.byteLength }, { json: ctx.json }); + return; + } + process.stdout.write(bytes); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addCompanyPostJson(parent: Command, name: string, description: string, path: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .option("-C, --company-id <id>", "Company ID") + .requiredOption("--payload-json <json>", "JSON payload") + .action(async (opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const result = await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson)); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); +} + +function addIdGet(parent: Command, name: string, description: string, resource: string, suffix?: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<id>", "ID") + .action(async (id: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get(`/api/${resource}/${encodeURIComponent(id)}${suffix ? `/${suffix}` : ""}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addPatchJson(parent: Command, name: string, description: string, resource: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<id>", "ID") + .requiredOption("--payload-json <json>", "JSON payload") + .action(async (id: string, opts: JsonPayloadOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.patch(`/api/${resource}/${encodeURIComponent(id)}`, parseJson(opts.payloadJson)); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addDelete(parent: Command, name: string, description: string, resource: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<id>", "ID") + .action(async (id: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.delete(`/api/${resource}/${encodeURIComponent(id)}`); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addPostEmpty(parent: Command, name: string, description: string, resource: string, suffix: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<id>", "ID") + .action(async (id: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.post(`/api/${resource}/${encodeURIComponent(id)}/${suffix}`, {}); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addRuntimeAction(parent: Command, name: string, description: string, resource: string, actionResource: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<id>", "Workspace ID") + .argument("<action>", "start, stop, restart, or run") + .option("--payload-json <json>", "Runtime target JSON payload", "{}") + .action(async (id: string, action: string, opts: RuntimeActionOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.post(`/api/${resource}/${encodeURIComponent(id)}/${actionResource}/${encodeURIComponent(action)}`, parseJson(opts.payloadJson ?? "{}")); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addProjectWorkspaceJson(parent: Command, name: string, description: string, method: "post" | "patch"): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<projectId>", "Project ID") + .argument("[workspaceId]", "Workspace ID for update") + .requiredOption("--payload-json <json>", "JSON payload") + .action(async (projectId: string, workspaceId: string | undefined, opts: JsonPayloadOptions) => { + try { + if (method === "patch" && !workspaceId) throw new Error("workspaceId is required for update"); + const ctx = resolveCommandContext(opts); + const path = method === "post" + ? apiPath`/api/projects/${projectId}/workspaces` + : apiPath`/api/projects/${projectId}/workspaces/${workspaceId}`; + const result = method === "post" + ? await ctx.api.post(path, parseJson(opts.payloadJson)) + : await ctx.api.patch(path, parseJson(opts.payloadJson)); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function addProjectRuntimeAction(parent: Command, name: string, description: string, actionResource: string): void { + addCommonClientOptions( + parent + .command(name) + .description(description) + .argument("<projectId>", "Project ID") + .argument("<workspaceId>", "Workspace ID") + .argument("<action>", "start, stop, restart, or run") + .option("--payload-json <json>", "Runtime target JSON payload", "{}") + .action(async (projectId: string, workspaceId: string, action: string, opts: RuntimeActionOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.post( + `${apiPath`/api/projects/${projectId}/workspaces/${workspaceId}`}/${actionResource}/${encodeURIComponent(action)}`, + parseJson(opts.payloadJson ?? "{}"), + ); + printOutput(result, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); +} + +function buildApiUrl(apiBase: string, path: string): string { + const url = new URL(apiBase); + url.pathname = `${url.pathname.replace(/\/+$/, "")}${path.startsWith("/") ? path : `/${path}`}`; + return url.toString(); +} + +function parseJson(value: string): unknown { + return JSON.parse(value) as unknown; +} diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index b7d2dcbc..7c9b391b 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -83,6 +83,7 @@ export async function configure(opts: { if (!configExists(opts.config)) { p.log.error("No config file found. Run `paperclipai onboard` first."); p.outro(""); + process.exitCode = 1; return; } @@ -103,6 +104,7 @@ export async function configure(opts: { if (section && !SECTION_LABELS[section]) { p.log.error(`Unknown section: ${section}. Choose from: ${Object.keys(SECTION_LABELS).join(", ")}`); p.outro(""); + process.exitCode = 1; return; } diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index db0ca008..a4c78cc0 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -1568,11 +1568,31 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt } } +type PnpmInstallInvocation = { + command: string; + argsPrefix: string[]; +}; + +export function resolvePnpmInstallInvocation( + env: NodeJS.ProcessEnv = process.env, + nodeExecPath = process.execPath, +): PnpmInstallInvocation { + const npmExecPath = nonEmpty(env.npm_execpath); + if (npmExecPath && npmExecPath.toLowerCase().includes("pnpm")) { + if (/\.(cjs|mjs|js)$/i.test(npmExecPath)) { + return { command: nodeExecPath, argsPrefix: [npmExecPath] }; + } + return { command: npmExecPath, argsPrefix: [] }; + } + return { command: "pnpm", argsPrefix: [] }; +} + function installDependenciesBestEffort(targetPath: string): void { const installSpinner = p.spinner(); installSpinner.start("Installing dependencies..."); + const pnpm = resolvePnpmInstallInvocation(); try { - execFileSync("pnpm", ["install"], { + execFileSync(pnpm.command, [...pnpm.argsPrefix, "install"], { cwd: targetPath, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/cli/src/index.ts b/cli/src/index.ts index cc113685..617da4d1 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -13,6 +13,8 @@ import { registerContextCommands } from "./commands/client/context.js"; import { registerCompanyCommands } from "./commands/client/company.js"; import { registerIssueCommands } from "./commands/client/issue.js"; import { registerAgentCommands } from "./commands/client/agent.js"; +import { registerProjectCommands } from "./commands/client/project.js"; +import { registerGoalCommands } from "./commands/client/goal.js"; import { registerApprovalCommands } from "./commands/client/approval.js"; import { registerActivityCommands } from "./commands/client/activity.js"; import { registerDashboardCommands } from "./commands/client/dashboard.js"; @@ -27,6 +29,17 @@ import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js"; import { registerWorktreeCommands } from "./commands/worktree.js"; import { registerPluginCommands } from "./commands/client/plugin.js"; import { registerClientAuthCommands } from "./commands/client/auth.js"; +import { registerConnectCommand } from "./commands/client/connect.js"; +import { registerTokenCommands } from "./commands/client/token.js"; +import { registerPromptCommands } from "./commands/client/prompt.js"; +import { registerRunCommands } from "./commands/client/run.js"; +import { registerCostCommands } from "./commands/client/cost.js"; +import { registerWorkspaceCommands } from "./commands/client/workspace.js"; +import { registerAccessCommands } from "./commands/client/access.js"; +import { registerRoutineApiCommands } from "./commands/client/routine-api.js"; +import { registerAdapterCommands } from "./commands/client/adapter.js"; +import { registerAssetCommands } from "./commands/client/asset.js"; +import { registerSkillCommands } from "./commands/client/skill.js"; import { cliVersion } from "./version.js"; const program = new Command(); @@ -107,7 +120,7 @@ program .option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP) .action(addAllowedHostname); -program +const run = program .command("run") .description("Bootstrap local setup (onboard + doctor) and run Paperclip") .option("-c, --config <path>", "Path to config file") @@ -118,6 +131,8 @@ program .option("--no-repair", "Disable automatic repairs during doctor") .action(runCommand); +registerRunCommands(run); + const heartbeat = program.command("heartbeat").description("Heartbeat utilities"); heartbeat @@ -142,12 +157,24 @@ heartbeat .action(heartbeatRun); registerContextCommands(program); +registerConnectCommand(program); registerCompanyCommands(program); registerIssueCommands(program); registerAgentCommands(program); +registerProjectCommands(program); +registerGoalCommands(program); +registerTokenCommands(program); +registerPromptCommands(program); registerApprovalCommands(program); registerActivityCommands(program); registerDashboardCommands(program); +registerCostCommands(program); +registerWorkspaceCommands(program); +registerAccessCommands(program); +registerRoutineApiCommands(program); +registerAdapterCommands(program); +registerAssetCommands(program); +registerSkillCommands(program); registerRoutineCommands(program); registerFeedbackCommands(program); registerSecretCommands(program); diff --git a/doc/CLI.md b/doc/CLI.md index 56e76520..200ba36b 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -65,6 +65,29 @@ All client commands support: Company-scoped commands also support `--company-id <id>`. +API base resolution order: + +1. `--api-base <url>` +2. `PAPERCLIP_API_URL` +3. selected context profile `apiBase` +4. local Paperclip config server port +5. `http://localhost:3100` + +Connection failures include the attempted URL and a `GET /api/health` check hint. + +## Connect Wizard + +```sh +pnpm paperclipai connect +``` + +`connect` confirms the resolved API base, verifies `GET /api/health`, authenticates board access when needed, and saves a persona-aware profile: + +- `persona=board` for board operator profiles +- `persona=agent` with `agentId` and `agentName` for agent profiles + +Profiles store token env-var names, not plaintext tokens. The wizard prints shell exports for the newly created token. + Use `--data-dir` on any CLI command to isolate all default local state (config/context/db/logs/storage/secrets) away from `~/.paperclip`: ```sh @@ -78,6 +101,7 @@ Store local defaults in `~/.paperclip/context.json`: ```sh pnpm paperclipai context set --api-base http://localhost:3100 --company-id <company-id> +pnpm paperclipai context set --persona agent --agent-id <agent-id> --api-key-env-var-name PAPERCLIP_API_KEY pnpm paperclipai context show pnpm paperclipai context list pnpm paperclipai context use default @@ -95,6 +119,17 @@ export PAPERCLIP_API_KEY=... ```sh pnpm paperclipai company list pnpm paperclipai company get <company-id> +pnpm paperclipai company stats +pnpm paperclipai company create --payload-json '{...}' +pnpm paperclipai company update <company-id> --payload-json '{...}' +pnpm paperclipai company branding:update <company-id> --payload-json '{...}' +pnpm paperclipai company archive <company-id> +pnpm paperclipai company export <company-id> --out ./company --include company,agents,projects,issues,skills +pnpm paperclipai company export:preview <company-id> --payload-json '{...}' +pnpm paperclipai company export:api <company-id> --payload-json '{...}' +pnpm paperclipai company import ./company --target new --new-company-name "Imported Company" +pnpm paperclipai company import:preview <company-id> --payload-json '{...}' +pnpm paperclipai company import:apply <company-id> --payload-json '{...}' pnpm paperclipai company delete <company-id-or-prefix> --yes --confirm <same-id-or-prefix> ``` @@ -117,9 +152,102 @@ pnpm paperclipai issue list --company-id <company-id> [--status todo,in_progress pnpm paperclipai issue get <issue-id-or-identifier> pnpm paperclipai issue create --company-id <company-id> --title "..." [--description "..."] [--status todo] [--priority high] pnpm paperclipai issue update <issue-id> [--status in_progress] [--comment "..."] +pnpm paperclipai issue delete <issue-id> --yes pnpm paperclipai issue comment <issue-id> --body "..." [--reopen] +pnpm paperclipai issue comments <issue-id> [--limit 50] +pnpm paperclipai issue comment:get <issue-id> <comment-id> +pnpm paperclipai issue comment:delete <issue-id> <comment-id> +pnpm paperclipai issue runs <issue-id-or-identifier> +pnpm paperclipai issue live-runs <issue-id-or-identifier> +pnpm paperclipai issue active-run <issue-id-or-identifier> +pnpm paperclipai issue heartbeat-context <issue-id> pnpm paperclipai issue checkout <issue-id> --agent-id <agent-id> [--expected-statuses todo,backlog,blocked] pnpm paperclipai issue release <issue-id> +pnpm paperclipai issue force-release <issue-id> +``` + +Issue subresources are exposed as Paperclip API wrappers. Commands that map to broad server schemas accept JSON payloads and validate them with shared schemas before sending. + +```sh +pnpm paperclipai issue child:create <issue-id> --payload-json '{"title":"Child task"}' +pnpm paperclipai issue approvals <issue-id> +pnpm paperclipai issue approval:link <issue-id> <approval-id> +pnpm paperclipai issue approval:unlink <issue-id> <approval-id> +pnpm paperclipai issue read <issue-id> +pnpm paperclipai issue unread <issue-id> +pnpm paperclipai issue archive <issue-id> +pnpm paperclipai issue unarchive <issue-id> +pnpm paperclipai issue recovery-actions <issue-id> +pnpm paperclipai issue recovery:resolve <issue-id> --outcome restored --source-issue-status todo +``` + +```sh +pnpm paperclipai issue documents <issue-id> [--include-system] +pnpm paperclipai issue document:get <issue-id> <key> +pnpm paperclipai issue document:put <issue-id> <key> --body-file ./plan.md [--title Plan] +pnpm paperclipai issue document:lock <issue-id> <key> +pnpm paperclipai issue document:unlock <issue-id> <key> +pnpm paperclipai issue document:revisions <issue-id> <key> +pnpm paperclipai issue document:restore <issue-id> <key> <revision-id> +pnpm paperclipai issue document:delete <issue-id> <key> +``` + +```sh +pnpm paperclipai issue work-products <issue-id> +pnpm paperclipai issue work-product:create <issue-id> --payload-json '{"type":"pull_request","provider":"github","title":"PR"}' +pnpm paperclipai issue work-product:update <work-product-id> --payload-json '{"status":"archived"}' +pnpm paperclipai issue work-product:delete <work-product-id> +pnpm paperclipai issue interactions <issue-id> +pnpm paperclipai issue interaction:create <issue-id> --payload-json '{"kind":"request_confirmation","payload":{"version":1,"prompt":"Continue?"}}' +pnpm paperclipai issue interaction:accept <issue-id> <interaction-id> [--selected-client-keys key1,key2] +pnpm paperclipai issue interaction:reject <issue-id> <interaction-id> [--reason "..."] +pnpm paperclipai issue interaction:respond <issue-id> <interaction-id> --answers-json '[{"questionId":"q1","optionIds":["yes"]}]' +pnpm paperclipai issue interaction:cancel <issue-id> <interaction-id> [--reason "..."] +``` + +```sh +pnpm paperclipai issue tree-state <issue-id> +pnpm paperclipai issue tree-preview <issue-id> --payload-json '{"mode":"pause"}' +pnpm paperclipai issue tree-holds <issue-id> [--status active] [--include-members] +pnpm paperclipai issue tree-hold:create <issue-id> --payload-json '{"mode":"pause","reason":"review"}' +pnpm paperclipai issue tree-hold:get <issue-id> <hold-id> +pnpm paperclipai issue tree-hold:release <issue-id> <hold-id> [--payload-json '{"reason":"done"}'] +pnpm paperclipai issue attachments <issue-id> +pnpm paperclipai issue attachment:upload <issue-id> --company-id <company-id> --file ./artifact.txt +pnpm paperclipai issue attachment:download <attachment-id> [--out ./artifact.txt] +pnpm paperclipai issue attachment:delete <attachment-id> +pnpm paperclipai issue label:list --company-id <company-id> +pnpm paperclipai issue label:create --company-id <company-id> --name bug --color '#ff0000' +pnpm paperclipai issue label:delete <label-id> +pnpm paperclipai issue feedback:votes <issue-id> +pnpm paperclipai issue feedback:vote <issue-id> --payload-json '{"targetType":"issue_comment","targetId":"...","vote":"up"}' +``` + +## Project Commands + +```sh +pnpm paperclipai project list --company-id <company-id> +pnpm paperclipai project get <project-id-or-shortname> [--company-id <company-id>] +pnpm paperclipai project create --company-id <company-id> --name "Launch Site" [--goal-ids <id1,id2>] [--lead-agent-id <id>] +pnpm paperclipai project update <project-id-or-shortname> [--status in_progress] [--company-id <company-id>] +pnpm paperclipai project delete <project-id-or-shortname> --yes [--company-id <company-id>] +``` + +Advanced project fields accept JSON: + +```sh +pnpm paperclipai project create --company-id <company-id> --name "Ops" --env-json '{"OPENAI_API_KEY":{"kind":"secret","secretName":"openai-api-key"}}' +pnpm paperclipai project update <project-id> --execution-workspace-policy-json '{"enabled":true,"defaultMode":"shared_workspace"}' +``` + +## Goal Commands + +```sh +pnpm paperclipai goal list --company-id <company-id> +pnpm paperclipai goal get <goal-id> +pnpm paperclipai goal create --company-id <company-id> --title "Grow revenue" [--level company] [--status active] +pnpm paperclipai goal update <goal-id> [--title "..."] [--status achieved] +pnpm paperclipai goal delete <goal-id> --yes ``` ## Agent Commands @@ -127,9 +255,44 @@ pnpm paperclipai issue release <issue-id> ```sh pnpm paperclipai agent list --company-id <company-id> pnpm paperclipai agent get <agent-id> +pnpm paperclipai agent create --company-id <company-id> --payload-json '{"name":"Builder","adapterType":"codex_local"}' +pnpm paperclipai agent hire --company-id <company-id> --payload-json '{...}' +pnpm paperclipai agent update <agent-id> --payload-json '{"title":"Senior Builder"}' +pnpm paperclipai agent delete <agent-id> --yes +pnpm paperclipai agent me +pnpm paperclipai agent inbox +pnpm paperclipai agent inbox-mine --user-id <board-user-id> +pnpm paperclipai agent wake <agent-id-or-shortname> [--company-id <company-id>] [--reason "..."] [--payload '{"issueId":"..."}'] +pnpm paperclipai agent pause <agent-id> +pnpm paperclipai agent resume <agent-id> +pnpm paperclipai agent approve <agent-id> +pnpm paperclipai agent terminate <agent-id> +pnpm paperclipai agent heartbeat:invoke <agent-id> +pnpm paperclipai agent claude-login <agent-id> pnpm paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id> ``` +Agent configuration and runtime endpoints: + +```sh +pnpm paperclipai agent permissions:update <agent-id> --payload-json '{"canCreateAgents":true,"canAssignTasks":true}' +pnpm paperclipai agent configuration <agent-id> +pnpm paperclipai agent config-revisions <agent-id> +pnpm paperclipai agent config-revision:get <agent-id> <revision-id> +pnpm paperclipai agent config-revision:rollback <agent-id> <revision-id> +pnpm paperclipai agent runtime-state <agent-id> +pnpm paperclipai agent runtime-state:reset-session <agent-id> [--task-key <key>] +pnpm paperclipai agent task-sessions <agent-id> +pnpm paperclipai agent skills <agent-id> +pnpm paperclipai agent skills:sync <agent-id> --desired-skills paperclip,github +pnpm paperclipai agent instructions-path:update <agent-id> --payload-json '{"path":"/path/to/AGENTS.md"}' +pnpm paperclipai agent instructions-bundle <agent-id> +pnpm paperclipai agent instructions-bundle:update <agent-id> --payload-json '{"mode":"managed"}' +pnpm paperclipai agent instructions-file:get <agent-id> --path AGENTS.md +pnpm paperclipai agent instructions-file:put <agent-id> --path AGENTS.md --content-file ./AGENTS.md +pnpm paperclipai agent instructions-file:delete <agent-id> --path AGENTS.md +``` + `agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent: - creates a new long-lived agent API key @@ -143,6 +306,75 @@ pnpm paperclipai agent local-cli codexcoder --company-id <company-id> pnpm paperclipai agent local-cli claudecoder --company-id <company-id> ``` +## Token Commands + +Agent API keys are scoped to one company and one agent. Plaintext tokens are printed once at creation. + +```sh +pnpm paperclipai token agent create --company-id <company-id> --agent <agent-id-or-name> --name external-worker +pnpm paperclipai token agent list --company-id <company-id> --agent <agent-id-or-name> +pnpm paperclipai token agent revoke --company-id <company-id> --agent <agent-id-or-name> <key-id> +``` + +Named board API keys use the board authorization model, support revocation and expiration metadata, and are audited server-side. + +```sh +pnpm paperclipai token board create --company-id <company-id> --name external-admin +pnpm paperclipai token board create --name short-lived --ttl-days 7 +pnpm paperclipai token board list +pnpm paperclipai token board revoke <key-id> +``` + +## Run Commands + +`paperclipai run` without a subcommand still bootstraps and starts a local Paperclip instance. The subcommands below inspect and control API heartbeat runs. + +```sh +pnpm paperclipai run list --company-id <company-id> [--agent-id <agent-id>] [--limit 50] +pnpm paperclipai run live --company-id <company-id> [--limit 50] [--min-count 0] +pnpm paperclipai run get <run-id> +pnpm paperclipai run events <run-id> [--after-seq 0] [--limit 200] +pnpm paperclipai run log <run-id> [--offset 0] [--limit-bytes 16384] [--text] +pnpm paperclipai run cancel <run-id> +pnpm paperclipai run issues <run-id> +pnpm paperclipai run workspace-operations <run-id> +pnpm paperclipai run workspace-log <operation-id> [--offset 0] [--limit-bytes 16384] [--text] +pnpm paperclipai run watchdog-decision <run-id> --decision continue [--reason "..."] +``` + +## Routine Commands + +`paperclipai routines disable-all` remains the local maintenance command. The singular `routine` group maps to the REST API. + +```sh +pnpm paperclipai routine list --company-id <company-id> [--project-id <project-id>] +pnpm paperclipai routine create --company-id <company-id> --payload-json '{...}' +pnpm paperclipai routine get <routine-id> +pnpm paperclipai routine update <routine-id> --payload-json '{...}' +pnpm paperclipai routine revisions <routine-id> +pnpm paperclipai routine revision:restore <routine-id> <revision-id> +pnpm paperclipai routine runs <routine-id> [--limit 50] +pnpm paperclipai routine run <routine-id> [--payload-json '{...}'] +pnpm paperclipai routine trigger:create <routine-id> --payload-json '{...}' +pnpm paperclipai routine trigger:update <trigger-id> --payload-json '{...}' +pnpm paperclipai routine trigger:delete <trigger-id> +pnpm paperclipai routine trigger:rotate-secret <trigger-id> +pnpm paperclipai routine trigger:fire <public-id> [--payload-json '{...}'] +``` + +## Prompt Handoff + +Prompt handoff creates Paperclip work. It does not create a chat session. + +```sh +pnpm paperclipai agent-prompt <agent-name-or-id> <agent-api-key> "Prompt here" +pnpm paperclipai agent prompt --agent <agent-name-or-id> --api-key-env PAPERCLIP_API_KEY "Prompt here" +pnpm paperclipai agent prompt --profile my-agent "Prompt here" +pnpm paperclipai board prompt --company-id <company-id> --agent <agent-name-or-id> "Prompt here" +``` + +By default the command creates a `todo` issue assigned to the target agent and wakes the agent. Use `--issue <issue-id>` to add a comment to existing work, and `--no-wake` to skip the wakeup. + ## Skills Commands `paperclipai skills` covers three distinct operations: @@ -269,6 +501,16 @@ pnpm paperclipai secrets declarations --company-id <company-id> [--include agent pnpm paperclipai secrets create --company-id <company-id> --name anthropic-api-key --value-env ANTHROPIC_API_KEY pnpm paperclipai secrets link --company-id <company-id> --name prod-stripe-key --provider aws_secrets_manager --external-ref <provider-ref> pnpm paperclipai secrets doctor --company-id <company-id> +pnpm paperclipai secrets provider-configs --company-id <company-id> +pnpm paperclipai secrets provider-config:create --company-id <company-id> --payload-json '{...}' +pnpm paperclipai secrets provider-config:discovery-preview --company-id <company-id> --payload-json '{...}' +pnpm paperclipai secrets provider-config:get <config-id> +pnpm paperclipai secrets provider-config:update <config-id> --payload-json '{...}' +pnpm paperclipai secrets provider-config:default <config-id> +pnpm paperclipai secrets provider-config:health <config-id> +pnpm paperclipai secrets provider-config:delete <config-id> +pnpm paperclipai secrets remote-import:preview --company-id <company-id> --payload-json '{...}' +pnpm paperclipai secrets remote-import --company-id <company-id> --payload-json '{...}' pnpm paperclipai secrets migrate-inline-env --company-id <company-id> [--apply] ``` @@ -280,10 +522,9 @@ env and the expected AWS SDK runtime credential source; do not store AWS bootstrap credentials in Paperclip secrets. Per-company provider vaults (multiple vault instances per provider, default -vault selection, coming-soon GCP/Vault) are configured from the board UI under -`Company Settings → Secrets → Provider vaults` or through -`/api/companies/{companyId}/secret-provider-configs`. There is no CLI surface -for vault management today. See the +vault selection, coming-soon GCP/Vault) can be configured from the board UI under +`Company Settings → Secrets → Provider vaults` or through the provider-config CLI +commands above. See the [secrets deploy guide](../docs/deploy/secrets.md#provider-vaults) and [API reference](../docs/api/secrets.md#provider-vaults) for the contract. @@ -304,6 +545,8 @@ pnpm paperclipai approval comment <approval-id> --body "..." ```sh pnpm paperclipai activity list --company-id <company-id> [--agent-id <agent-id>] [--entity-type issue] [--entity-id <id>] +pnpm paperclipai activity create --company-id <company-id> --payload-json '{...}' +pnpm paperclipai activity issue <issue-id> ``` ## Dashboard Commands @@ -312,6 +555,220 @@ pnpm paperclipai activity list --company-id <company-id> [--agent-id <agent-id>] pnpm paperclipai dashboard get --company-id <company-id> ``` +## Org And Agent Config Commands + +```sh +pnpm paperclipai whoami +pnpm paperclipai openapi +pnpm paperclipai org get --company-id <company-id> +pnpm paperclipai org svg --company-id <company-id> [--out org.svg] +pnpm paperclipai org png --company-id <company-id> [--out org.png] +pnpm paperclipai agent-config list --company-id <company-id> +``` + +## Access, Profile, And Instance Commands + +```sh +pnpm paperclipai profile session +pnpm paperclipai profile get +pnpm paperclipai profile update --payload-json '{...}' +pnpm paperclipai profile company-user <user-slug> --company-id <company-id> +pnpm paperclipai invite list --company-id <company-id> +pnpm paperclipai invite create --company-id <company-id> --payload-json '{...}' +pnpm paperclipai invite revoke <invite-id> +pnpm paperclipai invite show <token> +pnpm paperclipai invite accept <token> [--payload-json '{...}'] +pnpm paperclipai invite onboarding:text <token> +pnpm paperclipai join list --company-id <company-id> [--status pending_approval] +pnpm paperclipai join approve <request-id> --company-id <company-id> +pnpm paperclipai join reject <request-id> --company-id <company-id> +pnpm paperclipai join claim-key <request-id> --claim-secret <secret> +pnpm paperclipai member list --company-id <company-id> +pnpm paperclipai member update <member-id> --company-id <company-id> --payload-json '{...}' +pnpm paperclipai member role-and-grants <member-id> --company-id <company-id> --payload-json '{...}' +pnpm paperclipai member permissions <member-id> --company-id <company-id> --payload-json '{...}' +pnpm paperclipai member archive <member-id> --company-id <company-id> [--payload-json '{...}'] +pnpm paperclipai admin user list [--query <text>] +pnpm paperclipai admin user promote <user-id> +pnpm paperclipai admin user demote <user-id> +pnpm paperclipai admin user company-access <user-id> +pnpm paperclipai admin user company-access:update <user-id> --payload-json '{...}' +``` + +CLI auth challenge endpoints are also exposed for tooling that needs the raw challenge lifecycle: + +```sh +pnpm paperclipai auth challenge create --payload-json '{...}' +PAPERCLIP_CHALLENGE_SECRET=<challenge-secret> pnpm paperclipai auth challenge get <challenge-id> --token-env PAPERCLIP_CHALLENGE_SECRET +PAPERCLIP_CHALLENGE_SECRET=<challenge-secret> pnpm paperclipai auth challenge approve <challenge-id> --token-env PAPERCLIP_CHALLENGE_SECRET +PAPERCLIP_CHALLENGE_SECRET=<challenge-secret> pnpm paperclipai auth challenge cancel <challenge-id> --token-env PAPERCLIP_CHALLENGE_SECRET +pnpm paperclipai auth revoke-current +``` + +`--token <challenge-secret>` is still supported for compatibility, but `--token-env` avoids putting challenge secrets in shell history or process arguments. + +```sh +pnpm paperclipai instance scheduler-heartbeats +pnpm paperclipai instance settings:general +pnpm paperclipai instance settings:general:update --payload-json '{...}' +pnpm paperclipai instance settings:experimental +pnpm paperclipai instance settings:experimental:update --payload-json '{...}' +pnpm paperclipai instance database-backup +pnpm paperclipai sidebar preferences +pnpm paperclipai sidebar preferences:update --payload-json '{...}' +pnpm paperclipai sidebar project-preferences --company-id <company-id> +pnpm paperclipai sidebar project-preferences:update --company-id <company-id> --payload-json '{...}' +pnpm paperclipai sidebar badges --company-id <company-id> +pnpm paperclipai inbox dismissals --company-id <company-id> +pnpm paperclipai inbox dismiss --company-id <company-id> --payload-json '{"itemKey":"run:<run-id>"}' +pnpm paperclipai board-claim show <token> +pnpm paperclipai board-claim claim <token> [--payload-json '{...}'] +pnpm paperclipai openclaw invite-prompt --company-id <company-id> --payload-json '{...}' +pnpm paperclipai available-skill list +pnpm paperclipai available-skill index +pnpm paperclipai available-skill get <skill-name> +pnpm paperclipai llm agent-configuration +pnpm paperclipai llm agent-configuration:adapter <adapter-type> +pnpm paperclipai llm agent-icons +``` + +## Adapter, Asset, And Skill Commands + +```sh +pnpm paperclipai adapter list +pnpm paperclipai adapter install --payload-json '{"packageName":"@scope/adapter","version":"1.2.3"}' +pnpm paperclipai adapter get <adapter-type> +pnpm paperclipai adapter update <adapter-type> --payload-json '{"disabled":true}' +pnpm paperclipai adapter override <adapter-type> --payload-json '{"paused":true}' +pnpm paperclipai adapter reload <adapter-type> +pnpm paperclipai adapter reinstall <adapter-type> +pnpm paperclipai adapter delete <adapter-type> +pnpm paperclipai adapter config-schema <adapter-type> +pnpm paperclipai adapter ui-parser <adapter-type> +pnpm paperclipai adapter models <adapter-type> --company-id <company-id> [--refresh] [--environment-id <id>] +pnpm paperclipai adapter model-profiles <adapter-type> --company-id <company-id> +pnpm paperclipai adapter detect-model <adapter-type> --company-id <company-id> +pnpm paperclipai adapter test-environment <adapter-type> --company-id <company-id> --payload-json '{...}' +``` + +```sh +pnpm paperclipai asset image:upload --company-id <company-id> --file ./image.png [--namespace docs] [--alt "..."] +pnpm paperclipai asset logo:upload --company-id <company-id> --file ./logo.svg +pnpm paperclipai asset content <asset-id> --out ./asset.bin +``` + +```sh +pnpm paperclipai skill list --company-id <company-id> +pnpm paperclipai skill get <skill-id> --company-id <company-id> +pnpm paperclipai skill file <skill-id> --company-id <company-id> [--path SKILL.md] +pnpm paperclipai skill create --company-id <company-id> --payload-json '{...}' +pnpm paperclipai skill file:update <skill-id> --company-id <company-id> --payload-json '{...}' +pnpm paperclipai skill import --company-id <company-id> --payload-json '{"source":"github:owner/repo/path"}' +pnpm paperclipai skill scan-projects --company-id <company-id> --payload-json '{...}' +pnpm paperclipai skill update-status <skill-id> --company-id <company-id> +pnpm paperclipai skill install-update <skill-id> --company-id <company-id> +pnpm paperclipai skill delete <skill-id> --company-id <company-id> +``` + +## Cost, Finance, And Budget Commands + +```sh +pnpm paperclipai cost summary --company-id <company-id> +pnpm paperclipai cost by-agent --company-id <company-id> +pnpm paperclipai cost by-agent-model --company-id <company-id> +pnpm paperclipai cost by-provider --company-id <company-id> +pnpm paperclipai cost by-biller --company-id <company-id> +pnpm paperclipai cost by-project --company-id <company-id> +pnpm paperclipai cost window-spend --company-id <company-id> +pnpm paperclipai cost quota-windows --company-id <company-id> +pnpm paperclipai cost issue <issue-id> +pnpm paperclipai cost event:create --company-id <company-id> --payload-json '{...}' +``` + +```sh +pnpm paperclipai finance event:create --company-id <company-id> --payload-json '{...}' +pnpm paperclipai finance events --company-id <company-id> +pnpm paperclipai finance summary --company-id <company-id> +pnpm paperclipai finance by-biller --company-id <company-id> +pnpm paperclipai finance by-kind --company-id <company-id> +pnpm paperclipai budget overview --company-id <company-id> +pnpm paperclipai budget policy:upsert --company-id <company-id> --payload-json '{...}' +pnpm paperclipai budget company:update --company-id <company-id> --payload-json '{...}' +pnpm paperclipai budget agent:update <agent-id> --payload-json '{...}' +pnpm paperclipai budget incident:resolve <incident-id> --company-id <company-id> [--payload-json '{...}'] +``` + +## Workspace And Environment Commands + +```sh +pnpm paperclipai workspace list --company-id <company-id> +pnpm paperclipai workspace get <execution-workspace-id> +pnpm paperclipai workspace close-readiness <execution-workspace-id> +pnpm paperclipai workspace operations <execution-workspace-id> +pnpm paperclipai workspace update <execution-workspace-id> --payload-json '{...}' +pnpm paperclipai workspace runtime-service <execution-workspace-id> start --payload-json '{...}' +pnpm paperclipai workspace runtime-command <execution-workspace-id> run --payload-json '{...}' +``` + +```sh +pnpm paperclipai environment list --company-id <company-id> +pnpm paperclipai environment capabilities --company-id <company-id> +pnpm paperclipai environment create --company-id <company-id> --payload-json '{...}' +pnpm paperclipai environment get <environment-id> +pnpm paperclipai environment leases <environment-id> +pnpm paperclipai environment lease <lease-id> +pnpm paperclipai environment update <environment-id> --payload-json '{...}' +pnpm paperclipai environment delete <environment-id> +pnpm paperclipai environment probe <environment-id> +pnpm paperclipai environment probe-config --company-id <company-id> --payload-json '{...}' +``` + +```sh +pnpm paperclipai project-workspace list <project-id> +pnpm paperclipai project-workspace create <project-id> --payload-json '{...}' +pnpm paperclipai project-workspace update <project-id> <workspace-id> --payload-json '{...}' +pnpm paperclipai project-workspace delete <project-id> <workspace-id> +pnpm paperclipai project-workspace runtime-service <project-id> <workspace-id> restart --payload-json '{...}' +pnpm paperclipai project-workspace runtime-command <project-id> <workspace-id> run --payload-json '{...}' +``` + +## Plugin Commands + +Existing plugin lifecycle commands remain available: `plugin init`, `list`, `install`, `uninstall`, `enable`, `disable`, `inspect`, and `examples`. + +```sh +pnpm paperclipai plugin ui-contributions +pnpm paperclipai plugin tools +pnpm paperclipai plugin tool:execute --payload-json '{...}' +pnpm paperclipai plugin health <plugin-id> +pnpm paperclipai plugin logs <plugin-id> +pnpm paperclipai plugin upgrade <plugin-id> +pnpm paperclipai plugin config <plugin-id> +pnpm paperclipai plugin config:set <plugin-id> --payload-json '{"configJson":{...}}' +pnpm paperclipai plugin config:test <plugin-id> --payload-json '{"configJson":{...}}' +pnpm paperclipai plugin jobs <plugin-id> +pnpm paperclipai plugin job:runs <plugin-id> <job-id> +pnpm paperclipai plugin job:trigger <plugin-id> <job-id> [--payload-json '{...}'] +pnpm paperclipai plugin webhook <plugin-id> <endpoint-key> [--payload-json '{...}'] +pnpm paperclipai plugin dashboard <plugin-id> +pnpm paperclipai plugin bridge:data <plugin-id> --payload-json '{...}' +pnpm paperclipai plugin bridge:action <plugin-id> --payload-json '{...}' +pnpm paperclipai plugin bridge:stream <plugin-id> <channel> [--duration-ms 10000] +pnpm paperclipai plugin data <plugin-id> <key> --payload-json '{...}' +pnpm paperclipai plugin action <plugin-id> <key> --payload-json '{...}' +pnpm paperclipai plugin local-folders <plugin-id> --company-id <company-id> +pnpm paperclipai plugin local-folder:status <plugin-id> <folder-key> --company-id <company-id> +pnpm paperclipai plugin local-folder:validate <plugin-id> <folder-key> --company-id <company-id> [--payload-json '{...}'] +pnpm paperclipai plugin local-folder:set <plugin-id> <folder-key> --company-id <company-id> --payload-json '{...}' +``` + +Feedback traces can be fetched directly by ID when automating export workflows: + +```sh +pnpm paperclipai feedback trace <trace-id> +pnpm paperclipai feedback bundle <trace-id> +``` + ## Heartbeat Command `heartbeat run` now also supports context/api-key options and uses the shared client stack: diff --git a/doc/logs/2026-05-24-cli-api-parity-e2e-log.md b/doc/logs/2026-05-24-cli-api-parity-e2e-log.md new file mode 100644 index 00000000..2e2e4772 --- /dev/null +++ b/doc/logs/2026-05-24-cli-api-parity-e2e-log.md @@ -0,0 +1,1030 @@ +# 2026-05-24 CLI API Parity E2E Log + +## Scope + +Full Paperclip CLI/API parity smoke pass against a disposable local source-tree instance. + +## Isolation Contract + +- Repo: `/Users/aronprins/Documents/PaperclipAI/paperclip` +- Scratch root: `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity` +- `PAPERCLIP_HOME`: `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home` +- `PAPERCLIP_INSTANCE_ID`: `cli-api-parity` +- `PAPERCLIP_CONFIG`: `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/instances/cli-api-parity/config.json` +- `PAPERCLIP_CONTEXT`: `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/context.json` +- `PAPERCLIP_AUTH_STORE`: `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/auth.json` +- `PAPERCLIP_API_URL`: `http://127.0.0.1:3197` +- `PAPERCLIP_SERVER_PORT`: `3197` +- `PORT`: `3197` +- `CODEX_HOME`: `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/codex-home` +- `CLAUDE_HOME`: `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/claude-home` +- `DATABASE_URL`: unset +- `DATABASE_MIGRATION_URL`: unset + +## Current IDs + +- Company ID: `12e9db4b-f66c-459b-959e-d645002240fb` +- Imported Company ID: `0bdc6f69-733d-4b1c-b5c6-2246f9582598` (deleted from DB) +- Agent ID: `1dd601a1-031a-4225-b005-419427fd059f` +- Goal ID: `5b2a9135-1044-48d6-a17d-6b91dd9fdc74` +- Project ID: `d32032ce-d95e-4c4e-a942-dd98498025fb` +- Issue ID: `f0250734-95f1-4c28-9e10-f1954649fffb` (`CLI-1`) +- Checkout/release Issue ID: `1f7540d3-a3d3-48d2-b6c5-00d72c064e8f` +- Prompt Issue ID: `38b89e46-a775-43bc-a39a-c44ccd1f7f30` +- Board token ID: `45d843a2-9334-4dda-b53a-cd6f7e62149a` (revoked) +- Agent token ID: `d464c3fe-c760-4c1c-b6cd-f8f0cd6c1797` (revoked) + +## Command Log + +### 2026-05-24T11:06:22+02:00 - Read runbook and docs + +- Command: `sed -n ... paperclip-localdev-runbook.md`, `doc/DEVELOPING.md`, `doc/CLI.md`, `doc/DATABASE.md`, `doc/GOAL.md`, `doc/PRODUCT.md`, `doc/SPEC-implementation.md`, `doc/DEPLOYMENT-MODES.md`, `doc/plans/2026-05-23-cli-api-parity-openapi-reference.ts` +- Purpose: Establish the required isolated local-dev workflow and CLI/API parity reference. +- Prerequisites/IDs used: none. +- Expected result: Docs confirm scratch home, non-default port, embedded DB, and CLI command shapes. +- Actual result: Runbook requires explicit scratch paths, port `3197`, unset database env vars, `pnpm paperclipai onboard --yes --run --bind loopback`, and pre-test isolation checks. +- Status: PASS. +- Output summary: No destructive command run yet. `doc/bugs` did not exist, so this file defines the log format. +- Follow-up: Start isolated instance only after environment verification. + +### 2026-05-24T11:06:22+02:00 - Pre-start isolation check + +- Command: `env -u DATABASE_URL -u DATABASE_MIGRATION_URL ... zsh -lc 'printf ...'`; `lsof -nP -iTCP:3197 -sTCP:LISTEN || true` +- Purpose: Confirm all required environment variables resolve to the scratch instance and the non-default server port is free. +- Prerequisites/IDs used: none. +- Expected result: All Paperclip/Codex/Claude paths point under `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity`; `DATABASE_URL` and `DATABASE_MIGRATION_URL` are unset; port `3197` has no listener. +- Actual result: All required variables matched the isolation contract, database URLs were `<unset>`, and no listener was present on `3197`. +- Status: PASS. +- Output summary: No references to `~/.paperclip`, `~/.codex`, `~/.claude`, or `localhost:3100`. +- Follow-up: Start Paperclip with the runbook command. + +### 2026-05-24T11:06:45+02:00 - Start isolated instance + +- Command: `env -u DATABASE_URL -u DATABASE_MIGRATION_URL ... pnpm paperclipai onboard --yes --run --bind loopback` +- Purpose: Create and start the disposable source-tree Paperclip instance. +- Prerequisites/IDs used: scratch env from Isolation Contract. +- Expected result: Onboarding writes config, `.env`, secrets key, logs, storage, and embedded DB under the scratch instance; server listens on `127.0.0.1:3197`. +- Actual result: Server started in `local_trusted/private` mode on `127.0.0.1:3197`, with embedded PostgreSQL data at `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/instances/cli-api-parity/db`. +- Status: PASS. +- Output summary: Doctor passed 9 checks. Server selected embedded PostgreSQL port `54330` after `54329` was already in use. Health URL: `http://127.0.0.1:3197/api/health`. +- Follow-up: Run second-shell isolation checks before parity mutations. + +### 2026-05-24T11:07:15+02:00 - Verify started instance isolation + +- Command: `pnpm paperclipai env`; `pnpm paperclipai context show --json`; `curl -sS http://127.0.0.1:3197/api/health`; `find tmp/cli-api-parity/home -maxdepth 4 -type d` +- Purpose: Confirm the CLI and API target the disposable instance. +- Prerequisites/IDs used: isolated env; server session from previous step. +- Expected result: Config/context/auth paths are scratch paths; context path is scratch; health succeeds on `127.0.0.1:3197`; DB directory is under scratch home. +- Actual result: Config path, context path, storage path, secrets key path, and DB directory all resolve under `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity`; health returned `status: ok`, version `0.3.1`, `deploymentMode: local_trusted`, `companyDeletionEnabled: true`. +- Status: PASS. +- Output summary: `paperclipai env` redacted here because it prints the generated `PAPERCLIP_AGENT_JWT_SECRET`. Context existed at the scratch path with an empty `default` profile. +- Follow-up: Set API context and begin CLI parity checks. + +### 2026-05-24T11:08:20+02:00 - Basic context/auth/connectivity + +- Command: `pnpm paperclipai context set --api-base http://127.0.0.1:3197 --use --json`; `pnpm paperclipai whoami --json`; `pnpm paperclipai company list --json`; `pnpm paperclipai access whoami --json` +- Purpose: Exercise context setup/show, auth/access identity, and initial company listing. +- Prerequisites/IDs used: isolated env; no company ID yet. +- Expected result: Context stores non-default API base; `whoami` reports the implicit local board; company list is empty; documented `access whoami` either works or reveals current command drift. +- Actual result: `context set --api-base` wrote `apiBase: http://127.0.0.1:3197`; `whoami` returned `local-board` with `isInstanceAdmin: true`; company list returned `[]`; `access whoami` failed with `unknown command 'access'`. +- Status: PASS with docs/runbook mismatch. +- Output summary: Current CLI exposes `whoami` as a top-level command. The runbook/docs command `access whoami` is stale for this checkout. +- Follow-up: Use top-level `whoami` for access checks and record the mismatch below. + +### 2026-05-24T11:09:14+02:00 - Company create/get/update/context + +- Command: `pnpm paperclipai company create --payload-json '{"name":"CLI API Parity Test","description":"Disposable company for CLI API parity testing","goal":"Exercise the CLI API surface end to end"}' --json`; `pnpm paperclipai context set --company-id 12e9db4b-f66c-459b-959e-d645002240fb --use --json`; `pnpm paperclipai company get 12e9db4b-f66c-459b-959e-d645002240fb --json`; `pnpm paperclipai company update 12e9db4b-f66c-459b-959e-d645002240fb --payload-json '{"description":"Updated by CLI API parity test","budgetMonthlyCents":12345}' --json` +- Purpose: Exercise company creation, retrieval, update, and default company context. +- Prerequisites/IDs used: board identity; API base context. +- Expected result: Company is created, can be fetched, update persists, and context keeps both `apiBase` and `companyId`. +- Actual result: Company create/get/update succeeded. Created company `12e9db4b-f66c-459b-959e-d645002240fb`. Update changed description and `budgetMonthlyCents` to `12345`. `context set --company-id` unexpectedly removed the previously stored `apiBase`. +- Status: PASS with fixed bug. +- Output summary: Company issue prefix is `CLI`; status is `active`. +- Follow-up: Fix the context profile merge bug before continuing so later commands cannot fall back to `localhost:3100`. + +### 2026-05-24T11:11:00+02:00 - Fix and verify context profile merge + +- Command: edited `cli/src/commands/client/context.ts`, `cli/src/client/context.ts`, and `cli/src/__tests__/context.test.ts`; `pnpm exec vitest run cli/src/__tests__/context.test.ts`; `pnpm --dir cli typecheck`; `pnpm paperclipai context set --api-base http://127.0.0.1:3197 --company-id 12e9db4b-f66c-459b-959e-d645002240fb --use --json`; `pnpm paperclipai context show --json` +- Purpose: Preserve existing context profile fields when setting a subset of fields. +- Prerequisites/IDs used: company `12e9db4b-f66c-459b-959e-d645002240fb`. +- Expected result: Undefined patch fields do not erase existing profile values; context keeps both `apiBase` and `companyId`. +- Actual result: Targeted Vitest context test passed; CLI typecheck passed; scratch context now contains both `apiBase: http://127.0.0.1:3197` and `companyId: 12e9db4b-f66c-459b-959e-d645002240fb`. +- Status: PASS. +- Output summary: Added regression coverage for undefined context patch fields. +- Follow-up: Continue parity testing. + +### 2026-05-24T11:14:05+02:00 - Core domain CRUD and issue comments + +- Command: `dashboard get`; `goal list/create/get/update`; `project list/create/get/update`; `agent list/create/get/update/configuration`; `issue list/create/get/update/comment/comments/comment:get/checkout`; `activity list` +- Purpose: Exercise core company-scoped CLI/API parity with JSON outputs and captured IDs. +- Prerequisites/IDs used: company `12e9db4b-f66c-459b-959e-d645002240fb`; context profile with scratch `apiBase`; process adapter agent payload. +- Expected result: Goal, agent, project, and issue CRUD succeeds; comments can be created and read; checkout succeeds for a todo issue. +- Actual result: Goal `5b2a9135-1044-48d6-a17d-6b91dd9fdc74`, agent `1dd601a1-031a-4225-b005-419427fd059f`, project `d32032ce-d95e-4c4e-a942-dd98498025fb`, issue `f0250734-95f1-4c28-9e10-f1954649fffb`, and comment `231fd48a-9ed2-4e72-a3dc-3b762842f57d` were created/updated/read successfully. Explicit checkout of the first issue failed with 409 because assigning it at creation triggered automatic local process runs and checkout first. +- Status: PASS with expected concurrency conflict. +- Output summary: The assigned `process` adapter agent ran automatically and generated heartbeat runs. The issue later moved to `blocked` via recovery handling because the smoke process printed output without a concrete Paperclip disposition. +- Follow-up: Create a second unassigned issue for an uncontended checkout/release command test. + +### 2026-05-24T11:15:41+02:00 - Issue checkout/release + +- Command: `issue create --status todo` without assignee; `issue checkout 1f7540d3-a3d3-48d2-b6c5-00d72c064e8f --agent-id 1dd601a1-031a-4225-b005-419427fd059f --expected-statuses todo --json`; `issue release 1f7540d3-a3d3-48d2-b6c5-00d72c064e8f --json` +- Purpose: Exercise atomic checkout and release semantics without automatic assignment races. +- Prerequisites/IDs used: company `12e9db4b-f66c-459b-959e-d645002240fb`; agent `1dd601a1-031a-4225-b005-419427fd059f`; project `d32032ce-d95e-4c4e-a942-dd98498025fb`; goal `5b2a9135-1044-48d6-a17d-6b91dd9fdc74`. +- Expected result: Checkout moves issue to `in_progress` and assigns the agent; release moves issue to `todo` and clears assignee. +- Actual result: Checkout returned `status: in_progress` with the expected agent ID; release returned `status: todo` with `assigneeAgentId: null`. +- Status: PASS. +- Output summary: Issue `1f7540d3-a3d3-48d2-b6c5-00d72c064e8f`. +- Follow-up: Exercise token flows. + +### 2026-05-24T11:16:43+02:00 - Board and agent token lifecycle + +- Command: `token board create --company-id ... --name cli-parity-board --never-expires --json`; `token board list --json`; `whoami --api-key <board-token> --json`; `token agent create --company-id ... --agent ... --name cli-parity-agent --json`; `token agent list --company-id ... --agent ... --json`; `context set --profile cli-agent --persona agent ... --api-key-env-var-name PAPERCLIP_API_KEY --json`; `agent me --profile cli-agent --json`; `agent inbox --profile cli-agent --json`; `issue list --profile cli-agent --company-id ... --json`; `company list --profile cli-agent --json`; `token agent revoke ...`; `token board revoke ...` +- Purpose: Exercise board token creation/use/list/revoke; agent token creation/list/use/revoke; verify agent tokens cannot use board-only company list. +- Prerequisites/IDs used: company `12e9db4b-f66c-459b-959e-d645002240fb`; agent `1dd601a1-031a-4225-b005-419427fd059f`. +- Expected result: Board token works for `whoami`; agent token works for agent persona commands and company-scoped issue list; board-only command fails with clear 403; both tokens are revoked. +- Actual result: Board token `45d843a2-9334-4dda-b53a-cd6f7e62149a` was listed and `whoami` reported `source: board_key`. Agent token `d464c3fe-c760-4c1c-b6cd-f8f0cd6c1797` was listed; `agent me`, `agent inbox`, and issue list succeeded; `company list` failed with `API error 403: Board access required`; both tokens were revoked and later list output showed `revokedAt`. +- Status: PASS. +- Output summary: Plaintext token values were captured only in shell variables and were not written to repo files or this log. +- Follow-up: Exercise prompt/wake/run and safe ancillary surfaces. + +### 2026-05-24T11:18:06+02:00 - Prompt, wake, runs, and ancillary safe surfaces + +- Command: `board prompt --company-id ... --agent ... --title "CLI parity prompt issue" --no-wake ... --json`; `agent wake ... --reason "cli parity wake smoke" --payload '{"source":"cli-api-parity"}' --json`; `run list/get/events/log`; `dashboard get`; `activity list`; `cost summary`; `cost by-agent`; `finance summary`; `budget overview`; `secrets list/doctor/provider-configs`; `routine list`; `adapter list`; `plugin list`; `org get`; `agent-config list` +- Purpose: Exercise prompt handoff, wake/run inspection, and safe read-only activity/dashboard/cost/secrets/plugin/routine surfaces. +- Prerequisites/IDs used: company `12e9db4b-f66c-459b-959e-d645002240fb`; agent `1dd601a1-031a-4225-b005-419427fd059f`. +- Expected result: Prompt creates an issue without waking; wake creates/returns a run; run inspection endpoints work; safe list/read commands return JSON. +- Actual result: Prompt created issue `38b89e46-a775-43bc-a39a-c44ccd1f7f30`; wake/run ID `7b18a3ca-9875-4bfc-b910-db31deb2c0fa`; run list returned 10 recent runs; activity returned 50 rows; secrets and routines were empty; adapter list returned 13 adapters; plugin list succeeded. +- Status: PASS. +- Output summary: One transient UI/API background request for a just-created run log returned 404 and then succeeded on retry; direct CLI `run log` for the selected run succeeded. +- Follow-up: Exercise import/export and destructive operations in scratch data. + +### 2026-05-24T11:19:35+02:00 - Company export/import/delete and object deletes + +- Command: `company export 12e9db4b-f66c-459b-959e-d645002240fb --out tmp/cli-api-parity/exports/company-package --include company,agents,projects,issues,skills --json`; `company import <export-dir> --target new --new-company-name "Imported Company" --yes --json`; `company get <imported-id> --json`; `company delete <imported-id> --yes --confirm <imported-id> --json`; disposable `goal create/delete`, `project create/delete`, and `issue create/delete`; final list checks. +- Purpose: Exercise portability and destructive operations only in the isolated instance. +- Prerequisites/IDs used: original company `12e9db4b-f66c-459b-959e-d645002240fb`. +- Expected result: Export writes a package under scratch; import creates a new company; company delete removes the imported company; object delete commands remove disposable records. +- Actual result: Export wrote `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/exports/company-package`; import created company `0bdc6f69-733d-4b1c-b5c6-2246f9582598` named `Imported Company`; company delete returned `ok: true`; final list checks confirmed the imported company and disposable goal/project/issue were absent. +- Status: PASS. +- Output summary: Goal/project/issue delete commands return the deleted object rather than `{ ok: true }`, so success was verified by final absence from list commands. +- Follow-up: Run final lifecycle and verification checks. + +### 2026-05-24T11:20:45+02:00 - Agent pause/resume and final checks + +- Command: `agent pause 1dd601a1-031a-4225-b005-419427fd059f --json`; `agent resume 1dd601a1-031a-4225-b005-419427fd059f --json`; `agent get ... --json`; final `curl /api/health`; `token board list`; `token agent list`; `git status --short`; targeted verification commands. +- Purpose: Exercise agent pause/resume and confirm final service/token/code state. +- Prerequisites/IDs used: agent `1dd601a1-031a-4225-b005-419427fd059f`. +- Expected result: Agent pauses and resumes; health remains OK; tokens remain revoked; only expected repo files changed. +- Actual result: Pause returned `paused`, resume returned `idle`, final agent status is `idle`; health returned `status: ok`; board and agent tokens show `revokedAt`; git status shows only the context fix and this log. +- Status: PASS. +- Output summary: Server remains running on `127.0.0.1:3197`. +- Follow-up: Hand off summary and restart instructions. + +### 2026-05-24T11:26:43+02:00 - Resume verification before commit + +- Command: `git status --short --branch`; `curl -sS http://127.0.0.1:3197/api/health`; `git diff --stat`; `pnpm exec vitest run cli/src/__tests__/context.test.ts`; `pnpm --dir cli typecheck` +- Purpose: Re-establish current worktree/server state before committing the fixed bug and continuing the broader CLI parity loop. +- Prerequisites/IDs used: isolated server on `127.0.0.1:3197`; branch `improvement/cli-api-parity`. +- Expected result: Server remains healthy; worktree contains only the intended context fix and living log; focused test and CLI typecheck pass. +- Actual result: Health returned `status: ok`; worktree showed modifications to `cli/src/__tests__/context.test.ts`, `cli/src/client/context.ts`, `cli/src/commands/client/context.ts`, and new `doc/bugs/`; context Vitest file passed 5 tests; `pnpm --dir cli typecheck` passed. +- Status: PASS. +- Output summary: No additional files changed before commit. +- Follow-up: Stage and commit the context fix plus parity log, then continue with full CLI inventory and remaining command coverage. + +### 2026-05-24T11:28:30+02:00 - Commit context fix + +- Command: `git add cli/src/__tests__/context.test.ts cli/src/client/context.ts cli/src/commands/client/context.ts doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`; `git commit -m "Fix CLI context profile patching"` +- Purpose: Persist the verified context isolation fix before continuing broader parity testing. +- Prerequisites/IDs used: focused context test and CLI typecheck from previous entry. +- Expected result: Commit contains only the context patching fix and living parity log. +- Actual result: Commit `1da21a91` created with 4 files changed. +- Status: PASS. +- Output summary: The working tree was clean immediately after this commit. +- Follow-up: Continue full CLI inventory and commit each subsequent fix after focused verification. + +### 2026-05-24T11:30:29+02:00 - Approval and issue subresource partial pass + +- Command: `approval create/list/get/comment/request-revision/resubmit/approve/reject`; `issue approvals/approval:link/approval:unlink`; `issue read/unread/archive/unarchive`; `issue child:create/get`; `issue document:put/get/lock/unlock/revisions/restore`; `issue work-product:create/list/update/delete` +- Purpose: Exercise approval lifecycle and issue subresource CLI/API parity with JSON outputs. +- Prerequisites/IDs used: company `12e9db4b-f66c-459b-959e-d645002240fb`; issue `f0250734-95f1-4c28-9e10-f1954649fffb`; project `d32032ce-d95e-4c4e-a942-dd98498025fb`; goal `5b2a9135-1044-48d6-a17d-6b91dd9fdc74`. +- Expected result: Approval lifecycle commands mutate and read approvals; issue markers, child creation, documents, and work products succeed. +- Actual result: Approval lifecycle succeeded. Created/approved approval `c7f19d1c-fcb3-4e4d-87a7-e8a248a9eb09`; created/rejected approval `bbcfb3ae-38f1-43b0-8f9f-0661e291f29c`; linked and unlinked issue approvals successfully. Issue read/unread/archive/unarchive succeeded. Child issue `6e78d443-c9f4-46ba-9137-f1fa2b7a75c5` was created and fetched. Document create/get/lock/unlock/update/revisions/restore succeeded after supplying `--base-revision-id`; work product create/list/update/delete succeeded. +- Status: PASS with docs/operator learning. +- Output summary: A first document update attempt without `--base-revision-id` failed with `API error 409: Document update requires baseRevisionId`; help/source confirmed the flag exists and is required for updates. +- Follow-up: Continue interactions, tree holds, attachments, labels, feedback, and recovery checks. + +### 2026-05-24T11:36:54+02:00 - Fix optional interaction accept keys + +- Command: `issue interaction:create`; `issue interaction:accept <issue-id> <interaction-id>` without `--selected-client-keys`; edited `cli/src/commands/client/issue.ts` and `cli/src/__tests__/issue-subresources.test.ts`; `pnpm exec vitest run cli/src/__tests__/issue-subresources.test.ts`; `pnpm --dir cli typecheck` +- Purpose: Verify and fix request-confirmation acceptance without optional selected task keys. +- Prerequisites/IDs used: issue `f0250734-95f1-4c28-9e10-f1954649fffb`. +- Expected result: Omitting optional `--selected-client-keys` sends no `selectedClientKeys` field. +- Actual result: Before the fix, the CLI sent `selectedClientKeys: []` and local validation failed with `Array must contain at least 1 element(s)`. After the fix, the focused issue subresource test passed 4 tests and CLI typecheck passed. +- Status: PASS with fixed bug. +- Output summary: The command now preserves `undefined` for omitted optional CSV input while still parsing provided CSV values. +- Follow-up: Commit this fix before continuing, per user instruction. + +### 2026-05-24T11:39:47+02:00 - Interaction lifecycle and tree hold retry + +- Command: `issue interaction:create/accept/reject/respond/cancel`; `issue tree-state`; `issue tree-preview`; `issue tree-hold:create/list/get/release` +- Purpose: Exercise issue interaction lifecycle and tree control commands. +- Prerequisites/IDs used: issue `f0250734-95f1-4c28-9e10-f1954649fffb`. +- Expected result: Request confirmation accept/reject succeeds; question respond/cancel succeeds; tree hold create/list/get/release succeeds. +- Actual result: Request confirmation accept/reject succeeded. Ask-user-question respond succeeded. `interaction:cancel` only works for `ask_user_questions`; trying it against `request_confirmation` returned `API error 422: Only ask_user_questions interactions can be cancelled`. A corrected ask-user-question cancel succeeded. Tree hold create/list succeeded, but my script initially parsed the create response as `.id`; the API returns `.hold.id`, so the subsequent get used literal `null` and the server returned 500. +- Status: PASS with fixed server hardening and command UX mismatch. +- Output summary: Active tree hold `8f07dd71-092f-4746-9b6d-27bbb086b305` was later fetched and released using the correct `.hold.id`/list-derived ID. +- Follow-up: Harden tree hold routes so malformed hold IDs return 400 instead of database 500; log `interaction:cancel` kind-specific UX mismatch. + +### 2026-05-24T11:41:28+02:00 - Fix malformed tree hold ID 500 + +- Command: edited `server/src/routes/issue-tree-control.ts` and `server/src/__tests__/issue-tree-control-routes.test.ts`; `pnpm exec vitest run server/src/__tests__/issue-tree-control-routes.test.ts`; `pnpm --dir server typecheck` +- Purpose: Prevent malformed tree hold IDs from reaching PostgreSQL UUID comparisons. +- Prerequisites/IDs used: reproduction path `/api/issues/<issue-id>/tree-holds/null`. +- Expected result: Invalid hold IDs return a client error and do not call the tree hold service. +- Actual result: Focused route test passed 9 tests; server typecheck passed. +- Status: PASS with fixed bug. +- Output summary: Both `GET /tree-holds/null` and `POST /tree-holds/null/release` now validate UUID shape and return 400. +- Follow-up: Commit this fix before continuing, per user instruction. + +### 2026-05-24T11:43:22+02:00 - Attachments, labels, and feedback retry + +- Command: `issue tree-hold:get/release`; `issue attachment:upload/list/download/delete`; `issue label:create/list/delete`; `issue feedback:vote/votes/list/export`; `token agent create/revoke` +- Purpose: Resume the subresource pass after fixing the tree hold script shape and route hardening. +- Prerequisites/IDs used: issue `f0250734-95f1-4c28-9e10-f1954649fffb`; tree hold `8f07dd71-092f-4746-9b6d-27bbb086b305`; company `12e9db4b-f66c-459b-959e-d645002240fb`; agent `1dd601a1-031a-4225-b005-419427fd059f`. +- Expected result: Active hold is released; attachment upload/download round trip matches bytes; label lifecycle succeeds; feedback vote succeeds against a valid target. +- Actual result: Tree hold get/release succeeded. Attachment upload/list/download/delete succeeded and `cmp` verified downloaded bytes. Label create/list/delete succeeded. Feedback vote against board-authored comment failed with `API error 422: Feedback voting is only available on agent-authored issue comments`. A retry that created an isolated temporary agent token `a67f4f69-7250-43d6-9988-96e7692da605` still failed because `issue comment --api-key <agent-token>` did not produce an agent-authored feedback target. The temporary token was revoked immediately after the failure. +- Status: PARTIAL. +- Output summary: No plaintext token values were written to this log. Token `a67f4f69-7250-43d6-9988-96e7692da605` is revoked. +- Follow-up: Inspect agent-authored comment command/auth semantics before retrying feedback voting. + +### 2026-05-24T11:44:45+02:00 - Commit issue subresource fixes + +- Command: `git add cli/src/__tests__/issue-subresources.test.ts cli/src/commands/client/issue.ts server/src/__tests__/issue-tree-control-routes.test.ts server/src/routes/issue-tree-control.ts doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`; `git commit -m "Fix CLI issue subresource parity bugs"` +- Purpose: Persist verified fixes immediately after focused verification, per user instruction. +- Prerequisites/IDs used: `pnpm exec vitest run cli/src/__tests__/issue-subresources.test.ts`; `pnpm --dir cli typecheck`; `pnpm exec vitest run server/src/__tests__/issue-tree-control-routes.test.ts`; `pnpm --dir server typecheck`. +- Expected result: Commit contains only the optional interaction accept fix, malformed tree hold ID hardening, tests, and updated parity log. +- Actual result: Commit `73997628` created with 5 files changed. +- Status: PASS. +- Output summary: No plaintext tokens included in the commit. +- Follow-up: Continue parity testing and commit future fixes immediately after focused verification. + +### 2026-05-24T11:46:46+02:00 - Feedback and recovery completion + +- Command: `token agent create`; `issue comment --api-key <agent-token>`; `issue feedback:vote`; `issue feedback:votes`; `issue feedback:list`; `issue feedback:export`; `token agent revoke`; `issue recovery-actions`; `issue recovery:resolve` +- Purpose: Complete feedback voting/export and recovery resolution after the earlier invalid target and output-shape mistakes. +- Prerequisites/IDs used: company `12e9db4b-f66c-459b-959e-d645002240fb`; agent `1dd601a1-031a-4225-b005-419427fd059f`; issue `f0250734-95f1-4c28-9e10-f1954649fffb`. +- Expected result: Feedback vote is saved against an agent-authored target; feedback list/export work; temporary token is revoked; active recovery action resolves. +- Actual result: Correctly reading `token agent create` output from `.key.token` produced agent-authored comment `d4e2adbe-d94c-4d87-8205-828f3ddfa033`; feedback vote `24843ebd-456d-4534-89ec-bdbc0bb02170` was saved; feedback list/export completed. Temporary token `40e683ec-758f-4964-bdef-544bee16ee5a` was revoked. Recovery action `1151475f-c97f-456b-9c6a-8e0f936abe05` resolved after using `--source-issue-status todo`; the issue moved to `todo`. +- Status: PASS with command output-shape learning and help mismatch. +- Output summary: A parser mistake against feedback output (`.vote.id` instead of top-level `.id`) stopped one script after the vote succeeded; the token was manually revoked and the remaining list/export commands were run separately. +- Follow-up: Record recovery help mismatch and continue remaining CLI domains. + +### 2026-05-24T11:50:09+02:00 - Restart isolated server after committed fixes + +- Command: `kill <paperclip pid on 3197>`; `pnpm paperclipai onboard --yes --run --bind loopback`; `curl http://127.0.0.1:3197/api/health`; `issue tree-hold:get <issue-id> null --json` +- Purpose: Restart the disposable server so the committed server-side malformed hold ID fix is active in the running instance. +- Prerequisites/IDs used: scratch env from Isolation Contract; committed fix `73997628`. +- Expected result: Server restarts with the same scratch home/config/DB and returns 400 for malformed hold IDs. +- Actual result: Server restarted on `127.0.0.1:3197`, using the same embedded DB path and pg port `54330`; health returned `status: ok`; malformed hold ID now returns `API error 400: Invalid hold ID`. +- Status: PASS. +- Output summary: No real `~/.paperclip`, `~/.codex`, or `~/.claude` paths were used. The server session is currently running under the isolated environment. +- Follow-up: Continue remaining CLI domains. + +### 2026-05-24T11:52:20+02:00 - Advanced agent command pass + +- Command: disposable `agent create/list/get/update/delete`; `agent permissions:update`; `agent configuration`; `agent config-revisions`; `agent config-revision:get`; `agent runtime-state`; `agent runtime-state:reset-session`; `agent task-sessions`; `agent skills`; `agent skills:sync`; `agent instructions-path:update`; `agent instructions-bundle`; `agent instructions-bundle:update`; `agent instructions-file:get/put/delete`; `agent local-cli --no-install-skills`; `agent approve/pause/resume/heartbeat:invoke/terminate`; `token agent revoke` +- Purpose: Exercise advanced agent lifecycle, runtime, instructions, skills, and local CLI token flows on a disposable agent. +- Prerequisites/IDs used: company `12e9db4b-f66c-459b-959e-d645002240fb`; temp agent `f9dfad96-6045-4b97-a548-bdc95fb22ec4`. +- Expected result: Commands succeed without mutating the main parity worker; any token created by `local-cli` is revoked; temp agent is deleted. +- Actual result: Advanced agent commands passed after adapting `instructions-path:update` for process adapter constraints. `agent local-cli --no-install-skills` created key `a9bf0b28-8217-4c60-829c-cb1962203a21`, which was revoked. `agent heartbeat:invoke` passed. Temp agent `f9dfad96-6045-4b97-a548-bdc95fb22ec4` was terminated and deleted. A final key list for the main agent showed no unrevoked keys. +- Status: PASS with command UX mismatch. +- Output summary: First `instructions-path:update` attempt without `adapterConfigKey` failed with `No default instructions path key for adapter type 'process'. Provide adapterConfigKey.` A second relative-path attempt with `adapterConfigKey` failed because process adapters without `cwd` require an absolute path. The successful pass used `adapterConfigKey: instructionsFilePath` and an absolute scratch path. +- Follow-up: Record instructions-path UX mismatch and continue cost/finance/budget/access/admin domains. + +### 2026-05-24T11:57:20+02:00 - Cost, finance, and budget command pass + +- Command: `cost event:create`; `cost summary`; `cost by-agent`; `cost by-agent-model`; `cost by-provider`; `cost by-biller`; `cost by-project`; `cost window-spend`; `cost quota-windows`; `cost issue`; `finance event:create`; `finance events`; `finance summary`; `finance by-biller`; `finance by-kind`; `budget overview`; `budget policy:upsert`; `budget company:update`; `budget agent:update` +- Purpose: Exercise cost/finance event creation, rollups, issue cost lookup, and budget policy/update flows. +- Prerequisites/IDs used: company `12e9db4b-f66c-459b-959e-d645002240fb`; agent `1dd601a1-031a-4225-b005-419427fd059f`; issue `f0250734-95f1-4c28-9e10-f1954649fffb`; project `d32032ce-d95e-4c4e-a942-dd98498025fb`; goal `5b2a9135-1044-48d6-a17d-6b91dd9fdc74`. +- Expected result: Cost and finance events are recorded; all rollup commands return JSON; budget updates work and are restored. +- Actual result: Cost event `63d757ae-a7f4-40e1-8ee8-e7d3174be1a4` and finance event `bd38c196-7598-4591-8750-f992d4d9babf` were created. All listed cost/finance read commands succeeded. Budget policy upsert succeeded. Company budget was changed to `23456` then restored to `12345`; agent budget was changed to `4321` then restored to `0`. +- Status: PASS. +- Output summary: `budget incident:resolve` was not run because no budget incident was created by this safe smoke path. +- Follow-up: Continue access/profile/invite/admin/instance/sidebar/inbox/auth domains. + +### 2026-05-24T12:02:45+02:00 - Access, profile, invite, admin, instance, sidebar, inbox, and auth challenge pass + +- Command: `whoami`; `auth whoami`; `profile session/get/update/company-user`; `invite create/list/show/onboarding/onboarding:text/skills:index/skill/logo/revoke`; `join list/reject`; `member list/user-directory/update/permissions/role-and-grants/archive`; `admin user list/company-access/company-access:update`; `instance scheduler-heartbeats/settings:general/settings:general:update/settings:experimental/settings:experimental:update/database-backup`; `sidebar preferences/preferences:update/project-preferences/project-preferences:update/badges`; `inbox dismissals/dismiss`; `auth challenge create/get/cancel/approve`; `auth logout` +- Purpose: Exercise board access, current profile, disposable invite, member/admin, instance settings, sidebar, inbox, and auth challenge surfaces. +- Prerequisites/IDs used: company `12e9db4b-f66c-459b-959e-d645002240fb`; member `373f91e2-a433-46ee-8362-e61ab5e06593`; user `local-board`; project `d32032ce-d95e-4c4e-a942-dd98498025fb`; approval `c7f19d1c-fcb3-4e4d-87a7-e8a248a9eb09`. +- Expected result: Read commands return JSON; no-op updates preserve scratch user/company access; disposable invites/challenges are revoked/cancelled/approved; unsafe self-removal is rejected. +- Actual result: Identity/profile/session commands passed. Profile update preserved name `Board`. Disposable invite `b3317c94-4e46-4ceb-9a5f-6df179c4f77e` was created, inspected through show/onboarding/onboarding text/skills index/skill, then revoked. `invite logo` was treated as optional because the company has no logo. Join list passed; two disposable pending join requests were rejected during cleanup. Member list/user-directory/update/permissions/role-and-grants passed; self archive returned expected `403: You cannot remove yourself`. Admin user list/company-access/company-access:update passed with the same company ID. Instance settings read/no-op update and database backup passed. Sidebar preferences/project preferences/badges and inbox dismissal passed. Auth challenge cancel `a52af778-39c1-41a4-8f87-46fd7b100d16` and approve `70b51e40-e6d4-4e01-ae5d-16734897375e` passed. `auth logout` completed safely against the isolated auth store. +- Status: PASS with expected negative path and mismatches. +- Output summary: `invite test-resolution` failed because the CLI does not provide the API-required `url` query. `join approve` on a disposable agent join request failed with `409: Join request cannot be approved because this company has no active CEO`; the request was rejected afterward. +- Follow-up: Continue public catalog/LLM docs, adapter/environment/workspace/asset/skill/plugin/setup command domains. + +### 2026-05-24T12:04:55+02:00 - Public catalog and LLM docs command check + +- Command: `openapi`; `available-skill list`; `available-skill index`; `available-skill get cmux`; `llm agent-configuration`; `llm agent-icons`; `llm agent-configuration:adapter process` +- Purpose: Exercise OpenAPI, public skill catalog, and LLM prompt documentation CLI surfaces. +- Prerequisites/IDs used: isolated server on `127.0.0.1:3197`. +- Expected result: Commands return JSON or text for registered public routes. +- Actual result: `available-skill list` and `available-skill index` passed. `openapi` returned `API error 404: API route not found`. `available-skill get cmux` returned `API error 404: Skill not found` even though `cmux` was returned by `available-skill list`. `llm agent-configuration`, `llm agent-icons`, and `llm agent-configuration:adapter process` returned `API error 404: API route not found`. +- Status: PARTIAL with missing route/route mismatch issues. +- Output summary: These look like CLI/API parity gaps rather than test data problems; no code fix was applied yet. +- Follow-up: Record mismatches and continue remaining command domains. + +### 2026-05-24T12:10:10+02:00 - Adapter, environment, project workspace, plugin coverage + +- Command: `curl -sf http://127.0.0.1:3197/api/health`; `pnpm paperclipai health --json`; `pnpm paperclipai adapter list/get/config-schema/ui-parser/models/model-profiles/detect-model/test-environment ... --json`; `pnpm paperclipai environment list/capabilities/probe-config/create ... --json`; `pnpm paperclipai project-workspace create/list/update/delete ... --json`; `pnpm paperclipai workspace list --company-id ... --json`; `pnpm paperclipai plugin list/examples/ui-contributions/tools/init ... --json` +- Purpose: Cover remaining adapter, environment/workspace, and plugin command families with safe disposable state. +- Prerequisites/IDs used: Company `12e9db4b-f66c-459b-959e-d645002240fb`; project `d32032ce-d95e-4c4e-a942-dd98498025fb`; isolated API `http://127.0.0.1:3197`. +- Expected result: API health passes; registered CLI commands map to supported routes; disposable project workspace can be created, updated, and deleted; plugin read-only routes and scaffold init work. +- Actual result: API health passed. `paperclipai health` is not registered. Adapter list/get/model commands passed for `process`; `process` config schema and UI parser returned expected unsupported 404s. `adapter test-environment process` returned a structured failure because no process command was supplied. Environment list/capabilities/probe-config passed, but creating a second local environment returned a 500 due to the unique `environments_company_driver_idx` constraint. Project workspace create/list passed; the first update/delete attempt failed because my shell ID extraction broke, then the workspace was recovered from `project-workspace list` and deleted successfully with `project-workspace delete d32032ce-d95e-4c4e-a942-dd98498025fb e271b6bc-368e-4a89-9824-d9e2b2bedb66 --json`. Workspace list passed. Plugin list/examples/ui-contributions/tools passed; `plugin init` scaffolded a disposable plugin under `tmp/cli-api-parity/artifacts/cli-parity-plugin`. +- Status: MIXED. +- Output summary: New mismatches/bugs recorded as `MISMATCH-008` and `BUG-004`. No external plugin install was attempted; no built-in adapter delete/reinstall was attempted. +- Follow-up: Fix the duplicate local environment 500 and restart the isolated server before rerunning the failing CLI command against live code. + +### 2026-05-24T12:12:50+02:00 - Asset and company skill coverage + +- Command: `pnpm paperclipai asset image:upload --company-id <company-id> --file doc/assets/avatars/zinc.png --namespace cli-parity --alt ... --title ... --json`; `pnpm paperclipai asset content <asset-id> --out tmp/cli-api-parity/artifacts/asset-download.png --json`; `pnpm paperclipai asset logo:upload --company-id <company-id> --file ui/public/favicon-32x32.png --json`; `pnpm paperclipai skill list/create/get/file/file:update/update-status/install-update/delete/scan-projects ... --json` +- Purpose: Cover asset upload/download/logo and company skill CRUD/file commands with disposable resources. +- Prerequisites/IDs used: Company `12e9db4b-f66c-459b-959e-d645002240fb`; image asset `829fbd86-cd5c-4aaa-ad17-276faac7888b`; logo asset `1b3e7979-1359-4361-b3f5-c8a845e11659`; temporary skill `126ad416-864b-4136-8f48-f5adcf324f20`. +- Expected result: Image upload returns an asset ID; content download writes bytes; logo upload succeeds; local skill create/get/file/update/delete works; update check reports unsupported for local skills. +- Actual result: Image upload returned `assetId` and content download wrote `27949` bytes. Logo upload returned `assetId`. Skill list/create/get/file/file:update/update-status/delete/scan-projects passed. `skill install-update` returned `422: Only GitHub-managed skills support update checks`, matching the preceding `update-status supported: false` result. +- Status: PASS with expected negative check. +- Output summary: Temporary local skill was deleted. Uploaded image/logo assets remain in the disposable scratch storage as part of the test instance. +- Follow-up: None for asset/skill surface. + +### 2026-05-24T12:13:24+02:00 - Fix duplicate local environment create error + +- Command: `pnpm exec vitest run server/src/__tests__/environment-routes.test.ts`; `pnpm --dir server typecheck` +- Purpose: Verify the route-level fix for duplicate local environment creation before committing. +- Prerequisites/IDs used: `BUG-004` reproduction from isolated E2E. +- Expected result: Creating a second local environment returns a controlled conflict instead of leaking a database unique constraint as a 500. +- Actual result: Focused route suite passed with 31 tests; server typecheck passed. +- Status: PASS. +- Output summary: Added a pre-insert `local` environment conflict check and regression coverage. +- Follow-up: Commit immediately, then restart the isolated server and rerun the failing CLI command against the updated code. + +### 2026-05-24T12:16:05+02:00 - Rerun duplicate local environment create on restarted server + +- Command: `env -u DATABASE_URL -u DATABASE_MIGRATION_URL ... pnpm paperclipai environment create --company-id 12e9db4b-f66c-459b-959e-d645002240fb --payload-json '{"name":"CLI parity local env","description":"Disposable CLI parity environment","driver":"local","config":{"cwd":"/Users/aronprins/Documents/PaperclipAI/paperclip"}}' --json` +- Purpose: Verify `BUG-004` against the restarted isolated source-tree server. +- Prerequisites/IDs used: Same scratch env; server restarted with `pnpm paperclipai onboard --yes --run --bind loopback`; company `12e9db4b-f66c-459b-959e-d645002240fb`. +- Expected result: Controlled conflict instead of internal server error. +- Actual result: CLI returned `API error 409: A local environment already exists for this company.` +- Status: PASS. +- Output summary: Confirms the live CLI/API path now exercises the fixed route behavior. +- Follow-up: Continue remaining parity/fix pass. + +### 2026-05-24T12:17:05+02:00 - Environment, plugin, and secrets lifecycle coverage + +- Command: `pnpm paperclipai environment create/get/leases/probe/update/delete ... --json`; `pnpm paperclipai plugin install/list/inspect/health/config/jobs/local-folders/ui-contributions/disable/enable/uninstall ... --json`; `pnpm paperclipai secrets list/create/link/declarations/migrate-inline-env ... --json` +- Purpose: Add positive non-local environment coverage, plugin lifecycle coverage, and deeper secrets coverage. +- Prerequisites/IDs used: Company `12e9db4b-f66c-459b-959e-d645002240fb`; bundled plugin path `/Users/aronprins/Documents/PaperclipAI/paperclip/packages/plugins/plugin-workspace-diff`; temporary SSH environment `cc5ae311-13f5-42b8-8044-11065b4e1af0`; temporary plugin install `e8421ed5-c103-4950-afb7-1463a0fbb9c5`; temporary secret `20c74546-7bec-4766-80cd-0b6c57545f7d`. +- Expected result: SSH environment can be created, read, updated, and deleted; SSH probe can fail gracefully when local SSH is unavailable; bundled plugin can install and uninstall in the isolated instance; managed secret can be created and inspected through supported CLI flows. +- Actual result: SSH environment create/get/leases/update/delete passed; probe returned structured `ok: false` with connection refused, as expected on this host. Plugin install/list/inspect/health/config/jobs/local-folders/ui-contributions/disable/enable/uninstall passed; final plugin list returned `[]`. Secret create/list/declarations/migrate-inline-env dry-run passed. `secrets link --provider local_encrypted --external-ref ...` returned `400: local_encrypted does not support external reference secrets`, which is expected provider behavior. The batch exposed missing CLI wrappers for secret update/rotate/usage/access-events/delete. +- Status: MIXED. +- Output summary: New fixed parity gap recorded as `BUG-005`. Plugin was uninstalled; SSH environment was deleted; one managed secret remained briefly for lifecycle verification. +- Follow-up: Add missing secret lifecycle CLI commands, then update/rotate/inspect/delete the temporary secret through the new CLI paths. + +### 2026-05-24T12:19:37+02:00 - Fix missing secret lifecycle CLI commands + +- Command: `pnpm exec vitest run cli/src/__tests__/secrets.test.ts`; `pnpm --dir cli typecheck` +- Purpose: Verify the CLI wrappers for API-backed secret update, rotate, usage, access events, and delete. +- Prerequisites/IDs used: `BUG-005` parity gap from OpenAPI reference and E2E. +- Expected result: Commands map to `PATCH /api/secrets/:id`, `POST /api/secrets/:id/rotate`, `GET /api/secrets/:id/usage`, `GET /api/secrets/:id/access-events`, and `DELETE /api/secrets/:id`. +- Actual result: Focused CLI secrets test passed with 8 tests; CLI typecheck passed. +- Status: PASS. +- Output summary: Added destructive delete confirmation via `--yes --confirm <secret-id>`. +- Follow-up: Run new commands against temporary scratch secret. + +### 2026-05-24T12:20:20+02:00 - Live-verify new secret lifecycle commands + +- Command: `pnpm paperclipai secrets update 20c74546-7bec-4766-80cd-0b6c57545f7d --payload-json ... --json`; `pnpm paperclipai secrets rotate 20c74546-7bec-4766-80cd-0b6c57545f7d --value ... --json`; `pnpm paperclipai secrets usage 20c74546-7bec-4766-80cd-0b6c57545f7d --json`; `pnpm paperclipai secrets access-events 20c74546-7bec-4766-80cd-0b6c57545f7d --json`; `pnpm paperclipai secrets delete 20c74546-7bec-4766-80cd-0b6c57545f7d --yes --confirm 20c74546-7bec-4766-80cd-0b6c57545f7d --json`; `pnpm paperclipai secrets list --company-id 12e9db4b-f66c-459b-959e-d645002240fb --json` +- Purpose: Verify fixed commands against the live disposable instance and clean up the temporary managed secret. +- Prerequisites/IDs used: Temporary secret `20c74546-7bec-4766-80cd-0b6c57545f7d`. +- Expected result: Update/rotate/usage/access-events/delete all succeed; final list is empty. +- Actual result: All new commands passed. Usage returned no bindings, access-events returned `[]`, delete returned `{ "ok": true }`, and final list returned `[]`. +- Status: PASS. +- Output summary: No test secrets remain in the company after this verification. +- Follow-up: Commit the CLI fix and updated log. + +### 2026-05-24T12:22:36+02:00 - Fix access, health, invite, join, and issue UX mismatches + +- Command: `pnpm exec vitest run cli/src/__tests__/access-parity.test.ts cli/src/__tests__/issue-subresources.test.ts`; `pnpm --dir cli typecheck` +- Purpose: Verify CLI fixes for `MISMATCH-001`, `MISMATCH-002`, `MISMATCH-003`, `MISMATCH-005`, `MISMATCH-006`, and `MISMATCH-008`. +- Prerequisites/IDs used: Mismatches from earlier E2E batches. +- Expected result: `paperclipai health` exists; `paperclipai access whoami` works; `invite test-resolution` has a URL option; `join list --status pending` maps to `pending_approval`; issue help text no longer overstates valid cancel/recovery inputs. +- Actual result: Focused tests passed with 6 tests; CLI typecheck passed. +- Status: PASS. +- Output summary: Added a top-level health command, an `access whoami` alias, `invite test-resolution --url`, pending status normalization, and more precise issue command descriptions. +- Follow-up: Live-verify changed commands against scratch instance. + +### 2026-05-24T12:23:25+02:00 - Live-verify access/health/invite/join fixes + +- Command: `pnpm paperclipai health --json`; `pnpm paperclipai access whoami --json`; `pnpm paperclipai join list --company-id 12e9db4b-f66c-459b-959e-d645002240fb --status pending --request-type agent --json`; `pnpm paperclipai invite create --company-id 12e9db4b-f66c-459b-959e-d645002240fb --payload-json '{}' --json`; `pnpm paperclipai invite test-resolution <token> --url https://example.com/invite/<token> --json`; `pnpm paperclipai invite revoke <invite-id> --json`; `pnpm paperclipai issue recovery:resolve --help`; `pnpm paperclipai issue interaction:cancel --help` +- Purpose: Verify fixed commands on the disposable instance and confirm help text updates. +- Prerequisites/IDs used: Company `12e9db4b-f66c-459b-959e-d645002240fb`; disposable invite `57d7fb29-e29e-4327-9d11-7be325831da6` revoked after test. A first test-resolution attempt against `http://127.0.0.1:3197/...` intentionally hit the server's private-address guard and was replaced with a public HTTPS URL. +- Expected result: Health and alias commands pass; pending alias is accepted; invite test-resolution sends the URL query and returns route data; help text mentions the narrower constraints. +- Actual result: `health`, `access whoami`, and `join list --status pending` passed. Public invite resolution returned `status: reachable`, method `HEAD`, HTTP `404` from `example.com`, proving the command now supplies the URL. Invite was revoked. Help output includes `todo, done, or in_review for restored outcomes; blocked is only valid for blocked outcomes` and `Cancel an ask_user_questions issue thread interaction`. +- Status: PASS. +- Output summary: All fixed UX/parity paths are verified. No invite from this batch remains active. +- Follow-up: Commit this fix batch and continue unresolved docs/catalog parity gap investigation. + +### 2026-05-24T12:28:46+02:00 - Fix LLM docs and available skill catalog isolation + +- Command: `pnpm exec vitest run server/src/__tests__/llms-routes.test.ts cli/src/__tests__/access-parity.test.ts`; `pnpm --dir server typecheck`; `pnpm --dir cli typecheck` +- Purpose: Fix the docs/catalog subset of `MISMATCH-007` that was straightforward and isolation-sensitive. +- Prerequisites/IDs used: Isolated `CLAUDE_HOME=/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/claude-home`. +- Expected result: CLI LLM commands reach mounted routes, and available skill discovery does not read the real `~/.claude/skills` when `CLAUDE_HOME` is set. +- Actual result: Focused tests passed; server and CLI typechecks passed. +- Status: PASS. +- Output summary: Mounted `llmRoutes` under `/api` in addition to the existing root mount. Updated available-skill discovery to read `CLAUDE_HOME/skills` when configured, include built-in Paperclip repo skills in `available-skill list`, and allow `available-skill get` for safe listed/built-in skill names. +- Follow-up: Restart isolated server and live-verify LLM docs plus available skill list/get behavior. + +### 2026-05-24T12:30:05+02:00 - Live-verify LLM docs and available skill catalog isolation + +- Command: `pnpm paperclipai llm agent-configuration --json`; `pnpm paperclipai llm agent-icons --json`; `pnpm paperclipai llm agent-configuration:adapter process --json`; `pnpm paperclipai available-skill list --json`; `pnpm paperclipai available-skill get paperclip --json`; `pnpm paperclipai available-skill get cmux --json`; `pnpm paperclipai openapi --json` +- Purpose: Verify docs/catalog fixes on the restarted disposable source-tree server. +- Prerequisites/IDs used: Same isolated env; server restarted after code changes. +- Expected result: LLM docs commands pass; built-in Paperclip skills are listed and fetchable; real-user `~/.claude` skills are not listed; `openapi` still documents the unresolved gap if no route exists. +- Actual result: LLM docs commands passed. `available-skill list` returned Paperclip repo skills such as `diagnose-why-work-stopped`; `available-skill get paperclip` returned markdown. `available-skill get cmux` now returns 404 because `cmux` is no longer listed from the real Claude home. `openapi` still returns `404: API route not found`. +- Status: MIXED. +- Output summary: Fixed the LLM route and available-skill isolation/list-get consistency parts of `MISMATCH-007`; `GET /api/openapi.json` remains unresolved. +- Follow-up: Commit scoped fixes and leave OpenAPI generation as the remaining docs/catalog parity gap. + +### 2026-05-24T12:32:12+02:00 - Final cleanup and isolation verification + +- Command: environment echo; `pnpm paperclipai health --json`; `pnpm paperclipai token board list --json`; `pnpm paperclipai token board revoke <redacted-board-token-id> --json`; `pnpm paperclipai token agent list --company-id 12e9db4b-f66c-459b-959e-d645002240fb --agent 1dd601a1-031a-4225-b005-419427fd059f --json`; `pnpm paperclipai plugin list --json`; `pnpm paperclipai secrets list --company-id 12e9db4b-f66c-459b-959e-d645002240fb --json`; `pnpm paperclipai environment list --company-id 12e9db4b-f66c-459b-959e-d645002240fb --json`; `pnpm paperclipai project-workspace list d32032ce-d95e-4c4e-a942-dd98498025fb --json`; `pnpm paperclipai openapi --json` +- Purpose: Confirm the disposable instance remains isolated, clean up leftover tokens, and record final known gap. +- Prerequisites/IDs used: Isolated env from the Isolation Contract; board token `<redacted-board-token-id>`; main agent `1dd601a1-031a-4225-b005-419427fd059f`. +- Expected result: All env vars point under `tmp/cli-api-parity`; database env vars remain unset; health passes; no active disposable tokens, plugins, secrets, project workspaces, or non-default environments remain; OpenAPI still fails as the documented unresolved gap. +- Actual result: Env echoed the scratch paths and `DATABASE_URL`/`DATABASE_MIGRATION_URL` as unset. Health passed. Board token list found one active key (`<redacted-board-token-id>`) from the earlier board-token test, which was revoked. Agent token list showed only revoked keys. Plugin list and secrets list returned empty arrays. Environment list contains the default local environment only. Project workspace list returned empty. `openapi` still returned `404: API route not found`. +- Status: PASS with known OpenAPI gap. +- Output summary: No active API tokens created by this run remain. Scratch instance remains running on `http://127.0.0.1:3197`. +- Follow-up: Final report should call out `openapi` as unfixed and explain that implementing it needs a real OpenAPI generator/route rather than a small CLI wrapper correction. + +### 2026-05-24T12:45:40+02:00 - Root setup and local maintenance command coverage + +- Command: `pnpm paperclipai doctor --config <scratch-config>`; `pnpm paperclipai doctor --config <scratch-config> --repair --yes`; `pnpm paperclipai env --config <scratch-config>`; `pnpm paperclipai db:backup --config <scratch-config> --dir tmp/cli-api-parity/artifacts/root-setup/backups --retention-days 1 --filename-prefix cli-parity --json`; `pnpm paperclipai allowed-hostname cli-parity.test --config <scratch-config>`; `pnpm paperclipai auth bootstrap-ceo --config <scratch-config> --force --base-url http://127.0.0.1:3197`; `pnpm paperclipai auth whoami --json`; `pnpm paperclipai routines disable-all --config <scratch-config> --company-id 12e9db4b-f66c-459b-959e-d645002240fb --json`; `pnpm paperclipai env-lab doctor --instance cli-api-parity --json`; `pnpm paperclipai env-lab status --instance cli-api-parity --json` +- Purpose: Cover root/setup commands and local maintenance utilities against the disposable instance without touching real home state. +- Prerequisites/IDs used: Scratch config `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/instances/cli-api-parity/config.json`; company `12e9db4b-f66c-459b-959e-d645002240fb`. +- Expected result: Doctor and env introspection use scratch config; DB backup writes under scratch artifacts; allowed-hostname mutates only scratch config; bootstrap CEO is a no-op in `local_trusted`; routines disable-all is harmless with no routines; env-lab reports host capability/status without starting services. +- Actual result: All commands passed. `doctor` and `doctor --repair --yes` completed. `env` printed scratch deployment variables; the generated agent JWT secret is not copied here. `db:backup` created a one-off backup under `tmp/cli-api-parity/artifacts/root-setup/backups`. `allowed-hostname` added `cli-parity.test` to the scratch config and noted a restart is required for it to take effect. `auth bootstrap-ceo` correctly reported that bootstrap CEO invites are only required in authenticated mode. `auth whoami` returned the local implicit board identity. `routines disable-all` reported zero routines. `env-lab doctor` reported SSH env-lab is disabled on macOS unless explicitly opted in, and `env-lab status` reported no running fixture. +- Status: PASS. +- Output summary: This batch covered root setup/maintenance command surfaces that were previously only indirectly covered. Some artifact output files under `tmp/cli-api-parity/artifacts/root-setup` contain command output from the disposable instance. +- Follow-up: Continue remaining untested command families, especially cloud/worktree surfaces and any server-backed command gaps discovered by targeted help/source review. + +### 2026-05-24T12:47:54+02:00 - Worktree and cloud command gated coverage + +- Command: `PAPERCLIP_WORKTREES_DIR=tmp/cli-api-parity/worktrees-home pnpm paperclipai worktree:list --json`; `pnpm paperclipai worktree env --config <scratch-config> --json`; `pnpm paperclipai worktree:merge-history --from current --to current --company CLI --dry`; `pnpm paperclipai cloud push --company 12e9db4b-f66c-459b-959e-d645002240fb --dry-run --json` +- Purpose: Start worktree/cloud parity coverage with read-only or dry-run commands before attempting any lifecycle command that creates branches, worktrees, or external cloud connections. +- Prerequisites/IDs used: Scratch config `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/instances/cli-api-parity/config.json`; scratch worktree root `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/worktrees-home`; company `12e9db4b-f66c-459b-959e-d645002240fb`. +- Expected result: Worktree list and env introspection should use scratch config/environment; merge-history should reject identical source/target configs without mutating state; cloud push should fail safely if cloud sync is not enabled/configured. +- Actual result: `worktree:list` passed and showed the current repo branch `improvement/cli-api-parity` with no Paperclip worktree config. `worktree env --config <scratch-config> --json` passed and printed the scratch `PAPERCLIP_CONFIG` plus generated env values; the generated JWT secret is intentionally not copied into this log. `worktree:merge-history --from current --to current --company CLI --dry` failed as expected with `Source and target Paperclip configs are the same. Choose different --from/--to worktrees.` `cloud push --dry-run` failed as expected with `Cloud sync is disabled. Enable the cloud sync experimental setting before running paperclipai cloud push.` +- Status: PASS for safe/gated coverage. +- Output summary: Worktree read-only/dry-run paths behaved safely. Cloud push was not attempted against a real upstream and remained blocked by scratch instance settings. +- Follow-up: Continue with a scratch-only worktree lifecycle test. Cloud requires an experimental setting plus a configured upstream; keep it gated unless a disposable fake upstream can be wired without touching the real install. + +### 2026-05-24T12:55:45+02:00 - Scratch worktree lifecycle and fix verification + +- Command: `HOME=tmp/cli-api-parity/shell-home PAPERCLIP_WORKTREES_DIR=tmp/cli-api-parity/worktree-instances pnpm paperclipai worktree:make cli-parity-wt --home <scratch-worktree-home> --from-config <scratch-config> --server-port 3198 --db-port 54331 --seed-mode minimal`; `pkill` only for the runaway scratch install attempt; edited `cli/src/commands/worktree.ts` and `cli/src/__tests__/worktree.test.ts`; `pnpm exec vitest run cli/src/__tests__/worktree.test.ts`; `pnpm --dir cli typecheck`; `paperclipai worktree:cleanup cli-parity-wt --home <scratch-worktree-home> --force`; reran `paperclipai worktree:make ...`; `paperclipai worktree:list --json`; `paperclipai worktree env --config <scratch-worktree-config> --json`; `paperclipai worktree:merge-history --from paperclip-cli-parity-wt --to current --company CLI --dry` +- Purpose: Exercise scratch-only worktree creation, initialization, dependency install, minimal DB seed, list/env introspection, dry-run merge history, and cleanup behavior without touching the real home or default instance. +- Prerequisites/IDs used: Scratch `HOME` `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/shell-home`; scratch worktree instance home `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/worktree-instances`; source config `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/instances/cli-api-parity/config.json`; worktree branch/path `paperclip-cli-parity-wt`. +- Expected result: `worktree:make` creates `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/shell-home/paperclip-cli-parity-wt`, installs dependencies once, writes repo-local `.paperclip/config.json` and `.paperclip/.env`, seeds a minimal isolated DB on ports `3198`/`54331`, and leaves normal `worktree:list`, `worktree env`, and `worktree:merge-history --dry` usable. +- Actual result: The first live attempt exposed BUG-007: dependency installation recursively invoked the user pnpm shim when `HOME` was overridden. After the fix, focused worktree tests and CLI typecheck passed. `worktree:cleanup --force` removed the partial scratch branch/worktree. The rerun completed successfully: dependencies installed, minimal DB seed succeeded, repo config/env were written under the scratch worktree, instance data was written under scratch worktree home, `worktree:list` showed the new worktree with `hasPaperclipConfig: true`, `worktree env --json` printed the scratch worktree env, and `worktree:merge-history --dry` previewed zero inserts with existing company history already present. The generated worktree JWT secret is intentionally not copied here. +- Status: PASS after BUG-007 fix. +- Output summary: One disposable worktree remains for manual continuation at `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/shell-home/paperclip-cli-parity-wt`; its isolated config is `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/shell-home/paperclip-cli-parity-wt/.paperclip/config.json`. +- Follow-up: Commit BUG-007 fix, then continue remaining non-worktree command families. Cloud still requires a configured upstream or fake upstream harness for deeper coverage. + +### 2026-05-24T13:06:21+02:00 - Agent prompt, heartbeat, feedback, board claim, OpenClaw, and configure coverage + +- Command: `token agent create --company-id 12e9db4b-f66c-459b-959e-d645002240fb --agent 1dd601a1-031a-4225-b005-419427fd059f --name cli-agent-prompt-smoke --json`; `agent-prompt 1dd601a1-031a-4225-b005-419427fd059f <agent-token> "CLI parity agent-prompt smoke without wake" --title "CLI parity agent-prompt smoke" --no-wake --json`; `token agent revoke <key-id> --company-id ... --agent ... --json`; `heartbeat run --agent-id 1dd601a1-031a-4225-b005-419427fd059f --source on_demand --trigger manual --timeout-ms 5000 --json`; `feedback report/export/trace/bundle`; `company feedback:list`; `board-claim show invalid-claim-token --json`; `board-claim claim invalid-claim-token --payload-json '{}' --json`; `openclaw invite-prompt --company-id ... --payload-json '{"goal":"CLI parity OpenClaw invite prompt smoke"}' --json`; `configure --config <scratch-config> --section invalid-section` +- Purpose: Cover remaining safe operator/helper command surfaces that were not part of the earlier company/issue/agent/core batches. +- Prerequisites/IDs used: Company `12e9db4b-f66c-459b-959e-d645002240fb`; agent `1dd601a1-031a-4225-b005-419427fd059f`; feedback trace `6193ff3a-55d3-4c01-bfbf-78e82ed55793`; temporary agent token stored only under scratch artifacts and revoked after use. +- Expected result: `agent-prompt` creates or updates work through an agent token and no-wake path; heartbeat run returns a bounded on-demand invocation result; feedback report/export/trace/bundle can read the existing feedback trace; invalid board claim calls fail with controlled user-facing errors; OpenClaw invite prompt returns prompt data; invalid configure section should fail non-zero. +- Actual result: First `agent-prompt` attempts failed with `401` because the test script passed the wrong token field (`.key` object / empty shell variable) instead of `.key.token`. Retrying with `.key.token` passed and the token was revoked. `heartbeat run` completed within the timeout. Feedback report/export and trace/bundle passed using the existing trace. `board-claim show` returned expected `404: Board claim challenge not found`; `board-claim claim` returned expected `400: Claim code is required`. OpenClaw invite prompt passed. `configure --section invalid-section` initially printed an error but exited `0`, exposing BUG-008; after the fix, it exits `1`. +- Status: PASS after BUG-008 fix; board-claim paths covered only with invalid/gated tokens because no active board claim challenge exists in `local_trusted`. +- Output summary: Agent token artifacts are under `tmp/cli-api-parity/artifacts/agent-prompt-token*.json`; the token was revoked. Feedback artifacts are under `tmp/cli-api-parity/artifacts/feedback-*`. No real home state was used. +- Follow-up: Continue cloud/connect/run coverage decisions. `connect` and positive board-claim flows are interactive/bootstrap-token dependent and may remain documented as gated if no disposable token source is available. + +### 2026-05-24T13:11:19+02:00 - Cloud fake-upstream and remaining run/connect coverage + +- Command: disposable Node fake upstream on `http://127.0.0.1:3199`; `instance settings:experimental:update --payload-json '{"enableCloudSync":true}' --json`; `cloud connect http://127.0.0.1:3199 --no-browser --json`; `cloud push --company 12e9db4b-f66c-459b-959e-d645002240fb --remote-url http://127.0.0.1:3199 --dry-run --json`; `run live --company-id ... --limit 5 --min-count 1 --json`; `run issues 9c686a91-c88a-47aa-9326-a889c4281d2b --json`; `run workspace-operations 9c686a91-c88a-47aa-9326-a889c4281d2b --json`; `run workspace-log 00000000-0000-4000-8000-000000000000 --json`; `run cancel 9c686a91-c88a-47aa-9326-a889c4281d2b --json`; `run watchdog-decision 9c686a91-c88a-47aa-9326-a889c4281d2b --decision continue --reason "CLI parity watchdog decision smoke" --json`; `connect --persona board --api-base http://127.0.0.1:3197 --profile cli-connect-smoke --context <scratch-context> --json` +- Purpose: Exercise cloud connect/push without a real external Paperclip Cloud stack, finish run subcommands that were not covered by earlier run list/get/events/log checks, and verify `connect` behavior in the non-interactive test runner. +- Prerequisites/IDs used: Fake cloud server on loopback port `3199`; company `12e9db4b-f66c-459b-959e-d645002240fb`; heartbeat run `9c686a91-c88a-47aa-9326-a889c4281d2b` created by `heartbeat run`; scratch context and config paths. +- Expected result: Cloud sync can be enabled only in the scratch instance; `cloud connect` stores a fake upstream connection under scratch `PAPERCLIP_HOME`; `cloud push --dry-run` exports local company data and posts a preview bundle to the fake upstream; run read/control commands return structured results or controlled 404 for a nonexistent workspace operation; `connect` refuses non-interactive execution with guidance. +- Actual result: Experimental `enableCloudSync` was enabled in the scratch instance. `cloud connect --no-browser` completed against the fake upstream and stored a fake connection. `cloud push --dry-run` returned a fake preview response with summary `{create:0, update:0, adopt:0, skip:1, conflict:0, staleMapping:0}`. `run live`, `run issues`, and `run workspace-operations` passed; workspace operations returned an empty list. `run workspace-log` on a sentinel ID returned expected `404: Workspace operation not found`. `run cancel` on the already completed run returned the run unchanged with status `succeeded`. `run watchdog-decision` created a watchdog decision record. `connect` returned expected non-interactive error: use `--api-base/--api-key` or context/token commands for scripts. +- Status: PASS for fake-cloud dry-run and remaining safe run/connect coverage. +- Output summary: Fake cloud artifacts are under `tmp/cli-api-parity/artifacts/cloud-*`. The fake cloud token is synthetic and stored only in scratch Paperclip home. `connect` remains intentionally interactive; scriptable equivalent coverage is via `context set`, token commands, `whoami`, and agent/board prompt flows already tested. +- Follow-up: Stop the fake upstream server. Keep the real scratch Paperclip server running on `127.0.0.1:3197`. + +### 2026-05-24T13:15:09+02:00 - Routine lifecycle coverage + +- Command: `routine create --company-id 12e9db4b-f66c-459b-959e-d645002240fb --payload-json '{"title":"CLI parity routine smoke",...}' --json`; `routine list`; `routine get 8254ead3-7edd-43fc-97ca-cb3f477cefc9`; `routine update`; `routine revisions`; `routine runs`; `routine trigger:create <routine-id>` for API and webhook triggers; `routine trigger:update <api-trigger-id>`; `routine trigger:rotate-secret <api-trigger-id>`; `routine trigger:rotate-secret <webhook-trigger-id>`; `routine trigger:fire <webhook-public-id>`; `routine run <routine-id>` without and then with `assigneeAgentId`; `routine trigger:delete` for both triggers; final `routine update <routine-id> --payload-json '{"status":"archived"}'` +- Purpose: Exercise routine CRUD, revision/runs inspection, manual run, trigger create/update/delete, trigger secret rotation, public trigger fire validation, and cleanup by archiving the disposable routine. +- Prerequisites/IDs used: Company `12e9db4b-f66c-459b-959e-d645002240fb`; agent `1dd601a1-031a-4225-b005-419427fd059f`; routine `8254ead3-7edd-43fc-97ca-cb3f477cefc9`. +- Expected result: Routine can be created, listed, read, updated, inspected, manually run when an assignee is supplied, and archived. API trigger secret rotation should fail because only webhook triggers have secrets. Disabled webhook fire should fail cleanly. Trigger delete should remove disposable triggers. +- Actual result: Routine create/list/get/update/revisions/runs passed. API trigger create/update/delete passed. `trigger:rotate-secret` on the API trigger returned expected `422: Only webhook triggers can rotate secrets`; webhook trigger create and rotate passed. `trigger:fire` on the disabled webhook returned expected `409: Routine trigger is not active`. `routine run` without assignee returned expected `422: Default agent required`; rerun with `assigneeAgentId` passed and produced one routine run. Both triggers were deleted and the routine was archived. +- Status: PASS with expected validation failures. +- Output summary: Routine artifacts are under `tmp/cli-api-parity/artifacts/routine`. No active routine/trigger from this batch remains; the disposable routine is archived. +- Follow-up: Final inventory and status check. + +### 2026-05-24T13:19:42+02:00 - Final token-list fix verification + +- Command: `token agent list --company-id 12e9db4b-f66c-459b-959e-d645002240fb --agent 1dd601a1-031a-4225-b005-419427fd059f --json`; edited `cli/src/commands/client/token.ts` and `cli/src/__tests__/token.test.ts`; `pnpm exec vitest run cli/src/__tests__/token.test.ts`; `pnpm --dir cli typecheck`; reran the same live `token agent list` command. +- Purpose: Verify agent token list accepts the documented `--agent <agent-id>` shape during final cleanup. +- Prerequisites/IDs used: Company `12e9db4b-f66c-459b-959e-d645002240fb`; agent `1dd601a1-031a-4225-b005-419427fd059f`. +- Expected result: Agent token list resolves an agent ID directly and returns the key list. +- Actual result: Initial final cleanup attempt returned `404: Agent not found` for the agent ID, while `agent list` showed the agent exists and using `--agent "Parity Worker"` worked. After BUG-009 fix, the same ID-based command passed and showed no active unrevoked agent keys. +- Status: PASS after BUG-009 fix. +- Output summary: Verification artifact written to `tmp/cli-api-parity/artifacts/final-agent-tokens-by-id.json`. +- Follow-up: Commit BUG-009 fix, then run final clean status checks. + +### 2026-05-24T13:20:43+02:00 - Final status sweep + +- Command: `health --json`; `token board list --json`; `token agent list --company-id 12e9db4b-f66c-459b-959e-d645002240fb --agent 1dd601a1-031a-4225-b005-419427fd059f --json`; `routine list --company-id ... --json`; `plugin list --json`; `openapi --json`; `git status --short --branch`; `lsof -nP -iTCP:3197 -sTCP:LISTEN`; `lsof -nP -iTCP:3199 -sTCP:LISTEN` +- Purpose: Confirm the disposable instance is still healthy, no temporary token/plugin/routine resources remain active, the fake cloud server is stopped, and git state is clean after all fixes. +- Prerequisites/IDs used: Same scratch env and company/agent IDs. +- Expected result: Paperclip remains running on `127.0.0.1:3197`; fake cloud port `3199` is stopped; no active board or agent tokens from the test remain; plugin list is empty; disposable routine is archived; `openapi` remains the one documented unresolved API route gap. +- Actual result: Health returned `status: ok`; process `11566` is listening on `127.0.0.1:3197`; no process is listening on `3199`; final board token list has 2 revoked keys and no active keys; final agent token list has 4 revoked keys and no active keys; plugin list is empty; routine list contains the archived disposable routine and no active routines; `openapi --json` still returns `404: API route not found`; git status was clean before this final log update. +- Status: PASS with known unresolved OpenAPI gap. +- Output summary: Final artifacts are under `tmp/cli-api-parity/artifacts/final-*`. +- Follow-up: Final report should include restart commands and call out `openapi --json` as not fixed because the OpenAPI branch/generator has not been integrated into this repo. + +### 2026-05-24T13:25:20+02:00 - OpenAPI route fix verification + +- Command: Generated `server/src/routes/openapi.ts` from the route inventory in `doc/plans/2026-05-23-cli-api-parity-openapi-reference.ts`; mounted `openApiRoutes()` under `/api`; added `server/src/__tests__/openapi-routes.test.ts`; ran `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts`; `pnpm --dir server typecheck`; restarted the isolated runbook server with the scratch environment; `curl -fsS http://127.0.0.1:3197/api/openapi.json | jq '{openapi, pathCount:(.paths|keys|length)}'`; `pnpm --silent paperclipai openapi --json > tmp/cli-api-parity/artifacts/openapi-live-after-fix.json`. +- Purpose: Close the remaining documented `openapi` CLI/API parity gap without introducing a new generator dependency during the live parity run. +- Prerequisites/IDs used: Same scratch env, API URL `http://127.0.0.1:3197`, and local source-tree install. +- Expected result: `/api/openapi.json` and `paperclipai openapi --json` return a valid OpenAPI 3.0 document with the reference route inventory, including representative CLI/API parity paths such as `/api/companies/{companyId}/agents` and `/api/agents/{id}/keys`. +- Actual result: Focused test and `server` typecheck passed. After restart, direct curl returned `{"openapi":"3.0.0","pathCount":247}`. The CLI command returned `openapi: "3.0.0"`, title `Paperclip API`, `247` paths, `/api/openapi.json` summary `Get the generated OpenAPI document`, and `/api/agents/{id}/keys` POST summary `Create an agent API key`. +- Status: PASS after MISMATCH-007 OpenAPI fix. +- Output summary: Live OpenAPI artifact is `tmp/cli-api-parity/artifacts/openapi-live-after-fix.json`. The route exposes operation inventory, tags, summaries, and standard responses from the parity reference; it intentionally does not yet include full request/response schemas. +- Follow-up: Commit the OpenAPI route fix, then rerun the final inventory/status sweep. + +### 2026-05-24T13:31:50+02:00 - Instructions path help fix verification + +- Command: Edited `cli/src/commands/client/agent.ts`; ran `pnpm exec vitest run cli/src/__tests__/agent-lifecycle.test.ts`; `pnpm --dir cli typecheck`; `pnpm --silent paperclipai agent instructions-path:update --help`. +- Purpose: Close the remaining logged UX mismatch where process-adapter instructions path requirements were only discoverable through failing API calls. +- Prerequisites/IDs used: Local source-tree CLI; no live server mutation required. +- Expected result: Help text explains that process adapters require `adapterConfigKey`, relative paths require `adapterConfig.cwd`, and the JSON payload option includes a concrete example. +- Actual result: Focused agent lifecycle test and CLI typecheck passed. Help output now includes the process-adapter requirement, the relative path `adapterConfig.cwd` requirement, and example payload `{"path":"/tmp/AGENTS.md","adapterConfigKey":"instructionsFilePath"}`. +- Status: PASS after MISMATCH-004 help fix. +- Output summary: This is a help-only CLI change; no scratch instance resources were created. +- Follow-up: Commit the help fix, then continue residual command coverage. + +### 2026-05-24T13:33:50+02:00 - Residual company and skill command coverage + +- Command: `company stats`; disposable `company create`; `company branding:update`; `company archive`; `company delete`; `company export:preview`; `company export:api`; `company import:preview`; `company import:apply`; cleanup `company delete`; `skill import`; cleanup `skill delete`. +- Purpose: Cover company subcommands and `skill import` not explicitly exercised in earlier batches. +- Prerequisites/IDs used: Company `12e9db4b-f66c-459b-959e-d645002240fb`; disposable archive company `342c1b91-0f48-4a63-a9c5-fc7ffc758483`; raw-import company `dab6758c-dd30-4066-87f0-4df76bd21ea5`; imported skill `be9538e4-9827-426f-b82a-4228c5d3f851`. +- Expected result: Company stats read succeeds; disposable company can be branded, archived, and deleted; raw API portability commands work with API-shaped payloads; skill import from a local repo skill path succeeds and the imported skill can be deleted. +- Actual result: Company stats passed. Disposable company branding/archive/delete passed. First raw export attempt using CLI wrapper-style `{"include":["company"]}` returned expected API validation `include` object error, so the test was adapted to raw API shape `{"include":{"company":true}}`; export preview and export API then passed. Full exported package import via a shell variable was abandoned because markdown code fences in the large JSON payload caused shell transport issues; a minimal inline company package was used instead, and raw import preview/apply/delete passed. `skill import` imported one local skill and cleanup delete passed. +- Status: PASS after adapting raw API payload shape. +- Output summary: Artifacts are under `tmp/cli-api-parity/artifacts/residual-company-skill`. No disposable company or imported skill from this batch remains active. +- Follow-up: Continue advanced plugin surface coverage. + +### 2026-05-24T13:37:45+02:00 - Advanced plugin command coverage and tool-dispatch failure + +- Command: Installed bundled kitchen-sink plugin; `plugin list/inspect/health/logs/config/jobs/job:runs/job:trigger/webhook/dashboard/bridge:data/data/action/local-folders/upgrade/disable/enable/uninstall`; initial `plugin config:test/config:set/bridge:action/tool:execute/local-folder:*` attempts; corrected `config:test`, `config:set`, and `bridge:action`; final uninstall. +- Purpose: Exercise plugin command surfaces that require a plugin declaring jobs, webhooks, tools, bridge handlers, and UI contributions. +- Prerequisites/IDs used: Bundled plugin path `packages/plugins/examples/plugin-kitchen-sink-example`; company `12e9db4b-f66c-459b-959e-d645002240fb`; project `d32032ce-d95e-4c4e-a942-dd98498025fb`; agent `1dd601a1-031a-4225-b005-419427fd059f`; run `9c686a91-c88a-47aa-9326-a889c4281d2b`. +- Expected result: Plugin installs, exposes its manifest surfaces, handles job/webhook/bridge/data/action calls, rejects unsupported stream/local-folder calls cleanly, and uninstalls. Tool execution should work for the listed kitchen-sink echo tool. +- Actual result: Install/list/inspect/health/logs/config/jobs/job:runs/job:trigger/webhook/dashboard/bridge:data/data/action/upgrade/disable/enable/uninstall passed. Config commands initially failed until payloads were corrected to `{"configJson":{...}}`; bridge action initially failed until payload used `key` instead of `action`. `bridge:stream` returned expected `Plugin stream bridge is not enabled`. Local-folder calls returned expected validation because the kitchen-sink manifest declares no local folders. `plugin tools` listed `paperclip-kitchen-sink-example:echo`, but `plugin tool:execute` returned `502: worker for plugin "paperclip-kitchen-sink-example" is not running` even though bridge calls to the same plugin worker succeeded. +- Status: PASS for safe plugin surfaces; FAIL for `plugin tool:execute`, recorded as BUG-010. +- Output summary: Artifacts are under `tmp/cli-api-parity/artifacts/residual-plugin` and `tmp/cli-api-parity/artifacts/residual-plugin-corrected`. The kitchen-sink plugin was uninstalled after each batch. +- Follow-up: Fix BUG-010 and rerun `plugin tool:execute` live. + +### 2026-05-24T13:41:40+02:00 - Plugin tool-dispatch fix verification + +- Command: Edited `server/src/services/plugin-tool-dispatcher.ts`, `server/src/services/plugin-loader.ts`, and `server/src/__tests__/plugin-database.test.ts`; ran `pnpm exec vitest run server/src/__tests__/plugin-database.test.ts`; `pnpm --dir server typecheck`; restarted the isolated server; installed kitchen-sink plugin; `plugin tools`; `plugin tool:execute --payload-json '{"tool":"paperclip-kitchen-sink-example:echo",...}'`; cleanup `plugin uninstall --force`. +- Purpose: Verify plugin tool execution uses the plugin database ID for worker lookup while preserving plugin-key namespaced tool names. +- Prerequisites/IDs used: Same scratch server and kitchen-sink plugin; tool `paperclip-kitchen-sink-example:echo`. +- Expected result: The listed echo tool dispatches to the running kitchen-sink worker and returns the echo result. +- Actual result: Focused test and server typecheck passed. After restart, `plugin tools` listed `paperclip-kitchen-sink-example:echo`; `plugin tool:execute` returned `content: "CLI parity tool after fix"` and the expected run context. The plugin was uninstalled and `plugin list` returned `[]`. +- Status: PASS after BUG-010 fix. +- Output summary: Live verification artifacts are under `tmp/cli-api-parity/artifacts/residual-plugin-after-fix`. +- Follow-up: Commit BUG-010 fix, then rerun final inventory/status sweep. + +### 2026-05-24T13:47:02+02:00 - Routine webhook secret cleanup fix verification + +- Command: Final inventory found active managed secret `156c6074-37b7-4f8e-8619-a62027c2147e`; inspected routine trigger secret handling; edited `server/src/services/routines.ts` and `server/src/__tests__/routines-service.test.ts`; ran `pnpm exec vitest run server/src/__tests__/routines-service.test.ts`; `pnpm --dir server typecheck`; restarted isolated server; deleted the older leaked disposable secret; created temporary routine `60ac06c9-f8c4-4cb1-b9fd-ae52163eb3e6`; created webhook trigger `02838bc3-5b48-4f1e-aad0-ca63a48b926b`; deleted the trigger; verified secret `140c2608-0d8e-4f1e-aad0-ca63a48b926b` was absent from `secrets list`; archived the temporary routine. +- Purpose: Fix and verify cleanup for routine webhook trigger generated secrets. +- Prerequisites/IDs used: Company `12e9db4b-f66c-459b-959e-d645002240fb`; scratch server restarted with the patched code. +- Expected result: Deleting a webhook routine trigger removes the generated paperclip-managed secret and binding. No active secrets remain from parity cleanup. +- Actual result: Focused routine service test and server typecheck passed. Live trigger delete removed the generated secret; final `secrets list` returned `0` rows. The older leaked disposable secret was deleted through the CLI. +- Status: PASS after BUG-011 fix. +- Output summary: Artifacts are under `tmp/cli-api-parity/artifacts/routine-secret-cleanup-fix`. +- Follow-up: Commit BUG-011 fix, then rerun final inventory/status sweep. + +### 2026-05-24T13:48:40+02:00 - Final clean inventory sweep + +- Command: `health --json`; `openapi --json`; `token board list --json`; `token agent list --company-id 12e9db4b-f66c-459b-959e-d645002240fb --agent 1dd601a1-031a-4225-b005-419427fd059f --json`; `plugin list --json`; `routine list --company-id ... --json`; `secrets list --company-id ... --json`; `environment list --company-id ... --json`; `project-workspace list d32032ce-d95e-4c4e-a942-dd98498025fb --json`; `git status --short --branch`; `lsof -nP -iTCP:3197 -sTCP:LISTEN`; `lsof -nP -iTCP:3199 -sTCP:LISTEN`; environment echo for required isolation variables and unset database variables. +- Purpose: Confirm the disposable instance is healthy, isolated, cleaned up, and ready for manual continuation. +- Prerequisites/IDs used: Same scratch env and company/agent IDs. +- Expected result: Health and OpenAPI pass; all required env vars point under `tmp/cli-api-parity`; `DATABASE_URL` and `DATABASE_MIGRATION_URL` are unset; no active board/agent tokens, plugins, secrets, non-default environments, project workspaces, or active routines remain; only the scratch server listens on `127.0.0.1:3197`; fake cloud port `3199` is stopped; git is clean before this final log update. +- Actual result: Summary was `{health:"ok", openapi:"3.0.0", pathCount:247, activeBoardTokens:0, activeAgentTokens:0, plugins:0, routines:2, activeRoutines:0, secrets:0, environments:1, projectWorkspaces:0}`. `PAPERCLIP_HOME`, `PAPERCLIP_CONFIG`, `PAPERCLIP_CONTEXT`, `PAPERCLIP_AUTH_STORE`, `CODEX_HOME`, and `CLAUDE_HOME` all point under the scratch path. `DATABASE_URL` and `DATABASE_MIGRATION_URL` were `UNSET`. `node` PID `70429` is listening on `127.0.0.1:3197`; no process is listening on `3199`; git status was clean before this log update. +- Status: PASS. +- Output summary: Final clean artifacts are under `tmp/cli-api-parity/artifacts/final-clean-sweep`. +- Follow-up: Leave the scratch instance running for manual testing. + +### 2026-05-24T13:50:00+02:00 - Final user-facing handoff summary + +- Command: Recorded the final assistant handoff summary in this living log after the clean inventory sweep. +- Purpose: Preserve the exact user-facing closeout in the reproducible CLI/API parity audit trail. +- Prerequisites/IDs used: Scratch server on `http://127.0.0.1:3197`; company `12e9db4b-f66c-459b-959e-d645002240fb`; agent `1dd601a1-031a-4225-b005-419427fd059f`; final clean sweep artifacts under `tmp/cli-api-parity/artifacts/final-clean-sweep`. +- Expected result: The log includes the final paths, command coverage, bug/fix summary, cleanup state, verification commands, and manual continuation instructions that were reported to the user. +- Actual result: Final handoff stated that the full isolated CLI/API parity pass completed and was committed, the scratch Paperclip server was still running on `http://127.0.0.1:3197`, and goal usage was 2,625,855 tokens over about 2h 23m. +- Status: PASS. +- Output summary: + +```json +{ + "health": "ok", + "openapi": "3.0.0", + "pathCount": 247, + "activeBoardTokens": 0, + "activeAgentTokens": 0, + "plugins": 0, + "routines": 2, + "activeRoutines": 0, + "secrets": 0, + "environments": 1, + "projectWorkspaces": 0 +} +``` + +- Final paths: + - Scratch home: `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home` + - Config: `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/instances/cli-api-parity/config.json` + - Context: `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/context.json` + - Auth store: `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/auth.json` + - Embedded DB: `/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/instances/cli-api-parity/db`, active on pg port `54330` + - API URL: `http://127.0.0.1:3197` + - Log: `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md` +- Commands covered: health; context; auth/access/profile/session/invite/join/member/admin/sidebar/inbox/openclaw; company CRUD/export/import/raw portability/stats/branding/archive; goals; projects; agents; tokens; issues and subresources; approvals; feedback; dashboard/activity/cost/finance/budget; secrets lifecycle; adapters; environments; workspaces/project-workspaces; assets; skills/import; routines/triggers; prompt/wake/run/heartbeat; cloud fake upstream; worktree helpers; setup/doctor/env/db backup/env-lab; OpenAPI; advanced plugin lifecycle/tool/job/webhook/bridge/config surfaces. +- Latest continuation commits: + - `01579595` Log final CLI parity sweep + - `ce16de70` Clean up routine webhook secrets + - `1de4174f` Fix plugin tool worker lookup + - `ec3cb727` Clarify instructions path CLI help + - `c811bf07` Add OpenAPI CLI route +- Remaining caveats: + - Positive interactive `connect` was initially not run because the CLI intentionally rejects non-TTY use; this was resolved in the follow-up with prompt-driven board and agent persona command tests plus the already covered scriptable auth/context/token flows. + - Positive `board-claim claim` was initially not run because the live scratch server was `local_trusted`; this was resolved in the follow-up with an isolated authenticated-mode service test. + - OpenAPI initially worked only at route/operation inventory level; this was resolved in the follow-up with a schema-backed generator. +- Tokens and cleanup: All created board and agent tokens were revoked. Plugins were uninstalled. Temporary secrets are gone. Temporary non-default environments and project workspaces are gone. Two routines remain archived in the disposable instance. +- Verification run: + - `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts` + - `pnpm exec vitest run cli/src/__tests__/agent-lifecycle.test.ts` + - `pnpm exec vitest run server/src/__tests__/plugin-database.test.ts` + - `pnpm exec vitest run server/src/__tests__/routines-service.test.ts` + - `pnpm --dir cli typecheck` + - `pnpm --dir server typecheck` + - live isolated CLI reruns for each fixed failure +- Manual continuation: + +```sh +export PAPERCLIP_HOME=/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home +export PAPERCLIP_INSTANCE_ID=cli-api-parity +export PAPERCLIP_CONFIG=/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/instances/cli-api-parity/config.json +export PAPERCLIP_CONTEXT=/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/context.json +export PAPERCLIP_AUTH_STORE=/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/auth.json +export PAPERCLIP_API_URL=http://127.0.0.1:3197 +export PAPERCLIP_SERVER_PORT=3197 +export PORT=3197 +export CODEX_HOME=/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/codex-home +export CLAUDE_HOME=/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/claude-home +unset DATABASE_URL DATABASE_MIGRATION_URL +pnpm paperclipai health --json +``` + +- Follow-up: Commit this log-only update so the final handoff is preserved in git history. + +### 2026-05-24T14:07:24+02:00 - Caveat follow-up investigation + +- Command: `rg -n "openapi|OpenAPI|board-claim|connect" server cli packages doc/plans/2026-05-23-cli-api-parity-openapi-reference.ts`; `sed -n ... server/src/routes/openapi.ts`; `sed -n ... doc/plans/2026-05-23-cli-api-parity-openapi-reference.ts`; `sed -n ... server/src/board-claim.ts`; `sed -n ... cli/src/commands/client/connect.ts`; `lsof -nP -iTCP:3197 -sTCP:LISTEN`; `git status --short --branch`. +- Purpose: Re-open the three final caveats and distinguish true implementation gaps from harness-gated coverage. +- Prerequisites/IDs used: Existing isolated scratch server on `127.0.0.1:3197`, PID `70429`; same scratch env and repo branch `improvement/cli-api-parity`. +- Expected result: Determine whether OpenAPI requires more implementation, whether a positive board-claim claim can be tested, and whether interactive `connect` has untested behavior beyond the already verified scriptable equivalents. +- Actual result: OpenAPI is a true implementation-depth gap: `server/src/routes/openapi.ts` currently serves a 247-path operation inventory with generic responses, while `doc/plans/2026-05-23-cli-api-parity-openapi-reference.ts` already contains a real `OpenAPIRegistry`/`OpenApiGeneratorV3` implementation with request bodies from shared Zod schemas, auth/security fixups, and status overrides. Positive board-claim requires an authenticated-mode instance whose only instance admin is `local-board`; the current disposable server is `local_trusted`, where no challenge is generated. Interactive `connect` intentionally exits in non-TTY mode before any prompts; its network/token/context side effects are covered by scriptable command paths, but the prompt flow itself has not been PTY-tested. +- Status: PASS with OpenAPI implementation gap confirmed and fixed in the next entry. +- Output summary: OpenAPI needed to be upgraded from inventory stub to generated schema-backed document. Board-claim positive coverage needs a separate isolated authenticated-mode harness or focused route/service test. Interactive `connect` can be checked with a PTY/script harness if a local board-login challenge can be approved non-interactively. +- Follow-up: Implement full OpenAPI route from the reference file first, verify and commit, then evaluate scoped board-claim and connect harness options. + +### 2026-05-24T14:12:30+02:00 - Full OpenAPI generator implementation + +- Command: `pnpm add @asteasolutions/zod-to-openapi@7.3.4 --filter @paperclipai/server`; replaced `server/src/routes/openapi.ts` inventory stub with the schema-backed generator from `doc/plans/2026-05-23-cli-api-parity-openapi-reference.ts`; added route wrapper exports; tightened `server/src/__tests__/openapi-routes.test.ts`; `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts`; `pnpm --dir server typecheck`; restarted isolated server with the scratch env; `pnpm paperclipai openapi --json`. +- Purpose: Resolve the final OpenAPI caveat by serving a proper generated OpenAPI document with shared Zod request schemas, auth/security metadata, and response status fixups. +- Prerequisites/IDs used: Isolated scratch server restarted on `127.0.0.1:3197`; `DATABASE_URL` and `DATABASE_MIGRATION_URL` unset; `PAPERCLIP_HOME`, `PAPERCLIP_CONFIG`, `PAPERCLIP_CONTEXT`, `PAPERCLIP_AUTH_STORE`, `CODEX_HOME`, and `CLAUDE_HOME` all under `tmp/cli-api-parity`. +- Expected result: `/api/openapi.json` and `paperclipai openapi --json` return OpenAPI 3.0 with schema-backed request bodies, security schemes, public-operation security overrides, and create-operation `201` responses. +- Actual result: Focused OpenAPI test passed and asserts `BoardSessionAuth`, `BoardApiKeyAuth`, `AgentBearerAuth`, public `/api/health` security `[]`, `POST /api/companies` request body schema, `POST /api/companies` `201` response, and `POST /api/agents/{id}/keys` request body schema. Server typecheck passed. Live CLI returned `{openapi:"3.0.0", pathCount:259, security:["BoardSessionAuth","BoardApiKeyAuth","AgentBearerAuth"], companyCreateRequest:{type:"string",minLength:1}, companyCreateStatus:["201","400","401","403"], agentKeyRequest:{type:"string",minLength:1,default:"default"}}`. +- Status: PASS after OpenAPI caveat fix. +- Output summary: Live schema-backed OpenAPI artifact is `tmp/cli-api-parity/artifacts/caveat-followup/openapi-live-schema-backed.json`. +- Follow-up: Commit the OpenAPI fix, then continue positive board-claim and interactive connect follow-up testing. + +### 2026-05-24T14:16:10+02:00 - Positive board-claim claim verification + +- Command: Added `server/src/__tests__/board-claim.test.ts`; ran `pnpm exec vitest run server/src/__tests__/board-claim.test.ts`; ran `pnpm --dir server typecheck`. +- Purpose: Resolve the board-claim caveat with positive coverage for the authenticated-mode claim path without mutating the long-running `local_trusted` scratch instance. +- Prerequisites/IDs used: Fresh embedded-postgres test database; seeded one company, a real auth user, and `local-board` as the only `instance_admin`; initialized board-claim challenge with `deploymentMode: "authenticated"`. +- Expected result: A claim warning URL is generated; `inspectBoardClaimChallenge(token, code)` returns `available`; claiming as the signed-in user returns `claimed`; `local-board` loses instance admin; the signed-in user gains instance admin and active owner membership for the existing company; subsequent inspect returns `claimed`. +- Actual result: Initial test attempt exposed cleanup ordering only because the claim path creates `principal_permission_grants`; cleanup was fixed to delete grants before companies. The rerun passed. Server typecheck passed. +- Status: PASS. +- Output summary: Positive board-claim behavior is now covered by a focused authenticated-mode regression test. The live scratch instance remains `local_trusted`, which is correct for the main parity harness. +- Follow-up: Commit the board-claim positive coverage, then evaluate whether interactive `connect` can be PTY-tested or should remain classified as lower-risk because its side effects were exercised through scriptable paths. + +### 2026-05-24T14:18:20+02:00 - Interactive connect flow verification + +- Command: Added `cli/src/__tests__/connect.test.ts`; ran `pnpm exec vitest run cli/src/__tests__/connect.test.ts`; ran `pnpm --dir cli typecheck`. +- Purpose: Resolve the interactive `connect` caveat by exercising the actual TTY-gated command path with mocked prompts and mocked board-login approval, without opening a real browser or touching real auth state. +- Prerequisites/IDs used: Temp context files under the OS temp directory; mocked `process.stdin.isTTY` and `process.stdout.isTTY` to true; mocked `loginBoardCli` to return a board credential; mocked API responses for health, company list, board API key create, agent list, and agent API key create. +- Expected result: Board persona flow verifies health, completes board auth, lists companies, creates a board token, writes the selected board profile to context, and emits JSON output. Agent persona flow verifies health, completes board auth, lists companies and agents, creates an agent token, writes the selected agent profile to context, and emits JSON output. +- Actual result: Both prompt-driven `connect` tests passed. CLI typecheck passed. The test intentionally does not launch a browser; browser approval itself is already covered by CLI auth challenge route tests and mocked here as the boundary before profile selection/token creation. +- Status: PASS. +- Output summary: Interactive `connect` no longer has an untested command-flow caveat. Remaining real-browser/device approval behavior is covered by lower-level CLI auth challenge route tests and scriptable auth commands, not by manually approving in this terminal. +- Follow-up: Commit the connect flow coverage, then rerun final status and isolation checks. + +### 2026-05-24T14:19:04+02:00 - Caveat follow-up final status + +- Command: `pnpm paperclipai health --json`; `pnpm paperclipai openapi --json`; `lsof -nP -iTCP:3197 -sTCP:LISTEN`; `git status --short --branch`, all with the scratch `PAPERCLIP_*`, `CODEX_HOME`, and `CLAUDE_HOME` environment and with `DATABASE_URL`/`DATABASE_MIGRATION_URL` unset. +- Purpose: Confirm the three caveats are no longer unresolved after the follow-up fixes and coverage. +- Prerequisites/IDs used: Isolated scratch server restarted from local source on `127.0.0.1:3197`; PID `84908`; same `tmp/cli-api-parity` home/config/context/auth paths. +- Expected result: Scratch server is healthy; OpenAPI is schema-backed; git has no code changes before this final log entry; the only remaining difference is this log update. +- Actual result: Health returned `status:"ok"`, version `0.3.1`, deployment mode `local_trusted`, exposure `private`, auth ready, bootstrap ready. OpenAPI returned `{openapi:"3.0.0", pathCount:259, security:["BoardSessionAuth","BoardApiKeyAuth","AgentBearerAuth"], companyCreateStatus:["201","400","401","403"]}`. `node` PID `84908` is listening on `127.0.0.1:3197`. Git status was clean before this final log update. +- Status: PASS. +- Output summary: OpenAPI caveat fixed in commit `1ab85cb5`; positive board-claim caveat covered in commit `678fd3a8`; interactive connect caveat covered in commit `40480f38`. +- Follow-up: Commit this final log-only status entry. + +### 2026-05-24T14:22:00+02:00 - Detached scratch server continuation + +- Command: Stopped the foreground scratch server process; started the same runbook command in detached screen session `paperclip-cli-parity`; verified `pnpm paperclipai health --json`; checked `lsof -nP -iTCP:3197 -sTCP:LISTEN`; checked `screen -ls`. +- Purpose: Leave the disposable instance running without tying it to the active tool session. +- Prerequisites/IDs used: Same scratch env and unset database variables; detached screen session `91568.paperclip-cli-parity`. +- Expected result: Server continues running on non-default port `3197` with the same isolated home/config/context/auth paths. +- Actual result: Health returned `status:"ok"` with deployment mode `local_trusted`; `node` PID `91583` is listening on `127.0.0.1:3197`; `screen -ls` shows detached session `91568.paperclip-cli-parity`. +- Status: PASS. +- Output summary: Detached server log is `tmp/cli-api-parity/artifacts/caveat-followup/server-screen.log`. +- Follow-up: Manual continuation can use the same env block and `screen -r paperclip-cli-parity` to inspect the server session. + +### 2026-05-24T14:27:44+02:00 - Rename bug log directory to logs + +- Command: `git mv doc/bugs doc/logs`; appended this entry in `doc/logs/2026-05-24-cli-api-parity-e2e-log.md`; `git status --short --branch`; `git diff --check`. +- Purpose: Rename the living test/bug log directory from `doc/bugs` to `doc/logs` while preserving the existing audit trail. +- Prerequisites/IDs used: Existing clean branch `improvement/cli-api-parity`; single log file `2026-05-24-cli-api-parity-e2e-log.md`. +- Expected result: Git records the file as moved from `doc/bugs/` to `doc/logs/`; historical command strings inside the log remain unchanged because they record what was run at the time. +- Actual result: Directory rename is staged as a path move with this follow-up log entry. +- Status: PASS. +- Output summary: New log path is `doc/logs/2026-05-24-cli-api-parity-e2e-log.md`. +- Follow-up: Commit the directory rename. + +## Bugs And Mismatches + +### BUG-011 - Deleting a webhook routine trigger left its managed secret active + +- Status: Fixed and live-verified. +- Severity: Medium resource lifecycle leak. +- Reproduction command: `routine trigger:create <routine-id> --payload-json '{"kind":"webhook","signingMode":"bearer"}' --json`; `routine trigger:delete <trigger-id> --json`; `secrets list --company-id <company-id> --json`. +- Expected result: The webhook trigger's generated paperclip-managed secret and binding are removed when the trigger is deleted. +- Actual result: The trigger was deleted, but the generated secret stayed active with `referenceCount: 1` and description `Webhook auth for routine ...`. +- Suspected cause: `deleteTrigger()` deleted only the `routine_triggers` row and appended a revision; it did not remove `existing.secretId`. +- Files changed: `server/src/services/routines.ts`, `server/src/__tests__/routines-service.test.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: After successful trigger deletion, call `secretsSvc.remove(existing.secretId)` for webhook triggers with managed secrets. Added assertions that deleting a webhook trigger removes both `company_secrets` and `company_secret_bindings` rows. +- Verification command: `pnpm exec vitest run server/src/__tests__/routines-service.test.ts`; `pnpm --dir server typecheck`; live isolated webhook trigger create/delete; live `secrets list --company-id <company-id> --json`. +- Remaining risk: Low. If secret removal failed after trigger deletion, the trigger would already be gone; current provider-backed removal path was verified for the local encrypted provider. + +### BUG-010 - Plugin tools were listed but could not execute against a running plugin worker + +- Status: Fixed and live-verified. +- Severity: Medium plugin CLI/API parity bug. +- Reproduction command: Install `packages/plugins/examples/plugin-kitchen-sink-example`, then run `pnpm paperclipai plugin tool:execute --payload-json '{"tool":"paperclip-kitchen-sink-example:echo","parameters":{"message":"CLI parity tool"},"runContext":{"companyId":"<company-id>","projectId":"<project-id>","agentId":"<agent-id>","runId":"<run-id>"}}' --json`. +- Expected result: The listed tool dispatches to the running kitchen-sink worker and returns a `ToolResult`. +- Actual result: `plugin tools` listed `paperclip-kitchen-sink-example:echo`, and bridge data/action calls to the same plugin worker succeeded, but `tool:execute` returned `502: Cannot execute tool ... worker for plugin "paperclip-kitchen-sink-example" is not running`. +- Suspected cause: `plugin-loader` registered tools with only the plugin key, so `RegisteredTool.pluginDbId` defaulted to the plugin key. `plugin-worker-manager` tracks running workers by database plugin UUID, so the dispatcher looked up the wrong worker ID. +- Files changed: `server/src/services/plugin-tool-dispatcher.ts`, `server/src/services/plugin-loader.ts`, `server/src/__tests__/plugin-database.test.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Extended `registerPluginTools` to accept an optional database plugin ID, passed the database ID from the plugin loader, and added regression coverage that plugin activation registers manifest-key namespaced tools with the database ID for worker lookup. +- Verification command: `pnpm exec vitest run server/src/__tests__/plugin-database.test.ts`; `pnpm --dir server typecheck`; restarted isolated server; live kitchen-sink `plugin tools`; live `plugin tool:execute`; cleanup `plugin uninstall --force`. +- Remaining risk: Low; lifecycle DB-backed registration already used the database ID, and this aligns initial loader registration with that path. + +### BUG-009 - `token agent list --agent <agent-id>` failed even when the agent exists + +- Status: Fixed and live-verified. +- Severity: Low CLI argument parity bug. +- Reproduction command: `pnpm paperclipai token agent list --company-id 12e9db4b-f66c-459b-959e-d645002240fb --agent 1dd601a1-031a-4225-b005-419427fd059f --json`. +- Expected result: `--agent` accepts the documented agent ID, shortname, or unambiguous name. +- Actual result: The command returned `404: Agent not found` for the ID form; the name form worked. +- Suspected cause: The token command always called the reference lookup route `/api/agents/:ref?companyId=...`; the server route did not resolve the UUID ref in that lookup mode. +- Files changed: `cli/src/commands/client/token.ts`, `cli/src/__tests__/token.test.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Detect UUID-form agent refs in the CLI, fetch `/api/agents/:id` directly, and verify the returned agent belongs to the requested company before listing/creating/revoking keys. +- Verification command: `pnpm exec vitest run cli/src/__tests__/token.test.ts`; `pnpm --dir cli typecheck`; live isolated `token agent list --company-id <company-id> --agent <agent-id> --json`. +- Remaining risk: Low; non-ID references continue to use the existing company-scoped lookup path. + +### BUG-007 - `worktree:make` can recurse through pnpm shim when `HOME` is isolated + +- Status: Fixed and live-verified. +- Severity: Medium local-dev/worktree reliability bug. +- Reproduction command: `HOME=/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/shell-home pnpm paperclipai worktree:make cli-parity-wt --home /Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/worktree-instances --from-config /Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/instances/cli-api-parity/config.json --server-port 3198 --db-port 54331 --seed-mode minimal`. +- Expected result: The command creates the scratch git worktree and runs one dependency install inside it. +- Actual result: After creating the git worktree, `installDependenciesBestEffort()` executed bare `pnpm install`. With `HOME` redirected for isolation, the user's pnpm shim repeatedly spawned `pnpm add pnpm@9.15.4` under the scratch home and the command did not reach worktree initialization until the runaway process tree was stopped. +- Suspected cause: The CLI did not reuse the pnpm executable that launched the current Paperclip command, so dependency installation was subject to PATH/shim behavior under an overridden `HOME`. +- Files changed: `cli/src/commands/worktree.ts`, `cli/src/__tests__/worktree.test.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Added `resolvePnpmInstallInvocation()` and changed worktree dependency installation to reuse `npm_execpath` when the CLI was launched through pnpm, falling back to bare `pnpm` only when no pnpm launcher is available. +- Verification command: `pnpm exec vitest run cli/src/__tests__/worktree.test.ts`; `pnpm --dir cli typecheck`; live isolated `worktree:cleanup --force` for the partial worktree; live isolated `worktree:make ... --seed-mode minimal`; `worktree:list --json`; `worktree env --config <scratch-worktree-config> --json`; `worktree:merge-history --from paperclip-cli-parity-wt --to current --company CLI --dry`. +- Remaining risk: Low. If Paperclip is launched outside pnpm, dependency installation still falls back to PATH lookup as before. + +### BUG-008 - `configure --section <invalid>` printed an error but exited 0 + +- Status: Fixed and live-verified. +- Severity: Low command UX/scripting bug. +- Reproduction command: `pnpm paperclipai configure --config /Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/home/instances/cli-api-parity/config.json --section invalid-section`. +- Expected result: Invalid non-interactive configuration input should produce a failing process exit code so scripts can detect the error. +- Actual result: CLI printed `Unknown section: invalid-section...` but exited with status `0`. +- Suspected cause: `configure()` logged and returned without setting `process.exitCode`. +- Files changed: `cli/src/commands/configure.ts`, `cli/src/__tests__/configure.test.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Set `process.exitCode = 1` for missing config and unknown section early returns; added regression coverage for both paths. +- Verification command: `pnpm exec vitest run cli/src/__tests__/configure.test.ts`; `pnpm --dir cli typecheck`; live isolated `configure --config <scratch-config> --section invalid-section` returned exit code `1`. +- Remaining risk: Low; interactive configure paths were not changed. + +### BUG-001 - `context set` erased existing profile fields + +- Status: Fixed. +- Severity: High for isolated CLI testing; a non-default `apiBase` can be silently removed and later commands may fall back to `http://localhost:3100` if `PAPERCLIP_API_URL` is absent. +- Reproduction command: `pnpm paperclipai context set --api-base http://127.0.0.1:3197 --use --json`; then `pnpm paperclipai context set --company-id <company-id> --use --json`; then `pnpm paperclipai context show --json`. +- Expected result: Profile preserves existing `apiBase` while adding `companyId`. +- Actual result: Profile only contained `companyId`; `apiBase` was removed. +- Suspected cause: `context set` passed an object containing keys with `undefined` values into `upsertProfile`, and the merge spread those undefined values over existing properties. +- Files changed: `cli/src/commands/client/context.ts`; `cli/src/client/context.ts`; `cli/src/__tests__/context.test.ts`. +- Fix summary: Build context command patches from provided fields only, and make `upsertProfile` ignore undefined values while still allowing empty strings to delete fields. +- Verification command: `pnpm exec vitest run cli/src/__tests__/context.test.ts`; `pnpm --dir cli typecheck`; `pnpm paperclipai context show --json`. +- Remaining risk: Low; behavior is covered at the context store layer and typechecked. + +### MISMATCH-001 - Documented `access whoami` command is not registered + +- Status: Fixed and live-verified. +- Severity: Low command UX/docs drift. +- Reproduction command: `pnpm paperclipai access whoami --json`. +- Expected result: Access identity command succeeds as documented in the runbook. +- Actual result: CLI exits with `unknown command 'access'`. +- Suspected cause: `registerAccessCommands` registers `whoami` as a top-level command, not under an `access` group. +- Files changed: `cli/src/commands/client/access.ts`, `cli/src/__tests__/access-parity.test.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Added `paperclipai access whoami` as an alias for the existing top-level `whoami` command. +- Verification command: `pnpm exec vitest run cli/src/__tests__/access-parity.test.ts`; `pnpm --dir cli typecheck`; `pnpm paperclipai access whoami --json`. +- Remaining risk: Low. + +### BUG-002 - `issue interaction:accept` rejected omitted optional selected keys + +- Status: Fixed. +- Severity: Medium CLI/API parity bug; the command help marks `--selected-client-keys` optional, but omitting it made the CLI fail before calling the API. +- Reproduction command: `pnpm paperclipai issue interaction:accept <issue-id> <request-confirmation-interaction-id> --json`. +- Expected result: The CLI sends `{}` and the API accepts the pending request confirmation. +- Actual result: CLI validation failed with `selectedClientKeys` too small because omitted input was converted to `[]`. +- Suspected cause: `parseCsv(undefined)` returns `[]`, and `interaction:accept` always included that value in the payload. +- Files changed: `cli/src/commands/client/issue.ts`; `cli/src/__tests__/issue-subresources.test.ts`. +- Fix summary: Preserve `undefined` when `--selected-client-keys` is omitted; keep CSV parsing for explicit values. +- Verification command: `pnpm exec vitest run cli/src/__tests__/issue-subresources.test.ts`; `pnpm --dir cli typecheck`. +- Remaining risk: Low; focused CLI command wrapper coverage now includes omitted and explicit selected-key cases. + +### BUG-003 - Malformed tree hold ID returned server 500 + +- Status: Fixed. +- Severity: Medium API robustness bug; malformed user input reached a UUID database comparison and surfaced as a 500. +- Reproduction command: `pnpm paperclipai issue tree-hold:get <issue-id> null --json` or `pnpm paperclipai issue tree-hold:release <issue-id> null --json`. +- Expected result: Invalid hold IDs return a 400 client error without querying the tree hold service. +- Actual result: Server returned `API error 500: Internal server error`; server log showed `invalid input syntax for type uuid: "null"`. +- Suspected cause: Tree hold routes did not validate `holdId` before passing it to service/database code. +- Files changed: `server/src/routes/issue-tree-control.ts`; `server/src/__tests__/issue-tree-control-routes.test.ts`. +- Fix summary: Validate `holdId` with `isUuidLike` in get/release routes and return `{ error: "Invalid hold ID" }` with status 400. +- Verification command: `pnpm exec vitest run server/src/__tests__/issue-tree-control-routes.test.ts`; `pnpm --dir server typecheck`. +- Remaining risk: Low; route-level regression covers both malformed get and release paths. + +### MISMATCH-002 - `issue interaction:cancel` command is generic but API only cancels questions + +- Status: Fixed help text. +- Severity: Low command UX drift. +- Reproduction command: `pnpm paperclipai issue interaction:cancel <issue-id> <request-confirmation-interaction-id> --reason "..." --json`. +- Expected result: Either the command help states it only applies to `ask_user_questions`, or request confirmations expose a cancel/supersede flow. +- Actual result: API returns `422: Only ask_user_questions interactions can be cancelled`. +- Suspected cause: CLI command name/help is generic while server service method is `cancelQuestions`. +- Files changed: `cli/src/commands/client/issue.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Updated command description to say it cancels an `ask_user_questions` interaction. +- Verification command: `pnpm exec vitest run cli/src/__tests__/issue-subresources.test.ts`; `pnpm --dir cli typecheck`; `pnpm paperclipai issue interaction:cancel --help`. +- Remaining risk: Low; server still enforces the interaction kind. + +### MISMATCH-003 - `issue recovery:resolve` help overstates valid restored statuses + +- Status: Fixed help text. +- Severity: Low command UX drift. +- Reproduction command: `pnpm paperclipai issue recovery:resolve <issue-id> --action-id <action-id> --outcome restored --source-issue-status blocked --json`. +- Expected result: Help text and validation agree on valid source statuses for `restored` outcomes. +- Actual result: Help says `--source-issue-status` accepts `todo, done, in_review, or blocked`; validator rejects `blocked` for `--outcome restored` with `Restored recovery actions must move the source issue to todo, done, or in_review`. +- Suspected cause: CLI option description lists the broad enum rather than outcome-specific constraints. +- Files changed: `cli/src/commands/client/issue.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Updated option description to state `blocked` is only valid for blocked outcomes. +- Verification command: `pnpm exec vitest run cli/src/__tests__/issue-subresources.test.ts`; `pnpm --dir cli typecheck`; `pnpm paperclipai issue recovery:resolve --help`. +- Remaining risk: Low; validation remains server-side/schema-driven. + +### MISMATCH-004 - `agent instructions-path:update` help does not expose process adapter requirements + +- Status: Fixed and verified. +- Severity: Low command UX drift. +- Reproduction command: `pnpm paperclipai agent instructions-path:update <process-agent-id> --payload-json '{"path":"docs/cli-parity.md"}' --json`. +- Expected result: Help or validation guidance makes clear that process adapters need an explicit `adapterConfigKey`, and relative paths need `adapterConfig.cwd`. +- Actual result: First attempt failed with `No default instructions path key for adapter type 'process'. Provide adapterConfigKey.` A second attempt with a relative path and `adapterConfigKey` failed with `Relative instructions path requires adapterConfig.cwd to be set to an absolute path`. +- Suspected cause: CLI help only describes the JSON payload type; adapter-specific path requirements are enforced server-side. +- Files changed: `cli/src/commands/client/agent.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Updated the command description and `--payload-json` help to call out process-adapter `adapterConfigKey`, relative path `adapterConfig.cwd`, and an example payload. +- Verification command: `pnpm exec vitest run cli/src/__tests__/agent-lifecycle.test.ts`; `pnpm --dir cli typecheck`; `pnpm paperclipai agent instructions-path:update --help`. +- Remaining risk: Low; this is help text only and server-side validation remains authoritative. + +### MISMATCH-005 - `invite test-resolution` omits required URL query + +- Status: Fixed and live-verified. +- Severity: Low command/API parity bug. +- Reproduction command: `pnpm paperclipai invite test-resolution <invite-token> --json`. +- Expected result: Command either supplies a documented URL option or the API accepts token-only resolution testing. +- Actual result: API returns `400: url query parameter is required`. +- Suspected cause: CLI wrapper maps `invite test-resolution <token>` directly to `/api/invites/:token/test-resolution` without any `url` query option. +- Files changed: `cli/src/commands/client/access.ts`, `cli/src/__tests__/access-parity.test.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Added required `--url <url>` option and forwards it as the `url` query parameter. +- Verification command: `pnpm exec vitest run cli/src/__tests__/access-parity.test.ts`; `pnpm --dir cli typecheck`; `pnpm paperclipai invite test-resolution <token> --url https://example.com/invite/<token> --json`. +- Remaining risk: Low; local/private URLs are still rejected by the API guard as intended. + +### MISMATCH-006 - `join list --status pending` is rejected; API expects `pending_approval` + +- Status: Fixed and live-verified. +- Severity: Low command UX drift. +- Reproduction command: `pnpm paperclipai join list --company-id <company-id> --status pending --request-type agent --json`. +- Expected result: Help or docs clarify valid join statuses, or common alias `pending` is accepted. +- Actual result: API validation rejects `pending`; valid values include `pending_approval`, `approved`, and `rejected`. +- Suspected cause: CLI exposes a free-form status string with no enum guidance. +- Files changed: `cli/src/commands/client/access.ts`, `cli/src/__tests__/access-parity.test.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: `join list --status pending` now normalizes to `pending_approval`; help lists canonical statuses. +- Verification command: `pnpm exec vitest run cli/src/__tests__/access-parity.test.ts`; `pnpm --dir cli typecheck`; `pnpm paperclipai join list --company-id <company-id> --status pending --request-type agent --json`. +- Remaining risk: Low. + +### MISMATCH-007 - Public docs/catalog CLI routes missing or inconsistent + +- Status: Fixed and live-verified. +- Severity: Medium CLI/API parity gap. +- Reproduction command: `pnpm paperclipai openapi --json`; `pnpm paperclipai available-skill get cmux --json`; `pnpm paperclipai llm agent-configuration --json`; `pnpm paperclipai llm agent-icons --json`; `pnpm paperclipai llm agent-configuration:adapter process --json`. +- Expected result: Registered CLI commands map to available API routes and return the OpenAPI document, skill markdown, and LLM prompt docs. +- Actual result: Initially, `openapi` and all tested `llm` commands returned `404: API route not found`. `available-skill list` returned `cmux` from the real Claude home, but `available-skill get cmux` returned `404: Skill not found`. +- Suspected cause: LLM routes were mounted at root while the CLI calls `/api/llms`; available-skill discovery used `HOME/.claude/skills` instead of `CLAUDE_HOME`; OpenAPI generation was referenced by CLI/docs but no route was mounted. +- Files changed: `server/src/app.ts`, `server/src/routes/access.ts`, `server/src/routes/openapi.ts`, `server/src/__tests__/openapi-routes.test.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Mounted LLM docs routes under `/api`; made available-skill discovery honor `CLAUDE_HOME`, include built-in Paperclip repo skills, and fetch safe skill markdown consistently; added `/api/openapi.json`, then upgraded it from the initial path inventory to the schema-backed `OpenAPIRegistry`/`OpenApiGeneratorV3` implementation from the parity reference. +- Verification command: `pnpm exec vitest run server/src/__tests__/llms-routes.test.ts cli/src/__tests__/access-parity.test.ts`; `pnpm --dir server typecheck`; `pnpm --dir cli typecheck`; live `llm` and `available-skill` commands after restart; `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts`; live `curl http://127.0.0.1:3197/api/openapi.json`; live `pnpm paperclipai openapi --json`; follow-up live schema-backed `paperclipai openapi --json`. +- Remaining risk: Medium-low; the generator now includes shared Zod request schemas and security metadata, but response schemas remain intentionally generic for most endpoints until the API exports reusable response schemas. + +### BUG-006 - Available skill catalog ignored isolated `CLAUDE_HOME` + +- Status: Fixed and live-verified. +- Severity: Medium isolation bug for local E2E runs. +- Reproduction command: `CLAUDE_HOME=/Users/aronprins/Documents/PaperclipAI/paperclip/tmp/cli-api-parity/claude-home pnpm paperclipai available-skill list --json`. +- Expected result: Skill discovery uses the isolated Claude home or built-in repo skills only. +- Actual result: Before the fix, the list included `cmux` from the real user Claude skills home, and `available-skill get cmux` failed because only a hardcoded Paperclip subset was fetchable. +- Suspected cause: Server code read `HOME/.claude/skills` directly and did not add built-in Paperclip skills unless they were present in Claude's skills directory. +- Files changed: `server/src/routes/access.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Use `CLAUDE_HOME/skills` when `CLAUDE_HOME` is set, include built-in Paperclip skills in catalog output, and resolve safe skill markdown from both Claude and Paperclip skills directories. +- Verification command: live `available-skill list`, `available-skill get paperclip`, and `available-skill get cmux` after restarting the isolated server. +- Remaining risk: Low; this is runtime environment-sensitive and covered by live isolated verification. + +### MISMATCH-008 - `paperclipai health` is not registered + +- Status: Fixed and live-verified. +- Severity: Low command/API parity gap. +- Reproduction command: `pnpm paperclipai health --json`. +- Expected result: The CLI has a documented health command, or docs consistently direct users to `curl <api-url>/api/health`. +- Actual result: Commander returned `unknown command 'health'`. +- Suspected cause: Health checking exists as an API endpoint and setup/doctor workflow, but not as a CLI client command. +- Files changed: `cli/src/commands/client/access.ts`, `cli/src/__tests__/access-parity.test.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Added a top-level `health` command that calls `/api/health`. +- Verification command: `pnpm exec vitest run cli/src/__tests__/access-parity.test.ts`; `pnpm --dir cli typecheck`; `pnpm paperclipai health --json`. +- Remaining risk: Low. + +### BUG-004 - Creating a second local environment returned 500 instead of conflict + +- Status: Fixed and live-verified. +- Severity: Medium API error handling bug. +- Reproduction command: `pnpm paperclipai environment create --company-id 12e9db4b-f66c-459b-959e-d645002240fb --payload-json '{"name":"CLI parity local env","description":"Disposable CLI parity environment","driver":"local","config":{"cwd":"/Users/aronprins/Documents/PaperclipAI/paperclip"}}' --json`. +- Expected result: Controlled `409` or other user-facing validation error because a default local environment already exists for the company. +- Actual result: API returned `500: Internal server error`; server log showed duplicate key violation for `environments_company_driver_idx`. +- Suspected cause: The route attempted the insert without checking the partial unique constraint on `(company_id, driver)` for `driver = 'local'`. +- Files changed: `server/src/routes/environments.ts`, `server/src/__tests__/environment-routes.test.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Added a route-level pre-insert check that throws `409` when a local environment already exists for the company; added regression coverage. +- Verification command: `pnpm exec vitest run server/src/__tests__/environment-routes.test.ts`; `pnpm --dir server typecheck`; restarted isolated server and reran the reproduction command, which now returns `409`. +- Remaining risk: Low; create flow for non-local environment drivers still needs separate positive coverage. + +### BUG-005 - Secret lifecycle API endpoints lacked CLI wrappers + +- Status: Fixed and live-verified. +- Severity: Medium CLI/API parity gap. +- Reproduction command: `pnpm paperclipai secrets --help` did not expose commands for `PATCH /api/secrets/:id`, `POST /api/secrets/:id/rotate`, `GET /api/secrets/:id/usage`, `GET /api/secrets/:id/access-events`, or `DELETE /api/secrets/:id`. +- Expected result: CLI can update, rotate, inspect usage/access events, and delete a secret, matching the OpenAPI parity reference. +- Actual result: CLI only supported list/create/link/provider/import/declaration/migration commands; a disposable managed secret could be created but not cleaned up through CLI. +- Suspected cause: Secret provider/import commands were added without completing the single-secret lifecycle wrapper set. +- Files changed: `cli/src/commands/client/secrets.ts`, `cli/src/__tests__/secrets.test.ts`, `doc/bugs/2026-05-24-cli-api-parity-e2e-log.md`. +- Fix summary: Added `secrets update`, `secrets rotate`, `secrets usage`, `secrets access-events`, and guarded `secrets delete --yes --confirm <secret-id>` commands. +- Verification command: `pnpm exec vitest run cli/src/__tests__/secrets.test.ts`; `pnpm --dir cli typecheck`; live scratch commands for update/rotate/usage/access-events/delete. +- Remaining risk: Low; `secrets link` remains provider-dependent and correctly rejects `local_encrypted` external references. diff --git a/doc/plans/2026-05-23-cli-api-parity-openapi-reference.ts b/doc/plans/2026-05-23-cli-api-parity-openapi-reference.ts new file mode 100644 index 00000000..87d6c7c8 --- /dev/null +++ b/doc/plans/2026-05-23-cli-api-parity-openapi-reference.ts @@ -0,0 +1,3585 @@ +import { + OpenAPIRegistry, + OpenApiGeneratorV3, + extendZodWithOpenApi, +} from "@asteasolutions/zod-to-openapi"; +import { z } from "zod"; +import { + // Agent + createAgentSchema, + createAgentHireSchema, + updateAgentSchema, + updateAgentPermissionsSchema, + updateAgentInstructionsPathSchema, + updateAgentInstructionsBundleSchema, + upsertAgentInstructionsFileSchema, + createAgentKeySchema, + wakeAgentSchema, + resetAgentSessionSchema, + agentSkillSyncSchema, + testAdapterEnvironmentSchema, + // Issue + createIssueSchema, + updateIssueSchema, + createIssueLabelSchema, + addIssueCommentSchema, + checkoutIssueSchema, + linkIssueApprovalSchema, + createIssueWorkProductSchema, + updateIssueWorkProductSchema, + upsertIssueDocumentSchema, + restoreIssueDocumentRevisionSchema, + upsertIssueFeedbackVoteSchema, + // Project + createProjectSchema, + updateProjectSchema, + createProjectWorkspaceSchema, + updateProjectWorkspaceSchema, + // Company + createCompanySchema, + updateCompanySchema, + updateCompanyBrandingSchema, + // Routine + createRoutineSchema, + updateRoutineSchema, + createRoutineTriggerSchema, + updateRoutineTriggerSchema, + rotateRoutineTriggerSecretSchema, + runRoutineSchema, + // Goal + createGoalSchema, + updateGoalSchema, + // Secret + createSecretSchema, + updateSecretSchema, + rotateSecretSchema, + // Approval + createApprovalSchema, + resolveApprovalSchema, + requestApprovalRevisionSchema, + resubmitApprovalSchema, + addApprovalCommentSchema, + // Cost / budget + createCostEventSchema, + createFinanceEventSchema, + updateBudgetSchema, + upsertBudgetPolicySchema, + resolveBudgetIncidentSchema, + // Sidebar + upsertSidebarOrderPreferenceSchema, + // Execution workspaces + updateExecutionWorkspaceSchema, + workspaceRuntimeControlTargetSchema, + // Environments + createEnvironmentSchema, + updateEnvironmentSchema, + probeEnvironmentConfigSchema, + // Company skills + companySkillCreateSchema, + companySkillFileUpdateSchema, + companySkillImportSchema, + companySkillProjectScanRequestSchema, + // Issue tree + createIssueTreeHoldSchema, + previewIssueTreeControlSchema, + releaseIssueTreeHoldSchema, + // Issue interactions + createIssueThreadInteractionSchema, + createChildIssueSchema, + acceptIssueThreadInteractionSchema, + rejectIssueThreadInteractionSchema, + respondIssueThreadInteractionSchema, + // Auth / profile + updateCurrentUserProfileSchema, + // Company portability (legacy routes) + companyPortabilityExportSchema, + companyPortabilityPreviewSchema, + companyPortabilityImportSchema, + // Access / membership + acceptInviteSchema, + createCompanyInviteSchema, + createOpenClawInvitePromptSchema, + claimJoinRequestApiKeySchema, + createCliAuthChallengeSchema, + resolveCliAuthChallengeSchema, + updateCompanyMemberSchema, + updateCompanyMemberWithPermissionsSchema, + archiveCompanyMemberSchema, + updateMemberPermissionsSchema, + updateUserCompanyAccessSchema, + // Instance settings + patchInstanceGeneralSettingsSchema, + patchInstanceExperimentalSettingsSchema, +} from "@paperclipai/shared"; + +extendZodWithOpenApi(z); + +const registry = new OpenAPIRegistry(); + +// ─── Common schemas ────────────────────────────────────────────────────────── + +const ErrorSchema = registry.register( + "Error", + z.object({ error: z.string() }).openapi({ title: "Error" }), +); + +const responses = { + ok: (schema: z.ZodTypeAny = z.record(z.unknown())) => ({ + description: "Success", + content: { "application/json": { schema } }, + }), + noContent: { description: "No content" }, + badRequest: { + description: "Bad request", + content: { "application/json": { schema: ErrorSchema } }, + }, + unauthorized: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorSchema } }, + }, + forbidden: { + description: "Forbidden", + content: { "application/json": { schema: ErrorSchema } }, + }, + notFound: { + description: "Not found", + content: { "application/json": { schema: ErrorSchema } }, + }, + serverError: { + description: "Internal server error", + content: { "application/json": { schema: ErrorSchema } }, + }, +}; + +const jsonBody = (schema: z.ZodTypeAny) => ({ + content: { "application/json": { schema } }, + required: true as const, +}); + +const r = responses; + +type OpenApiAuthLevel = + | "public" + | "authenticated" + | "board" + | "instance_admin"; + +const BOARD_SESSION_AUTH_SCHEME = "BoardSessionAuth"; +const BOARD_API_KEY_AUTH_SCHEME = "BoardApiKeyAuth"; +const AGENT_BEARER_AUTH_SCHEME = "AgentBearerAuth"; + +function securityRequirement(name: string): Record<string, string[]> { + return { [name]: [] }; +} + +const BOARD_SECURITY: Array<Record<string, string[]>> = [ + securityRequirement(BOARD_SESSION_AUTH_SCHEME), + securityRequirement(BOARD_API_KEY_AUTH_SCHEME), +]; + +const AUTHENTICATED_SECURITY: Array<Record<string, string[]>> = [ + ...BOARD_SECURITY, + securityRequirement(AGENT_BEARER_AUTH_SCHEME), +]; + +const PUBLIC_OPERATIONS = new Set([ + "GET /api/health", + "GET /api/openapi.json", + "GET /api/board-claim/{token}", + "POST /api/cli-auth/challenges", + "GET /api/cli-auth/challenges/{id}", + "POST /api/cli-auth/challenges/{id}/cancel", + "GET /api/invites/{token}", + "GET /api/invites/{token}/logo", + "GET /api/invites/{token}/onboarding", + "GET /api/invites/{token}/onboarding.txt", + "GET /api/invites/{token}/skills/index", + "GET /api/invites/{token}/skills/{skillName}", + "GET /api/invites/{token}/test-resolution", + "POST /api/invites/{token}/accept", + "POST /api/join-requests/{requestId}/claim-api-key", +]); + +const BOARD_ONLY_PREFIXES = [ + "/api/auth/", + "/api/admin/", + "/api/plugins", + "/api/instance/", +]; + +const BOARD_ONLY_OPERATIONS = new Set([ + "GET /api/companies", + "POST /api/companies", + "GET /api/companies/stats", + "GET /api/companies/issues", + "POST /api/board-claim/{token}/claim", + "GET /api/cli-auth/me", + "POST /api/companies/{companyId}/invites", + "GET /api/companies/{companyId}/invites", + "POST /api/companies/{companyId}/openclaw/invite-prompt", + "GET /api/companies/{companyId}/join-requests", + "POST /api/companies/{companyId}/join-requests/{requestId}/approve", + "POST /api/companies/{companyId}/join-requests/{requestId}/reject", + "GET /api/companies/{companyId}/members", + "PATCH /api/companies/{companyId}/members/{memberId}", + "PATCH /api/companies/{companyId}/members/{memberId}/role-and-grants", + "POST /api/companies/{companyId}/members/{memberId}/archive", + "PATCH /api/companies/{companyId}/members/{memberId}/permissions", + "GET /api/companies/{companyId}/user-directory", + "POST /api/issues/{id}/interactions/{interactionId}/accept", + "POST /api/issues/{id}/interactions/{interactionId}/reject", + "POST /api/issues/{id}/interactions/{interactionId}/respond", +]); + +const INSTANCE_ADMIN_OPERATIONS = new Set([ + "POST /api/companies", + "POST /api/plugins/install", + "POST /api/instance/database-backups", + "POST /api/admin/users/{userId}/promote-instance-admin", + "POST /api/admin/users/{userId}/demote-instance-admin", + "PUT /api/admin/users/{userId}/company-access", +]); + +const CREATED_OPERATIONS = new Set([ + "POST /api/adapters/install", + "POST /api/companies/{companyId}/agent-hires", + "POST /api/companies/{companyId}/agents", + "POST /api/agents/{id}/keys", + "POST /api/companies/{companyId}/approvals", + "POST /api/approvals/{id}/comments", + "POST /api/companies/{companyId}/assets/images", + "POST /api/companies/{companyId}/logo", + "POST /api/cli-auth/challenges", + "POST /api/companies", + "POST /api/companies/{companyId}/invites", + "POST /api/companies/{companyId}/openclaw/invite-prompt", + "POST /api/companies/{companyId}/cost-events", + "POST /api/companies/{companyId}/finance-events", + "POST /api/companies/{companyId}/environments", + "POST /api/companies/{companyId}/goals", + "POST /api/companies/{companyId}/labels", + "POST /api/issues/{id}/work-products", + "POST /api/issues/{id}/approvals", + "POST /api/companies/{companyId}/issues", + "POST /api/issues/{id}/children", + "POST /api/issues/{id}/interactions", + "POST /api/issues/{id}/comments", + "POST /api/companies/{companyId}/issues/{issueId}/attachments", + "POST /api/companies/{companyId}/projects", + "POST /api/projects/{id}/workspaces", + "POST /api/companies/{companyId}/routines", + "POST /api/routines/{id}/triggers", + "POST /api/companies/{companyId}/secrets", + "POST /api/companies/{companyId}/skills", + "POST /api/companies/{companyId}/skills/import", + "POST /api/join-requests/{requestId}/claim-api-key", + "POST /api/admin/users/{userId}/promote-instance-admin", + "POST /api/plugins/install", + "POST /api/instance/database-backups", +]); + +const ACCEPTED_OPERATIONS = new Set([ + "POST /api/invites/{token}/accept", +]); + +const FORBIDDEN_RESPONSE = { + description: "Forbidden", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, +}; + +function operationKey(method: string, path: string) { + return `${method.toUpperCase()} ${path}`; +} + +function isBoardOnlyOperation(method: string, path: string) { + const key = operationKey(method, path); + if (BOARD_ONLY_OPERATIONS.has(key)) return true; + return BOARD_ONLY_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + +function resolveOperationAuthLevel(method: string, path: string): OpenApiAuthLevel { + const key = operationKey(method, path); + if (PUBLIC_OPERATIONS.has(key)) return "public"; + if (INSTANCE_ADMIN_OPERATIONS.has(key)) return "instance_admin"; + if (isBoardOnlyOperation(method, path)) return "board"; + return "authenticated"; +} + +function applyOperationStatusOverride( + operation: Record<string, unknown>, + fromStatus: string, + toStatus: string, +) { + const responses = operation.responses as Record<string, unknown> | undefined; + if (!responses || !responses[fromStatus] || responses[toStatus]) return; + responses[toStatus] = responses[fromStatus]; + delete responses[fromStatus]; +} + +function applyDocumentFixups(document: any): any { + document.components ??= {}; + document.components.securitySchemes = { + [BOARD_SESSION_AUTH_SCHEME]: { + type: "apiKey", + in: "cookie", + name: "paperclip_session", + description: + "Board session cookie in authenticated mode. Paperclip uses Better Auth; cookie transport may vary by deployment.", + }, + [BOARD_API_KEY_AUTH_SCHEME]: { + type: "http", + scheme: "bearer", + bearerFormat: "Board API Key", + description: "Board API key presented in the Authorization bearer header.", + }, + [AGENT_BEARER_AUTH_SCHEME]: { + type: "http", + scheme: "bearer", + bearerFormat: "Agent API Key or Agent JWT", + description: + "Agent API key or Paperclip-issued local agent JWT presented in the Authorization bearer header.", + }, + }; + document.security = AUTHENTICATED_SECURITY; + + for (const [path, pathItem] of Object.entries(document.paths ?? {})) { + for (const [method, operation] of Object.entries(pathItem as Record<string, any>)) { + const authLevel = resolveOperationAuthLevel(method, path); + if (authLevel === "public") { + operation.security = []; + } else if (authLevel === "authenticated") { + operation.security = AUTHENTICATED_SECURITY; + } else { + operation.security = BOARD_SECURITY; + } + + operation["x-paperclip-authorization"] = + authLevel === "instance_admin" + ? { actor: "board", instanceAdmin: true } + : authLevel === "board" + ? { actor: "board" } + : authLevel === "authenticated" + ? { actor: "board_or_agent" } + : { actor: "public" }; + + const key = operationKey(method, path); + if (authLevel !== "public") { + const responses = (operation.responses ??= {}) as Record<string, unknown>; + if (!responses["403"]) { + responses["403"] = FORBIDDEN_RESPONSE; + } + } + if (CREATED_OPERATIONS.has(key)) { + applyOperationStatusOverride(operation, "200", "201"); + } + if (ACCEPTED_OPERATIONS.has(key)) { + applyOperationStatusOverride(operation, "200", "202"); + } + } + } + + return document; +} + +// ─── Health ────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/health", + tags: ["health"], + summary: "Health check", + responses: { + 200: r.ok(z.object({ + status: z.enum(["ok", "unhealthy"]), + version: z.string().optional(), + deploymentMode: z.string().optional(), + bootstrapStatus: z.enum(["ready", "bootstrap_pending"]).optional(), + bootstrapInviteActive: z.boolean().optional(), + })), + 503: { description: "Service unavailable", content: { "application/json": { schema: ErrorSchema } } }, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/openapi.json", + tags: ["health"], + summary: "Get the generated OpenAPI document", + responses: { 200: r.ok() }, +}); + +// ─── Companies ─────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies", + tags: ["companies"], + summary: "List companies", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies", + tags: ["companies"], + summary: "Create a company", + request: { body: jsonBody(createCompanySchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/stats", + tags: ["companies"], + summary: "Company stats", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}", + tags: ["companies"], + summary: "Get a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}", + tags: ["companies"], + summary: "Update a company", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(updateCompanySchema.partial()), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/branding", + tags: ["companies"], + summary: "Update company branding", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(updateCompanyBrandingSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/archive", + tags: ["companies"], + summary: "Archive a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/companies/{companyId}", + tags: ["companies"], + summary: "Delete a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/feedback-traces", + tags: ["companies"], + summary: "List company feedback traces", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/exports", + tags: ["companies"], + summary: "Export company data", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/exports/preview", + tags: ["companies"], + summary: "Preview company export", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/imports/preview", + tags: ["companies"], + summary: "Preview company import", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/imports/apply", + tags: ["companies"], + summary: "Apply company import", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Agents ────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/agents", + tags: ["agents"], + summary: "List agents in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/agents", + tags: ["agents"], + summary: "Create an agent", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createAgentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/agent-hires", + tags: ["agents"], + summary: "Hire an agent", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createAgentHireSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/agent-configurations", + tags: ["agents"], + summary: "List agent configurations for a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/org", + tags: ["agents"], + summary: "Get org chart data", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/me", + tags: ["agents"], + summary: "Get the current agent", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/me/inbox-lite", + tags: ["agents"], + summary: "Get current agent inbox (lite)", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/me/inbox/mine", + tags: ["agents"], + summary: "Get current agent assigned inbox items", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}", + tags: ["agents"], + summary: "Get an agent", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/agents/{id}", + tags: ["agents"], + summary: "Update an agent", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateAgentSchema.omit({ permissions: true })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/agents/{id}", + tags: ["agents"], + summary: "Delete an agent", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/agents/{id}/permissions", + tags: ["agents"], + summary: "Update agent permissions", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateAgentPermissionsSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/agents/{id}/instructions-path", + tags: ["agents"], + summary: "Update agent instructions path", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateAgentInstructionsPathSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/instructions-bundle", + tags: ["agents"], + summary: "Get agent instructions bundle", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/agents/{id}/instructions-bundle", + tags: ["agents"], + summary: "Update agent instructions bundle", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateAgentInstructionsBundleSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/instructions-bundle/file", + tags: ["agents"], + summary: "Get agent instructions file", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "put", + path: "/api/agents/{id}/instructions-bundle/file", + tags: ["agents"], + summary: "Upsert agent instructions file", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(upsertAgentInstructionsFileSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/agents/{id}/instructions-bundle/file", + tags: ["agents"], + summary: "Delete agent instructions file", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/configuration", + tags: ["agents"], + summary: "Get agent configuration", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/config-revisions", + tags: ["agents"], + summary: "List agent config revisions", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/config-revisions/{revisionId}", + tags: ["agents"], + summary: "Get an agent config revision", + request: { params: z.object({ id: z.string(), revisionId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/config-revisions/{revisionId}/rollback", + tags: ["agents"], + summary: "Roll back to a config revision", + request: { params: z.object({ id: z.string(), revisionId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/runtime-state", + tags: ["agents"], + summary: "Get agent runtime state", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/runtime-state/reset-session", + tags: ["agents"], + summary: "Reset agent session", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(resetAgentSessionSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/task-sessions", + tags: ["agents"], + summary: "List agent task sessions", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/skills", + tags: ["agents"], + summary: "List agent skills", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/skills/sync", + tags: ["agents"], + summary: "Sync desired skills onto an agent configuration", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(agentSkillSyncSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/keys", + tags: ["agents"], + summary: "List agent API keys", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/keys", + tags: ["agents"], + summary: "Create an agent API key", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(createAgentKeySchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/agents/{id}/keys/{keyId}", + tags: ["agents"], + summary: "Delete an agent API key", + request: { params: z.object({ id: z.string(), keyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/wakeup", + tags: ["agents"], + summary: "Wake up an agent", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(wakeAgentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/pause", + tags: ["agents"], + summary: "Pause an agent", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/resume", + tags: ["agents"], + summary: "Resume an agent", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/terminate", + tags: ["agents"], + summary: "Terminate an agent", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/instance/scheduler-heartbeats", + tags: ["agents"], + summary: "List scheduler heartbeats", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Adapters ──────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/adapters/{type}/models", + tags: ["adapters"], + summary: "List models for an adapter type", + request: { params: z.object({ companyId: z.string(), type: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/adapters/{type}/detect-model", + tags: ["adapters"], + summary: "Detect active model for an adapter", + request: { params: z.object({ companyId: z.string(), type: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/adapters/{type}/test-environment", + tags: ["adapters"], + summary: "Validate adapter environment access for a company", + request: { + params: z.object({ companyId: z.string(), type: z.string() }), + body: jsonBody(testAdapterEnvironmentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Issues ────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/issues", + tags: ["issues"], + summary: "List issues in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/issues", + tags: ["issues"], + summary: "Create an issue", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createIssueSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}", + tags: ["issues"], + summary: "Get an issue", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/issues/{id}", + tags: ["issues"], + summary: "Update an issue", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateIssueSchema.partial()), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/issues/{id}", + tags: ["issues"], + summary: "Delete an issue", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/heartbeat-context", + tags: ["issues"], + summary: "Get issue heartbeat context", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/work-products", + tags: ["issues"], + summary: "List issue work products", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/work-products", + tags: ["issues"], + summary: "Create an issue work product", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(createIssueWorkProductSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/work-products/{id}", + tags: ["issues"], + summary: "Update a work product", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateIssueWorkProductSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/work-products/{id}", + tags: ["issues"], + summary: "Delete a work product", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/documents", + tags: ["issues"], + summary: "List issue documents", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/documents/{key}", + tags: ["issues"], + summary: "Get an issue document", + request: { params: z.object({ id: z.string(), key: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "put", + path: "/api/issues/{id}/documents/{key}", + tags: ["issues"], + summary: "Upsert an issue document", + request: { + params: z.object({ id: z.string(), key: z.string() }), + body: jsonBody(upsertIssueDocumentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/issues/{id}/documents/{key}", + tags: ["issues"], + summary: "Delete an issue document", + request: { params: z.object({ id: z.string(), key: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/documents/{key}/revisions", + tags: ["issues"], + summary: "List issue document revisions", + request: { params: z.object({ id: z.string(), key: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/documents/{key}/revisions/{revisionId}/restore", + tags: ["issues"], + summary: "Restore a document revision", + request: { + params: z.object({ id: z.string(), key: z.string(), revisionId: z.string() }), + body: jsonBody(restoreIssueDocumentRevisionSchema), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/comments", + tags: ["issues"], + summary: "List issue comments", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/comments", + tags: ["issues"], + summary: "Add a comment to an issue", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(addIssueCommentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/issues/{id}/comments/{commentId}", + tags: ["issues"], + summary: "Delete an issue comment", + request: { params: z.object({ id: z.string(), commentId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/approvals", + tags: ["issues"], + summary: "List issue approvals", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/approvals", + tags: ["issues"], + summary: "Link an approval to an issue", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(linkIssueApprovalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/issues/{id}/approvals/{approvalId}", + tags: ["issues"], + summary: "Unlink an approval from an issue", + request: { params: z.object({ id: z.string(), approvalId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/checkout", + tags: ["issues"], + summary: "Check out an issue", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(checkoutIssueSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/release", + tags: ["issues"], + summary: "Release an issue", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/read", + tags: ["issues"], + summary: "Mark an issue as read", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/issues/{id}/read", + tags: ["issues"], + summary: "Mark an issue as unread", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/inbox-archive", + tags: ["issues"], + summary: "Archive issue from inbox", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/issues/{id}/inbox-archive", + tags: ["issues"], + summary: "Un-archive issue from inbox", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/feedback-votes", + tags: ["issues"], + summary: "List issue feedback votes", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/feedback-votes", + tags: ["issues"], + summary: "Upsert a feedback vote", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(upsertIssueFeedbackVoteSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/feedback-traces", + tags: ["issues"], + summary: "List issue feedback traces", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/feedback-traces/{traceId}", + tags: ["issues"], + summary: "Get a feedback trace", + request: { params: z.object({ traceId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/feedback-traces/{traceId}/bundle", + tags: ["issues"], + summary: "Get a feedback trace bundle", + request: { params: z.object({ traceId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/attachments", + tags: ["issues"], + summary: "List issue attachments", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/labels", + tags: ["issues"], + summary: "List labels in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/labels", + tags: ["issues"], + summary: "Create a label", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createIssueLabelSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/labels/{labelId}", + tags: ["issues"], + summary: "Delete a label", + request: { params: z.object({ labelId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Projects ──────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/projects", + tags: ["projects"], + summary: "List projects in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/projects", + tags: ["projects"], + summary: "Create a project", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createProjectSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/projects/{id}", + tags: ["projects"], + summary: "Get a project", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/projects/{id}", + tags: ["projects"], + summary: "Update a project", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateProjectSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/projects/{id}", + tags: ["projects"], + summary: "Delete a project", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/projects/{id}/workspaces", + tags: ["projects"], + summary: "List project workspaces", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/projects/{id}/workspaces", + tags: ["projects"], + summary: "Create a project workspace", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(createProjectWorkspaceSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/projects/{id}/workspaces/{workspaceId}", + tags: ["projects"], + summary: "Update a project workspace", + request: { + params: z.object({ id: z.string(), workspaceId: z.string() }), + body: jsonBody(updateProjectWorkspaceSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/projects/{id}/workspaces/{workspaceId}", + tags: ["projects"], + summary: "Delete a project workspace", + request: { params: z.object({ id: z.string(), workspaceId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Routines ──────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/routines", + tags: ["routines"], + summary: "List routines in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/routines", + tags: ["routines"], + summary: "Create a routine", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createRoutineSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/routines/{id}", + tags: ["routines"], + summary: "Get a routine", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/routines/{id}", + tags: ["routines"], + summary: "Update a routine", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateRoutineSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/routines/{id}/runs", + tags: ["routines"], + summary: "List runs for a routine", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/routines/{id}/run", + tags: ["routines"], + summary: "Manually run a routine", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(runRoutineSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/routines/{id}/triggers", + tags: ["routines"], + summary: "Create a routine trigger", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(createRoutineTriggerSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/routine-triggers/{id}", + tags: ["routines"], + summary: "Update a routine trigger", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateRoutineTriggerSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/routine-triggers/{id}", + tags: ["routines"], + summary: "Delete a routine trigger", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/routine-triggers/{id}/rotate-secret", + tags: ["routines"], + summary: "Rotate a routine trigger secret", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(rotateRoutineTriggerSecretSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/routine-triggers/public/{publicId}/fire", + tags: ["routines"], + summary: "Fire a public routine trigger", + request: { params: z.object({ publicId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +// ─── Goals ─────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/goals", + tags: ["goals"], + summary: "List goals in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/goals", + tags: ["goals"], + summary: "Create a goal", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createGoalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/goals/{id}", + tags: ["goals"], + summary: "Get a goal", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/goals/{id}", + tags: ["goals"], + summary: "Update a goal", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateGoalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/goals/{id}", + tags: ["goals"], + summary: "Delete a goal", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Secrets ───────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/secret-providers", + tags: ["secrets"], + summary: "List secret providers", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/secrets", + tags: ["secrets"], + summary: "List secrets in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/secrets", + tags: ["secrets"], + summary: "Create a secret", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createSecretSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/secrets/{id}", + tags: ["secrets"], + summary: "Update a secret", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateSecretSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/secrets/{id}/rotate", + tags: ["secrets"], + summary: "Rotate a secret", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(rotateSecretSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/secrets/{id}", + tags: ["secrets"], + summary: "Delete a secret", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Approvals ─────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/approvals", + tags: ["approvals"], + summary: "List approvals in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/approvals", + tags: ["approvals"], + summary: "Create an approval", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createApprovalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/approvals/{id}", + tags: ["approvals"], + summary: "Get an approval", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/approvals/{id}/issues", + tags: ["approvals"], + summary: "List issues linked to an approval", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/approvals/{id}/approve", + tags: ["approvals"], + summary: "Approve an approval", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(resolveApprovalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/approvals/{id}/reject", + tags: ["approvals"], + summary: "Reject an approval", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(resolveApprovalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/approvals/{id}/request-revision", + tags: ["approvals"], + summary: "Request revision on an approval", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(requestApprovalRevisionSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/approvals/{id}/resubmit", + tags: ["approvals"], + summary: "Resubmit an approval", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(resubmitApprovalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/approvals/{id}/comments", + tags: ["approvals"], + summary: "List approval comments", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/approvals/{id}/comments", + tags: ["approvals"], + summary: "Add a comment to an approval", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(addApprovalCommentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Costs ─────────────────────────────────────────────────────────────────── + +const costSummaryPaths = [ + "summary", "by-agent", "by-agent-model", "by-provider", + "by-biller", "by-project", "finance-summary", "finance-by-biller", + "finance-by-kind", "finance-events", "window-spend", "quota-windows", +] as const; + +for (const segment of costSummaryPaths) { + registry.registerPath({ + method: "get", + path: `/api/companies/{companyId}/costs/${segment}`, + tags: ["costs"], + summary: `Cost report: ${segment}`, + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, + }); +} + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/cost-events", + tags: ["costs"], + summary: "Record a cost event", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createCostEventSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/finance-events", + tags: ["costs"], + summary: "Record a finance event", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createFinanceEventSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/budgets/policies", + tags: ["costs"], + summary: "Create or update a budget policy", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(upsertBudgetPolicySchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/budget-incidents/{incidentId}/resolve", + tags: ["costs"], + summary: "Resolve a budget incident", + request: { + params: z.object({ companyId: z.string(), incidentId: z.string() }), + body: jsonBody(resolveBudgetIncidentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/budgets/overview", + tags: ["costs"], + summary: "Get budget overview", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/budgets", + tags: ["costs"], + summary: "Update company budget", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(updateBudgetSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/agents/{agentId}/budgets", + tags: ["costs"], + summary: "Update agent budget", + request: { + params: z.object({ agentId: z.string() }), + body: jsonBody(updateBudgetSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Activity ──────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/activity", + tags: ["activity"], + summary: "List company activity", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/activity", + tags: ["activity"], + summary: "Create an activity entry", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(z.object({ + actorType: z.enum(["agent", "user", "system", "plugin"]).optional(), + actorId: z.string().min(1), + action: z.string().min(1), + entityType: z.string().min(1), + entityId: z.string().min(1), + agentId: z.string().uuid().optional().nullable(), + details: z.record(z.unknown()).optional().nullable(), + })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/activity", + tags: ["activity"], + summary: "List activity for an issue", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/runs", + tags: ["activity"], + summary: "List runs for an issue", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/heartbeat-runs/{runId}/issues", + tags: ["activity"], + summary: "List issues for a heartbeat run", + request: { params: z.object({ runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Dashboard ─────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/dashboard", + tags: ["dashboard"], + summary: "Get dashboard data", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Sidebar ───────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/sidebar-badges", + tags: ["sidebar"], + summary: "Get sidebar badge counts", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/sidebar-preferences/me", + tags: ["sidebar"], + summary: "Get current user sidebar preferences", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "put", + path: "/api/sidebar-preferences/me", + tags: ["sidebar"], + summary: "Update current user sidebar preferences", + request: { body: jsonBody(upsertSidebarOrderPreferenceSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/sidebar-preferences/me", + tags: ["sidebar"], + summary: "Get sidebar preferences for company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "put", + path: "/api/companies/{companyId}/sidebar-preferences/me", + tags: ["sidebar"], + summary: "Update sidebar preferences for company", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(upsertSidebarOrderPreferenceSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Inbox dismissals ──────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/inbox-dismissals", + tags: ["inbox"], + summary: "List inbox dismissals", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/inbox-dismissals", + tags: ["inbox"], + summary: "Create an inbox dismissal", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(z.object({ + itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"), + })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Instance settings ──────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/instance/settings/general", + tags: ["instance"], + summary: "Get general instance settings", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/instance/settings/general", + tags: ["instance"], + summary: "Update general instance settings", + request: { body: jsonBody(patchInstanceGeneralSettingsSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/instance/settings/experimental", + tags: ["instance"], + summary: "Get experimental instance settings", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/instance/settings/experimental", + tags: ["instance"], + summary: "Update experimental instance settings", + request: { body: jsonBody(patchInstanceExperimentalSettingsSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Access / invites / members ─────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/invites", + tags: ["access"], + summary: "List company invites", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/invites", + tags: ["access"], + summary: "Create a company invite", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createCompanyInviteSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/join-requests", + tags: ["access"], + summary: "List company join requests", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/join-requests/{requestId}/approve", + tags: ["access"], + summary: "Approve a company join request", + request: { params: z.object({ companyId: z.string(), requestId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/join-requests/{requestId}/reject", + tags: ["access"], + summary: "Reject a company join request", + request: { params: z.object({ companyId: z.string(), requestId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/invites/{inviteId}/revoke", + tags: ["access"], + summary: "Revoke an invite", + request: { params: z.object({ inviteId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}", + tags: ["access"], + summary: "Get an invite by token", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/invites/{token}/accept", + tags: ["access"], + summary: "Accept an invite and create or replay a join request", + request: { + params: z.object({ token: z.string() }), + body: jsonBody(acceptInviteSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/members", + tags: ["access"], + summary: "List company members", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/members/{memberId}", + tags: ["access"], + summary: "Update a company member status or role", + request: { + params: z.object({ companyId: z.string(), memberId: z.string() }), + body: jsonBody(updateCompanyMemberSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/members/{memberId}/role-and-grants", + tags: ["access"], + summary: "Update a company member role and explicit grants", + request: { + params: z.object({ companyId: z.string(), memberId: z.string() }), + body: jsonBody(updateCompanyMemberWithPermissionsSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/members/{memberId}/archive", + tags: ["access"], + summary: "Archive a company member", + request: { + params: z.object({ companyId: z.string(), memberId: z.string() }), + body: jsonBody(archiveCompanyMemberSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/members/{memberId}/permissions", + tags: ["access"], + summary: "Update explicit company member permissions", + request: { + params: z.object({ companyId: z.string(), memberId: z.string() }), + body: jsonBody(updateMemberPermissionsSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/user-directory", + tags: ["access"], + summary: "Get company user directory", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/cli-auth/me", + tags: ["access"], + summary: "Get current CLI auth session", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/openclaw/invite-prompt", + tags: ["access"], + summary: "Create an OpenClaw invite prompt bundle", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createOpenClawInvitePromptSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/cli-auth/challenges", + tags: ["access"], + summary: "Create a CLI auth challenge", + request: { body: jsonBody(createCliAuthChallengeSchema) }, + responses: { 200: r.ok(), 400: r.badRequest }, +}); + +registry.registerPath({ + method: "post", + path: "/api/cli-auth/challenges/{id}/approve", + tags: ["access"], + summary: "Approve a CLI auth challenge", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(resolveCliAuthChallengeSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/cli-auth/challenges/{id}/cancel", + tags: ["access"], + summary: "Cancel a CLI auth challenge", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(resolveCliAuthChallengeSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/cli-auth/revoke-current", + tags: ["access"], + summary: "Revoke current CLI auth session", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/skills/available", + tags: ["access"], + summary: "List available skills", + responses: { 200: r.ok() }, +}); + +registry.registerPath({ + method: "get", + path: "/api/skills/index", + tags: ["access"], + summary: "Get skills index", + responses: { 200: r.ok() }, +}); + +registry.registerPath({ + method: "get", + path: "/api/skills/{skillName}", + tags: ["access"], + summary: "Get a skill by name", + request: { params: z.object({ skillName: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/join-requests/{requestId}/claim-api-key", + tags: ["access"], + summary: "Claim the initial API key for an approved agent join request", + request: { + params: z.object({ requestId: z.string() }), + body: jsonBody(claimJoinRequestApiKeySchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/admin/users", + tags: ["admin"], + summary: "List all users (admin)", + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + +// ─── Auth / profile ────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/auth/get-session", + tags: ["auth"], + summary: "Get current session", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/auth/profile", + tags: ["auth"], + summary: "Get current user profile", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/auth/profile", + tags: ["auth"], + summary: "Update current user profile", + request: { body: jsonBody(updateCurrentUserProfileSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/users/{userSlug}/profile", + tags: ["auth"], + summary: "Get a user profile within a company", + request: { params: z.object({ companyId: z.string(), userSlug: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +// ─── Heartbeat runs ────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/heartbeat-runs", + tags: ["runs"], + summary: "List heartbeat runs for a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/live-runs", + tags: ["runs"], + summary: "List live runs for a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{issueId}/live-runs", + tags: ["runs"], + summary: "List live runs for an issue", + request: { params: z.object({ issueId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{issueId}/active-run", + tags: ["runs"], + summary: "Get active run for an issue", + request: { params: z.object({ issueId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/heartbeat-runs/{runId}", + tags: ["runs"], + summary: "Get a heartbeat run", + request: { params: z.object({ runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/heartbeat-runs/{runId}/cancel", + tags: ["runs"], + summary: "Cancel a heartbeat run", + request: { params: z.object({ runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/heartbeat-runs/{runId}/watchdog-decisions", + tags: ["runs"], + summary: "Submit watchdog decisions for a run", + request: { + params: z.object({ runId: z.string() }), + body: jsonBody(z.object({ + decision: z.enum(["snooze", "continue", "dismissed_false_positive"]), + evaluationIssueId: z.string().optional().nullable(), + reason: z.string().optional().nullable(), + snoozedUntil: z.string().datetime().optional().nullable(), + })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/heartbeat-runs/{runId}/events", + tags: ["runs"], + summary: "Get events for a heartbeat run", + request: { params: z.object({ runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/heartbeat-runs/{runId}/log", + tags: ["runs"], + summary: "Get log for a heartbeat run", + request: { params: z.object({ runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/heartbeat-runs/{runId}/workspace-operations", + tags: ["runs"], + summary: "List workspace operations for a run", + request: { params: z.object({ runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/workspace-operations/{operationId}/log", + tags: ["runs"], + summary: "Get log for a workspace operation", + request: { params: z.object({ operationId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Agent runs & heartbeat ─────────────────────────────────────────────────── + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/approve", + tags: ["agents"], + summary: "Approve a pending agent action", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/heartbeat/invoke", + tags: ["agents"], + summary: "Invoke agent heartbeat", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/claude-login", + tags: ["agents"], + summary: "Trigger Claude login for agent", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Issue interactions & tree ─────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/interactions", + tags: ["issues"], + summary: "List issue thread interactions", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/interactions", + tags: ["issues"], + summary: "Create an issue thread interaction", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(createIssueThreadInteractionSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/interactions/{interactionId}/accept", + tags: ["issues"], + summary: "Accept an issue thread interaction", + request: { + params: z.object({ id: z.string(), interactionId: z.string() }), + body: jsonBody(acceptIssueThreadInteractionSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/interactions/{interactionId}/reject", + tags: ["issues"], + summary: "Reject an issue thread interaction", + request: { + params: z.object({ id: z.string(), interactionId: z.string() }), + body: jsonBody(rejectIssueThreadInteractionSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/interactions/{interactionId}/respond", + tags: ["issues"], + summary: "Answer questions on an issue thread interaction", + request: { + params: z.object({ id: z.string(), interactionId: z.string() }), + body: jsonBody(respondIssueThreadInteractionSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/children", + tags: ["issues"], + summary: "Create child issues", + request: { params: z.object({ id: z.string() }), body: jsonBody(createChildIssueSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/admin/force-release", + tags: ["issues"], + summary: "Force-release an issue (admin)", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/tree-control/state", + tags: ["issues"], + summary: "Get issue tree control state", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/tree-control/preview", + tags: ["issues"], + summary: "Preview issue tree control changes", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(previewIssueTreeControlSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/tree-holds", + tags: ["issues"], + summary: "List issue tree holds", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/tree-holds", + tags: ["issues"], + summary: "Create an issue tree hold", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(createIssueTreeHoldSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/tree-holds/{holdId}", + tags: ["issues"], + summary: "Get an issue tree hold", + request: { params: z.object({ id: z.string(), holdId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/tree-holds/{holdId}/release", + tags: ["issues"], + summary: "Release an issue tree hold", + request: { + params: z.object({ id: z.string(), holdId: z.string() }), + body: jsonBody(releaseIssueTreeHoldSchema), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Attachments ────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/issues/{issueId}/attachments", + tags: ["assets"], + summary: "Upload an attachment to an issue", + request: { params: z.object({ companyId: z.string(), issueId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/attachments/{attachmentId}/content", + tags: ["assets"], + summary: "Download attachment content", + request: { params: z.object({ attachmentId: z.string() }) }, + responses: { 200: { description: "File content" }, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/attachments/{attachmentId}", + tags: ["assets"], + summary: "Delete an attachment", + request: { params: z.object({ attachmentId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Assets ────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/assets/images", + tags: ["assets"], + summary: "Upload an image asset", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/logo", + tags: ["assets"], + summary: "Upload company logo", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/assets/{assetId}/content", + tags: ["assets"], + summary: "Download asset content", + request: { params: z.object({ assetId: z.string() }) }, + responses: { 200: { description: "File content" }, 401: r.unauthorized, 404: r.notFound }, +}); + +// ─── Company skills ─────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/skills", + tags: ["skills"], + summary: "List skills for a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/skills/{skillId}", + tags: ["skills"], + summary: "Get a company skill", + request: { params: z.object({ companyId: z.string(), skillId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/skills/{skillId}/update-status", + tags: ["skills"], + summary: "Get skill update status", + request: { params: z.object({ companyId: z.string(), skillId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/skills/{skillId}/files", + tags: ["skills"], + summary: "List skill files", + request: { params: z.object({ companyId: z.string(), skillId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/skills", + tags: ["skills"], + summary: "Create a company skill", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(companySkillCreateSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/skills/{skillId}/files", + tags: ["skills"], + summary: "Update a skill file", + request: { + params: z.object({ companyId: z.string(), skillId: z.string() }), + body: jsonBody(companySkillFileUpdateSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/skills/import", + tags: ["skills"], + summary: "Import a skill", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(companySkillImportSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/skills/scan-projects", + tags: ["skills"], + summary: "Scan project for skills", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(companySkillProjectScanRequestSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/skills/{skillId}/install-update", + tags: ["skills"], + summary: "Install a skill update", + request: { params: z.object({ companyId: z.string(), skillId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/companies/{companyId}/skills/{skillId}", + tags: ["skills"], + summary: "Delete a company skill", + request: { params: z.object({ companyId: z.string(), skillId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Execution workspaces ───────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/execution-workspaces", + tags: ["execution-workspaces"], + summary: "List execution workspaces for a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/execution-workspaces/{id}", + tags: ["execution-workspaces"], + summary: "Get an execution workspace", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/execution-workspaces/{id}/close-readiness", + tags: ["execution-workspaces"], + summary: "Check close-readiness of a workspace", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/execution-workspaces/{id}/workspace-operations", + tags: ["execution-workspaces"], + summary: "List workspace operations", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/execution-workspaces/{id}", + tags: ["execution-workspaces"], + summary: "Update an execution workspace", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateExecutionWorkspaceSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/execution-workspaces/{id}/runtime-services/{action}", + tags: ["execution-workspaces"], + summary: "Control a runtime service in a workspace", + request: { + params: z.object({ id: z.string(), action: z.string() }), + body: jsonBody(workspaceRuntimeControlTargetSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/execution-workspaces/{id}/runtime-commands/{action}", + tags: ["execution-workspaces"], + summary: "Run a runtime command in a workspace", + request: { + params: z.object({ id: z.string(), action: z.string() }), + body: jsonBody(workspaceRuntimeControlTargetSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Environments ───────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/environments", + tags: ["environments"], + summary: "List environments for a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/environments/capabilities", + tags: ["environments"], + summary: "Get environment capabilities", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/environments", + tags: ["environments"], + summary: "Create an environment", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createEnvironmentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/environments/{id}", + tags: ["environments"], + summary: "Get an environment", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/environments/{id}/leases", + tags: ["environments"], + summary: "List leases for an environment", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/environment-leases/{leaseId}", + tags: ["environments"], + summary: "Get an environment lease", + request: { params: z.object({ leaseId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/environments/{id}", + tags: ["environments"], + summary: "Update an environment", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateEnvironmentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/environments/{id}", + tags: ["environments"], + summary: "Delete an environment", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/environments/{id}/probe", + tags: ["environments"], + summary: "Probe an environment", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/environments/probe-config", + tags: ["environments"], + summary: "Probe environment config", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(probeEnvironmentConfigSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Adapters (full) ────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/adapters", + tags: ["adapters"], + summary: "List all adapters", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/adapters/install", + tags: ["adapters"], + summary: "Install an adapter", + request: { + body: jsonBody(z.object({ + packageName: z.string(), + isLocalPath: z.boolean().optional(), + version: z.string().optional(), + })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/adapters/{type}", + tags: ["adapters"], + summary: "Enable or disable an adapter", + request: { + params: z.object({ type: z.string() }), + body: jsonBody(z.object({ disabled: z.boolean() })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/adapters/{type}/override", + tags: ["adapters"], + summary: "Pause or resume an adapter's override of a builtin", + request: { + params: z.object({ type: z.string() }), + body: jsonBody(z.object({ paused: z.boolean() })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/adapters/{type}", + tags: ["adapters"], + summary: "Delete an adapter", + request: { params: z.object({ type: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/adapters/{type}/reload", + tags: ["adapters"], + summary: "Reload an adapter", + request: { params: z.object({ type: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/adapters/{type}/reinstall", + tags: ["adapters"], + summary: "Reinstall an adapter", + request: { params: z.object({ type: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/adapters/{type}/config-schema", + tags: ["adapters"], + summary: "Get adapter config schema", + request: { params: z.object({ type: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Plugins ────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/plugins", + tags: ["plugins"], + summary: "List installed plugins", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/examples", + tags: ["plugins"], + summary: "List example plugins", + responses: { 200: r.ok() }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/ui-contributions", + tags: ["plugins"], + summary: "List plugin UI contributions", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/tools", + tags: ["plugins"], + summary: "List plugin tools", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/tools/execute", + tags: ["plugins"], + summary: "Execute a plugin tool", + request: { + body: jsonBody(z.object({ + tool: z.string(), + parameters: z.record(z.unknown()).optional(), + runContext: z.object({ + agentId: z.string(), + runId: z.string(), + companyId: z.string(), + projectId: z.string(), + }), + })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/install", + tags: ["plugins"], + summary: "Install a plugin", + request: { + body: jsonBody(z.object({ + packageName: z.string(), + version: z.string().optional(), + isLocalPath: z.boolean().optional(), + })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}", + tags: ["plugins"], + summary: "Get a plugin", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/plugins/{pluginId}", + tags: ["plugins"], + summary: "Delete a plugin", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/enable", + tags: ["plugins"], + summary: "Enable a plugin", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/disable", + tags: ["plugins"], + summary: "Disable a plugin", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/health", + tags: ["plugins"], + summary: "Get plugin health", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/logs", + tags: ["plugins"], + summary: "Get plugin logs", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/upgrade", + tags: ["plugins"], + summary: "Upgrade a plugin", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/config", + tags: ["plugins"], + summary: "Get plugin config", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/config", + tags: ["plugins"], + summary: "Set plugin config", + request: { + params: z.object({ pluginId: z.string() }), + body: jsonBody(z.object({ configJson: z.record(z.unknown()) })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/config/test", + tags: ["plugins"], + summary: "Test plugin config", + request: { + params: z.object({ pluginId: z.string() }), + body: jsonBody(z.object({ configJson: z.record(z.unknown()) })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/jobs", + tags: ["plugins"], + summary: "List plugin jobs", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/jobs/{jobId}/runs", + tags: ["plugins"], + summary: "List runs for a plugin job", + request: { params: z.object({ pluginId: z.string(), jobId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/jobs/{jobId}/trigger", + tags: ["plugins"], + summary: "Trigger a plugin job", + request: { params: z.object({ pluginId: z.string(), jobId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/webhooks/{endpointKey}", + tags: ["plugins"], + summary: "Deliver an external webhook payload to a plugin", + request: { + params: z.object({ pluginId: z.string(), endpointKey: z.string() }), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/dashboard", + tags: ["plugins"], + summary: "Get plugin dashboard data", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/bridge/data", + tags: ["plugins"], + summary: "Send data via plugin bridge", + request: { + params: z.object({ pluginId: z.string() }), + body: jsonBody(z.object({ + key: z.string(), + companyId: z.string().optional(), + params: z.record(z.unknown()).optional(), + })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/bridge/action", + tags: ["plugins"], + summary: "Send action via plugin bridge", + request: { + params: z.object({ pluginId: z.string() }), + body: jsonBody(z.object({ + key: z.string(), + companyId: z.string().optional(), + params: z.record(z.unknown()).optional(), + })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/data/{key}", + tags: ["plugins"], + summary: "Get plugin data by key (URL-keyed bridge)", + request: { + params: z.object({ pluginId: z.string(), key: z.string() }), + body: jsonBody(z.object({ + companyId: z.string().optional(), + params: z.record(z.unknown()).optional(), + })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/actions/{key}", + tags: ["plugins"], + summary: "Invoke a plugin action (URL-keyed bridge)", + request: { + params: z.object({ pluginId: z.string(), key: z.string() }), + body: jsonBody(z.object({ + companyId: z.string().optional(), + params: z.record(z.unknown()).optional(), + })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Instance database backups ──────────────────────────────────────────────── + +registry.registerPath({ + method: "post", + path: "/api/instance/database-backups", + tags: ["instance"], + summary: "Trigger a database backup", + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + +// ─── LLM text endpoints ─────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/llms/agent-configuration.txt", + tags: ["llms"], + summary: "Get agent configuration as plain text (for LLM context)", + responses: { 200: { description: "Plain text agent configuration" }, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/llms/agent-configuration/{adapterType}.txt", + tags: ["llms"], + summary: "Get agent configuration for a specific adapter type", + request: { params: z.object({ adapterType: z.string() }) }, + responses: { 200: { description: "Plain text agent configuration" }, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/llms/agent-icons.txt", + tags: ["llms"], + summary: "Get agent icon names as plain text", + responses: { 200: { description: "Plain text icon list" }, 401: r.unauthorized }, +}); + +// ─── Issues (legacy / misc) ─────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/issues", + tags: ["issues"], + summary: "Legacy — returns error directing to /api/companies/{companyId}/issues", + responses: { 400: r.badRequest }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/comments/{commentId}", + tags: ["issues"], + summary: "Get a single issue comment", + request: { params: z.object({ id: z.string(), commentId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +// ─── Org chart images ───────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/org.svg", + tags: ["companies"], + summary: "Get org chart as SVG", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: { description: "SVG image" }, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/org.png", + tags: ["companies"], + summary: "Get org chart as PNG", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: { description: "PNG image" }, 401: r.unauthorized }, +}); + +// ─── Company portability (legacy routes) ───────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/issues", + tags: ["companies"], + summary: "Legacy — returns error directing to correct issues path", + responses: { 400: r.badRequest }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/export", + tags: ["companies"], + summary: "Export a company (legacy singular form)", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(companyPortabilityExportSchema), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/import/preview", + tags: ["companies"], + summary: "Preview a company import (legacy route)", + request: { body: jsonBody(companyPortabilityPreviewSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/import", + tags: ["companies"], + summary: "Apply a company import (legacy route)", + request: { body: jsonBody(companyPortabilityImportSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Board claim & CLI auth ─────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/board-claim/{token}", + tags: ["access"], + summary: "Get board claim details by token", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/board-claim/{token}/claim", + tags: ["access"], + summary: "Claim a board token", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/cli-auth/challenges/{id}", + tags: ["access"], + summary: "Get a CLI auth challenge", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +// ─── Invite onboarding ──────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}/logo", + tags: ["access"], + summary: "Get company logo for an invite", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: { description: "Image file" }, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}/onboarding", + tags: ["access"], + summary: "Get onboarding data for an invite", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}/onboarding.txt", + tags: ["access"], + summary: "Get onboarding instructions as plain text", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: { description: "Plain text onboarding instructions" }, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}/skills/index", + tags: ["access"], + summary: "Get skills index for an invite", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}/skills/{skillName}", + tags: ["access"], + summary: "Get a skill by name for an invite", + request: { params: z.object({ token: z.string(), skillName: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}/test-resolution", + tags: ["access"], + summary: "Test invite token resolution", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +// ─── Admin ──────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/admin/users/{userId}/company-access", + tags: ["admin"], + summary: "Get company access for a user (admin)", + request: { params: z.object({ userId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + +registry.registerPath({ + method: "put", + path: "/api/admin/users/{userId}/company-access", + tags: ["admin"], + summary: "Set company access for a user (admin)", + request: { + params: z.object({ userId: z.string() }), + body: jsonBody(updateUserCompanyAccessSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, +}); + +registry.registerPath({ + method: "post", + path: "/api/admin/users/{userId}/promote-instance-admin", + tags: ["admin"], + summary: "Promote a user to instance admin", + request: { params: z.object({ userId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/admin/users/{userId}/demote-instance-admin", + tags: ["admin"], + summary: "Demote a user from instance admin", + request: { params: z.object({ userId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +// ─── Project workspace runtime ──────────────────────────────────────────────── + +registry.registerPath({ + method: "post", + path: "/api/projects/{id}/workspaces/{workspaceId}/runtime-services/{action}", + tags: ["projects"], + summary: "Control a runtime service in a project workspace", + request: { + params: z.object({ id: z.string(), workspaceId: z.string(), action: z.string() }), + body: jsonBody(workspaceRuntimeControlTargetSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/projects/{id}/workspaces/{workspaceId}/runtime-commands/{action}", + tags: ["projects"], + summary: "Run a runtime command in a project workspace", + request: { + params: z.object({ id: z.string(), workspaceId: z.string(), action: z.string() }), + body: jsonBody(workspaceRuntimeControlTargetSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Plugin bridge stream ───────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/bridge/stream/{channel}", + tags: ["plugins"], + summary: "Subscribe to a plugin bridge SSE stream", + request: { params: z.object({ pluginId: z.string(), channel: z.string() }) }, + responses: { + 200: { description: "Server-sent event stream (text/event-stream)" }, + 401: r.unauthorized, + }, +}); + +// ─── Plugin UI static ───────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/_plugins/{pluginId}/ui/{filePath}", + tags: ["plugins"], + summary: "Serve plugin UI static file", + request: { params: z.object({ pluginId: z.string(), filePath: z.string() }) }, + responses: { 200: { description: "Static file content" }, 404: r.notFound }, +}); + +// ─── Adapter UI parser ──────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/adapters/{type}/ui-parser.js", + tags: ["adapters"], + summary: "Get adapter UI parser script", + request: { params: z.object({ type: z.string() }) }, + responses: { 200: { description: "JavaScript file" }, 404: r.notFound }, +}); + +// ─── Spec builder ───────────────────────────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function buildOpenApiSpec(): any { + const generator = new OpenApiGeneratorV3(registry.definitions); + return applyDocumentFixups(generator.generateDocument({ + openapi: "3.0.0", + info: { + title: "Paperclip API", + version: "1.0.0", + description: "REST API for the Paperclip AI agent management platform", + }, + servers: [{ url: "/" }], + })); +} diff --git a/doc/plans/2026-05-23-cli-api-parity.md b/doc/plans/2026-05-23-cli-api-parity.md new file mode 100644 index 00000000..46e934b8 --- /dev/null +++ b/doc/plans/2026-05-23-cli-api-parity.md @@ -0,0 +1,623 @@ +# CLI API Parity PRD + +Date: 2026-05-23 +Branch: `improvement/cli-api-parity` +Status: PRD + +## Summary + +Paperclip already exposes a broad REST API, but the CLI only covers a narrow operator slice: setup/configuration, context profiles, board auth, companies import/export/delete, issues basic CRUD/comments/checkout/release, approvals, agents list/get/local CLI key export, activity, dashboard, secrets basics, plugin lifecycle basics, feedback export, and cloud sync. + +The next CLI product slice should make the CLI a real external API entry point: + +1. Connect interactively as a board operator or as one agent in one company. +2. Mint, list, revoke, and use board and agent tokens intentionally. +3. Provide single-command agent execution and prompt handoff for scripts. +4. Add CLI coverage for API surfaces that are currently UI-only or curl-only. + +The most important requirement is credential ergonomics. External integrations need a reliable "way in" to Paperclip: + +- full board access via a board token approved by a user +- individual agent access via an agent API key scoped to a specific company and agent +- saved CLI profiles that know whether they are board or agent personas +- non-interactive commands that can run from shell scripts without a prior wizard + +## Existing CLI Coverage + +Current top-level command families: + +- Setup/runtime: `onboard`, `doctor`, `configure`, `env`, `run`, `db:backup`, `allowed-hostname`, `env-lab`, `worktree` +- Context/auth: `context`, `auth login`, `auth logout`, `auth whoami`, `auth bootstrap-ceo` +- Companies: `company list`, `company get`, `company export`, `company import`, `company delete`, company feedback export +- Issues: `issue list`, `issue get`, `issue create`, `issue update`, `issue comment`, `issue checkout`, `issue release`, issue feedback export +- Agents: `agent list`, `agent get`, `agent local-cli` +- Approvals: `approval list/get/create/approve/reject/request-revision/resubmit/comment` +- Activity/dashboard: `activity list`, `dashboard get` +- Secrets: `secrets list/declarations/create/link/doctor/providers/migrate-inline-env` +- Plugins/cloud/feedback: basic lifecycle and transfer workflows + +Current auth behavior: + +- `auth login` creates a CLI auth challenge, opens the board approval URL, and stores the approved board token locally. +- `agent local-cli` creates an agent API key through board access, installs local skills, and prints `PAPERCLIP_API_URL`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_API_KEY`. +- Every client command can accept `--api-base`, `--api-key`, `--context`, `--profile`, `--company-id`, and `--json`. + +Main limitation: + +- The CLI has no explicit concept of "I am connected as board" versus "I am connected as this agent in this company". It only has a raw bearer token plus optional company context. + +## Product Goals + +1. Make the CLI the canonical external connection surface for scripts, local agents, and human operators. +2. Reach near-parity with first-class REST API domains, starting with company-scoped control-plane operations. +3. Make token creation safe and auditable: keys are named, scoped, shown once, and easy to revoke. +4. Support both interactive and single-command flows. +5. Preserve existing API authorization boundaries: board has operator control; agent keys remain company and agent scoped. + +## Non-Goals + +- Do not turn the CLI into a full TUI replacement for the board UI. +- Do not weaken agent authorization to make script flows easier. +- Do not store plaintext tokens in repo files. +- Do not add project/issue privacy semantics; V1 visibility remains company-scoped. +- Do not make a generic `curl` passthrough the primary parity story. + +## API Location Requirements + +The CLI must always know which Paperclip API it is operating against. This is especially important for fork/local development, where Paperclip may run on `3101+` rather than the upstream default `3100`. + +Resolution order: + +1. Explicit `--api-base <url>`. +2. `PAPERCLIP_API_URL`. +3. Selected context profile `apiBase`. +4. Repo-local or instance config port, when available. +5. Default `http://localhost:3100`. + +Behavior requirements: + +- `paperclipai connect` must show the resolved API base before any auth or mutation and allow the user to override it. +- Non-interactive commands must accept `--api-base` and produce a clear connection error that includes the attempted URL and a health-check hint. +- Profiles must persist `apiBase` so a board/agent persona is always tied to the API instance it was created for. +- Commands that mint or use tokens must not silently fall back to a different API base if a stored credential is missing. They should ask interactively or fail with instructions in non-interactive mode. +- The quick verification after `connect` should call `GET /api/health` against the selected API base. + +## Target User Flows + +### Interactive Connection Wizard + +Command: + +```sh +paperclipai connect +``` + +Flow: + +1. Resolve or ask for API base. +2. Fetch accessible companies with current board auth, or trigger `auth login`. +3. Ask whether the user wants to connect as: + - Board operator + - Agent in a company +4. If board: + - Mint or reuse a named board token. + - Save profile with `persona=board`, `apiBase`, `companyId`, and token env-var preference. +5. If agent: + - Ask for company. + - List agents in that company. + - Create a named agent API key for the selected agent. + - Save profile with `persona=agent`, `companyId`, `agentId`, `agentName`, and token env-var preference. +6. Print shell exports and a verification command. + +Expected profile shape should evolve from today's context: + +```json +{ + "version": 2, + "currentProfile": "default", + "profiles": { + "default": { + "apiBase": "http://localhost:3100", + "companyId": "company-id", + "persona": "agent", + "agentId": "agent-id", + "apiKeyEnvVarName": "PAPERCLIP_API_KEY" + } + } +} +``` + +### Board Token Flow + +Commands: + +```sh +paperclipai token board create --company-id <company-id> --name "external-admin" +paperclipai token board list +paperclipai token board revoke <key-id> +``` + +Requirements: + +- Board token creation must require an authenticated board approval or an existing board token with sufficient authority. +- Token output shows plaintext once. +- Tokens should have names, creation time, last-used time, expiration, and revoked status. +- A company ID in the profile selects the operating company, but full board tokens retain the server's board authorization model. +- If product wants company-limited board keys, add that as an explicit server-side scope rather than relying on client context. + +Current API support: + +- Existing challenge flow supports browser-approved board token minting via `/api/cli-auth/challenges`. +- Existing revocation only covers the current CLI key via `/api/cli-auth/revoke-current`. + +API gap: + +- There is no first-class board API key list/create/revoke endpoint for named external tokens. Add endpoints such as: + - `GET /api/board-api-keys` + - `POST /api/board-api-keys` + - `DELETE /api/board-api-keys/:keyId` + +### Agent Token Flow + +Commands: + +```sh +paperclipai token agent create --company-id <company-id> --agent <agent-id-or-name> --name "external-worker" +paperclipai token agent list --company-id <company-id> --agent <agent-id-or-name> +paperclipai token agent revoke --agent <agent-id-or-name> <key-id> +``` + +Requirements: + +- Requires board access to create/list/revoke long-lived agent keys. +- Agent selector accepts UUID, url key, or unambiguous name within company. +- Output includes `agentId`, `companyId`, key id, key name, and plaintext token once. +- Agent keys remain scoped to one agent and one company, matching `agent_api_keys`. + +Current API support: + +- `GET /api/agents/:id/keys` +- `POST /api/agents/:id/keys` +- `DELETE /api/agents/:id/keys/:keyId` + +CLI gap: + +- `agent local-cli` can create a key, but it is bundled with skill installation and local CLI setup. +- There is no generic token command for list/revoke/create. + +### Single-Command Prompt Handoff + +Required user-facing shape: + +```sh +paperclipai agent-prompt <agent-name-or-id> <agent-api-key> "Prompt here" +``` + +Recommended safer variants: + +```sh +paperclipai agent prompt --agent <agent-name-or-id> --api-key-env PAPERCLIP_API_KEY "Prompt here" +paperclipai agent prompt --profile my-agent "Prompt here" +paperclipai board prompt --agent <agent-name-or-id> "Prompt here" +``` + +Behavior: + +- With an agent key: + - Verify identity with `GET /api/agents/me`. + - Resolve the provided agent name/id against the authenticated agent. If they do not match, fail clearly. + - Create a new issue assigned to that agent, or append to a specified issue when `--issue` is passed. + - Optionally invoke/wake the agent when the authenticated agent is allowed to do so. +- With board auth: + - Resolve company and target agent. + - Create a board-authored issue assigned to that agent. + - Wake/invoke the agent when requested. + +Open decision: + +- Default prompt target should be `issue create + assign + wake`, because Paperclip's communication model is tasks/comments, not chat. +- A direct "send message" mode can be `--issue <id>` and should add an issue comment plus optional wake. + +## Missing CLI Coverage By API Domain + +Priority is based on external API usefulness, not raw endpoint count. + +OpenAPI source audit: + +- Source branch: `feature/openapi-spec` +- Source file: `server/src/openapi.ts` +- Local snapshot for this PRD: `doc/plans/2026-05-23-cli-api-parity-openapi-reference.ts` +- Extracted operations: 307 +- Validation context: that branch includes `server/src/__tests__/openapi-spec.test.ts`, which asserts the OpenAPI document covers mounted server routes exactly. +- Snapshot purpose: keep the full operation registrations, request schemas, auth annotations, response status overrides, and tag/summary values next to the CLI parity plan even before the OpenAPI branch is merged. + +Additional gaps made explicit by the OpenAPI branch: + +- Public/bootstrap surfaces need CLI decisions, not just board UI paths: `GET /api/openapi.json`, board-claim get/claim, invite onboarding docs, skill docs, join key claim, and CLI auth challenge status/approve/cancel. +- User/profile and admin surfaces were under-specified in the first PRD: auth session/profile, company user profile lookup, admin user list/promote/demote/company access. +- Legacy compatibility routes still exist and need an explicit stance: `/api/companies/:companyId/export`, `/api/companies/import/preview`, `/api/companies/import`, `/api/companies/issues`, and bare `GET /api/issues`. +- Agent operations need several extra CLI items: skills list/sync, `claude-login`, scheduler heartbeat visibility, org SVG/PNG export, adapter UI parser, and agent approval. +- Cost/budget coverage must reconcile the OpenAPI branch and current main. The OpenAPI branch lists `GET /api/companies/:companyId/cost-events`; current main exposes `POST /api/companies/:companyId/cost-events` plus additional summary and finance read endpoints. Treat this as a spec/code drift item before implementation. +- The current main branch includes secrets provider-config and remote-import routes beyond the OpenAPI branch list. Keep them in scope for CLI parity even though they are absent from that branch's generated spec. + +### P0: Connection, Tokens, and Identity + +Missing or incomplete CLI surfaces: + +- Board token lifecycle: + - `GET /api/cli-auth/me` is covered by `auth whoami`. + - `POST /api/cli-auth/revoke-current` is covered by `auth logout`. + - Missing named board key list/create/revoke API and CLI. +- Agent identity: + - Missing `agent me` for `GET /api/agents/me`. + - Missing `agent inbox` for `GET /api/agents/me/inbox-lite` and `GET /api/agents/me/inbox/mine`. +- Agent token lifecycle: + - Missing generic CLI for `GET/POST/DELETE /api/agents/:id/keys`. +- Connect wizard: + - No CLI command combines company selection, persona selection, token minting, profile saving, and verification. +- Public/bootstrap auth helpers: + - `GET /api/board-claim/:token` + - `POST /api/board-claim/:token/claim` + - `POST /api/cli-auth/challenges` + - `GET /api/cli-auth/challenges/:id` + - `POST /api/cli-auth/challenges/:id/approve` + - `POST /api/cli-auth/challenges/:id/cancel` + - `POST /api/join-requests/:requestId/claim-api-key` + +### P0: Prompt, Wake, and Run Control + +Missing CLI surfaces: + +- `POST /api/agents/:id/wakeup` +- `POST /api/agents/:id/heartbeat/invoke` is partially covered by `heartbeat run`, but not integrated with prompt handoff. +- `GET /api/companies/:companyId/heartbeat-runs` +- `GET /api/companies/:companyId/live-runs` +- `GET /api/heartbeat-runs/:runId` +- `POST /api/heartbeat-runs/:runId/cancel` +- `GET /api/heartbeat-runs/:runId/events` +- `GET /api/heartbeat-runs/:runId/log` +- `GET /api/issues/:issueId/live-runs` +- `GET /api/issues/:issueId/active-run` +- `GET /api/issues/:id/runs` +- `GET /api/heartbeat-runs/:runId/issues` +- `POST /api/heartbeat-runs/:runId/watchdog-decisions` +- `GET /api/heartbeat-runs/:runId/workspace-operations` +- `GET /api/workspace-operations/:operationId/log` + +CLI commands to add: + +```sh +paperclipai agent wake <agent> +paperclipai run list --company-id <company-id> +paperclipai run get <run-id> +paperclipai run log <run-id> +paperclipai run cancel <run-id> +paperclipai issue runs <issue-id> +``` + +### P1: Projects and Goals + +Missing CLI surfaces: + +- `GET /api/companies/:companyId/projects` +- `POST /api/companies/:companyId/projects` +- `GET /api/projects/:id` +- `PATCH /api/projects/:id` +- `DELETE /api/projects/:id` +- `GET /api/companies/:companyId/goals` +- `POST /api/companies/:companyId/goals` +- `GET /api/goals/:id` +- `PATCH /api/goals/:id` +- `DELETE /api/goals/:id` + +Commands: + +```sh +paperclipai project list|get|create|update|delete +paperclipai goal list|get|create|update|delete +``` + +### P1: Issue Parity Beyond Basic CRUD + +Missing CLI surfaces: + +- Issue counts/search/labels: + - `GET /api/issues` + - `GET /api/companies/:companyId/search` + - `GET /api/companies/:companyId/issues/count` + - `GET /api/companies/issues` + - `GET/POST /api/companies/:companyId/labels` + - `DELETE /api/labels/:labelId` +- Child issues: + - `POST /api/issues/:id/children` +- Force-release/admin recovery: + - `POST /api/issues/:id/admin/force-release` +- Documents: + - `GET /api/issues/:id/documents` + - `GET/PUT/DELETE /api/issues/:id/documents/:key` + - lock/unlock/revisions/restore endpoints +- Work products: + - `GET/POST /api/issues/:id/work-products` + - `PATCH/DELETE /api/work-products/:id` +- Interactions: + - `GET/POST /api/issues/:id/interactions` + - accept/reject/respond/cancel endpoints +- Read/archive state: + - `POST/DELETE /api/issues/:id/read` + - `POST/DELETE /api/issues/:id/inbox-archive` +- Attachments: + - `GET /api/issues/:id/attachments` + - `POST /api/companies/:companyId/issues/:issueId/attachments` + - `GET /api/attachments/:attachmentId/content` + - `DELETE /api/attachments/:attachmentId` +- Comment-specific access: + - `GET /api/issues/:id/comments/:commentId` + - `DELETE /api/issues/:id/comments/:commentId` +- Recovery/tree control: + - `GET /api/issues/:id/recovery-actions` + - `POST /api/issues/:id/recovery-actions/resolve` + - tree hold and preview endpoints + +Commands: + +```sh +paperclipai issue child create <issue-id> +paperclipai issue document list|get|put|delete|lock|unlock|revisions|restore +paperclipai issue work-product list|create|update|delete +paperclipai issue interaction list|create|accept|reject|respond|cancel +paperclipai issue attachment list|upload|download|delete +paperclipai issue force-release <issue-id> +paperclipai issue label list|create|delete +paperclipai issue read|unread|archive|unarchive +``` + +### P1: Agent Lifecycle and Configuration + +Missing CLI surfaces: + +- Create/update/pause/resume/approve/terminate/delete: + - `POST /api/companies/:companyId/agents` + - `PATCH /api/agents/:id` + - `POST /api/agents/:id/pause` + - `POST /api/agents/:id/resume` + - `POST /api/agents/:id/approve` + - `POST /api/agents/:id/terminate` + - `DELETE /api/agents/:id` +- Org and config: + - `GET /api/companies/:companyId/org` + - `GET /api/companies/:companyId/org.svg` + - `GET /api/companies/:companyId/org.png` + - `GET /api/companies/:companyId/agent-configurations` + - `GET /api/agents/:id/configuration` + - config revision list/get/rollback + - runtime state and task sessions +- Instructions: + - instructions bundle, path, and file endpoints +- Adapter support: + - models, model profiles, detect model, test environment +- Skills and local-auth helpers: + - `GET /api/agents/:id/skills` + - `POST /api/agents/:id/skills/sync` + - `POST /api/agents/:id/claude-login` +- Scheduler visibility: + - `GET /api/instance/scheduler-heartbeats` + +Commands: + +```sh +paperclipai agent create|update|pause|resume|approve|terminate|delete +paperclipai agent org +paperclipai agent config get|revisions|rollback +paperclipai agent instructions get|set|file +paperclipai adapter list|models|profiles|detect|test|install|enable|disable|reload +``` + +### P1: Costs, Budgets, and Finance + +Missing CLI surfaces: + +- `POST /api/companies/:companyId/cost-events` +- `GET /api/companies/:companyId/cost-events` from the OpenAPI branch needs reconciliation with main before implementation. +- `POST /api/companies/:companyId/finance-events` +- cost summaries by agent/model/provider/biller/project +- finance summaries by biller/kind and finance events +- quota windows and window spend +- budget overview, budget policies, budget incident resolution +- `PATCH /api/companies/:companyId/budgets` +- `PATCH /api/agents/:agentId/budgets` +- `GET /api/issues/:id/cost-summary` + +Commands: + +```sh +paperclipai cost summary|by-agent|by-project|by-provider|issue +paperclipai cost event create +paperclipai finance event create|list|summary +paperclipai budget overview|set-company|set-agent|policy-create|incident-resolve +``` + +### P1: Access, Invites, and Memberships + +Missing CLI surfaces: + +- Invite creation/list/revoke and onboarding manifests +- Join request list/approve/reject/claim API key +- Company members and user directory +- Member role/grant/permission updates +- Admin users and company access management +- Board claim endpoints +- Skills index/invite onboarding docs +- Auth/profile endpoints: + - `GET /api/auth/get-session` + - `GET /api/auth/profile` + - `PATCH /api/auth/profile` + - `GET /api/companies/:companyId/users/:userSlug/profile` + +Commands: + +```sh +paperclipai invite create|list|revoke|show|onboarding +paperclipai join list|approve|reject|claim-key +paperclipai member list|update|archive|permissions +paperclipai admin user list|promote|demote|company-access +``` + +### P2: Routines, Workspaces, Environments + +Missing CLI surfaces: + +- Routines API: + - list/create/get/update/revisions/restore/runs/run/triggers/rotate-secret/public fire +- Environments API: + - list/capabilities/create/get/update/delete/probe/leases +- Execution and project workspaces: + - execution workspace list/get/patch/close readiness/operations/runtime actions + - project workspace list/create/update/delete/runtime actions + +Commands: + +```sh +paperclipai routine list|create|get|update|run|runs|trigger|revision +paperclipai environment list|create|get|update|delete|probe|leases +paperclipai workspace list|get|update|operations|runtime +paperclipai project workspace list|create|update|delete|runtime +``` + +### P2: Instance, Sidebar, Assets, Profile, and Miscellaneous + +Missing CLI surfaces: + +- Instance settings general/experimental and database backups API +- Sidebar preferences and sidebar badges +- Asset image/logo upload and asset content download +- User profile read/update and company user profile lookup +- LLM prompt docs endpoints +- Public API documentation endpoint: + - `GET /api/openapi.json` +- Plugin deeper surfaces: + - tools list/execute + - UI contributions + - plugin config/test + - plugin health/logs/jobs/webhooks/local folders/dashboard +- Company create/update/archive/branding/stats are missing or partial in CLI. +- Company portability compatibility routes: + - `POST /api/companies/:companyId/export` + - `POST /api/companies/import/preview` + - `POST /api/companies/import` + - `POST /api/companies/:companyId/exports` + - `POST /api/companies/:companyId/exports/preview` + - `POST /api/companies/:companyId/imports/preview` + - `POST /api/companies/:companyId/imports/apply` + +## Command Taxonomy + +Recommended command hierarchy: + +```text +paperclipai connect +paperclipai token board|agent create|list|revoke +paperclipai whoami +paperclipai prompt ... +paperclipai board ... +paperclipai agent ... +paperclipai issue ... +paperclipai project ... +paperclipai goal ... +paperclipai run ... +paperclipai cost ... +paperclipai budget ... +paperclipai routine ... +paperclipai environment ... +paperclipai workspace ... +paperclipai invite ... +paperclipai member ... +paperclipai plugin ... +paperclipai instance ... +``` + +Alias policy: + +- Keep existing commands working. +- Add aliases only for high-frequency flows, for example `paperclipai ask` as an alias for `paperclipai prompt`. + +## Authorization Rules + +- Board commands should use board tokens and fail clearly when an agent key is supplied. +- Agent commands should prefer `GET /api/agents/me` to establish identity instead of trusting CLI flags. +- `--company-id` is a context selector, not an authorization bypass. +- Token creation and revocation must log activity through existing server routes. +- Commands that mutate company state should print the actor type and target company in `--json` output when practical. + +## Testing Rules + +Automated tests should prefer mocked HTTP/server fixtures where possible. Live/API verification is allowed, but it must be isolated: + +- Live tests must create a new disposable company specifically for that test run. +- Live tests must never use an existing company from the operator's profile, local instance, or shared environment. +- The disposable company name should include a clear prefix such as `CLI Parity Test` plus a timestamp or random suffix. +- All agents, projects, issues, tokens, budgets, secrets, routines, workspaces, and other test data must be created inside the disposable company. +- Agent API keys used in tests must be minted only for agents created inside the disposable company. +- Board token tests must use a test-specific key name and revoke the key during cleanup when the API supports it. +- Cleanup should archive or delete the disposable company when the server permits it. If deletion is disabled, the test must leave the company clearly named as disposable and report its ID. +- Commands must provide a `--yes` or non-interactive path for test setup so CI and local verification do not depend on manual prompts. +- Destructive tests must require an explicit test opt-in such as an env var or a dedicated test command; normal unit tests must not mutate a real running Paperclip instance. + +## Implementation Plan + +### Phase 1: Credential and Persona Foundation + +- Extend CLI context to version 2 with `persona`, `agentId`, and token metadata. +- Add `connect` wizard. +- Add `token agent create/list/revoke`. +- Add `agent me`. +- Add `agent prompt` and `prompt` using issue create/comment plus optional wake. +- Harden API base resolution and connection diagnostics. +- Add tests around context migration, explicit token precedence, and persona mismatch failures. +- Add live-test helpers that always create a disposable company before exercising real API mutations. + +### Phase 2: Board Token Management + +- Add server endpoints for named board API key lifecycle if product approves direct token management. +- Add CLI `token board create/list/revoke`. +- Keep browser approval as the default interactive path. +- Add expiration and naming options. + +### Phase 3: Core API Parity + +- Add projects/goals. +- Add agent lifecycle/config/instructions. +- Add run/heartbeat inspection and cancellation. +- Add issue documents/work products/interactions/attachments/labels. + +### Phase 4: Operations Parity + +- Add costs/budgets/finance. +- Add access/invites/members/admin users. +- Add routines/environments/workspaces. +- Expand plugin and instance settings surfaces. + +## Acceptance Criteria + +- A new user can run `paperclipai connect`, confirm or override the API base, select board or agent, and get a saved working profile tied to that API base. +- A board operator can mint an agent key for a selected agent in a selected company without using `agent local-cli`. +- A script can run a one-liner equivalent to: + +```sh +paperclipai agent-prompt AgentName "$AGENT_API_KEY" "Prompt here" +``` + +- The one-liner creates or updates Paperclip work, does not require a browser, and fails with a clear company/agent mismatch error when the token does not belong to the requested agent. +- Live/API verification creates and uses a disposable company only; no existing company is used for testing. +- CLI docs list which API route families are covered and which remain UI-only. +- Token creation, revocation, and prompt handoff have tests for board and agent auth paths. + +## Risks + +- Board token lifecycle endpoints may create a broader security surface if expiration, revocation, and audit logging are incomplete. +- A raw prompt command can look like chat; the implementation must keep prompts attached to issues/comments. +- Agent name selectors can be ambiguous; require exact UUID/urlKey or fail on duplicate names. +- CLI parity can sprawl. Ship by user workflow, not by endpoint count alone. + +## OpenAPI Reference + +The full OpenAPI source snapshot is kept next to this PRD at `doc/plans/2026-05-23-cli-api-parity-openapi-reference.ts`. Use that file when request body schemas, auth levels, response statuses, tags, operation summaries, or the complete endpoint inventory are needed. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 51e7f20d..1c552a1f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1044,6 +1044,7 @@ export { boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, + createBoardApiKeySchema, currentUserProfileSchema, authSessionSchema, updateCurrentUserProfileSchema, @@ -1066,6 +1067,7 @@ export { type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, + type CreateBoardApiKey, type CurrentUserProfile, type AuthSession, type UpdateCurrentUserProfile, diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index e104bdec..243e7ddd 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -85,6 +85,14 @@ export const resolveCliAuthChallengeSchema = z.object({ export type ResolveCliAuthChallenge = z.infer<typeof resolveCliAuthChallengeSchema>; +export const createBoardApiKeySchema = z.object({ + name: z.string().trim().min(1).max(120).default("paperclipai cli"), + expiresAt: z.coerce.date().optional().nullable(), + requestedCompanyId: z.string().uuid().optional().nullable(), +}); + +export type CreateBoardApiKey = z.infer<typeof createBoardApiKeySchema>; + export const updateMemberPermissionsSchema = z.object({ grants: z.array( z.object({ diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index c424e711..79b80161 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -403,6 +403,7 @@ export { boardCliAuthAccessLevelSchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, + createBoardApiKeySchema, currentUserProfileSchema, authSessionSchema, updateCurrentUserProfileSchema, @@ -421,6 +422,7 @@ export { type BoardCliAuthAccessLevel, type CreateCliAuthChallenge, type ResolveCliAuthChallenge, + type CreateBoardApiKey, type CurrentUserProfile, type AuthSession, type UpdateCurrentUserProfile, diff --git a/server/src/__tests__/board-claim.test.ts b/server/src/__tests__/board-claim.test.ts new file mode 100644 index 00000000..f408a8fb --- /dev/null +++ b/server/src/__tests__/board-claim.test.ts @@ -0,0 +1,128 @@ +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + authUsers, + companies, + companyMemberships, + createDb, + instanceUserRoles, + principalPermissionGrants, +} from "@paperclipai/db"; +import { + claimBoardOwnership, + getBoardClaimWarningUrl, + initializeBoardClaimChallenge, + inspectBoardClaimChallenge, +} from "../board-claim.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("board claim", () => { + let db!: ReturnType<typeof createDb>; + let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-board-claim-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await initializeBoardClaimChallenge(db, { deploymentMode: "local_trusted" }); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(companies); + await db.delete(instanceUserRoles); + await db.delete(authUsers); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("lets a signed-in user claim a local-board-only authenticated instance", async () => { + const now = new Date(); + const userId = `claim-user-${randomUUID()}`; + const company = await db + .insert(companies) + .values({ + name: "Board Claim Co", + issuePrefix: `BC${randomUUID().slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); + + await db.insert(authUsers).values({ + id: userId, + name: "Board Claim User", + email: "board-claim@example.test", + emailVerified: true, + createdAt: now, + updatedAt: now, + }); + await db.insert(instanceUserRoles).values({ + userId: "local-board", + role: "instance_admin", + }); + + await initializeBoardClaimChallenge(db, { deploymentMode: "authenticated" }); + const warningUrl = getBoardClaimWarningUrl("127.0.0.1", 3197); + expect(warningUrl).toBeTruthy(); + + const parsed = new URL(warningUrl!); + const token = parsed.pathname.split("/").pop()!; + const code = parsed.searchParams.get("code")!; + + expect(inspectBoardClaimChallenge(token, code)).toMatchObject({ + status: "available", + requiresSignIn: true, + claimedByUserId: null, + }); + + await expect( + claimBoardOwnership(db, { token, code, userId }), + ).resolves.toEqual({ + status: "claimed", + claimedByUserId: userId, + }); + + await expect( + db + .select() + .from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, "local-board"), eq(instanceUserRoles.role, "instance_admin"))), + ).resolves.toHaveLength(0); + await expect( + db + .select() + .from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))), + ).resolves.toHaveLength(1); + await expect( + db + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, company.id), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), + ), + ), + ).resolves.toMatchObject([ + { + status: "active", + membershipRole: "owner", + }, + ]); + expect(inspectBoardClaimChallenge(token, code)).toMatchObject({ + status: "claimed", + claimedByUserId: userId, + }); + }); +}); diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index 37f3630d..39151085 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -21,6 +21,9 @@ const mockBoardAuthService = vi.hoisted(() => ({ resolveBoardActivityCompanyIds: vi.fn(), assertCurrentBoardKey: vi.fn(), revokeBoardApiKey: vi.fn(), + listBoardApiKeys: vi.fn(), + createNamedBoardApiKey: vi.fn(), + getBoardApiKeyForUser: vi.fn(), })); const mockLogActivity = vi.hoisted(() => vi.fn()); @@ -302,4 +305,121 @@ describe.sequential("cli auth routes", () => { }), ); }); + + it.sequential("creates a named board API key and logs audit activity", async () => { + mockBoardAuthService.createNamedBoardApiKey.mockResolvedValue({ + id: "board-key-4", + name: "external-admin", + token: "pcp_board_plaintext", + createdAt: new Date("2026-05-23T12:00:00.000Z"), + lastUsedAt: null, + revokedAt: null, + expiresAt: new Date("2026-06-23T12:00:00.000Z"), + }); + mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["11111111-1111-4111-8111-111111111111"]); + + const app = await createApp({ + type: "board", + userId: "user-1", + source: "board_key", + isInstanceAdmin: false, + companyIds: ["11111111-1111-4111-8111-111111111111"], + }); + const res = await request(app) + .post("/api/board-api-keys") + .send({ + name: "external-admin", + requestedCompanyId: "11111111-1111-4111-8111-111111111111", + expiresAt: "2026-06-23T12:00:00.000Z", + }); + + expect(res.status, res.text || JSON.stringify(res.body)).toBe(201); + expect(res.body).toMatchObject({ + id: "board-key-4", + name: "external-admin", + token: "pcp_board_plaintext", + expiresAt: "2026-06-23T12:00:00.000Z", + }); + expect(mockBoardAuthService.createNamedBoardApiKey).toHaveBeenCalledWith({ + userId: "user-1", + name: "external-admin", + expiresAt: new Date("2026-06-23T12:00:00.000Z"), + }); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + companyId: "11111111-1111-4111-8111-111111111111", + action: "board_api_key.created", + details: expect.objectContaining({ name: "external-admin" }), + }), + ); + }); + + it.sequential("lists and revokes named board API keys for the current board user", async () => { + const keyId = "55555555-5555-4555-8555-555555555555"; + mockBoardAuthService.listBoardApiKeys.mockResolvedValue([ + { + id: keyId, + name: "external-admin", + createdAt: new Date("2026-05-23T12:00:00.000Z"), + lastUsedAt: null, + revokedAt: null, + expiresAt: null, + }, + ]); + mockBoardAuthService.getBoardApiKeyForUser.mockResolvedValue({ + id: keyId, + userId: "user-1", + name: "external-admin", + }); + mockBoardAuthService.revokeBoardApiKey.mockResolvedValue({ + id: keyId, + userId: "user-1", + name: "external-admin", + }); + mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]); + + const app = await createApp({ + type: "board", + userId: "user-1", + source: "board_key", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const listRes = await request(app).get("/api/board-api-keys"); + expect(listRes.status).toBe(200); + expect(listRes.body[0]).toMatchObject({ id: keyId, name: "external-admin" }); + expect(mockBoardAuthService.listBoardApiKeys).toHaveBeenCalledWith( + "user-1", + { includeInactive: false }, + ); + + const revokeRes = await request(app).delete(`/api/board-api-keys/${keyId}`); + expect(revokeRes.status).toBe(200); + expect(revokeRes.body).toEqual({ ok: true, keyId }); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + companyId: "company-1", + action: "board_api_key.revoked", + }), + ); + }); + + it.sequential("rejects malformed board API key IDs before database lookup", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + source: "board_key", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const res = await request(app).delete("/api/board-api-keys/not-a-uuid"); + + expect(res.status).toBe(400); + expect(mockBoardAuthService.getBoardApiKeyForUser).not.toHaveBeenCalled(); + expect(mockBoardAuthService.revokeBoardApiKey).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts index 6d74de22..788726df 100644 --- a/server/src/__tests__/environment-routes.test.ts +++ b/server/src/__tests__/environment-routes.test.ts @@ -146,6 +146,7 @@ describe("environment routes", () => { mockIssueService.getById.mockReset(); mockProjectService.getById.mockReset(); mockEnvironmentService.list.mockReset(); + mockEnvironmentService.list.mockResolvedValue([]); mockEnvironmentService.getById.mockReset(); mockEnvironmentService.create.mockReset(); mockEnvironmentService.update.mockReset(); @@ -420,6 +421,27 @@ describe("environment routes", () => { ); }); + it("returns conflict when creating a second local environment", async () => { + mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app) + .post("/api/companies/company-1/environments") + .send({ + name: "Another Local", + driver: "local", + config: {}, + }); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("A local environment already exists for this company."); + expect(mockEnvironmentService.create).not.toHaveBeenCalled(); + }); + it("allows non-admin board users with environments:manage to create environments", async () => { const environment = createEnvironment(); mockAccessService.canUser.mockResolvedValue(true); diff --git a/server/src/__tests__/issue-tree-control-routes.test.ts b/server/src/__tests__/issue-tree-control-routes.test.ts index b2de69b1..c71a354b 100644 --- a/server/src/__tests__/issue-tree-control-routes.test.ts +++ b/server/src/__tests__/issue-tree-control-routes.test.ts @@ -100,6 +100,27 @@ describe("issue tree control routes", () => { expect(mockTreeControlService.createHold).not.toHaveBeenCalled(); }); + it("rejects malformed tree hold IDs before querying the hold service", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["company-2"], + source: "session", + isInstanceAdmin: false, + }); + + const getRes = await request(app) + .get("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds/null"); + const releaseRes = await request(app) + .post("/api/issues/11111111-1111-4111-8111-111111111111/tree-holds/null/release") + .send({ reason: "bad hold id" }); + + expect(getRes.status).toBe(400); + expect(releaseRes.status).toBe(400); + expect(mockTreeControlService.getHold).not.toHaveBeenCalled(); + expect(mockTreeControlService.releaseHold).not.toHaveBeenCalled(); + }); + it("cancels active descendant runs when creating a pause hold", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts new file mode 100644 index 00000000..248818e3 --- /dev/null +++ b/server/src/__tests__/openapi-routes.test.ts @@ -0,0 +1,45 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { openApiRoutes } from "../routes/openapi.js"; + +function createApp() { + const app = express(); + app.use("/api", openApiRoutes()); + app.use(errorHandler); + return app; +} + +describe("openapi routes", () => { + it("serves the generated OpenAPI document", async () => { + const res = await request(createApp()).get("/api/openapi.json"); + + expect(res.status).toBe(200); + expect(res.body.openapi).toBe("3.0.0"); + expect(res.body.info.title).toBe("Paperclip API"); + expect(res.body.paths["/api/openapi.json"].get.summary).toBe("Get the generated OpenAPI document"); + expect(res.body.paths["/api/companies/{companyId}/agents"].get.summary).toBe("List agents in a company"); + expect(res.body.paths["/api/agents/{id}/keys"].post.summary).toBe("Create an agent API key"); + expect(res.body.components.securitySchemes).toMatchObject({ + BoardSessionAuth: { type: "apiKey", in: "cookie" }, + BoardApiKeyAuth: { type: "http", scheme: "bearer" }, + AgentBearerAuth: { type: "http", scheme: "bearer" }, + }); + expect(res.body.paths["/api/health"].get.security).toEqual([]); + expect(res.body.paths["/api/companies"].post.responses["201"]).toBeDefined(); + expect(res.body.paths["/api/companies"].post.requestBody.content["application/json"].schema).toMatchObject({ + type: "object", + properties: { + name: { type: "string", minLength: 1 }, + }, + required: ["name"], + }); + expect(res.body.paths["/api/agents/{id}/keys"].post.requestBody.content["application/json"].schema).toMatchObject({ + type: "object", + properties: { + name: { type: "string" }, + }, + }); + }); +}); diff --git a/server/src/__tests__/plugin-database.test.ts b/server/src/__tests__/plugin-database.test.ts index d69fd34a..82b455a9 100644 --- a/server/src/__tests__/plugin-database.test.ts +++ b/server/src/__tests__/plugin-database.test.ts @@ -520,10 +520,19 @@ describeEmbeddedPostgres("plugin database namespaces", () => { const staleManifest = manifest("paperclip.refresh"); const refreshedManifest: PaperclipPluginManifestV1 = { ...staleManifest, + capabilities: [...staleManifest.capabilities, "agent.tools.register"], database: { ...staleManifest.database!, coreReadTables: ["companies"], }, + tools: [ + { + name: "db-smoke", + displayName: "DB Smoke", + description: "Exercises plugin tool registration worker lookup.", + parametersSchema: { type: "object", properties: {} }, + }, + ], }; const namespace = derivePluginDatabaseNamespace(refreshedManifest.id); const packageRoot = await createInstallablePluginPackage( @@ -548,6 +557,9 @@ describeEmbeddedPostgres("plugin database namespaces", () => { startWorker: vi.fn().mockResolvedValue(undefined), stopAll: vi.fn().mockResolvedValue(undefined), }; + const toolDispatcher = { + registerPluginTools: vi.fn(), + }; const loader = pluginLoader(db, { enableLocalFilesystem: false, enableNpmDiscovery: false, @@ -564,9 +576,7 @@ describeEmbeddedPostgres("plugin database namespaces", () => { jobStore: { syncJobDeclarations: vi.fn().mockResolvedValue(undefined), }, - toolDispatcher: { - registerPluginTools: vi.fn(), - }, + toolDispatcher, lifecycleManager: { markError: vi.fn().mockResolvedValue(undefined), }, @@ -595,6 +605,13 @@ describeEmbeddedPostgres("plugin database namespaces", () => { }), }), ); + expect(toolDispatcher.registerPluginTools).toHaveBeenCalledWith( + refreshedManifest.id, + expect.objectContaining({ + tools: refreshedManifest.tools, + }), + pluginId, + ); const [plugin] = await db .select() .from(plugins) diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index e449bd30..eb70d023 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -477,6 +477,8 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { replayWindowSec: 300, }, {}); await svc.deleteTrigger(created.trigger.id, {}); + await expect(db.select().from(companySecrets).where(eq(companySecrets.id, created.trigger.secretId!))).resolves.toHaveLength(0); + await expect(db.select().from(companySecretBindings).where(eq(companySecretBindings.secretId, created.trigger.secretId!))).resolves.toHaveLength(0); const restored = await svc.restoreRevision(routine.id, created.revision.id, {}); @@ -563,6 +565,8 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { const deleted = await svc.deleteTrigger(created.trigger.id, {}); expect(deleted.revision?.revisionNumber).toBe(5); + await expect(db.select().from(companySecrets).where(eq(companySecrets.id, created.trigger.secretId!))).resolves.toHaveLength(0); + await expect(db.select().from(companySecretBindings).where(eq(companySecretBindings.secretId, created.trigger.secretId!))).resolves.toHaveLength(0); const revisions = await svc.listRevisions(routine.id); const serialized = JSON.stringify(revisions.map((revision) => revision.snapshot)); diff --git a/server/src/app.ts b/server/src/app.ts index f3a0867f..57f5fae5 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -31,6 +31,7 @@ import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; import { resourceMembershipRoutes } from "./routes/resource-memberships.js"; import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; +import { openApiRoutes } from "./routes/openapi.js"; import { instanceDatabaseBackupRoutes, type InstanceDatabaseBackupService, @@ -207,7 +208,9 @@ export async function createApp( companyDeletionEnabled: opts.companyDeletionEnabled, }), ); + api.use(openApiRoutes()); api.use("/companies", companyRoutes(db, opts.storageService)); + api.use(llmRoutes(db)); api.use(companySkillRoutes(db)); api.use(agentRoutes(db, { pluginWorkerManager: workerManager })); api.use(assetRoutes(db, opts.storageService)); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 0478f60e..a4a2fea1 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -32,6 +32,7 @@ import { acceptInviteSchema, createCliAuthChallengeSchema, claimJoinRequestApiKeySchema, + createBoardApiKeySchema, createCompanyInviteSchema, createOpenClawInvitePromptSchema, listCompanyInvitesQuerySchema, @@ -43,7 +44,8 @@ import { archiveCompanyMemberSchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, - PERMISSION_KEYS + PERMISSION_KEYS, + isUuidLike, } from "@paperclipai/shared"; import type { DeploymentExposure, DeploymentMode, HumanCompanyMembershipRole, PermissionKey } from "@paperclipai/shared"; import { @@ -139,20 +141,17 @@ function buildCliAuthApprovalPath(challengeId: string, token: string) { function readSkillMarkdown(skillName: string): string | null { const normalized = skillName.trim().toLowerCase(); - if ( - normalized !== "paperclip" && - normalized !== "paperclip-create-agent" && - normalized !== "paperclip-create-plugin" && - normalized !== "paperclip-converting-plans-to-tasks" && - normalized !== "para-memory-files" - ) + if (!isSafeSkillName(normalized)) { return null; + } const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const claudeSkillsDir = resolveClaudeSkillsDir(); const candidates = [ + claudeSkillsDir ? path.resolve(claudeSkillsDir, normalized, "SKILL.md") : null, path.resolve(moduleDir, "../../skills", normalized, "SKILL.md"), // published: dist/routes/ -> <pkg>/skills/ path.resolve(process.cwd(), "skills", normalized, "SKILL.md"), // cwd (e.g. monorepo root) path.resolve(moduleDir, "../../../skills", normalized, "SKILL.md") // dev: src/routes/ -> repo root/skills/ - ]; + ].filter((candidate): candidate is string => Boolean(candidate)); for (const skillPath of candidates) { try { return fs.readFileSync(skillPath, "utf8"); @@ -163,6 +162,10 @@ function readSkillMarkdown(skillName: string): string | null { return null; } +function isSafeSkillName(skillName: string): boolean { + return /^[a-z0-9][a-z0-9._-]*$/.test(skillName); +} + /** Resolve the Paperclip repo skills directory (built-in / managed skills). */ function resolvePaperclipSkillsDir(): string | null { const moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -206,10 +209,17 @@ interface AvailableSkill { isPaperclipManaged: boolean; } -/** Discover all available Claude Code skills from ~/.claude/skills/. */ +/** Discover all available Claude Code skills from CLAUDE_HOME or ~/.claude. */ +function resolveClaudeSkillsDir(): string { + const configuredClaudeHome = process.env.CLAUDE_HOME?.trim(); + const claudeHome = configuredClaudeHome + ? path.resolve(configuredClaudeHome) + : path.join(process.env.HOME || process.env.USERPROFILE || "", ".claude"); + return path.join(claudeHome, "skills"); +} + function listAvailableSkills(): AvailableSkill[] { - const homeDir = process.env.HOME || process.env.USERPROFILE || ""; - const claudeSkillsDir = path.join(homeDir, ".claude", "skills"); + const claudeSkillsDir = resolveClaudeSkillsDir(); const paperclipSkillsDir = resolvePaperclipSkillsDir(); // Build set of Paperclip-managed skill names @@ -241,7 +251,27 @@ function listAvailableSkills(): AvailableSkill[] { isPaperclipManaged: paperclipSkillNames.has(entry.name), }); } - } catch { /* ~/.claude/skills/ doesn't exist */ } + } catch { /* Claude skills directory doesn't exist */ } + + if (paperclipSkillsDir) { + const existingNames = new Set(skills.map((skill) => skill.name)); + try { + for (const entry of fs.readdirSync(paperclipSkillsDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith(".") || existingNames.has(entry.name)) continue; + const skillMdPath = path.join(paperclipSkillsDir, entry.name, "SKILL.md"); + let description = ""; + try { + const md = fs.readFileSync(skillMdPath, "utf8"); + description = parseSkillFrontmatter(md).description; + } catch { /* no SKILL.md or unreadable */ } + skills.push({ + name: entry.name, + description, + isPaperclipManaged: true, + }); + } + } catch { /* skip Paperclip skills directory */ } + } skills.sort((a, b) => a.name.localeCompare(b.name)); return skills; @@ -2610,6 +2640,95 @@ export function accessRoutes( }); }); + router.get("/board-api-keys", async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + const keys = await boardAuth.listBoardApiKeys(req.actor.userId, { + includeInactive: req.query.includeInactive === "true", + }); + res.json(keys); + }); + + router.post( + "/board-api-keys", + validate(createBoardApiKeySchema), + async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + + if (req.body.requestedCompanyId) { + assertCompanyAccess(req, req.body.requestedCompanyId); + } + + const key = await boardAuth.createNamedBoardApiKey({ + userId: req.actor.userId, + name: req.body.name, + expiresAt: req.body.expiresAt === undefined ? undefined : req.body.expiresAt, + }); + const companyIds = await boardAuth.resolveBoardActivityCompanyIds({ + userId: req.actor.userId, + requestedCompanyId: req.body.requestedCompanyId ?? null, + boardApiKeyId: key.id, + }); + for (const companyId of companyIds) { + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId, + action: "board_api_key.created", + entityType: "user", + entityId: req.actor.userId, + details: { + boardApiKeyId: key.id, + name: key.name, + requestedCompanyId: req.body.requestedCompanyId ?? null, + expiresAt: key.expiresAt?.toISOString() ?? null, + }, + }); + } + + res.status(201).json(key); + }, + ); + + router.delete("/board-api-keys/:keyId", async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + const keyId = (req.params.keyId as string).trim(); + if (!isUuidLike(keyId)) { + throw badRequest("Invalid board API key ID"); + } + const key = await boardAuth.getBoardApiKeyForUser(keyId, req.actor.userId); + if (!key) throw notFound("Board API key not found"); + const revoked = await boardAuth.revokeBoardApiKey(key.id); + if (!revoked) throw notFound("Board API key not found"); + + const companyIds = await boardAuth.resolveBoardActivityCompanyIds({ + userId: req.actor.userId, + boardApiKeyId: key.id, + }); + for (const companyId of companyIds) { + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId, + action: "board_api_key.revoked", + entityType: "user", + entityId: req.actor.userId, + details: { + boardApiKeyId: key.id, + name: key.name, + revokedVia: "board_api_key_lifecycle", + }, + }); + } + + res.json({ ok: true, keyId: key.id }); + }); + router.post("/cli-auth/revoke-current", async (req, res) => { if (req.actor.type !== "board" || req.actor.source !== "board_key") { throw badRequest("Current board API key context is required"); diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 8d1d2cec..afdc6762 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -350,6 +350,21 @@ export function adapterRoutes() { } }); + router.get("/adapters/:type", async (req, res) => { + assertBoardOrgAccess(req); + + const adapterType = req.params.type; + const adapter = findServerAdapter(adapterType); + if (!adapter) { + res.status(404).json({ error: `Adapter "${adapterType}" is not registered.` }); + return; + } + + const externalRecord = getAdapterPluginByType(adapterType); + const disabledSet = new Set(getDisabledAdapterTypes()); + res.json(buildAdapterInfo(adapter, externalRecord, disabledSet)); + }); + /** * PATCH /api/adapters/:type * diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 2c0daded..d6b09385 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -7,7 +7,7 @@ import { probeEnvironmentConfigSchema, updateEnvironmentSchema, } from "@paperclipai/shared"; -import { forbidden } from "../errors.js"; +import { conflict, forbidden } from "../errors.js"; import { validate } from "../middleware/validate.js"; import { accessService, @@ -196,6 +196,12 @@ export function environmentRoutes( router.post("/companies/:companyId/environments", validate(createEnvironmentSchema), async (req, res) => { const companyId = req.params.companyId as string; await assertCanMutateEnvironments(req, companyId); + if (req.body.driver === "local") { + const existingLocal = await svc.list(companyId, { driver: "local" }); + if (existingLocal.length > 0) { + throw conflict("A local environment already exists for this company."); + } + } const actor = getActorInfo(req); const input = { ...req.body, diff --git a/server/src/routes/issue-tree-control.ts b/server/src/routes/issue-tree-control.ts index 44212128..e1268321 100644 --- a/server/src/routes/issue-tree-control.ts +++ b/server/src/routes/issue-tree-control.ts @@ -3,6 +3,7 @@ import type { Request } from "express"; import type { Db } from "@paperclipai/db"; import { createIssueTreeHoldSchema, + isUuidLike, previewIssueTreeControlSchema, releaseIssueTreeHoldSchema, } from "@paperclipai/shared"; @@ -340,7 +341,13 @@ export function issueTreeControlRoutes(db: Db) { } assertCompanyAccess(req, root.companyId); - const hold = await treeControlSvc.getHold(root.companyId, req.params.holdId as string); + const holdId = req.params.holdId as string; + if (!isUuidLike(holdId)) { + res.status(400).json({ error: "Invalid hold ID" }); + return; + } + + const hold = await treeControlSvc.getHold(root.companyId, holdId); if (!hold || hold.rootIssueId !== root.id) { res.status(404).json({ error: "Issue tree hold not found" }); return; @@ -360,8 +367,14 @@ export function issueTreeControlRoutes(db: Db) { } assertCompanyAccess(req, root.companyId); + const holdId = req.params.holdId as string; + if (!isUuidLike(holdId)) { + res.status(400).json({ error: "Invalid hold ID" }); + return; + } + const actor = getActorInfo(req); - const hold = await treeControlSvc.releaseHold(root.companyId, root.id, req.params.holdId as string, { + const hold = await treeControlSvc.releaseHold(root.companyId, root.id, holdId, { ...req.body, actor: { actorType: actor.actorType, diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts new file mode 100644 index 00000000..4e1ece62 --- /dev/null +++ b/server/src/routes/openapi.ts @@ -0,0 +1,3842 @@ +import { Router } from "express"; +import { z } from "zod"; +import { + // Agent + createAgentSchema, + createAgentHireSchema, + updateAgentSchema, + updateAgentPermissionsSchema, + updateAgentInstructionsPathSchema, + updateAgentInstructionsBundleSchema, + upsertAgentInstructionsFileSchema, + createAgentKeySchema, + wakeAgentSchema, + resetAgentSessionSchema, + agentSkillSyncSchema, + testAdapterEnvironmentSchema, + // Issue + createIssueSchema, + updateIssueSchema, + createIssueLabelSchema, + addIssueCommentSchema, + checkoutIssueSchema, + linkIssueApprovalSchema, + createIssueWorkProductSchema, + updateIssueWorkProductSchema, + upsertIssueDocumentSchema, + restoreIssueDocumentRevisionSchema, + upsertIssueFeedbackVoteSchema, + // Project + createProjectSchema, + updateProjectSchema, + createProjectWorkspaceSchema, + updateProjectWorkspaceSchema, + // Company + createCompanySchema, + updateCompanySchema, + updateCompanyBrandingSchema, + // Routine + createRoutineSchema, + updateRoutineSchema, + createRoutineTriggerSchema, + updateRoutineTriggerSchema, + rotateRoutineTriggerSecretSchema, + runRoutineSchema, + // Goal + createGoalSchema, + updateGoalSchema, + // Secret + createSecretSchema, + updateSecretSchema, + rotateSecretSchema, + // Approval + createApprovalSchema, + resolveApprovalSchema, + requestApprovalRevisionSchema, + resubmitApprovalSchema, + addApprovalCommentSchema, + // Cost / budget + createCostEventSchema, + createFinanceEventSchema, + updateBudgetSchema, + upsertBudgetPolicySchema, + resolveBudgetIncidentSchema, + // Sidebar + upsertSidebarOrderPreferenceSchema, + // Execution workspaces + updateExecutionWorkspaceSchema, + workspaceRuntimeControlTargetSchema, + // Environments + createEnvironmentSchema, + updateEnvironmentSchema, + probeEnvironmentConfigSchema, + // Company skills + companySkillCreateSchema, + companySkillFileUpdateSchema, + companySkillImportSchema, + companySkillProjectScanRequestSchema, + // Issue tree + createIssueTreeHoldSchema, + previewIssueTreeControlSchema, + releaseIssueTreeHoldSchema, + // Issue interactions + createIssueThreadInteractionSchema, + createChildIssueSchema, + acceptIssueThreadInteractionSchema, + rejectIssueThreadInteractionSchema, + respondIssueThreadInteractionSchema, + // Auth / profile + updateCurrentUserProfileSchema, + // Company portability (legacy routes) + companyPortabilityExportSchema, + companyPortabilityPreviewSchema, + companyPortabilityImportSchema, + // Access / membership + acceptInviteSchema, + createCompanyInviteSchema, + createOpenClawInvitePromptSchema, + claimJoinRequestApiKeySchema, + createCliAuthChallengeSchema, + resolveCliAuthChallengeSchema, + updateCompanyMemberSchema, + updateCompanyMemberWithPermissionsSchema, + archiveCompanyMemberSchema, + updateMemberPermissionsSchema, + updateUserCompanyAccessSchema, + // Instance settings + patchInstanceGeneralSettingsSchema, + patchInstanceExperimentalSettingsSchema, +} from "@paperclipai/shared"; + +type JsonSchema = Record<string, unknown>; +type OpenApiResponse = Record<string, unknown>; +type OpenApiPathRegistration = { + method: string; + path: string; + request?: { + params?: z.ZodTypeAny; + body?: { + content: Record<string, { schema: unknown }>; + required?: boolean; + }; + }; + responses?: Record<string, OpenApiResponse>; + [key: string]: unknown; +}; + +const zodTypeName = (schema: z.ZodTypeAny) => schema._def.typeName as string; + +function unwrapSchema(schema: z.ZodTypeAny): z.ZodTypeAny { + const typeName = zodTypeName(schema); + if (typeName === "ZodOptional" || typeName === "ZodDefault" || typeName === "ZodCatch") { + return unwrapSchema(schema._def.innerType); + } + if (typeName === "ZodEffects") { + return unwrapSchema(schema._def.schema); + } + return schema; +} + +function isOptionalSchema(schema: z.ZodTypeAny): boolean { + const typeName = zodTypeName(schema); + if (typeName === "ZodOptional" || typeName === "ZodDefault" || typeName === "ZodCatch") { + return true; + } + if (typeName === "ZodEffects") { + return isOptionalSchema(schema._def.schema); + } + if (typeName === "ZodNullable") { + return isOptionalSchema(schema._def.innerType); + } + return false; +} + +function applyStringChecks(jsonSchema: JsonSchema, checks: Array<Record<string, unknown>>) { + for (const check of checks) { + if (check.kind === "min") jsonSchema.minLength = check.value; + if (check.kind === "max") jsonSchema.maxLength = check.value; + if (check.kind === "email") jsonSchema.format = "email"; + if (check.kind === "url") jsonSchema.format = "uri"; + if (check.kind === "uuid") jsonSchema.format = "uuid"; + if (check.kind === "datetime") jsonSchema.format = "date-time"; + if (check.kind === "regex" && check.regex instanceof RegExp) { + jsonSchema.pattern = check.regex.source; + } + } +} + +function applyNumberChecks(jsonSchema: JsonSchema, checks: Array<Record<string, unknown>>) { + for (const check of checks) { + if (check.kind === "int") jsonSchema.type = "integer"; + if (check.kind === "min") { + jsonSchema.minimum = check.value; + if (!check.inclusive) jsonSchema.exclusiveMinimum = true; + } + if (check.kind === "max") { + jsonSchema.maximum = check.value; + if (!check.inclusive) jsonSchema.exclusiveMaximum = true; + } + } +} + +function zodToOpenApiSchema(schema: z.ZodTypeAny): JsonSchema { + const unwrapped = unwrapSchema(schema); + const typeName = zodTypeName(unwrapped); + + if (typeName === "ZodString") { + const jsonSchema: JsonSchema = { type: "string" }; + applyStringChecks(jsonSchema, unwrapped._def.checks ?? []); + return jsonSchema; + } + + if (typeName === "ZodNumber") { + const jsonSchema: JsonSchema = { type: "number" }; + applyNumberChecks(jsonSchema, unwrapped._def.checks ?? []); + return jsonSchema; + } + + if (typeName === "ZodBoolean") return { type: "boolean" }; + if (typeName === "ZodDate") return { type: "string", format: "date-time" }; + if (typeName === "ZodAny" || typeName === "ZodUnknown") return {}; + + if (typeName === "ZodLiteral") { + const value = unwrapped._def.value; + return { type: typeof value, enum: [value] }; + } + + if (typeName === "ZodEnum") { + return { type: "string", enum: unwrapped._def.values }; + } + + if (typeName === "ZodNativeEnum") { + const values = Object.values(unwrapped._def.values).filter( + (value) => typeof value === "string" || typeof value === "number", + ); + return { enum: Array.from(new Set(values)) }; + } + + if (typeName === "ZodArray") { + return { type: "array", items: zodToOpenApiSchema(unwrapped._def.type) }; + } + + if (typeName === "ZodRecord") { + return { + type: "object", + additionalProperties: zodToOpenApiSchema(unwrapped._def.valueType), + }; + } + + if (typeName === "ZodNullable") { + return { ...zodToOpenApiSchema(unwrapped._def.innerType), nullable: true }; + } + + if (typeName === "ZodUnion") { + return { oneOf: unwrapped._def.options.map((option: z.ZodTypeAny) => zodToOpenApiSchema(option)) }; + } + + if (typeName === "ZodDiscriminatedUnion") { + return { + oneOf: Array.from(unwrapped._def.options.values()).map((option) => + zodToOpenApiSchema(option as z.ZodTypeAny), + ), + }; + } + + if (typeName === "ZodIntersection") { + return { + allOf: [ + zodToOpenApiSchema(unwrapped._def.left), + zodToOpenApiSchema(unwrapped._def.right), + ], + }; + } + + if (typeName === "ZodObject") { + const shape = unwrapped._def.shape(); + const properties: Record<string, JsonSchema> = {}; + const required: string[] = []; + for (const [key, value] of Object.entries(shape)) { + const propertySchema = value as z.ZodTypeAny; + properties[key] = zodToOpenApiSchema(propertySchema); + if (!isOptionalSchema(propertySchema)) required.push(key); + } + const jsonSchema: JsonSchema = { type: "object", properties }; + if (required.length > 0) jsonSchema.required = required; + return jsonSchema; + } + + return {}; +} + +function normalizeContent(content: Record<string, { schema: unknown }>) { + return Object.fromEntries( + Object.entries(content).map(([contentType, media]) => [ + contentType, + { + ...media, + schema: isZodSchema(media.schema) + ? zodToOpenApiSchema(media.schema) + : media.schema, + }, + ]), + ); +} + +function isZodSchema(value: unknown): value is z.ZodTypeAny { + return Boolean( + value && + typeof value === "object" && + "_def" in value && + typeof (value as z.ZodTypeAny).safeParse === "function", + ); +} + +function normalizeResponses(responses: Record<string, OpenApiResponse> = {}) { + return Object.fromEntries( + Object.entries(responses).map(([status, response]) => { + const content = response.content as Record<string, { schema: unknown }> | undefined; + return [ + status, + content + ? { + ...response, + content: normalizeContent(content), + } + : response, + ]; + }), + ); +} + +function parametersFromSchema(schema: z.ZodTypeAny, location: "path" | "query") { + const objectSchema = unwrapSchema(schema); + if (zodTypeName(objectSchema) !== "ZodObject") return []; + const shape = objectSchema._def.shape(); + return Object.entries(shape).map(([name, value]) => ({ + name, + in: location, + required: location === "path" ? true : !isOptionalSchema(value as z.ZodTypeAny), + schema: zodToOpenApiSchema(value as z.ZodTypeAny), + })); +} + +class OpenAPIRegistry { + private readonly schemas: Record<string, JsonSchema> = {}; + private readonly paths: Array<OpenApiPathRegistration> = []; + + register(name: string, schema: z.ZodTypeAny) { + this.schemas[name] = zodToOpenApiSchema(schema); + return { $ref: `#/components/schemas/${name}` }; + } + + registerPath(pathRegistration: OpenApiPathRegistration) { + this.paths.push(pathRegistration); + } + + buildPaths() { + const paths: Record<string, Record<string, unknown>> = {}; + for (const { method, path, request, responses, ...operation } of this.paths) { + const normalizedOperation: Record<string, unknown> = { + ...operation, + responses: normalizeResponses(responses), + }; + if (request?.params) { + normalizedOperation.parameters = parametersFromSchema(request.params, "path"); + } + if (request?.body) { + normalizedOperation.requestBody = { + ...request.body, + content: normalizeContent(request.body.content), + }; + } + paths[path] ??= {}; + paths[path][method] = normalizedOperation; + } + return paths; + } + + buildComponents() { + return { schemas: this.schemas }; + } +} + +const registry = new OpenAPIRegistry(); + +// ─── Common schemas ────────────────────────────────────────────────────────── + +const ErrorSchema = registry.register( + "Error", + z.object({ error: z.string() }), +); + +const responses = { + ok: (schema: z.ZodTypeAny = z.record(z.unknown())) => ({ + description: "Success", + content: { "application/json": { schema } }, + }), + noContent: { description: "No content" }, + badRequest: { + description: "Bad request", + content: { "application/json": { schema: ErrorSchema } }, + }, + unauthorized: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorSchema } }, + }, + forbidden: { + description: "Forbidden", + content: { "application/json": { schema: ErrorSchema } }, + }, + notFound: { + description: "Not found", + content: { "application/json": { schema: ErrorSchema } }, + }, + serverError: { + description: "Internal server error", + content: { "application/json": { schema: ErrorSchema } }, + }, +}; + +const jsonBody = (schema: z.ZodTypeAny) => ({ + content: { "application/json": { schema } }, + required: true as const, +}); + +const r = responses; + +type OpenApiAuthLevel = + | "public" + | "authenticated" + | "board" + | "instance_admin"; + +const BOARD_SESSION_AUTH_SCHEME = "BoardSessionAuth"; +const BOARD_API_KEY_AUTH_SCHEME = "BoardApiKeyAuth"; +const AGENT_BEARER_AUTH_SCHEME = "AgentBearerAuth"; + +function securityRequirement(name: string): Record<string, string[]> { + return { [name]: [] }; +} + +const BOARD_SECURITY: Array<Record<string, string[]>> = [ + securityRequirement(BOARD_SESSION_AUTH_SCHEME), + securityRequirement(BOARD_API_KEY_AUTH_SCHEME), +]; + +const AUTHENTICATED_SECURITY: Array<Record<string, string[]>> = [ + ...BOARD_SECURITY, + securityRequirement(AGENT_BEARER_AUTH_SCHEME), +]; + +const PUBLIC_OPERATIONS = new Set([ + "GET /api/health", + "GET /api/openapi.json", + "GET /api/board-claim/{token}", + "POST /api/cli-auth/challenges", + "GET /api/cli-auth/challenges/{id}", + "POST /api/cli-auth/challenges/{id}/cancel", + "GET /api/invites/{token}", + "GET /api/invites/{token}/logo", + "GET /api/invites/{token}/onboarding", + "GET /api/invites/{token}/onboarding.txt", + "GET /api/invites/{token}/skills/index", + "GET /api/invites/{token}/skills/{skillName}", + "GET /api/invites/{token}/test-resolution", + "POST /api/invites/{token}/accept", + "POST /api/join-requests/{requestId}/claim-api-key", +]); + +const BOARD_ONLY_PREFIXES = [ + "/api/auth/", + "/api/admin/", + "/api/plugins", + "/api/instance/", +]; + +const BOARD_ONLY_OPERATIONS = new Set([ + "GET /api/companies", + "POST /api/companies", + "GET /api/companies/stats", + "GET /api/companies/issues", + "POST /api/board-claim/{token}/claim", + "GET /api/cli-auth/me", + "POST /api/companies/{companyId}/invites", + "GET /api/companies/{companyId}/invites", + "POST /api/companies/{companyId}/openclaw/invite-prompt", + "GET /api/companies/{companyId}/join-requests", + "POST /api/companies/{companyId}/join-requests/{requestId}/approve", + "POST /api/companies/{companyId}/join-requests/{requestId}/reject", + "GET /api/companies/{companyId}/members", + "PATCH /api/companies/{companyId}/members/{memberId}", + "PATCH /api/companies/{companyId}/members/{memberId}/role-and-grants", + "POST /api/companies/{companyId}/members/{memberId}/archive", + "PATCH /api/companies/{companyId}/members/{memberId}/permissions", + "GET /api/companies/{companyId}/user-directory", + "POST /api/issues/{id}/interactions/{interactionId}/accept", + "POST /api/issues/{id}/interactions/{interactionId}/reject", + "POST /api/issues/{id}/interactions/{interactionId}/respond", +]); + +const INSTANCE_ADMIN_OPERATIONS = new Set([ + "POST /api/companies", + "POST /api/plugins/install", + "POST /api/instance/database-backups", + "POST /api/admin/users/{userId}/promote-instance-admin", + "POST /api/admin/users/{userId}/demote-instance-admin", + "PUT /api/admin/users/{userId}/company-access", +]); + +const CREATED_OPERATIONS = new Set([ + "POST /api/adapters/install", + "POST /api/companies/{companyId}/agent-hires", + "POST /api/companies/{companyId}/agents", + "POST /api/agents/{id}/keys", + "POST /api/companies/{companyId}/approvals", + "POST /api/approvals/{id}/comments", + "POST /api/companies/{companyId}/assets/images", + "POST /api/companies/{companyId}/logo", + "POST /api/cli-auth/challenges", + "POST /api/companies", + "POST /api/companies/{companyId}/invites", + "POST /api/companies/{companyId}/openclaw/invite-prompt", + "POST /api/companies/{companyId}/cost-events", + "POST /api/companies/{companyId}/finance-events", + "POST /api/companies/{companyId}/environments", + "POST /api/companies/{companyId}/goals", + "POST /api/companies/{companyId}/labels", + "POST /api/issues/{id}/work-products", + "POST /api/issues/{id}/approvals", + "POST /api/companies/{companyId}/issues", + "POST /api/issues/{id}/children", + "POST /api/issues/{id}/interactions", + "POST /api/issues/{id}/comments", + "POST /api/companies/{companyId}/issues/{issueId}/attachments", + "POST /api/companies/{companyId}/projects", + "POST /api/projects/{id}/workspaces", + "POST /api/companies/{companyId}/routines", + "POST /api/routines/{id}/triggers", + "POST /api/companies/{companyId}/secrets", + "POST /api/companies/{companyId}/skills", + "POST /api/companies/{companyId}/skills/import", + "POST /api/join-requests/{requestId}/claim-api-key", + "POST /api/admin/users/{userId}/promote-instance-admin", + "POST /api/plugins/install", + "POST /api/instance/database-backups", +]); + +const ACCEPTED_OPERATIONS = new Set([ + "POST /api/invites/{token}/accept", +]); + +const FORBIDDEN_RESPONSE = { + description: "Forbidden", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, +}; + +function operationKey(method: string, path: string) { + return `${method.toUpperCase()} ${path}`; +} + +function isBoardOnlyOperation(method: string, path: string) { + const key = operationKey(method, path); + if (BOARD_ONLY_OPERATIONS.has(key)) return true; + return BOARD_ONLY_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + +function resolveOperationAuthLevel(method: string, path: string): OpenApiAuthLevel { + const key = operationKey(method, path); + if (PUBLIC_OPERATIONS.has(key)) return "public"; + if (INSTANCE_ADMIN_OPERATIONS.has(key)) return "instance_admin"; + if (isBoardOnlyOperation(method, path)) return "board"; + return "authenticated"; +} + +function applyOperationStatusOverride( + operation: Record<string, unknown>, + fromStatus: string, + toStatus: string, +) { + const responses = operation.responses as Record<string, unknown> | undefined; + if (!responses || !responses[fromStatus] || responses[toStatus]) return; + responses[toStatus] = responses[fromStatus]; + delete responses[fromStatus]; +} + +function applyDocumentFixups(document: any): any { + document.components ??= {}; + document.components.securitySchemes = { + [BOARD_SESSION_AUTH_SCHEME]: { + type: "apiKey", + in: "cookie", + name: "paperclip_session", + description: + "Board session cookie in authenticated mode. Paperclip uses Better Auth; cookie transport may vary by deployment.", + }, + [BOARD_API_KEY_AUTH_SCHEME]: { + type: "http", + scheme: "bearer", + bearerFormat: "Board API Key", + description: "Board API key presented in the Authorization bearer header.", + }, + [AGENT_BEARER_AUTH_SCHEME]: { + type: "http", + scheme: "bearer", + bearerFormat: "Agent API Key or Agent JWT", + description: + "Agent API key or Paperclip-issued local agent JWT presented in the Authorization bearer header.", + }, + }; + document.security = AUTHENTICATED_SECURITY; + + for (const [path, pathItem] of Object.entries(document.paths ?? {})) { + for (const [method, operation] of Object.entries(pathItem as Record<string, any>)) { + const authLevel = resolveOperationAuthLevel(method, path); + if (authLevel === "public") { + operation.security = []; + } else if (authLevel === "authenticated") { + operation.security = AUTHENTICATED_SECURITY; + } else { + operation.security = BOARD_SECURITY; + } + + operation["x-paperclip-authorization"] = + authLevel === "instance_admin" + ? { actor: "board", instanceAdmin: true } + : authLevel === "board" + ? { actor: "board" } + : authLevel === "authenticated" + ? { actor: "board_or_agent" } + : { actor: "public" }; + + const key = operationKey(method, path); + if (authLevel !== "public") { + const responses = (operation.responses ??= {}) as Record<string, unknown>; + if (!responses["403"]) { + responses["403"] = FORBIDDEN_RESPONSE; + } + } + if (CREATED_OPERATIONS.has(key)) { + applyOperationStatusOverride(operation, "200", "201"); + } + if (ACCEPTED_OPERATIONS.has(key)) { + applyOperationStatusOverride(operation, "200", "202"); + } + } + } + + return document; +} + +// ─── Health ────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/health", + tags: ["health"], + summary: "Health check", + responses: { + 200: r.ok(z.object({ + status: z.enum(["ok", "unhealthy"]), + version: z.string().optional(), + deploymentMode: z.string().optional(), + bootstrapStatus: z.enum(["ready", "bootstrap_pending"]).optional(), + bootstrapInviteActive: z.boolean().optional(), + })), + 503: { description: "Service unavailable", content: { "application/json": { schema: ErrorSchema } } }, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/openapi.json", + tags: ["health"], + summary: "Get the generated OpenAPI document", + responses: { 200: r.ok() }, +}); + +// ─── Companies ─────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies", + tags: ["companies"], + summary: "List companies", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies", + tags: ["companies"], + summary: "Create a company", + request: { body: jsonBody(createCompanySchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/stats", + tags: ["companies"], + summary: "Company stats", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}", + tags: ["companies"], + summary: "Get a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}", + tags: ["companies"], + summary: "Update a company", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(updateCompanySchema.partial()), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/branding", + tags: ["companies"], + summary: "Update company branding", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(updateCompanyBrandingSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/archive", + tags: ["companies"], + summary: "Archive a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/companies/{companyId}", + tags: ["companies"], + summary: "Delete a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/feedback-traces", + tags: ["companies"], + summary: "List company feedback traces", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/exports", + tags: ["companies"], + summary: "Export company data", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/exports/preview", + tags: ["companies"], + summary: "Preview company export", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/imports/preview", + tags: ["companies"], + summary: "Preview company import", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/imports/apply", + tags: ["companies"], + summary: "Apply company import", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Agents ────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/agents", + tags: ["agents"], + summary: "List agents in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/agents", + tags: ["agents"], + summary: "Create an agent", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createAgentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/agent-hires", + tags: ["agents"], + summary: "Hire an agent", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createAgentHireSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/agent-configurations", + tags: ["agents"], + summary: "List agent configurations for a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/org", + tags: ["agents"], + summary: "Get org chart data", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/me", + tags: ["agents"], + summary: "Get the current agent", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/me/inbox-lite", + tags: ["agents"], + summary: "Get current agent inbox (lite)", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/me/inbox/mine", + tags: ["agents"], + summary: "Get current agent assigned inbox items", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}", + tags: ["agents"], + summary: "Get an agent", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/agents/{id}", + tags: ["agents"], + summary: "Update an agent", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateAgentSchema.omit({ permissions: true })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/agents/{id}", + tags: ["agents"], + summary: "Delete an agent", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/agents/{id}/permissions", + tags: ["agents"], + summary: "Update agent permissions", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateAgentPermissionsSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/agents/{id}/instructions-path", + tags: ["agents"], + summary: "Update agent instructions path", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateAgentInstructionsPathSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/instructions-bundle", + tags: ["agents"], + summary: "Get agent instructions bundle", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/agents/{id}/instructions-bundle", + tags: ["agents"], + summary: "Update agent instructions bundle", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateAgentInstructionsBundleSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/instructions-bundle/file", + tags: ["agents"], + summary: "Get agent instructions file", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "put", + path: "/api/agents/{id}/instructions-bundle/file", + tags: ["agents"], + summary: "Upsert agent instructions file", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(upsertAgentInstructionsFileSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/agents/{id}/instructions-bundle/file", + tags: ["agents"], + summary: "Delete agent instructions file", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/configuration", + tags: ["agents"], + summary: "Get agent configuration", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/config-revisions", + tags: ["agents"], + summary: "List agent config revisions", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/config-revisions/{revisionId}", + tags: ["agents"], + summary: "Get an agent config revision", + request: { params: z.object({ id: z.string(), revisionId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/config-revisions/{revisionId}/rollback", + tags: ["agents"], + summary: "Roll back to a config revision", + request: { params: z.object({ id: z.string(), revisionId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/runtime-state", + tags: ["agents"], + summary: "Get agent runtime state", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/runtime-state/reset-session", + tags: ["agents"], + summary: "Reset agent session", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(resetAgentSessionSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/task-sessions", + tags: ["agents"], + summary: "List agent task sessions", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/skills", + tags: ["agents"], + summary: "List agent skills", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/skills/sync", + tags: ["agents"], + summary: "Sync desired skills onto an agent configuration", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(agentSkillSyncSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/keys", + tags: ["agents"], + summary: "List agent API keys", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/keys", + tags: ["agents"], + summary: "Create an agent API key", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(createAgentKeySchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/agents/{id}/keys/{keyId}", + tags: ["agents"], + summary: "Delete an agent API key", + request: { params: z.object({ id: z.string(), keyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/wakeup", + tags: ["agents"], + summary: "Wake up an agent", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(wakeAgentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/pause", + tags: ["agents"], + summary: "Pause an agent", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/resume", + tags: ["agents"], + summary: "Resume an agent", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/terminate", + tags: ["agents"], + summary: "Terminate an agent", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/instance/scheduler-heartbeats", + tags: ["agents"], + summary: "List scheduler heartbeats", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Adapters ──────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/adapters/{type}/models", + tags: ["adapters"], + summary: "List models for an adapter type", + request: { params: z.object({ companyId: z.string(), type: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/adapters/{type}/detect-model", + tags: ["adapters"], + summary: "Detect active model for an adapter", + request: { params: z.object({ companyId: z.string(), type: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/adapters/{type}/test-environment", + tags: ["adapters"], + summary: "Validate adapter environment access for a company", + request: { + params: z.object({ companyId: z.string(), type: z.string() }), + body: jsonBody(testAdapterEnvironmentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Issues ────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/issues", + tags: ["issues"], + summary: "List issues in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/issues", + tags: ["issues"], + summary: "Create an issue", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createIssueSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}", + tags: ["issues"], + summary: "Get an issue", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/issues/{id}", + tags: ["issues"], + summary: "Update an issue", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateIssueSchema.partial()), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/issues/{id}", + tags: ["issues"], + summary: "Delete an issue", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/heartbeat-context", + tags: ["issues"], + summary: "Get issue heartbeat context", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/work-products", + tags: ["issues"], + summary: "List issue work products", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/work-products", + tags: ["issues"], + summary: "Create an issue work product", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(createIssueWorkProductSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/work-products/{id}", + tags: ["issues"], + summary: "Update a work product", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateIssueWorkProductSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/work-products/{id}", + tags: ["issues"], + summary: "Delete a work product", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/documents", + tags: ["issues"], + summary: "List issue documents", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/documents/{key}", + tags: ["issues"], + summary: "Get an issue document", + request: { params: z.object({ id: z.string(), key: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "put", + path: "/api/issues/{id}/documents/{key}", + tags: ["issues"], + summary: "Upsert an issue document", + request: { + params: z.object({ id: z.string(), key: z.string() }), + body: jsonBody(upsertIssueDocumentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/issues/{id}/documents/{key}", + tags: ["issues"], + summary: "Delete an issue document", + request: { params: z.object({ id: z.string(), key: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/documents/{key}/revisions", + tags: ["issues"], + summary: "List issue document revisions", + request: { params: z.object({ id: z.string(), key: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/documents/{key}/revisions/{revisionId}/restore", + tags: ["issues"], + summary: "Restore a document revision", + request: { + params: z.object({ id: z.string(), key: z.string(), revisionId: z.string() }), + body: jsonBody(restoreIssueDocumentRevisionSchema), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/comments", + tags: ["issues"], + summary: "List issue comments", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/comments", + tags: ["issues"], + summary: "Add a comment to an issue", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(addIssueCommentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/issues/{id}/comments/{commentId}", + tags: ["issues"], + summary: "Delete an issue comment", + request: { params: z.object({ id: z.string(), commentId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/approvals", + tags: ["issues"], + summary: "List issue approvals", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/approvals", + tags: ["issues"], + summary: "Link an approval to an issue", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(linkIssueApprovalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/issues/{id}/approvals/{approvalId}", + tags: ["issues"], + summary: "Unlink an approval from an issue", + request: { params: z.object({ id: z.string(), approvalId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/checkout", + tags: ["issues"], + summary: "Check out an issue", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(checkoutIssueSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/release", + tags: ["issues"], + summary: "Release an issue", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/read", + tags: ["issues"], + summary: "Mark an issue as read", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/issues/{id}/read", + tags: ["issues"], + summary: "Mark an issue as unread", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/inbox-archive", + tags: ["issues"], + summary: "Archive issue from inbox", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/issues/{id}/inbox-archive", + tags: ["issues"], + summary: "Un-archive issue from inbox", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/feedback-votes", + tags: ["issues"], + summary: "List issue feedback votes", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/feedback-votes", + tags: ["issues"], + summary: "Upsert a feedback vote", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(upsertIssueFeedbackVoteSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/feedback-traces", + tags: ["issues"], + summary: "List issue feedback traces", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/feedback-traces/{traceId}", + tags: ["issues"], + summary: "Get a feedback trace", + request: { params: z.object({ traceId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/feedback-traces/{traceId}/bundle", + tags: ["issues"], + summary: "Get a feedback trace bundle", + request: { params: z.object({ traceId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/attachments", + tags: ["issues"], + summary: "List issue attachments", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/labels", + tags: ["issues"], + summary: "List labels in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/labels", + tags: ["issues"], + summary: "Create a label", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createIssueLabelSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/labels/{labelId}", + tags: ["issues"], + summary: "Delete a label", + request: { params: z.object({ labelId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Projects ──────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/projects", + tags: ["projects"], + summary: "List projects in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/projects", + tags: ["projects"], + summary: "Create a project", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createProjectSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/projects/{id}", + tags: ["projects"], + summary: "Get a project", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/projects/{id}", + tags: ["projects"], + summary: "Update a project", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateProjectSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/projects/{id}", + tags: ["projects"], + summary: "Delete a project", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/projects/{id}/workspaces", + tags: ["projects"], + summary: "List project workspaces", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/projects/{id}/workspaces", + tags: ["projects"], + summary: "Create a project workspace", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(createProjectWorkspaceSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/projects/{id}/workspaces/{workspaceId}", + tags: ["projects"], + summary: "Update a project workspace", + request: { + params: z.object({ id: z.string(), workspaceId: z.string() }), + body: jsonBody(updateProjectWorkspaceSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/projects/{id}/workspaces/{workspaceId}", + tags: ["projects"], + summary: "Delete a project workspace", + request: { params: z.object({ id: z.string(), workspaceId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Routines ──────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/routines", + tags: ["routines"], + summary: "List routines in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/routines", + tags: ["routines"], + summary: "Create a routine", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createRoutineSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/routines/{id}", + tags: ["routines"], + summary: "Get a routine", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/routines/{id}", + tags: ["routines"], + summary: "Update a routine", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateRoutineSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/routines/{id}/runs", + tags: ["routines"], + summary: "List runs for a routine", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/routines/{id}/run", + tags: ["routines"], + summary: "Manually run a routine", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(runRoutineSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/routines/{id}/triggers", + tags: ["routines"], + summary: "Create a routine trigger", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(createRoutineTriggerSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/routine-triggers/{id}", + tags: ["routines"], + summary: "Update a routine trigger", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateRoutineTriggerSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/routine-triggers/{id}", + tags: ["routines"], + summary: "Delete a routine trigger", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/routine-triggers/{id}/rotate-secret", + tags: ["routines"], + summary: "Rotate a routine trigger secret", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(rotateRoutineTriggerSecretSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/routine-triggers/public/{publicId}/fire", + tags: ["routines"], + summary: "Fire a public routine trigger", + request: { params: z.object({ publicId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +// ─── Goals ─────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/goals", + tags: ["goals"], + summary: "List goals in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/goals", + tags: ["goals"], + summary: "Create a goal", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createGoalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/goals/{id}", + tags: ["goals"], + summary: "Get a goal", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/goals/{id}", + tags: ["goals"], + summary: "Update a goal", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateGoalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/goals/{id}", + tags: ["goals"], + summary: "Delete a goal", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Secrets ───────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/secret-providers", + tags: ["secrets"], + summary: "List secret providers", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/secrets", + tags: ["secrets"], + summary: "List secrets in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/secrets", + tags: ["secrets"], + summary: "Create a secret", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createSecretSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/secrets/{id}", + tags: ["secrets"], + summary: "Update a secret", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateSecretSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/secrets/{id}/rotate", + tags: ["secrets"], + summary: "Rotate a secret", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(rotateSecretSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/secrets/{id}", + tags: ["secrets"], + summary: "Delete a secret", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Approvals ─────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/approvals", + tags: ["approvals"], + summary: "List approvals in a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/approvals", + tags: ["approvals"], + summary: "Create an approval", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createApprovalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/approvals/{id}", + tags: ["approvals"], + summary: "Get an approval", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/approvals/{id}/issues", + tags: ["approvals"], + summary: "List issues linked to an approval", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/approvals/{id}/approve", + tags: ["approvals"], + summary: "Approve an approval", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(resolveApprovalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/approvals/{id}/reject", + tags: ["approvals"], + summary: "Reject an approval", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(resolveApprovalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/approvals/{id}/request-revision", + tags: ["approvals"], + summary: "Request revision on an approval", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(requestApprovalRevisionSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/approvals/{id}/resubmit", + tags: ["approvals"], + summary: "Resubmit an approval", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(resubmitApprovalSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/approvals/{id}/comments", + tags: ["approvals"], + summary: "List approval comments", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/approvals/{id}/comments", + tags: ["approvals"], + summary: "Add a comment to an approval", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(addApprovalCommentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Costs ─────────────────────────────────────────────────────────────────── + +const costSummaryPaths = [ + "summary", "by-agent", "by-agent-model", "by-provider", + "by-biller", "by-project", "finance-summary", "finance-by-biller", + "finance-by-kind", "finance-events", "window-spend", "quota-windows", +] as const; + +for (const segment of costSummaryPaths) { + registry.registerPath({ + method: "get", + path: `/api/companies/{companyId}/costs/${segment}`, + tags: ["costs"], + summary: `Cost report: ${segment}`, + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, + }); +} + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/cost-events", + tags: ["costs"], + summary: "Record a cost event", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createCostEventSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/finance-events", + tags: ["costs"], + summary: "Record a finance event", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createFinanceEventSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/budgets/policies", + tags: ["costs"], + summary: "Create or update a budget policy", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(upsertBudgetPolicySchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/budget-incidents/{incidentId}/resolve", + tags: ["costs"], + summary: "Resolve a budget incident", + request: { + params: z.object({ companyId: z.string(), incidentId: z.string() }), + body: jsonBody(resolveBudgetIncidentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/budgets/overview", + tags: ["costs"], + summary: "Get budget overview", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/budgets", + tags: ["costs"], + summary: "Update company budget", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(updateBudgetSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/agents/{agentId}/budgets", + tags: ["costs"], + summary: "Update agent budget", + request: { + params: z.object({ agentId: z.string() }), + body: jsonBody(updateBudgetSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Activity ──────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/activity", + tags: ["activity"], + summary: "List company activity", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/activity", + tags: ["activity"], + summary: "Create an activity entry", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(z.object({ + actorType: z.enum(["agent", "user", "system", "plugin"]).optional(), + actorId: z.string().min(1), + action: z.string().min(1), + entityType: z.string().min(1), + entityId: z.string().min(1), + agentId: z.string().uuid().optional().nullable(), + details: z.record(z.unknown()).optional().nullable(), + })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/activity", + tags: ["activity"], + summary: "List activity for an issue", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/runs", + tags: ["activity"], + summary: "List runs for an issue", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/heartbeat-runs/{runId}/issues", + tags: ["activity"], + summary: "List issues for a heartbeat run", + request: { params: z.object({ runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Dashboard ─────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/dashboard", + tags: ["dashboard"], + summary: "Get dashboard data", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Sidebar ───────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/sidebar-badges", + tags: ["sidebar"], + summary: "Get sidebar badge counts", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/sidebar-preferences/me", + tags: ["sidebar"], + summary: "Get current user sidebar preferences", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "put", + path: "/api/sidebar-preferences/me", + tags: ["sidebar"], + summary: "Update current user sidebar preferences", + request: { body: jsonBody(upsertSidebarOrderPreferenceSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/sidebar-preferences/me", + tags: ["sidebar"], + summary: "Get sidebar preferences for company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "put", + path: "/api/companies/{companyId}/sidebar-preferences/me", + tags: ["sidebar"], + summary: "Update sidebar preferences for company", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(upsertSidebarOrderPreferenceSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Inbox dismissals ──────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/inbox-dismissals", + tags: ["inbox"], + summary: "List inbox dismissals", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/inbox-dismissals", + tags: ["inbox"], + summary: "Create an inbox dismissal", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(z.object({ + itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"), + })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Instance settings ──────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/instance/settings/general", + tags: ["instance"], + summary: "Get general instance settings", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/instance/settings/general", + tags: ["instance"], + summary: "Update general instance settings", + request: { body: jsonBody(patchInstanceGeneralSettingsSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/instance/settings/experimental", + tags: ["instance"], + summary: "Get experimental instance settings", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/instance/settings/experimental", + tags: ["instance"], + summary: "Update experimental instance settings", + request: { body: jsonBody(patchInstanceExperimentalSettingsSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Access / invites / members ─────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/invites", + tags: ["access"], + summary: "List company invites", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/invites", + tags: ["access"], + summary: "Create a company invite", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createCompanyInviteSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/join-requests", + tags: ["access"], + summary: "List company join requests", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/join-requests/{requestId}/approve", + tags: ["access"], + summary: "Approve a company join request", + request: { params: z.object({ companyId: z.string(), requestId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/join-requests/{requestId}/reject", + tags: ["access"], + summary: "Reject a company join request", + request: { params: z.object({ companyId: z.string(), requestId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/invites/{inviteId}/revoke", + tags: ["access"], + summary: "Revoke an invite", + request: { params: z.object({ inviteId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}", + tags: ["access"], + summary: "Get an invite by token", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/invites/{token}/accept", + tags: ["access"], + summary: "Accept an invite and create or replay a join request", + request: { + params: z.object({ token: z.string() }), + body: jsonBody(acceptInviteSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/members", + tags: ["access"], + summary: "List company members", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/members/{memberId}", + tags: ["access"], + summary: "Update a company member status or role", + request: { + params: z.object({ companyId: z.string(), memberId: z.string() }), + body: jsonBody(updateCompanyMemberSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/members/{memberId}/role-and-grants", + tags: ["access"], + summary: "Update a company member role and explicit grants", + request: { + params: z.object({ companyId: z.string(), memberId: z.string() }), + body: jsonBody(updateCompanyMemberWithPermissionsSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/members/{memberId}/archive", + tags: ["access"], + summary: "Archive a company member", + request: { + params: z.object({ companyId: z.string(), memberId: z.string() }), + body: jsonBody(archiveCompanyMemberSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/members/{memberId}/permissions", + tags: ["access"], + summary: "Update explicit company member permissions", + request: { + params: z.object({ companyId: z.string(), memberId: z.string() }), + body: jsonBody(updateMemberPermissionsSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/user-directory", + tags: ["access"], + summary: "Get company user directory", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/cli-auth/me", + tags: ["access"], + summary: "Get current CLI auth session", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/openclaw/invite-prompt", + tags: ["access"], + summary: "Create an OpenClaw invite prompt bundle", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createOpenClawInvitePromptSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/cli-auth/challenges", + tags: ["access"], + summary: "Create a CLI auth challenge", + request: { body: jsonBody(createCliAuthChallengeSchema) }, + responses: { 200: r.ok(), 400: r.badRequest }, +}); + +registry.registerPath({ + method: "post", + path: "/api/cli-auth/challenges/{id}/approve", + tags: ["access"], + summary: "Approve a CLI auth challenge", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(resolveCliAuthChallengeSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/cli-auth/challenges/{id}/cancel", + tags: ["access"], + summary: "Cancel a CLI auth challenge", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(resolveCliAuthChallengeSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/cli-auth/revoke-current", + tags: ["access"], + summary: "Revoke current CLI auth session", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/skills/available", + tags: ["access"], + summary: "List available skills", + responses: { 200: r.ok() }, +}); + +registry.registerPath({ + method: "get", + path: "/api/skills/index", + tags: ["access"], + summary: "Get skills index", + responses: { 200: r.ok() }, +}); + +registry.registerPath({ + method: "get", + path: "/api/skills/{skillName}", + tags: ["access"], + summary: "Get a skill by name", + request: { params: z.object({ skillName: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/join-requests/{requestId}/claim-api-key", + tags: ["access"], + summary: "Claim the initial API key for an approved agent join request", + request: { + params: z.object({ requestId: z.string() }), + body: jsonBody(claimJoinRequestApiKeySchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/admin/users", + tags: ["admin"], + summary: "List all users (admin)", + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + +// ─── Auth / profile ────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/auth/get-session", + tags: ["auth"], + summary: "Get current session", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/auth/profile", + tags: ["auth"], + summary: "Get current user profile", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/auth/profile", + tags: ["auth"], + summary: "Update current user profile", + request: { body: jsonBody(updateCurrentUserProfileSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/users/{userSlug}/profile", + tags: ["auth"], + summary: "Get a user profile within a company", + request: { params: z.object({ companyId: z.string(), userSlug: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +// ─── Heartbeat runs ────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/heartbeat-runs", + tags: ["runs"], + summary: "List heartbeat runs for a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/live-runs", + tags: ["runs"], + summary: "List live runs for a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{issueId}/live-runs", + tags: ["runs"], + summary: "List live runs for an issue", + request: { params: z.object({ issueId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{issueId}/active-run", + tags: ["runs"], + summary: "Get active run for an issue", + request: { params: z.object({ issueId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/heartbeat-runs/{runId}", + tags: ["runs"], + summary: "Get a heartbeat run", + request: { params: z.object({ runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/heartbeat-runs/{runId}/cancel", + tags: ["runs"], + summary: "Cancel a heartbeat run", + request: { params: z.object({ runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/heartbeat-runs/{runId}/watchdog-decisions", + tags: ["runs"], + summary: "Submit watchdog decisions for a run", + request: { + params: z.object({ runId: z.string() }), + body: jsonBody(z.object({ + decision: z.enum(["snooze", "continue", "dismissed_false_positive"]), + evaluationIssueId: z.string().optional().nullable(), + reason: z.string().optional().nullable(), + snoozedUntil: z.string().datetime().optional().nullable(), + })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/heartbeat-runs/{runId}/events", + tags: ["runs"], + summary: "Get events for a heartbeat run", + request: { params: z.object({ runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/heartbeat-runs/{runId}/log", + tags: ["runs"], + summary: "Get log for a heartbeat run", + request: { params: z.object({ runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/heartbeat-runs/{runId}/workspace-operations", + tags: ["runs"], + summary: "List workspace operations for a run", + request: { params: z.object({ runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/workspace-operations/{operationId}/log", + tags: ["runs"], + summary: "Get log for a workspace operation", + request: { params: z.object({ operationId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Agent runs & heartbeat ─────────────────────────────────────────────────── + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/approve", + tags: ["agents"], + summary: "Approve a pending agent action", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/heartbeat/invoke", + tags: ["agents"], + summary: "Invoke agent heartbeat", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/claude-login", + tags: ["agents"], + summary: "Trigger Claude login for agent", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Issue interactions & tree ─────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/interactions", + tags: ["issues"], + summary: "List issue thread interactions", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/interactions", + tags: ["issues"], + summary: "Create an issue thread interaction", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(createIssueThreadInteractionSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/interactions/{interactionId}/accept", + tags: ["issues"], + summary: "Accept an issue thread interaction", + request: { + params: z.object({ id: z.string(), interactionId: z.string() }), + body: jsonBody(acceptIssueThreadInteractionSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/interactions/{interactionId}/reject", + tags: ["issues"], + summary: "Reject an issue thread interaction", + request: { + params: z.object({ id: z.string(), interactionId: z.string() }), + body: jsonBody(rejectIssueThreadInteractionSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/interactions/{interactionId}/respond", + tags: ["issues"], + summary: "Answer questions on an issue thread interaction", + request: { + params: z.object({ id: z.string(), interactionId: z.string() }), + body: jsonBody(respondIssueThreadInteractionSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/children", + tags: ["issues"], + summary: "Create child issues", + request: { params: z.object({ id: z.string() }), body: jsonBody(createChildIssueSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/admin/force-release", + tags: ["issues"], + summary: "Force-release an issue (admin)", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/tree-control/state", + tags: ["issues"], + summary: "Get issue tree control state", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/tree-control/preview", + tags: ["issues"], + summary: "Preview issue tree control changes", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(previewIssueTreeControlSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/tree-holds", + tags: ["issues"], + summary: "List issue tree holds", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/tree-holds", + tags: ["issues"], + summary: "Create an issue tree hold", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(createIssueTreeHoldSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/tree-holds/{holdId}", + tags: ["issues"], + summary: "Get an issue tree hold", + request: { params: z.object({ id: z.string(), holdId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/tree-holds/{holdId}/release", + tags: ["issues"], + summary: "Release an issue tree hold", + request: { + params: z.object({ id: z.string(), holdId: z.string() }), + body: jsonBody(releaseIssueTreeHoldSchema), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Attachments ────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/issues/{issueId}/attachments", + tags: ["assets"], + summary: "Upload an attachment to an issue", + request: { params: z.object({ companyId: z.string(), issueId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/attachments/{attachmentId}/content", + tags: ["assets"], + summary: "Download attachment content", + request: { params: z.object({ attachmentId: z.string() }) }, + responses: { 200: { description: "File content" }, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/attachments/{attachmentId}", + tags: ["assets"], + summary: "Delete an attachment", + request: { params: z.object({ attachmentId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Assets ────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/assets/images", + tags: ["assets"], + summary: "Upload an image asset", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/logo", + tags: ["assets"], + summary: "Upload company logo", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/assets/{assetId}/content", + tags: ["assets"], + summary: "Download asset content", + request: { params: z.object({ assetId: z.string() }) }, + responses: { 200: { description: "File content" }, 401: r.unauthorized, 404: r.notFound }, +}); + +// ─── Company skills ─────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/skills", + tags: ["skills"], + summary: "List skills for a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/skills/{skillId}", + tags: ["skills"], + summary: "Get a company skill", + request: { params: z.object({ companyId: z.string(), skillId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/skills/{skillId}/update-status", + tags: ["skills"], + summary: "Get skill update status", + request: { params: z.object({ companyId: z.string(), skillId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/skills/{skillId}/files", + tags: ["skills"], + summary: "List skill files", + request: { params: z.object({ companyId: z.string(), skillId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/skills", + tags: ["skills"], + summary: "Create a company skill", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(companySkillCreateSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/skills/{skillId}/files", + tags: ["skills"], + summary: "Update a skill file", + request: { + params: z.object({ companyId: z.string(), skillId: z.string() }), + body: jsonBody(companySkillFileUpdateSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/skills/import", + tags: ["skills"], + summary: "Import a skill", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(companySkillImportSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/skills/scan-projects", + tags: ["skills"], + summary: "Scan project for skills", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(companySkillProjectScanRequestSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/skills/{skillId}/install-update", + tags: ["skills"], + summary: "Install a skill update", + request: { params: z.object({ companyId: z.string(), skillId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/companies/{companyId}/skills/{skillId}", + tags: ["skills"], + summary: "Delete a company skill", + request: { params: z.object({ companyId: z.string(), skillId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Execution workspaces ───────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/execution-workspaces", + tags: ["execution-workspaces"], + summary: "List execution workspaces for a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/execution-workspaces/{id}", + tags: ["execution-workspaces"], + summary: "Get an execution workspace", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/execution-workspaces/{id}/close-readiness", + tags: ["execution-workspaces"], + summary: "Check close-readiness of a workspace", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/execution-workspaces/{id}/workspace-operations", + tags: ["execution-workspaces"], + summary: "List workspace operations", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/execution-workspaces/{id}", + tags: ["execution-workspaces"], + summary: "Update an execution workspace", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateExecutionWorkspaceSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/execution-workspaces/{id}/runtime-services/{action}", + tags: ["execution-workspaces"], + summary: "Control a runtime service in a workspace", + request: { + params: z.object({ id: z.string(), action: z.string() }), + body: jsonBody(workspaceRuntimeControlTargetSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/execution-workspaces/{id}/runtime-commands/{action}", + tags: ["execution-workspaces"], + summary: "Run a runtime command in a workspace", + request: { + params: z.object({ id: z.string(), action: z.string() }), + body: jsonBody(workspaceRuntimeControlTargetSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Environments ───────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/environments", + tags: ["environments"], + summary: "List environments for a company", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/environments/capabilities", + tags: ["environments"], + summary: "Get environment capabilities", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/environments", + tags: ["environments"], + summary: "Create an environment", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createEnvironmentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/environments/{id}", + tags: ["environments"], + summary: "Get an environment", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/environments/{id}/leases", + tags: ["environments"], + summary: "List leases for an environment", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/environment-leases/{leaseId}", + tags: ["environments"], + summary: "Get an environment lease", + request: { params: z.object({ leaseId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/environments/{id}", + tags: ["environments"], + summary: "Update an environment", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(updateEnvironmentSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/environments/{id}", + tags: ["environments"], + summary: "Delete an environment", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/environments/{id}/probe", + tags: ["environments"], + summary: "Probe an environment", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/environments/probe-config", + tags: ["environments"], + summary: "Probe environment config", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(probeEnvironmentConfigSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Adapters (full) ────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/adapters", + tags: ["adapters"], + summary: "List all adapters", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/adapters/install", + tags: ["adapters"], + summary: "Install an adapter", + request: { + body: jsonBody(z.object({ + packageName: z.string(), + isLocalPath: z.boolean().optional(), + version: z.string().optional(), + })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/adapters/{type}", + tags: ["adapters"], + summary: "Enable or disable an adapter", + request: { + params: z.object({ type: z.string() }), + body: jsonBody(z.object({ disabled: z.boolean() })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/adapters/{type}/override", + tags: ["adapters"], + summary: "Pause or resume an adapter's override of a builtin", + request: { + params: z.object({ type: z.string() }), + body: jsonBody(z.object({ paused: z.boolean() })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/adapters/{type}", + tags: ["adapters"], + summary: "Delete an adapter", + request: { params: z.object({ type: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/adapters/{type}/reload", + tags: ["adapters"], + summary: "Reload an adapter", + request: { params: z.object({ type: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/adapters/{type}/reinstall", + tags: ["adapters"], + summary: "Reinstall an adapter", + request: { params: z.object({ type: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/adapters/{type}/config-schema", + tags: ["adapters"], + summary: "Get adapter config schema", + request: { params: z.object({ type: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Plugins ────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/plugins", + tags: ["plugins"], + summary: "List installed plugins", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/examples", + tags: ["plugins"], + summary: "List example plugins", + responses: { 200: r.ok() }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/ui-contributions", + tags: ["plugins"], + summary: "List plugin UI contributions", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/tools", + tags: ["plugins"], + summary: "List plugin tools", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/tools/execute", + tags: ["plugins"], + summary: "Execute a plugin tool", + request: { + body: jsonBody(z.object({ + tool: z.string(), + parameters: z.record(z.unknown()).optional(), + runContext: z.object({ + agentId: z.string(), + runId: z.string(), + companyId: z.string(), + projectId: z.string(), + }), + })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/install", + tags: ["plugins"], + summary: "Install a plugin", + request: { + body: jsonBody(z.object({ + packageName: z.string(), + version: z.string().optional(), + isLocalPath: z.boolean().optional(), + })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}", + tags: ["plugins"], + summary: "Get a plugin", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/plugins/{pluginId}", + tags: ["plugins"], + summary: "Delete a plugin", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/enable", + tags: ["plugins"], + summary: "Enable a plugin", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/disable", + tags: ["plugins"], + summary: "Disable a plugin", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/health", + tags: ["plugins"], + summary: "Get plugin health", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/logs", + tags: ["plugins"], + summary: "Get plugin logs", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/upgrade", + tags: ["plugins"], + summary: "Upgrade a plugin", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/config", + tags: ["plugins"], + summary: "Get plugin config", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/config", + tags: ["plugins"], + summary: "Set plugin config", + request: { + params: z.object({ pluginId: z.string() }), + body: jsonBody(z.object({ configJson: z.record(z.unknown()) })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/config/test", + tags: ["plugins"], + summary: "Test plugin config", + request: { + params: z.object({ pluginId: z.string() }), + body: jsonBody(z.object({ configJson: z.record(z.unknown()) })), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/jobs", + tags: ["plugins"], + summary: "List plugin jobs", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/jobs/{jobId}/runs", + tags: ["plugins"], + summary: "List runs for a plugin job", + request: { params: z.object({ pluginId: z.string(), jobId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/jobs/{jobId}/trigger", + tags: ["plugins"], + summary: "Trigger a plugin job", + request: { params: z.object({ pluginId: z.string(), jobId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/webhooks/{endpointKey}", + tags: ["plugins"], + summary: "Deliver an external webhook payload to a plugin", + request: { + params: z.object({ pluginId: z.string(), endpointKey: z.string() }), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/dashboard", + tags: ["plugins"], + summary: "Get plugin dashboard data", + request: { params: z.object({ pluginId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/bridge/data", + tags: ["plugins"], + summary: "Send data via plugin bridge", + request: { + params: z.object({ pluginId: z.string() }), + body: jsonBody(z.object({ + key: z.string(), + companyId: z.string().optional(), + params: z.record(z.unknown()).optional(), + })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/bridge/action", + tags: ["plugins"], + summary: "Send action via plugin bridge", + request: { + params: z.object({ pluginId: z.string() }), + body: jsonBody(z.object({ + key: z.string(), + companyId: z.string().optional(), + params: z.record(z.unknown()).optional(), + })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/data/{key}", + tags: ["plugins"], + summary: "Get plugin data by key (URL-keyed bridge)", + request: { + params: z.object({ pluginId: z.string(), key: z.string() }), + body: jsonBody(z.object({ + companyId: z.string().optional(), + params: z.record(z.unknown()).optional(), + })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/plugins/{pluginId}/actions/{key}", + tags: ["plugins"], + summary: "Invoke a plugin action (URL-keyed bridge)", + request: { + params: z.object({ pluginId: z.string(), key: z.string() }), + body: jsonBody(z.object({ + companyId: z.string().optional(), + params: z.record(z.unknown()).optional(), + })), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +// ─── Instance database backups ──────────────────────────────────────────────── + +registry.registerPath({ + method: "post", + path: "/api/instance/database-backups", + tags: ["instance"], + summary: "Trigger a database backup", + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + +// ─── LLM text endpoints ─────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/llms/agent-configuration.txt", + tags: ["llms"], + summary: "Get agent configuration as plain text (for LLM context)", + responses: { 200: { description: "Plain text agent configuration" }, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/llms/agent-configuration/{adapterType}.txt", + tags: ["llms"], + summary: "Get agent configuration for a specific adapter type", + request: { params: z.object({ adapterType: z.string() }) }, + responses: { 200: { description: "Plain text agent configuration" }, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/llms/agent-icons.txt", + tags: ["llms"], + summary: "Get agent icon names as plain text", + responses: { 200: { description: "Plain text icon list" }, 401: r.unauthorized }, +}); + +// ─── Issues (legacy / misc) ─────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/issues", + tags: ["issues"], + summary: "Legacy — returns error directing to /api/companies/{companyId}/issues", + responses: { 400: r.badRequest }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/comments/{commentId}", + tags: ["issues"], + summary: "Get a single issue comment", + request: { params: z.object({ id: z.string(), commentId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +// ─── Org chart images ───────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/org.svg", + tags: ["companies"], + summary: "Get org chart as SVG", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: { description: "SVG image" }, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/org.png", + tags: ["companies"], + summary: "Get org chart as PNG", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: { description: "PNG image" }, 401: r.unauthorized }, +}); + +// ─── Company portability (legacy routes) ───────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/companies/issues", + tags: ["companies"], + summary: "Legacy — returns error directing to correct issues path", + responses: { 400: r.badRequest }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/export", + tags: ["companies"], + summary: "Export a company (legacy singular form)", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(companyPortabilityExportSchema), + }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/import/preview", + tags: ["companies"], + summary: "Preview a company import (legacy route)", + request: { body: jsonBody(companyPortabilityPreviewSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/import", + tags: ["companies"], + summary: "Apply a company import (legacy route)", + request: { body: jsonBody(companyPortabilityImportSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Board claim & CLI auth ─────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/board-claim/{token}", + tags: ["access"], + summary: "Get board claim details by token", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/board-claim/{token}/claim", + tags: ["access"], + summary: "Claim a board token", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/cli-auth/challenges/{id}", + tags: ["access"], + summary: "Get a CLI auth challenge", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +// ─── Invite onboarding ──────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}/logo", + tags: ["access"], + summary: "Get company logo for an invite", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: { description: "Image file" }, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}/onboarding", + tags: ["access"], + summary: "Get onboarding data for an invite", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}/onboarding.txt", + tags: ["access"], + summary: "Get onboarding instructions as plain text", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: { description: "Plain text onboarding instructions" }, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}/skills/index", + tags: ["access"], + summary: "Get skills index for an invite", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}/skills/{skillName}", + tags: ["access"], + summary: "Get a skill by name for an invite", + request: { params: z.object({ token: z.string(), skillName: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/invites/{token}/test-resolution", + tags: ["access"], + summary: "Test invite token resolution", + request: { params: z.object({ token: z.string() }) }, + responses: { 200: r.ok(), 404: r.notFound }, +}); + +// ─── Admin ──────────────────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/admin/users/{userId}/company-access", + tags: ["admin"], + summary: "Get company access for a user (admin)", + request: { params: z.object({ userId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + +registry.registerPath({ + method: "put", + path: "/api/admin/users/{userId}/company-access", + tags: ["admin"], + summary: "Set company access for a user (admin)", + request: { + params: z.object({ userId: z.string() }), + body: jsonBody(updateUserCompanyAccessSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, +}); + +registry.registerPath({ + method: "post", + path: "/api/admin/users/{userId}/promote-instance-admin", + tags: ["admin"], + summary: "Promote a user to instance admin", + request: { params: z.object({ userId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/admin/users/{userId}/demote-instance-admin", + tags: ["admin"], + summary: "Demote a user from instance admin", + request: { params: z.object({ userId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +// ─── Project workspace runtime ──────────────────────────────────────────────── + +registry.registerPath({ + method: "post", + path: "/api/projects/{id}/workspaces/{workspaceId}/runtime-services/{action}", + tags: ["projects"], + summary: "Control a runtime service in a project workspace", + request: { + params: z.object({ id: z.string(), workspaceId: z.string(), action: z.string() }), + body: jsonBody(workspaceRuntimeControlTargetSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/projects/{id}/workspaces/{workspaceId}/runtime-commands/{action}", + tags: ["projects"], + summary: "Run a runtime command in a project workspace", + request: { + params: z.object({ id: z.string(), workspaceId: z.string(), action: z.string() }), + body: jsonBody(workspaceRuntimeControlTargetSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +// ─── Plugin bridge stream ───────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/plugins/{pluginId}/bridge/stream/{channel}", + tags: ["plugins"], + summary: "Subscribe to a plugin bridge SSE stream", + request: { params: z.object({ pluginId: z.string(), channel: z.string() }) }, + responses: { + 200: { description: "Server-sent event stream (text/event-stream)" }, + 401: r.unauthorized, + }, +}); + +// ─── Plugin UI static ───────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/_plugins/{pluginId}/ui/{filePath}", + tags: ["plugins"], + summary: "Serve plugin UI static file", + request: { params: z.object({ pluginId: z.string(), filePath: z.string() }) }, + responses: { 200: { description: "Static file content" }, 404: r.notFound }, +}); + +// ─── Adapter UI parser ──────────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/adapters/{type}/ui-parser.js", + tags: ["adapters"], + summary: "Get adapter UI parser script", + request: { params: z.object({ type: z.string() }) }, + responses: { 200: { description: "JavaScript file" }, 404: r.notFound }, +}); + +// ─── Spec builder ───────────────────────────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function buildOpenApiDocument(): any { + return applyDocumentFixups({ + openapi: "3.0.0", + info: { + title: "Paperclip API", + version: "1.0.0", + description: "REST API for the Paperclip AI agent management platform", + }, + servers: [{ url: "/" }], + components: registry.buildComponents(), + paths: registry.buildPaths(), + }); +} + +export const buildOpenApiSpec = buildOpenApiDocument; + +export function openApiRoutes() { + const router = Router(); + router.get("/openapi.json", (_req, res) => { + res.json(buildOpenApiDocument()); + }); + return router; +} diff --git a/server/src/services/board-auth.ts b/server/src/services/board-auth.ts index 0637457c..35ed3eef 100644 --- a/server/src/services/board-auth.ts +++ b/server/src/services/board-auth.ts @@ -1,5 +1,5 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; -import { and, eq, isNull, sql } from "drizzle-orm"; +import { and, eq, gt, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { authUsers, @@ -160,6 +160,79 @@ export function boardAuthService(db: Db) { .then((rows) => rows[0] ?? null); } + async function createNamedBoardApiKey(input: { + userId: string; + name: string; + expiresAt?: Date | null; + }) { + const token = createBoardApiToken(); + const created = await db + .insert(boardApiKeys) + .values({ + userId: input.userId, + name: input.name.trim(), + keyHash: hashBearerToken(token), + expiresAt: input.expiresAt === undefined ? boardApiKeyExpiresAt() : input.expiresAt, + }) + .returning() + .then((rows) => rows[0]); + + return { + id: created.id, + name: created.name, + token, + createdAt: created.createdAt, + lastUsedAt: created.lastUsedAt, + revokedAt: created.revokedAt, + expiresAt: created.expiresAt, + }; + } + + async function listBoardApiKeys( + userId: string, + opts: { includeInactive?: boolean } = {}, + ) { + const conditions = [eq(boardApiKeys.userId, userId)]; + if (!opts.includeInactive) { + const activeExpirationCondition = or( + isNull(boardApiKeys.expiresAt), + gt(boardApiKeys.expiresAt, new Date()), + ); + conditions.push( + isNull(boardApiKeys.revokedAt), + ); + if (activeExpirationCondition) conditions.push(activeExpirationCondition); + } + return db + .select({ + id: boardApiKeys.id, + name: boardApiKeys.name, + createdAt: boardApiKeys.createdAt, + lastUsedAt: boardApiKeys.lastUsedAt, + revokedAt: boardApiKeys.revokedAt, + expiresAt: boardApiKeys.expiresAt, + }) + .from(boardApiKeys) + .where(and(...conditions)) + .orderBy(sql`${boardApiKeys.createdAt} desc`); + } + + async function getBoardApiKeyForUser(keyId: string, userId: string) { + return db + .select({ + id: boardApiKeys.id, + userId: boardApiKeys.userId, + name: boardApiKeys.name, + createdAt: boardApiKeys.createdAt, + lastUsedAt: boardApiKeys.lastUsedAt, + revokedAt: boardApiKeys.revokedAt, + expiresAt: boardApiKeys.expiresAt, + }) + .from(boardApiKeys) + .where(and(eq(boardApiKeys.id, keyId), eq(boardApiKeys.userId, userId))) + .then((rows) => rows[0] ?? null); + } + async function createCliAuthChallenge(input: { command: string; clientName?: string | null; @@ -348,6 +421,9 @@ export function boardAuthService(db: Db) { findBoardApiKeyByToken, touchBoardApiKey, revokeBoardApiKey, + createNamedBoardApiKey, + listBoardApiKeys, + getBoardApiKeyForUser, createCliAuthChallenge, getCliAuthChallengeBySecret, describeCliAuthChallenge, diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index 94b0a9f3..8d7c10bc 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -1932,7 +1932,7 @@ export function pluginLoader( // ------------------------------------------------------------------ const toolDeclarations = manifest.tools ?? []; if (toolDeclarations.length > 0) { - toolDispatcher.registerPluginTools(pluginKey, manifest); + toolDispatcher.registerPluginTools(pluginKey, manifest, pluginId); registered.tools = toolDeclarations.length; log.info( diff --git a/server/src/services/plugin-tool-dispatcher.ts b/server/src/services/plugin-tool-dispatcher.ts index 18ea075b..e0b0eca5 100644 --- a/server/src/services/plugin-tool-dispatcher.ts +++ b/server/src/services/plugin-tool-dispatcher.ts @@ -150,12 +150,14 @@ export interface PluginToolDispatcher { * This is called automatically when a plugin transitions to `ready`. * Can also be called manually for testing or recovery scenarios. * - * @param pluginId - The plugin's unique identifier + * @param pluginId - The plugin's stable manifest/plugin key used for tool namespacing * @param manifest - The plugin manifest containing tool declarations + * @param pluginDbId - The plugin database ID used for worker lookup */ registerPluginTools( pluginId: string, manifest: PaperclipPluginManifestV1, + pluginDbId?: string, ): void; /** @@ -429,8 +431,9 @@ export function createPluginToolDispatcher( registerPluginTools( pluginId: string, manifest: PaperclipPluginManifestV1, + pluginDbId?: string, ): void { - registry.registerPlugin(pluginId, manifest); + registry.registerPlugin(pluginId, manifest, pluginDbId); }, unregisterPluginTools(pluginId: string): void { diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 80e22176..8e4c4091 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -1880,6 +1880,16 @@ export function routineService( }); return { deleted: true, revision: appended.revision }; }); + if (result.deleted && existing.secretId) { + try { + await secretsSvc.remove(existing.secretId); + } catch (err) { + logger.warn( + { err, routineId: existing.routineId, triggerId: existing.id, secretId: existing.secretId }, + "failed to remove routine trigger webhook secret after trigger deletion", + ); + } + } return result; },