Improve CLI API parity coverage (#6626)

## 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 <devin@devinfoley.com>
This commit is contained in:
Aron Prins 2026-06-03 02:13:29 +02:00 committed by GitHub
parent 68401f82f3
commit 70b1a9109d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 18175 additions and 111 deletions

View file

@ -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<void> {
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);
}

View file

@ -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<void> {
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);
}

View file

@ -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<void> {
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);
}

View file

@ -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<void> {
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);
}

View file

@ -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);
}

View file

@ -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");
});
});

View file

@ -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 });
}
});
});

View file

@ -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<string, Record<string, unknown>>;
};
}
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",
},
},
});
});
});

View file

@ -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: {

View file

@ -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);
});
});

View file

@ -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<void> {
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);
}

View file

@ -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<void> {
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);
}

View file

@ -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");
});
});

View file

@ -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<string, unknown> = {}) {
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");
});
});

View file

@ -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<void> {
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);
}

View file

@ -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");
});
});

View file

@ -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<void> {
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);
}

View file

@ -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`);
});
});

View file

@ -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({

View file

@ -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<string, ClientContextProfile>;
}
@ -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<string, unknown>;
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<string, unknown>;
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;

View file

@ -81,6 +81,13 @@ export class PaperclipApiClient {
}, opts);
}
put<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
return this.request<T>(path, {
method: "PUT",
body: body === undefined ? undefined : JSON.stringify(body),
}, opts);
}
delete<T>(path: string, opts?: RequestOptions): Promise<T | null> {
return this.request<T>(path, { method: "DELETE" }, opts);
}

View file

@ -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("<userSlug>", "User slug")
.option("-C, --company-id <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("<inviteId>", "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("<token>", "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("<token>", "Invite token")
.requiredOption("--url <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("<token>", "Invite token")
.argument("<skillName>", "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("<token>", "Invite token")
.option("--payload-json <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 <id>", "Company ID")
.option("--status <status>", "Filter by status (pending_approval, approved, rejected; pending alias accepted)")
.option("--request-type <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("<requestId>", "Join request ID")
.requiredOption("--claim-secret <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 <text>", "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("<userId>", "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("<userId>", "User ID")
.requiredOption("--payload-json <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("<token>", "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("<token>", "Claim token")
.option("--payload-json <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("<skillName>", "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("<adapterType>", "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>", "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>", "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 <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 <id>", "Company ID").requiredOption("--payload-json <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 <id>", "Company ID").requiredOption("--payload-json <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("<requestId>", "Join request ID").option("-C, --company-id <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("<memberId>", "Member ID").option("-C, --company-id <id>", "Company ID").requiredOption("--payload-json <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("<memberId>", "Member ID").option("-C, --company-id <id>", "Company ID").option("--payload-json <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("<userId>", "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;
}

View file

@ -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<ActivityEvent[]>(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 <id>", "Company ID")
.requiredOption("--payload-json <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("<issueId>", "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;
}

View file

@ -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("<type>", "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("<type>", "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("<type>", "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("<type>", "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("<type>", "Adapter type")
.option("-C, --company-id <id>", "Company ID")
.option("--refresh", "Refresh provider model list", false)
.option("--environment-id <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>", "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("<type>", "Adapter type")
.requiredOption("--payload-json <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("<type>", "Adapter type")
.option("--payload-json <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("<type>", "Adapter type")
.option("-C, --company-id <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("<type>", "Adapter type")
.option("-C, --company-id <id>", "Company ID")
.option("--payload-json <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;
}

View file

@ -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<Agent>("/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<Issue[]>("/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 <id>", "Board user ID")
.option("--status <csv>", "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<Issue[]>(`/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<Agent[]>(`/api/companies/${ctx.companyId}/agents`)) ?? [];
const rows = (await ctx.api.get<Agent[]>(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<Agent>(`/api/agents/${agentId}`);
const row = await ctx.api.get<Agent>(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 <id>", "Company ID")
.requiredOption("--payload-json <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<Agent>(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 <id>", "Company ID")
.requiredOption("--payload-json <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("<agentId>", "Agent ID")
.requiredOption("--payload-json <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<Agent>(apiPath`/api/agents/${agentId}`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
agent
.command("delete")
.description("Delete an agent")
.argument("<agentId>", "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("<agentId>", "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("<agentId>", "Agent ID")
.requiredOption("--payload-json <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("<agentId>", "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("<agentId>", "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("<agentId>", "Agent ID")
.argument("<revisionId>", "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("<agentId>", "Agent ID")
.argument("<revisionId>", "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("<agentId>", "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("<agentId>", "Agent ID")
.option("--task-key <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("<agentId>", "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("<agentId>", "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("<agentId>", "Agent ID")
.requiredOption("--desired-skills <csv>", "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("<agentId>", "Agent ID")
.requiredOption("--payload-json <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("<agentId>", "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("<agentId>", "Agent ID")
.requiredOption("--payload-json <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("<agentId>", "Agent ID")
.requiredOption("--path <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("<agentId>", "Agent ID")
.requiredOption("--path <path>", "Bundle-relative file path")
.option("--content <text>", "File content")
.option("--content-file <path>", "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("<agentId>", "Agent ID")
.requiredOption("--path <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("<agentRef>", "Agent ID or shortname/url-key")
.option("-C, --company-id <id>", "Company ID for shortname/url-key lookup")
.option("--source <source>", "Invocation source (timer, assignment, on_demand, automation)", "on_demand")
.option("--trigger <trigger>", "Trigger detail (manual, ping, callback, system)", "manual")
.option("--reason <text>", "Wakeup reason")
.option("--payload <json>", "JSON object payload")
.option("--idempotency-key <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<Agent>(`${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<AgentWakeupResponse>(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<Agent>(
`/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<CreatedAgentKey>(`/api/agents/${agentRow.id}/keys`, { name: keyName });
const key = await ctx.api.post<CreatedAgentKey>(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<string, unknown> | 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<string, unknown>;
}
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);
}

View file

@ -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<Approval[]>(
`/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<Approval>(`/api/approvals/${approvalId}`);
const row = await ctx.api.get<Approval>(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<Approval>(`/api/companies/${ctx.companyId}/approvals`, payload);
const created = await ctx.api.post<Approval>(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<Approval>(`/api/approvals/${approvalId}/approve`, payload);
const updated = await ctx.api.post<Approval>(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<Approval>(`/api/approvals/${approvalId}/reject`, payload);
const updated = await ctx.api.post<Approval>(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<Approval>(`/api/approvals/${approvalId}/request-revision`, payload);
const updated = await ctx.api.post<Approval>(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<Approval>(`/api/approvals/${approvalId}/resubmit`, payload);
const updated = await ctx.api.post<Approval>(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<ApprovalComment>(`/api/approvals/${approvalId}/comments`, {
const created = await ctx.api.post<ApprovalComment>(apiPath`/api/approvals/${approvalId}/comments`, {
body: opts.body,
});
printOutput(created, { json: ctx.json });

View file

@ -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 <path>", "Image file path")
.option("-C, --company-id <id>", "Company ID")
.option("--namespace <value>", "Asset namespace suffix")
.option("--alt <text>", "Alt text metadata")
.option("--title <text>", "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 <path>", "Logo file path")
.option("-C, --company-id <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("<assetId>", "Asset ID")
.option("--out <path>", "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<unknown> {
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<Buffer> {
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<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 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;
}
}

View file

@ -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 <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("<id>", "Challenge ID")
.option("--token <token>", "Challenge secret")
.option("--token-env <name>", "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("<id>", "Challenge ID")
.option("--token <token>", "Challenge secret")
.option("--token-env <name>", "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.");
}

View file

@ -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<LocalUpstreamExportBundle> {
const exported = await input.localApi.post<CompanyPortabilityExportResult>(
`/api/companies/${input.localCompanyId}/export`,
apiPath`/api/companies/${input.localCompanyId}/export`,
{
include: {
company: true,

View file

@ -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<BaseClientOptions, "apiBase" | "config">, 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 | number | boolean | null | undefined>): 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<BaseClientOptions, "apiKey">,
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 || "");

View file

@ -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<void> {
export async function writeExportToFolder(outDir: string, exported: CompanyPortabilityExportResult): Promise<void> {
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<void> {
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<Company>(`/api/companies/${companyId}`);
const row = await ctx.api.get<Company>(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 <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("<companyId>", "Company ID")
.requiredOption("--payload-json <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("<companyId>", "Company ID")
.requiredOption("--payload-json <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("<companyId>", "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<FeedbackTrace[]>(
`/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<FeedbackTrace[]>(
`/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<CompanyPortabilityExportResult>(
`/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<Company>(`/api/companies/${imported.company.id}`);
const importedCompany = await ctx.api.get<Company>(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<Company>(`/api/companies/${normalizedSelector}`, { ignoreNotFound: true });
const byId = await ctx.api.get<Company>(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<Company>(`/api/companies/${ctx.companyId}`, { ignoreNotFound: true });
const scoped = await ctx.api.get<Company>(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("<companyId>", "Company ID")
.requiredOption("--payload-json <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;
}

View file

@ -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>", "Persona to configure: board or agent")
.option("--api-key-env-var-name <name>", "Env var name to store in the profile", "PAPERCLIP_API_KEY")
.option("--token-name <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<Company[]>("/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<CreatedBoardKey>("/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<Agent[]>(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<CreatedAgentKey>(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<void> {
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<string> {
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<Company | null> {
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<Agent> {
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<T>(value: T | symbol): asserts value is T {
if (p.isCancel(value)) {
p.cancel("Cancelled.");
process.exit(0);
}
}

View file

@ -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 <name>", "Profile name (default: current profile)")
.option("--api-base <url>", "Default API base URL")
.option("--company-id <id>", "Default company ID")
.option("--persona <persona>", "Profile persona: board or agent")
.option("--agent-id <id>", "Default agent ID for agent persona")
.option("--agent-name <name>", "Default agent display name")
.option("--api-key-env-var-name <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<K extends keyof ClientContextProfile>(
patch: Partial<ClientContextProfile>,
key: K,
value: ClientContextProfile[K] | undefined,
): void {
if (value !== undefined) {
patch[key] = value;
}
}
function buildContextPatch(opts: ContextSetOptions): Partial<ClientContextProfile> {
const patch: Partial<ClientContextProfile> = {};
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.");
}

View file

@ -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("<issueId>", "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 <id>", "Company ID")
.requiredOption("--payload-json <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("<agentId>", "Agent ID")
.requiredOption("--payload-json <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("<incidentId>", "Budget incident ID")
.option("-C, --company-id <id>", "Company ID")
.option("--payload-json <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 <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 <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 parseJson(value: string): unknown {
return JSON.parse(value) as unknown;
}

View file

@ -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<DashboardSummary>(`/api/companies/${ctx.companyId}/dashboard`);
const row = await ctx.api.get<DashboardSummary>(apiPath`/api/companies/${ctx.companyId}/dashboard`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);

View file

@ -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("<traceId>", "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("<traceId>", "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<FeedbackTrace[]> {
return (
(await ctx.api.get<FeedbackTrace[]>(
`/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<FeedbackTraceBundle> {
const bundle = await ctx.api.get<FeedbackTraceBundle>(`/api/feedback-traces/${traceId}/bundle`);
const bundle = await ctx.api.get<FeedbackTraceBundle>(apiPath`/api/feedback-traces/${traceId}/bundle`);
if (!bundle) {
throw new Error(`Feedback trace bundle ${traceId} not found`);
}

View file

@ -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 <id>", "Company ID")
.action(async (opts: GoalListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const rows = (await ctx.api.get<Goal[]>(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("<goalId>", "Goal ID")
.action(async (goalId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const row = await ctx.api.get<Goal>(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 <id>", "Company ID")
.requiredOption("--title <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;
}

File diff suppressed because it is too large Load diff

View file

@ -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();
}

View file

@ -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)}`);
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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 });
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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"],
});

View file

@ -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);

View file

@ -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:

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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.

View file

@ -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,

View file

@ -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({

View file

@ -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,

View file

@ -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,
});
});
});

View file

@ -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();
});
});

View file

@ -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);

View file

@ -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",

View file

@ -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" },
},
});
});
});

View file

@ -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)

View file

@ -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));

View file

@ -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));

View file

@ -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");

View file

@ -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
*

View file

@ -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,

View file

@ -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,

3842
server/src/routes/openapi.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -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,

View file

@ -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(

View file

@ -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 {

View file

@ -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;
},