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