mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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:
parent
68401f82f3
commit
70b1a9109d
74 changed files with 18175 additions and 111 deletions
140
cli/src/__tests__/access-parity.test.ts
Normal file
140
cli/src/__tests__/access-parity.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerAccessCommands } from "../commands/client/access.js";
|
||||
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const USER_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const INVITE_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const JOIN_ID = "55555555-5555-4555-8555-555555555555";
|
||||
const MEMBER_ID = "66666666-6666-4666-8666-666666666666";
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
||||
registerAccessCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]): Promise<void> {
|
||||
await createProgram().parseAsync([...args, "--api-base", "http://localhost:3100", "--api-key", "board-token"], { from: "user" });
|
||||
}
|
||||
|
||||
describe("access parity commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("wraps auth, invites, joins, members, and admin endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["health"]);
|
||||
await run(["whoami"]);
|
||||
await run(["access", "whoami"]);
|
||||
await run(["profile", "session"]);
|
||||
await run(["profile", "get"]);
|
||||
await run(["profile", "update", "--payload-json", "{}"]);
|
||||
await run(["invite", "list", "--company-id", COMPANY_ID]);
|
||||
await run(["invite", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["invite", "revoke", INVITE_ID]);
|
||||
await run(["invite", "show", "token-1"]);
|
||||
await run(["invite", "test-resolution", "token-1", "--url", "http://localhost:3100/invite/token-1"]);
|
||||
await run(["invite", "accept", "token-1"]);
|
||||
await run(["join", "list", "--company-id", COMPANY_ID, "--status", "pending"]);
|
||||
await run(["join", "approve", JOIN_ID, "--company-id", COMPANY_ID]);
|
||||
await run(["join", "reject", JOIN_ID, "--company-id", COMPANY_ID]);
|
||||
await run(["join", "claim-key", JOIN_ID, "--claim-secret", "secret"]);
|
||||
await run(["member", "list", "--company-id", COMPANY_ID]);
|
||||
await run(["member", "update", MEMBER_ID, "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["member", "archive", MEMBER_ID, "--company-id", COMPANY_ID]);
|
||||
await run(["admin", "user", "list"]);
|
||||
await run(["admin", "user", "promote", USER_ID]);
|
||||
await run(["admin", "user", "company-access:update", USER_ID, "--payload-json", "{}"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", "http://localhost:3100/api/health"],
|
||||
["GET", "http://localhost:3100/api/cli-auth/me"],
|
||||
["GET", "http://localhost:3100/api/cli-auth/me"],
|
||||
["GET", "http://localhost:3100/api/auth/get-session"],
|
||||
["GET", "http://localhost:3100/api/auth/profile"],
|
||||
["PATCH", "http://localhost:3100/api/auth/profile"],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/invites`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/invites`],
|
||||
["POST", `http://localhost:3100/api/invites/${INVITE_ID}/revoke`],
|
||||
["GET", "http://localhost:3100/api/invites/token-1"],
|
||||
["GET", "http://localhost:3100/api/invites/token-1/test-resolution?url=http%3A%2F%2Flocalhost%3A3100%2Finvite%2Ftoken-1"],
|
||||
["POST", "http://localhost:3100/api/invites/token-1/accept"],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/join-requests?status=pending_approval`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/join-requests/${JOIN_ID}/approve`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/join-requests/${JOIN_ID}/reject`],
|
||||
["POST", `http://localhost:3100/api/join-requests/${JOIN_ID}/claim-api-key`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/members`],
|
||||
["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}/members/${MEMBER_ID}`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/members/${MEMBER_ID}/archive`],
|
||||
["GET", "http://localhost:3100/api/admin/users"],
|
||||
["POST", `http://localhost:3100/api/admin/users/${USER_ID}/promote-instance-admin`],
|
||||
["PUT", `http://localhost:3100/api/admin/users/${USER_ID}/company-access`],
|
||||
]);
|
||||
});
|
||||
|
||||
it("wraps instance, sidebar, llm, and openapi endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["openapi"]);
|
||||
await run(["instance", "scheduler-heartbeats"]);
|
||||
await run(["instance", "settings:general"]);
|
||||
await run(["instance", "settings:general:update", "--payload-json", "{}"]);
|
||||
await run(["instance", "database-backup"]);
|
||||
await run(["sidebar", "preferences"]);
|
||||
await run(["sidebar", "preferences:update", "--payload-json", "{}"]);
|
||||
await run(["sidebar", "project-preferences", "--company-id", COMPANY_ID]);
|
||||
await run(["sidebar", "project-preferences:update", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["sidebar", "badges", "--company-id", COMPANY_ID]);
|
||||
await run(["inbox", "dismissals", "--company-id", COMPANY_ID]);
|
||||
await run(["inbox", "dismiss", "--company-id", COMPANY_ID, "--payload-json", "{\"itemKey\":\"run:1\"}"]);
|
||||
await run(["board-claim", "show", "claim-token"]);
|
||||
await run(["board-claim", "claim", "claim-token", "--payload-json", "{}"]);
|
||||
await run(["openclaw", "invite-prompt", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["available-skill", "list"]);
|
||||
await run(["available-skill", "index"]);
|
||||
await run(["available-skill", "get", "paperclip"]);
|
||||
await run(["llm", "agent-configuration"]);
|
||||
await run(["llm", "agent-configuration:adapter", "codex_local"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", "http://localhost:3100/api/openapi.json"],
|
||||
["GET", "http://localhost:3100/api/instance/scheduler-heartbeats"],
|
||||
["GET", "http://localhost:3100/api/instance/settings/general"],
|
||||
["PATCH", "http://localhost:3100/api/instance/settings/general"],
|
||||
["POST", "http://localhost:3100/api/instance/database-backups"],
|
||||
["GET", "http://localhost:3100/api/sidebar-preferences/me"],
|
||||
["PUT", "http://localhost:3100/api/sidebar-preferences/me"],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/sidebar-preferences/me`],
|
||||
["PUT", `http://localhost:3100/api/companies/${COMPANY_ID}/sidebar-preferences/me`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/sidebar-badges`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/inbox-dismissals`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/inbox-dismissals`],
|
||||
["GET", "http://localhost:3100/api/board-claim/claim-token"],
|
||||
["POST", "http://localhost:3100/api/board-claim/claim-token/claim"],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/openclaw/invite-prompt`],
|
||||
["GET", "http://localhost:3100/api/skills/available"],
|
||||
["GET", "http://localhost:3100/api/skills/index"],
|
||||
["GET", "http://localhost:3100/api/skills/paperclip"],
|
||||
["GET", "http://localhost:3100/api/llms/agent-configuration.txt"],
|
||||
["GET", "http://localhost:3100/api/llms/agent-configuration/codex_local.txt"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
|
||||
return new Response(JSON.stringify(body), init);
|
||||
}
|
||||
53
cli/src/__tests__/activity-parity.test.ts
Normal file
53
cli/src/__tests__/activity-parity.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerActivityCommands } from "../commands/client/activity.js";
|
||||
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const ISSUE_ID = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
||||
registerActivityCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]): Promise<void> {
|
||||
await createProgram().parseAsync([...args, "--api-base", "http://localhost:3100", "--api-key", "board-token"], { from: "user" });
|
||||
}
|
||||
|
||||
describe("activity parity commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("wraps activity endpoints", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => Promise.resolve(jsonResponse([])))
|
||||
.mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["activity", "list", "--company-id", COMPANY_ID, "--agent-id", "agent-1"]);
|
||||
await run(["activity", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["activity", "issue", ISSUE_ID]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/activity?agentId=agent-1`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/activity`],
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/activity`],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
|
||||
return new Response(JSON.stringify(body), init);
|
||||
}
|
||||
177
cli/src/__tests__/admin-asset-skill-parity.test.ts
Normal file
177
cli/src/__tests__/admin-asset-skill-parity.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerAdapterCommands } from "../commands/client/adapter.js";
|
||||
import { registerAssetCommands } from "../commands/client/asset.js";
|
||||
import { registerCompanyCommands, resolveExportOutputPath } from "../commands/client/company.js";
|
||||
import { registerSkillCommands } from "../commands/client/skill.js";
|
||||
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const SKILL_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const ASSET_ID = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
||||
registerCompanyCommands(program);
|
||||
registerAdapterCommands(program);
|
||||
registerAssetCommands(program);
|
||||
registerSkillCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]): Promise<void> {
|
||||
await createProgram().parseAsync([
|
||||
...args,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
], { from: "user" });
|
||||
}
|
||||
|
||||
describe("admin, asset, and skill parity commands", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), "paperclip-cli-parity-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("wraps company management and raw portability endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["company", "stats"]);
|
||||
await run(["company", "create", "--payload-json", "{}"]);
|
||||
await run(["company", "update", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["company", "branding:update", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["company", "archive", COMPANY_ID]);
|
||||
await run(["company", "export:preview", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["company", "export:api", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["company", "import:preview", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["company", "import:apply", COMPANY_ID, "--payload-json", "{}"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", "http://localhost:3100/api/companies/stats"],
|
||||
["POST", "http://localhost:3100/api/companies"],
|
||||
["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}`],
|
||||
["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}/branding`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/archive`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/exports/preview`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/exports`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/imports/preview`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/imports/apply`],
|
||||
]);
|
||||
});
|
||||
|
||||
it("wraps adapter management and company adapter endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["adapter", "list"]);
|
||||
await run(["adapter", "install", "--payload-json", "{\"packageName\":\"adapter\"}"]);
|
||||
await run(["adapter", "get", "codex_local"]);
|
||||
await run(["adapter", "get", "codex/local"]);
|
||||
await run(["adapter", "update", "codex_local", "--payload-json", "{\"disabled\":true}"]);
|
||||
await run(["adapter", "override", "codex_local", "--payload-json", "{\"paused\":true}"]);
|
||||
await run(["adapter", "reload", "codex_local"]);
|
||||
await run(["adapter", "reinstall", "codex_local"]);
|
||||
await run(["adapter", "config-schema", "codex_local"]);
|
||||
await run(["adapter", "ui-parser", "codex_local"]);
|
||||
await run(["adapter", "models", "codex_local", "--company-id", COMPANY_ID, "--refresh", "--environment-id", "env-1"]);
|
||||
await run(["adapter", "model-profiles", "codex_local", "--company-id", COMPANY_ID]);
|
||||
await run(["adapter", "detect-model", "codex_local", "--company-id", COMPANY_ID]);
|
||||
await run(["adapter", "test-environment", "codex_local", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["adapter", "delete", "codex_local"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", "http://localhost:3100/api/adapters"],
|
||||
["POST", "http://localhost:3100/api/adapters/install"],
|
||||
["GET", "http://localhost:3100/api/adapters/codex_local"],
|
||||
["GET", "http://localhost:3100/api/adapters/codex%2Flocal"],
|
||||
["PATCH", "http://localhost:3100/api/adapters/codex_local"],
|
||||
["PATCH", "http://localhost:3100/api/adapters/codex_local/override"],
|
||||
["POST", "http://localhost:3100/api/adapters/codex_local/reload"],
|
||||
["POST", "http://localhost:3100/api/adapters/codex_local/reinstall"],
|
||||
["GET", "http://localhost:3100/api/adapters/codex_local/config-schema"],
|
||||
["GET", "http://localhost:3100/api/adapters/codex_local/ui-parser.js"],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/adapters/codex_local/models?refresh=true&environmentId=env-1`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/adapters/codex_local/model-profiles`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/adapters/codex_local/detect-model`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/adapters/codex_local/test-environment`],
|
||||
["DELETE", "http://localhost:3100/api/adapters/codex_local"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("wraps asset upload/download endpoints", async () => {
|
||||
const imagePath = path.join(tempDir, "logo.png");
|
||||
const outputPath = path.join(tempDir, "asset.bin");
|
||||
await writeFile(imagePath, Buffer.from("png"));
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => Promise.resolve(jsonResponse({ assetId: ASSET_ID }, { status: 201 })))
|
||||
.mockImplementationOnce(() => Promise.resolve(jsonResponse({ assetId: ASSET_ID }, { status: 201 })))
|
||||
.mockImplementationOnce(() => Promise.resolve(new Response("asset-bytes")));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["asset", "image:upload", "--company-id", COMPANY_ID, "--file", imagePath, "--namespace", "docs", "--alt", "Logo"]);
|
||||
await run(["asset", "logo:upload", "--company-id", COMPANY_ID, "--file", imagePath]);
|
||||
await run(["asset", "content", ASSET_ID, "--out", outputPath]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/assets/images`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/logo`],
|
||||
["GET", `http://localhost:3100/api/assets/${ASSET_ID}/content`],
|
||||
]);
|
||||
const firstUpload = fetchMock.mock.calls[0]?.[1]?.body as FormData;
|
||||
expect((firstUpload.get("file") as File).type).toBe("image/png");
|
||||
});
|
||||
|
||||
it("rejects portable export paths outside the output directory", async () => {
|
||||
expect(() => resolveExportOutputPath(tempDir, "../outside.md")).toThrow("outside output directory");
|
||||
});
|
||||
|
||||
it("wraps company skill endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["skill", "list", "--company-id", COMPANY_ID]);
|
||||
await run(["skill", "get", SKILL_ID, "--company-id", COMPANY_ID]);
|
||||
await run(["skill", "file", SKILL_ID, "--company-id", COMPANY_ID, "--path", "SKILL.md"]);
|
||||
await run(["skill", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["skill", "file:update", SKILL_ID, "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["skill", "import", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["skill", "scan-projects", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["skill", "update-status", SKILL_ID, "--company-id", COMPANY_ID]);
|
||||
await run(["skill", "install-update", SKILL_ID, "--company-id", COMPANY_ID]);
|
||||
await run(["skill", "delete", SKILL_ID, "--company-id", COMPANY_ID]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/skills`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}/files?path=SKILL.md`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/skills`],
|
||||
["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}/files`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/import`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/scan-projects`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}/update-status`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}/install-update`],
|
||||
["DELETE", `http://localhost:3100/api/companies/${COMPANY_ID}/skills/${SKILL_ID}`],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
|
||||
return new Response(JSON.stringify(body), init);
|
||||
}
|
||||
117
cli/src/__tests__/agent-lifecycle.test.ts
Normal file
117
cli/src/__tests__/agent-lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerAgentCommands } from "../commands/client/agent.js";
|
||||
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const REVISION_ID = "33333333-3333-4333-8333-333333333333";
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({
|
||||
writeOut: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
registerAgentCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]): Promise<void> {
|
||||
await createProgram().parseAsync([
|
||||
...args,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
], { from: "user" });
|
||||
}
|
||||
|
||||
describe("agent lifecycle commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("wraps agent lifecycle and state endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run([
|
||||
"agent", "create",
|
||||
"--company-id", COMPANY_ID,
|
||||
"--payload-json", JSON.stringify({ name: "Builder", adapterType: "codex_local" }),
|
||||
]);
|
||||
await run(["agent", "hire", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["agent", "update", AGENT_ID, "--payload-json", JSON.stringify({ title: "Senior Builder" })]);
|
||||
await run(["agent", "pause", AGENT_ID]);
|
||||
await run(["agent", "resume", AGENT_ID]);
|
||||
await run(["agent", "approve", AGENT_ID]);
|
||||
await run(["agent", "terminate", AGENT_ID]);
|
||||
await run(["agent", "heartbeat:invoke", AGENT_ID]);
|
||||
await run(["agent", "claude-login", AGENT_ID]);
|
||||
await run(["agent", "delete", AGENT_ID, "--yes"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/agents`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/agent-hires`],
|
||||
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}`],
|
||||
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/pause`],
|
||||
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/resume`],
|
||||
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/approve`],
|
||||
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/terminate`],
|
||||
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/heartbeat/invoke`],
|
||||
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/claude-login`],
|
||||
["DELETE", `http://localhost:3100/api/agents/${AGENT_ID}`],
|
||||
]);
|
||||
});
|
||||
|
||||
it("wraps configuration, runtime, skills, and instructions endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["agent", "permissions:update", AGENT_ID, "--payload-json", JSON.stringify({ canCreateAgents: true, canAssignTasks: true })]);
|
||||
await run(["agent", "configuration", AGENT_ID]);
|
||||
await run(["agent", "config-revisions", AGENT_ID]);
|
||||
await run(["agent", "config-revision:get", AGENT_ID, REVISION_ID]);
|
||||
await run(["agent", "config-revision:rollback", AGENT_ID, REVISION_ID]);
|
||||
await run(["agent", "runtime-state", AGENT_ID]);
|
||||
await run(["agent", "runtime-state:reset-session", AGENT_ID, "--task-key", "task-1"]);
|
||||
await run(["agent", "task-sessions", AGENT_ID]);
|
||||
await run(["agent", "skills", AGENT_ID]);
|
||||
await run(["agent", "skills:sync", AGENT_ID, "--desired-skills", "paperclip,github"]);
|
||||
await run(["agent", "instructions-path:update", AGENT_ID, "--payload-json", JSON.stringify({ path: "/tmp/AGENTS.md" })]);
|
||||
await run(["agent", "instructions-bundle", AGENT_ID]);
|
||||
await run(["agent", "instructions-bundle:update", AGENT_ID, "--payload-json", JSON.stringify({ mode: "managed" })]);
|
||||
await run(["agent", "instructions-file:get", AGENT_ID, "--path", "AGENTS.md"]);
|
||||
await run(["agent", "instructions-file:put", AGENT_ID, "--path", "AGENTS.md", "--content", "hello"]);
|
||||
await run(["agent", "instructions-file:delete", AGENT_ID, "--path", "AGENTS.md"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/permissions`],
|
||||
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/configuration`],
|
||||
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/config-revisions`],
|
||||
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/config-revisions/${REVISION_ID}`],
|
||||
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/config-revisions/${REVISION_ID}/rollback`],
|
||||
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/runtime-state`],
|
||||
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/runtime-state/reset-session`],
|
||||
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/task-sessions`],
|
||||
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/skills`],
|
||||
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/skills/sync`],
|
||||
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-path`],
|
||||
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle`],
|
||||
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle`],
|
||||
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle/file?path=AGENTS.md`],
|
||||
["PUT", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle/file`],
|
||||
["DELETE", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle/file?path=AGENTS.md`],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
|
||||
return new Response(JSON.stringify(body), init);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Command } from "commander";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerClientAuthCommands } from "../commands/client/auth.js";
|
||||
|
||||
describe("registerClientAuthCommands", () => {
|
||||
|
|
@ -14,3 +14,58 @@ describe("registerClientAuthCommands", () => {
|
|||
expect(login?.options.filter((option) => option.long === "--company-id")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("client auth API commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
delete process.env.PAPERCLIP_TEST_CHALLENGE_TOKEN;
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("wraps CLI auth challenge endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
async function run(args: string[]) {
|
||||
const program = new Command();
|
||||
const auth = program.command("auth");
|
||||
program.exitOverride();
|
||||
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
||||
registerClientAuthCommands(auth);
|
||||
await program.parseAsync([
|
||||
"auth",
|
||||
...args,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
], { from: "user" });
|
||||
}
|
||||
|
||||
await run(["challenge", "create", "--payload-json", "{}"]);
|
||||
await run(["challenge", "get", "challenge-1", "--token", "secret"]);
|
||||
await run(["challenge", "approve", "challenge-1", "--token", "secret"]);
|
||||
process.env.PAPERCLIP_TEST_CHALLENGE_TOKEN = "env-secret";
|
||||
await run(["challenge", "approve", "challenge/2", "--token-env", "PAPERCLIP_TEST_CHALLENGE_TOKEN"]);
|
||||
await run(["challenge", "cancel", "challenge-1", "--token", "secret"]);
|
||||
await run(["revoke-current"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["POST", "http://localhost:3100/api/cli-auth/challenges"],
|
||||
["GET", "http://localhost:3100/api/cli-auth/challenges/challenge-1?token=secret"],
|
||||
["POST", "http://localhost:3100/api/cli-auth/challenges/challenge-1/approve"],
|
||||
["POST", "http://localhost:3100/api/cli-auth/challenges/challenge%2F2/approve"],
|
||||
["POST", "http://localhost:3100/api/cli-auth/challenges/challenge-1/cancel"],
|
||||
["POST", "http://localhost:3100/api/cli-auth/revoke-current"],
|
||||
]);
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[3]?.[1]?.body))).toEqual({ token: "env-secret" });
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
|
||||
return new Response(JSON.stringify(body), init);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { writeContext } from "../client/context.js";
|
||||
import { resolveCommandContext } from "../commands/client/common.js";
|
||||
import { setStoredBoardCredential } from "../client/board-auth.js";
|
||||
import { resolveApiBase, resolveCommandContext } from "../commands/client/common.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
|
|
@ -18,6 +19,8 @@ describe("resolveCommandContext", () => {
|
|||
delete process.env.PAPERCLIP_API_URL;
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_COMPANY_ID;
|
||||
delete process.env.PAPERCLIP_AUTH_STORE;
|
||||
delete process.env.PAPERCLIP_SERVER_PORT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -29,7 +32,7 @@ describe("resolveCommandContext", () => {
|
|||
|
||||
writeContext(
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
currentProfile: "ops",
|
||||
profiles: {
|
||||
ops: {
|
||||
|
|
@ -53,7 +56,7 @@ describe("resolveCommandContext", () => {
|
|||
const contextPath = createTempPath("context.json");
|
||||
writeContext(
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
currentProfile: "default",
|
||||
profiles: {
|
||||
default: {
|
||||
|
|
@ -84,7 +87,7 @@ describe("resolveCommandContext", () => {
|
|||
const contextPath = createTempPath("context.json");
|
||||
writeContext(
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
currentProfile: "default",
|
||||
profiles: { default: {} },
|
||||
},
|
||||
|
|
@ -95,4 +98,63 @@ describe("resolveCommandContext", () => {
|
|||
resolveCommandContext({ context: contextPath, apiBase: "http://localhost:3100" }, { requireCompany: true }),
|
||||
).toThrow(/Company ID is required/);
|
||||
});
|
||||
|
||||
it("resolves api base by explicit, env, profile, then config/default precedence", () => {
|
||||
const configPath = createTempPath("config.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
$meta: { version: 1, updatedAt: "2026-05-23T00:00:00.000Z", source: "onboard" },
|
||||
database: { mode: "embedded-postgres" },
|
||||
logging: { mode: "file" },
|
||||
server: { deploymentMode: "local_trusted", exposure: "private", host: "127.0.0.1", port: 4111 },
|
||||
}));
|
||||
|
||||
expect(resolveApiBase({ apiBase: "http://explicit:1", config: configPath }, { apiBase: "http://profile:2" }))
|
||||
.toBe("http://explicit:1");
|
||||
|
||||
process.env.PAPERCLIP_API_URL = "http://env:3/";
|
||||
expect(resolveApiBase({ config: configPath }, { apiBase: "http://profile:2" })).toBe("http://env:3");
|
||||
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
expect(resolveApiBase({ config: configPath }, { apiBase: "http://profile:2/" })).toBe("http://profile:2");
|
||||
expect(resolveApiBase({ config: configPath }, {})).toBe("http://localhost:4111");
|
||||
});
|
||||
|
||||
it("prefers explicit and env tokens over profile env and stored board auth", () => {
|
||||
const contextPath = createTempPath("context.json");
|
||||
const authStorePath = createTempPath("auth.json");
|
||||
process.env.PAPERCLIP_AUTH_STORE = authStorePath;
|
||||
process.env.PROFILE_KEY = "profile-token";
|
||||
setStoredBoardCredential({
|
||||
apiBase: "http://localhost:3100",
|
||||
token: "stored-board-token",
|
||||
userId: "user-1",
|
||||
storePath: authStorePath,
|
||||
});
|
||||
writeContext(
|
||||
{
|
||||
version: 2,
|
||||
currentProfile: "default",
|
||||
profiles: {
|
||||
default: {
|
||||
apiBase: "http://localhost:3100",
|
||||
apiKeyEnvVarName: "PROFILE_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
|
||||
const profileResolved = resolveCommandContext({ context: contextPath });
|
||||
expect(profileResolved.api.apiKey).toBe("profile-token");
|
||||
expect(profileResolved.authSource).toBe("profile_env");
|
||||
|
||||
process.env.PAPERCLIP_API_KEY = "env-token";
|
||||
const envResolved = resolveCommandContext({ context: contextPath });
|
||||
expect(envResolved.api.apiKey).toBe("env-token");
|
||||
expect(envResolved.authSource).toBe("env");
|
||||
|
||||
const explicitResolved = resolveCommandContext({ context: contextPath, apiKey: "explicit-token" });
|
||||
expect(explicitResolved.api.apiKey).toBe("explicit-token");
|
||||
expect(explicitResolved.authSource).toBe("explicit");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
99
cli/src/__tests__/configure.test.ts
Normal file
99
cli/src/__tests__/configure.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { configure } from "../commands/configure.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_EXIT_CODE = process.exitCode;
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = ORIGINAL_EXIT_CODE;
|
||||
});
|
||||
|
||||
function writeBaseConfig(configPath: string) {
|
||||
const base: PaperclipConfig = {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: "/tmp/paperclip-db",
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: "/tmp/paperclip-backups",
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: "/tmp/paperclip-logs",
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
bind: "loopback",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: { baseDir: "/tmp/paperclip-storage" },
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: { keyFilePath: "/tmp/paperclip-secrets/master.key" },
|
||||
},
|
||||
};
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify(base, null, 2));
|
||||
}
|
||||
|
||||
describe("configure command", () => {
|
||||
it("sets a failing exit code for unknown sections", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-configure-"));
|
||||
const configPath = path.join(root, "config.json");
|
||||
writeBaseConfig(configPath);
|
||||
|
||||
try {
|
||||
await configure({ config: configPath, section: "invalid-section" });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sets a failing exit code when no config exists", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-configure-missing-"));
|
||||
const configPath = path.join(root, "missing.json");
|
||||
|
||||
try {
|
||||
await configure({ config: configPath, section: "server" });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
197
cli/src/__tests__/connect.test.ts
Normal file
197
cli/src/__tests__/connect.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as prompts from "@clack/prompts";
|
||||
import { registerConnectCommand } from "../commands/client/connect.js";
|
||||
import { loginBoardCli } from "../client/board-auth.js";
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
intro: vi.fn(),
|
||||
outro: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
isCancel: vi.fn(() => false),
|
||||
text: vi.fn(),
|
||||
select: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../client/board-auth.js", () => ({
|
||||
loginBoardCli: vi.fn(),
|
||||
}));
|
||||
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const AGENT_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const API_BASE = "http://127.0.0.1:3197";
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
||||
registerConnectCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
|
||||
return new Response(JSON.stringify(body), init);
|
||||
}
|
||||
|
||||
function createTempContextPath() {
|
||||
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-connect-test-")), "context.json");
|
||||
}
|
||||
|
||||
function readContext(filePath: string) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as {
|
||||
currentProfile: string;
|
||||
profiles: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("connect command", () => {
|
||||
let originalStdinIsTTY: boolean | undefined;
|
||||
let originalStdoutIsTTY: boolean | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalStdinIsTTY = process.stdin.isTTY;
|
||||
originalStdoutIsTTY = process.stdout.isTTY;
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(loginBoardCli).mockResolvedValue({
|
||||
token: "board-login-token",
|
||||
approvalUrl: `${API_BASE}/cli-auth/challenge-1`,
|
||||
userId: "user-1",
|
||||
});
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: originalStdinIsTTY, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: originalStdoutIsTTY, configurable: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("drives the interactive board profile flow through prompts and context writes", async () => {
|
||||
const contextPath = createTempContextPath();
|
||||
vi.mocked(prompts.text).mockResolvedValue(API_BASE);
|
||||
vi.mocked(prompts.select).mockResolvedValue(COMPANY_ID);
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(String(input));
|
||||
if (url.pathname === "/api/health") return jsonResponse({ status: "ok" });
|
||||
if (url.pathname === "/api/companies") {
|
||||
return jsonResponse([{ id: COMPANY_ID, name: "Connect Co" }]);
|
||||
}
|
||||
if (url.pathname === "/api/board-api-keys" && init?.method === "POST") {
|
||||
return jsonResponse({
|
||||
id: "board-key-1",
|
||||
name: "connect-board-token",
|
||||
token: "pcp_board_created",
|
||||
createdAt: "2026-05-24T12:00:00.000Z",
|
||||
expiresAt: null,
|
||||
});
|
||||
}
|
||||
return jsonResponse({ error: `Unexpected ${init?.method ?? "GET"} ${url.pathname}` }, { status: 500 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"connect",
|
||||
"--persona",
|
||||
"board",
|
||||
"--profile",
|
||||
"cli-board",
|
||||
"--token-name",
|
||||
"connect-board-token",
|
||||
"--context",
|
||||
contextPath,
|
||||
"--api-base",
|
||||
API_BASE,
|
||||
"--json",
|
||||
], { from: "user" });
|
||||
|
||||
expect(loginBoardCli).toHaveBeenCalledWith(expect.objectContaining({
|
||||
apiBase: API_BASE,
|
||||
requestedAccess: "board",
|
||||
command: "paperclipai connect",
|
||||
}));
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", new URL(String(call[0])).pathname])).toEqual([
|
||||
["GET", "/api/health"],
|
||||
["GET", "/api/companies"],
|
||||
["POST", "/api/board-api-keys"],
|
||||
]);
|
||||
expect(readContext(contextPath)).toMatchObject({
|
||||
currentProfile: "cli-board",
|
||||
profiles: {
|
||||
"cli-board": {
|
||||
apiBase: API_BASE,
|
||||
companyId: COMPANY_ID,
|
||||
persona: "board",
|
||||
tokenId: "board-key-1",
|
||||
tokenName: "connect-board-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("drives the interactive agent profile flow through prompts and context writes", async () => {
|
||||
const contextPath = createTempContextPath();
|
||||
vi.mocked(prompts.text).mockResolvedValue(API_BASE);
|
||||
vi.mocked(prompts.select).mockResolvedValue(AGENT_ID);
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(String(input));
|
||||
if (url.pathname === "/api/health") return jsonResponse({ status: "ok" });
|
||||
if (url.pathname === "/api/companies") {
|
||||
return jsonResponse([{ id: COMPANY_ID, name: "Connect Co" }]);
|
||||
}
|
||||
if (url.pathname === `/api/companies/${COMPANY_ID}/agents`) {
|
||||
return jsonResponse([{ id: AGENT_ID, name: "Connect Agent", role: "Operator" }]);
|
||||
}
|
||||
if (url.pathname === `/api/agents/${AGENT_ID}/keys` && init?.method === "POST") {
|
||||
return jsonResponse({
|
||||
id: "agent-key-1",
|
||||
name: "connect-agent-token",
|
||||
token: "pcp_agent_created",
|
||||
createdAt: "2026-05-24T12:00:00.000Z",
|
||||
});
|
||||
}
|
||||
return jsonResponse({ error: `Unexpected ${init?.method ?? "GET"} ${url.pathname}` }, { status: 500 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"connect",
|
||||
"--persona",
|
||||
"agent",
|
||||
"--profile",
|
||||
"cli-agent",
|
||||
"--token-name",
|
||||
"connect-agent-token",
|
||||
"--context",
|
||||
contextPath,
|
||||
"--api-base",
|
||||
API_BASE,
|
||||
"--json",
|
||||
], { from: "user" });
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", new URL(String(call[0])).pathname])).toEqual([
|
||||
["GET", "/api/health"],
|
||||
["GET", "/api/companies"],
|
||||
["GET", `/api/companies/${COMPANY_ID}/agents`],
|
||||
["POST", `/api/agents/${AGENT_ID}/keys`],
|
||||
]);
|
||||
expect(readContext(contextPath)).toMatchObject({
|
||||
currentProfile: "cli-agent",
|
||||
profiles: {
|
||||
"cli-agent": {
|
||||
apiBase: API_BASE,
|
||||
companyId: COMPANY_ID,
|
||||
persona: "agent",
|
||||
agentId: AGENT_ID,
|
||||
agentName: "Connect Agent",
|
||||
tokenId: "agent-key-1",
|
||||
tokenName: "connect-agent-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -30,6 +30,9 @@ describe("client context store", () => {
|
|||
{
|
||||
apiBase: "http://localhost:3100",
|
||||
companyId: "company-123",
|
||||
persona: "agent",
|
||||
agentId: "agent-123",
|
||||
agentName: "Agent One",
|
||||
apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN",
|
||||
},
|
||||
contextPath,
|
||||
|
|
@ -42,15 +45,75 @@ describe("client context store", () => {
|
|||
expect(context.profiles.work).toEqual({
|
||||
apiBase: "http://localhost:3100",
|
||||
companyId: "company-123",
|
||||
persona: "agent",
|
||||
agentId: "agent-123",
|
||||
agentName: "Agent One",
|
||||
apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing profile values when patch fields are undefined", () => {
|
||||
const contextPath = createTempContextPath();
|
||||
|
||||
upsertProfile(
|
||||
"default",
|
||||
{
|
||||
apiBase: "http://127.0.0.1:3197",
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
|
||||
upsertProfile(
|
||||
"default",
|
||||
{
|
||||
apiBase: undefined,
|
||||
companyId: "company-123",
|
||||
persona: undefined,
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
|
||||
const context = readContext(contextPath);
|
||||
expect(context.profiles.default).toEqual({
|
||||
apiBase: "http://127.0.0.1:3197",
|
||||
companyId: "company-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates version 1 context files to version 2 with persona metadata", () => {
|
||||
const contextPath = createTempContextPath();
|
||||
fs.writeFileSync(
|
||||
contextPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
currentProfile: "legacy",
|
||||
profiles: {
|
||||
legacy: {
|
||||
apiBase: "http://localhost:3101",
|
||||
companyId: "company-legacy",
|
||||
persona: "board",
|
||||
apiKeyEnvVarName: "PAPERCLIP_BOARD_TOKEN",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const context = readContext(contextPath);
|
||||
|
||||
expect(context.version).toBe(2);
|
||||
expect(context.profiles.legacy).toEqual({
|
||||
apiBase: "http://localhost:3101",
|
||||
companyId: "company-legacy",
|
||||
persona: "board",
|
||||
apiKeyEnvVarName: "PAPERCLIP_BOARD_TOKEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes invalid file content to safe defaults", () => {
|
||||
const contextPath = createTempContextPath();
|
||||
writeContext(
|
||||
{
|
||||
version: 1,
|
||||
version: 2,
|
||||
currentProfile: "x",
|
||||
profiles: {
|
||||
x: {
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ describe("registerFeedbackCommands", () => {
|
|||
|
||||
const feedback = program.commands.find((command) => command.name() === "feedback");
|
||||
expect(feedback).toBeDefined();
|
||||
expect(feedback?.commands.map((command) => command.name())).toEqual(["report", "export"]);
|
||||
expect(feedback?.commands.map((command) => command.name())).toEqual(["report", "export", "trace", "bundle"]);
|
||||
expect(feedback?.commands[0]?.options.filter((option) => option.long === "--company-id")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
223
cli/src/__tests__/issue-subresources.test.ts
Normal file
223
cli/src/__tests__/issue-subresources.test.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { Command } from "commander";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerIssueCommands } from "../commands/client/issue.js";
|
||||
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const ISSUE_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const COMMENT_ID = "55555555-5555-4555-8555-555555555555";
|
||||
const APPROVAL_ID = "66666666-6666-4666-8666-666666666666";
|
||||
const PRODUCT_ID = "77777777-7777-4777-8777-777777777777";
|
||||
const INTERACTION_ID = "88888888-8888-4888-8888-888888888888";
|
||||
const HOLD_ID = "99999999-9999-4999-8999-999999999999";
|
||||
const ATTACHMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const LABEL_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({
|
||||
writeOut: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
registerIssueCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]): Promise<void> {
|
||||
await createProgram().parseAsync([
|
||||
...args,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
], { from: "user" });
|
||||
}
|
||||
|
||||
describe("issue subresource commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("wraps core issue get, update, and delete endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["issue", "get", ISSUE_ID]);
|
||||
await run(["issue", "update", ISSUE_ID, "--title", "New title"]);
|
||||
await run(["issue", "delete", ISSUE_ID, "--yes"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}`],
|
||||
["PATCH", `http://localhost:3100/api/issues/${ISSUE_ID}`],
|
||||
["DELETE", `http://localhost:3100/api/issues/${ISSUE_ID}`],
|
||||
]);
|
||||
});
|
||||
|
||||
it("wraps comments, approvals, markers, and recovery action endpoints", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["issue", "comments", ISSUE_ID, "--limit", "10"]);
|
||||
await run(["issue", "comment:get", ISSUE_ID, COMMENT_ID]);
|
||||
await run(["issue", "comment:delete", ISSUE_ID, COMMENT_ID]);
|
||||
await run(["issue", "approvals", ISSUE_ID]);
|
||||
await run(["issue", "approval:link", ISSUE_ID, APPROVAL_ID]);
|
||||
await run(["issue", "approval:unlink", ISSUE_ID, APPROVAL_ID]);
|
||||
await run(["issue", "read", ISSUE_ID]);
|
||||
await run(["issue", "unread", ISSUE_ID]);
|
||||
await run(["issue", "archive", ISSUE_ID]);
|
||||
await run(["issue", "unarchive", ISSUE_ID]);
|
||||
await run(["issue", "recovery-actions", ISSUE_ID]);
|
||||
await run([
|
||||
"issue", "recovery:resolve", ISSUE_ID,
|
||||
"--outcome", "restored",
|
||||
"--source-issue-status", "todo",
|
||||
"--action-id", APPROVAL_ID,
|
||||
]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/comments?limit=10`],
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/comments/${COMMENT_ID}`],
|
||||
["DELETE", `http://localhost:3100/api/issues/${ISSUE_ID}/comments/${COMMENT_ID}`],
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/approvals`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/approvals`],
|
||||
["DELETE", `http://localhost:3100/api/issues/${ISSUE_ID}/approvals/${APPROVAL_ID}`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/read`],
|
||||
["DELETE", `http://localhost:3100/api/issues/${ISSUE_ID}/read`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/inbox-archive`],
|
||||
["DELETE", `http://localhost:3100/api/issues/${ISSUE_ID}/inbox-archive`],
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/recovery-actions`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/recovery-actions/resolve`],
|
||||
]);
|
||||
});
|
||||
|
||||
it("wraps document and work product endpoints", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["issue", "documents", ISSUE_ID, "--include-system"]);
|
||||
await run(["issue", "document:get", ISSUE_ID, "plan"]);
|
||||
await run(["issue", "document:put", ISSUE_ID, "plan", "--body", "# Plan", "--title", "Plan"]);
|
||||
await run(["issue", "document:lock", ISSUE_ID, "plan"]);
|
||||
await run(["issue", "document:unlock", ISSUE_ID, "plan"]);
|
||||
await run(["issue", "document:revisions", ISSUE_ID, "plan"]);
|
||||
await run(["issue", "document:restore", ISSUE_ID, "plan", APPROVAL_ID]);
|
||||
await run(["issue", "document:delete", ISSUE_ID, "plan"]);
|
||||
await run(["issue", "work-products", ISSUE_ID]);
|
||||
await run([
|
||||
"issue", "work-product:create", ISSUE_ID,
|
||||
"--payload-json", JSON.stringify({ type: "pull_request", provider: "github", title: "PR", url: "https://example.com/pr/1" }),
|
||||
]);
|
||||
await run([
|
||||
"issue", "work-product:update", PRODUCT_ID,
|
||||
"--payload-json", JSON.stringify({ title: "Updated PR" }),
|
||||
]);
|
||||
await run(["issue", "work-product:delete", PRODUCT_ID]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/documents?includeSystem=true`],
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan`],
|
||||
["PUT", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan/lock`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan/unlock`],
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan/revisions`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan/revisions/${APPROVAL_ID}/restore`],
|
||||
["DELETE", `http://localhost:3100/api/issues/${ISSUE_ID}/documents/plan`],
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/work-products`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/work-products`],
|
||||
["PATCH", `http://localhost:3100/api/work-products/${PRODUCT_ID}`],
|
||||
["DELETE", `http://localhost:3100/api/work-products/${PRODUCT_ID}`],
|
||||
]);
|
||||
});
|
||||
|
||||
it("wraps interactions, tree holds, labels, feedback votes, and attachments", async () => {
|
||||
const tmp = await mkdtemp(join(tmpdir(), "paperclip-cli-test-"));
|
||||
const filePath = join(tmp, "attachment.txt");
|
||||
await writeFile(filePath, "hello", "utf8");
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
try {
|
||||
await run(["issue", "interactions", ISSUE_ID]);
|
||||
await run([
|
||||
"issue", "interaction:create", ISSUE_ID,
|
||||
"--payload-json", JSON.stringify({
|
||||
kind: "request_confirmation",
|
||||
payload: { version: 1, prompt: "Continue?" },
|
||||
}),
|
||||
]);
|
||||
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID]);
|
||||
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID, "--selected-client-keys", "yes"]);
|
||||
await run(["issue", "interaction:reject", ISSUE_ID, INTERACTION_ID, "--reason", "no"]);
|
||||
await run(["issue", "interaction:cancel", ISSUE_ID, INTERACTION_ID, "--reason", "stale"]);
|
||||
await run([
|
||||
"issue", "interaction:respond", ISSUE_ID, INTERACTION_ID,
|
||||
"--answers-json", JSON.stringify([{ questionId: "q1", optionIds: ["a1"] }]),
|
||||
]);
|
||||
await run(["issue", "tree-state", ISSUE_ID]);
|
||||
await run(["issue", "tree-preview", ISSUE_ID, "--payload-json", JSON.stringify({ mode: "pause" })]);
|
||||
await run(["issue", "tree-holds", ISSUE_ID, "--status", "active", "--include-members"]);
|
||||
await run(["issue", "tree-hold:create", ISSUE_ID, "--payload-json", JSON.stringify({ mode: "pause", reason: "test" })]);
|
||||
await run(["issue", "tree-hold:get", ISSUE_ID, HOLD_ID]);
|
||||
await run(["issue", "tree-hold:release", ISSUE_ID, HOLD_ID]);
|
||||
await run(["issue", "attachments", ISSUE_ID]);
|
||||
await run(["issue", "attachment:upload", ISSUE_ID, "--company-id", COMPANY_ID, "--file", filePath]);
|
||||
await run(["issue", "attachment:download", ATTACHMENT_ID]);
|
||||
await run(["issue", "attachment:delete", ATTACHMENT_ID]);
|
||||
await run(["issue", "label:list", "--company-id", COMPANY_ID]);
|
||||
await run(["issue", "label:create", "--company-id", COMPANY_ID, "--name", "bug", "--color", "#ff0000"]);
|
||||
await run(["issue", "label:delete", LABEL_ID]);
|
||||
await run(["issue", "feedback:votes", ISSUE_ID]);
|
||||
await run([
|
||||
"issue", "feedback:vote", ISSUE_ID,
|
||||
"--payload-json", JSON.stringify({ targetType: "issue_comment", targetId: COMMENT_ID, vote: "up" }),
|
||||
]);
|
||||
} finally {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/reject`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/cancel`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/respond`],
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/tree-control/state`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/tree-control/preview`],
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/tree-holds?status=active&includeMembers=true`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/tree-holds`],
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/tree-holds/${HOLD_ID}`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/tree-holds/${HOLD_ID}/release`],
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/attachments`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/issues/${ISSUE_ID}/attachments`],
|
||||
["GET", `http://localhost:3100/api/attachments/${ATTACHMENT_ID}/content`],
|
||||
["DELETE", `http://localhost:3100/api/attachments/${ATTACHMENT_ID}`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/labels`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/labels`],
|
||||
["DELETE", `http://localhost:3100/api/labels/${LABEL_ID}`],
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/feedback-votes`],
|
||||
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/feedback-votes`],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
|
||||
return new Response(JSON.stringify(body), init);
|
||||
}
|
||||
136
cli/src/__tests__/operations-parity.test.ts
Normal file
136
cli/src/__tests__/operations-parity.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerCostCommands } from "../commands/client/cost.js";
|
||||
import { registerWorkspaceCommands } from "../commands/client/workspace.js";
|
||||
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const ISSUE_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const WORKSPACE_ID = "55555555-5555-4555-8555-555555555555";
|
||||
const PROJECT_ID = "66666666-6666-4666-8666-666666666666";
|
||||
const PROJECT_WORKSPACE_ID = "77777777-7777-4777-8777-777777777777";
|
||||
const ENV_ID = "88888888-8888-4888-8888-888888888888";
|
||||
const INCIDENT_ID = "99999999-9999-4999-8999-999999999999";
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
||||
registerCostCommands(program);
|
||||
registerWorkspaceCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]): Promise<void> {
|
||||
await createProgram().parseAsync([
|
||||
...args,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
], { from: "user" });
|
||||
}
|
||||
|
||||
describe("operations parity commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("wraps cost, finance, and budget endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["cost", "summary", "--company-id", COMPANY_ID]);
|
||||
await run(["cost", "by-agent", "--company-id", COMPANY_ID]);
|
||||
await run(["cost", "by-project", "--company-id", COMPANY_ID]);
|
||||
await run(["cost", "issue", ISSUE_ID]);
|
||||
await run(["cost", "event:create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["finance", "event:create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["finance", "summary", "--company-id", COMPANY_ID]);
|
||||
await run(["budget", "overview", "--company-id", COMPANY_ID]);
|
||||
await run(["budget", "policy:upsert", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["budget", "company:update", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["budget", "agent:update", AGENT_ID, "--payload-json", "{}"]);
|
||||
await run(["budget", "incident:resolve", INCIDENT_ID, "--company-id", COMPANY_ID]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/costs/summary`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/costs/by-agent`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/costs/by-project`],
|
||||
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/cost-summary`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/cost-events`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/finance-events`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/costs/finance-summary`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/budgets/overview`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/budgets/policies`],
|
||||
["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}/budgets`],
|
||||
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/budgets`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/budget-incidents/${INCIDENT_ID}/resolve`],
|
||||
]);
|
||||
});
|
||||
|
||||
it("wraps org, execution workspace, environment, and project workspace endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["org", "get", "--company-id", COMPANY_ID]);
|
||||
await run(["org", "svg", "--company-id", COMPANY_ID]);
|
||||
await run(["agent-config", "list", "--company-id", COMPANY_ID]);
|
||||
await run(["workspace", "list", "--company-id", COMPANY_ID]);
|
||||
await run(["workspace", "get", WORKSPACE_ID]);
|
||||
await run(["workspace", "close-readiness", WORKSPACE_ID]);
|
||||
await run(["workspace", "operations", WORKSPACE_ID]);
|
||||
await run(["workspace", "update", WORKSPACE_ID, "--payload-json", "{}"]);
|
||||
await run(["workspace", "runtime-service", WORKSPACE_ID, "restart", "--payload-json", "{}"]);
|
||||
await run(["environment", "list", "--company-id", COMPANY_ID]);
|
||||
await run(["environment", "capabilities", "--company-id", COMPANY_ID]);
|
||||
await run(["environment", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["environment", "get", ENV_ID]);
|
||||
await run(["environment", "leases", ENV_ID]);
|
||||
await run(["environment", "update", ENV_ID, "--payload-json", "{}"]);
|
||||
await run(["environment", "delete", ENV_ID]);
|
||||
await run(["environment", "probe", ENV_ID]);
|
||||
await run(["environment", "probe-config", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["project-workspace", "list", PROJECT_ID]);
|
||||
await run(["project-workspace", "create", PROJECT_ID, "--payload-json", "{}"]);
|
||||
await run(["project-workspace", "update", PROJECT_ID, PROJECT_WORKSPACE_ID, "--payload-json", "{}"]);
|
||||
await run(["project-workspace", "runtime-command", PROJECT_ID, PROJECT_WORKSPACE_ID, "run", "--payload-json", "{}"]);
|
||||
await run(["project-workspace", "delete", PROJECT_ID, PROJECT_WORKSPACE_ID]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/org`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/org.svg`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/agent-configurations`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/execution-workspaces`],
|
||||
["GET", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}`],
|
||||
["GET", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}/close-readiness`],
|
||||
["GET", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}/workspace-operations`],
|
||||
["PATCH", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}`],
|
||||
["POST", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}/runtime-services/restart`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/environments`],
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/environments/capabilities`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/environments`],
|
||||
["GET", `http://localhost:3100/api/environments/${ENV_ID}`],
|
||||
["GET", `http://localhost:3100/api/environments/${ENV_ID}/leases`],
|
||||
["PATCH", `http://localhost:3100/api/environments/${ENV_ID}`],
|
||||
["DELETE", `http://localhost:3100/api/environments/${ENV_ID}`],
|
||||
["POST", `http://localhost:3100/api/environments/${ENV_ID}/probe`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/environments/probe-config`],
|
||||
["GET", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces`],
|
||||
["POST", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces`],
|
||||
["PATCH", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces/${PROJECT_WORKSPACE_ID}`],
|
||||
["POST", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces/${PROJECT_WORKSPACE_ID}/runtime-commands/run`],
|
||||
["DELETE", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces/${PROJECT_WORKSPACE_ID}`],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
|
||||
return new Response(JSON.stringify(body), init);
|
||||
}
|
||||
155
cli/src/__tests__/project-goal.test.ts
Normal file
155
cli/src/__tests__/project-goal.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerGoalCommands } from "../commands/client/goal.js";
|
||||
import { registerProjectCommands } from "../commands/client/project.js";
|
||||
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const PROJECT_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const GOAL_ID = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({
|
||||
writeOut: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
registerProjectCommands(program);
|
||||
registerGoalCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("project and goal commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates and updates projects with shared schemas", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: PROJECT_ID,
|
||||
companyId: COMPANY_ID,
|
||||
name: "Launch Site",
|
||||
status: "planned",
|
||||
goalIds: [GOAL_ID],
|
||||
}), { status: 201 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: PROJECT_ID,
|
||||
companyId: COMPANY_ID,
|
||||
name: "Launch Site",
|
||||
status: "in_progress",
|
||||
goalIds: [GOAL_ID],
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"project", "create",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--company-id", COMPANY_ID,
|
||||
"--name", "Launch Site",
|
||||
"--status", "planned",
|
||||
"--goal-ids", GOAL_ID,
|
||||
], { from: "user" });
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"project", "update", PROJECT_ID,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--status", "in_progress",
|
||||
], { from: "user" });
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/projects`);
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
|
||||
name: "Launch Site",
|
||||
status: "planned",
|
||||
goalIds: [GOAL_ID],
|
||||
});
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/projects/${PROJECT_ID}`);
|
||||
expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("PATCH");
|
||||
});
|
||||
|
||||
it("lists and deletes projects", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify([{ id: PROJECT_ID, companyId: COMPANY_ID, name: "Launch Site", status: "planned", goalIds: [] }]), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ id: PROJECT_ID, companyId: COMPANY_ID, name: "Launch Site" }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"project", "list",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--company-id", COMPANY_ID,
|
||||
], { from: "user" });
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"project", "delete", PROJECT_ID,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--yes",
|
||||
], { from: "user" });
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/projects`);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/projects/${PROJECT_ID}`);
|
||||
expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("creates, updates, lists, and deletes goals", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ id: GOAL_ID, companyId: COMPANY_ID, title: "Grow", level: "company", status: "active" }), { status: 201 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ id: GOAL_ID, companyId: COMPANY_ID, title: "Grow faster", level: "company", status: "active" }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify([{ id: GOAL_ID, companyId: COMPANY_ID, title: "Grow faster", level: "company", status: "active" }]), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ id: GOAL_ID, companyId: COMPANY_ID, title: "Grow faster" }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"goal", "create",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--company-id", COMPANY_ID,
|
||||
"--title", "Grow",
|
||||
"--level", "company",
|
||||
"--status", "active",
|
||||
], { from: "user" });
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"goal", "update", GOAL_ID,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--title", "Grow faster",
|
||||
], { from: "user" });
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"goal", "list",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--company-id", COMPANY_ID,
|
||||
], { from: "user" });
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"goal", "delete", GOAL_ID,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--yes",
|
||||
], { from: "user" });
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/goals`);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/goals/${GOAL_ID}`);
|
||||
expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("PATCH");
|
||||
expect(fetchMock.mock.calls[2]?.[0]).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/goals`);
|
||||
expect(fetchMock.mock.calls[3]?.[0]).toBe(`http://localhost:3100/api/goals/${GOAL_ID}`);
|
||||
expect(fetchMock.mock.calls[3]?.[1]?.method).toBe("DELETE");
|
||||
});
|
||||
});
|
||||
102
cli/src/__tests__/prompt.test.ts
Normal file
102
cli/src/__tests__/prompt.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { writeContext } from "../client/context.js";
|
||||
import { runAgentPrompt } from "../commands/client/prompt.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function createTempContextPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-prompt-"));
|
||||
return path.join(dir, "context.json");
|
||||
}
|
||||
|
||||
function agent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "22222222-2222-4222-8222-222222222222",
|
||||
name: "Worker",
|
||||
urlKey: "worker",
|
||||
role: "Engineer",
|
||||
status: "active",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("prompt handoff", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("fails when an agent prompt uses a board persona profile", async () => {
|
||||
const contextPath = createTempContextPath();
|
||||
writeContext(
|
||||
{
|
||||
version: 2,
|
||||
currentProfile: "board",
|
||||
profiles: {
|
||||
board: {
|
||||
apiBase: "http://localhost:3100",
|
||||
persona: "board",
|
||||
},
|
||||
},
|
||||
},
|
||||
contextPath,
|
||||
);
|
||||
|
||||
await expect(runAgentPrompt("worker", "Do the work", { context: contextPath, apiKey: "agent-token" }))
|
||||
.rejects
|
||||
.toThrow(/persona=board/);
|
||||
});
|
||||
|
||||
it("fails when the supplied agent key belongs to a different agent", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(agent()), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(runAgentPrompt("other-agent", "Do the work", {
|
||||
apiBase: "http://localhost:3100",
|
||||
apiKey: "agent-token",
|
||||
})).rejects.toThrow(/Agent key belongs to Worker/);
|
||||
});
|
||||
|
||||
it("creates an assigned issue and wakes the authenticated agent", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(agent()), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: "issue-1",
|
||||
companyId: "22222222-2222-4222-8222-222222222222",
|
||||
title: "Investigate queue lag",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
}), { status: 201 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ id: "run-1", status: "queued" }), { status: 202 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await runAgentPrompt("worker", "Investigate queue lag", {
|
||||
apiBase: "http://localhost:3100",
|
||||
apiKey: "agent-token",
|
||||
});
|
||||
|
||||
expect(result.mode).toBe("issue");
|
||||
expect(result.agent.id).toBe("11111111-1111-4111-8111-111111111111");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3100/api/companies/22222222-2222-4222-8222-222222222222/issues");
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({
|
||||
title: "Investigate queue lag",
|
||||
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
});
|
||||
expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3100/api/agents/11111111-1111-4111-8111-111111111111/wakeup");
|
||||
});
|
||||
});
|
||||
130
cli/src/__tests__/routine-plugin-parity.test.ts
Normal file
130
cli/src/__tests__/routine-plugin-parity.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerPluginCommands } from "../commands/client/plugin.js";
|
||||
import { registerRoutineApiCommands } from "../commands/client/routine-api.js";
|
||||
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const ROUTINE_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const REVISION_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const TRIGGER_ID = "55555555-5555-4555-8555-555555555555";
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
||||
registerRoutineApiCommands(program);
|
||||
registerPluginCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]): Promise<void> {
|
||||
await createProgram().parseAsync([...args, "--api-base", "http://localhost:3100", "--api-key", "board-token"], { from: "user" });
|
||||
}
|
||||
|
||||
describe("routine and plugin parity commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("wraps routine API endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["routine", "list", "--company-id", COMPANY_ID, "--project-id", "p1"]);
|
||||
await run(["routine", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["routine", "get", ROUTINE_ID]);
|
||||
await run(["routine", "update", ROUTINE_ID, "--payload-json", "{}"]);
|
||||
await run(["routine", "revisions", ROUTINE_ID]);
|
||||
await run(["routine", "revision:restore", ROUTINE_ID, REVISION_ID]);
|
||||
await run(["routine", "runs", ROUTINE_ID, "--limit", "5"]);
|
||||
await run(["routine", "run", ROUTINE_ID]);
|
||||
await run(["routine", "trigger:create", ROUTINE_ID, "--payload-json", "{}"]);
|
||||
await run(["routine", "trigger:update", TRIGGER_ID, "--payload-json", "{}"]);
|
||||
await run(["routine", "trigger:delete", TRIGGER_ID]);
|
||||
await run(["routine", "trigger:rotate-secret", TRIGGER_ID]);
|
||||
await run(["routine", "trigger:fire", "public-id"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/routines?projectId=p1`],
|
||||
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/routines`],
|
||||
["GET", `http://localhost:3100/api/routines/${ROUTINE_ID}`],
|
||||
["PATCH", `http://localhost:3100/api/routines/${ROUTINE_ID}`],
|
||||
["GET", `http://localhost:3100/api/routines/${ROUTINE_ID}/revisions`],
|
||||
["POST", `http://localhost:3100/api/routines/${ROUTINE_ID}/revisions/${REVISION_ID}/restore`],
|
||||
["GET", `http://localhost:3100/api/routines/${ROUTINE_ID}/runs?limit=5`],
|
||||
["POST", `http://localhost:3100/api/routines/${ROUTINE_ID}/run`],
|
||||
["POST", `http://localhost:3100/api/routines/${ROUTINE_ID}/triggers`],
|
||||
["PATCH", `http://localhost:3100/api/routine-triggers/${TRIGGER_ID}`],
|
||||
["DELETE", `http://localhost:3100/api/routine-triggers/${TRIGGER_ID}`],
|
||||
["POST", `http://localhost:3100/api/routine-triggers/${TRIGGER_ID}/rotate-secret`],
|
||||
["POST", "http://localhost:3100/api/routine-triggers/public/public-id/fire"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("wraps deeper plugin endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await run(["plugin", "ui-contributions"]);
|
||||
await run(["plugin", "tools"]);
|
||||
await run(["plugin", "tool:execute", "--payload-json", "{}"]);
|
||||
await run(["plugin", "health", "plug"]);
|
||||
await run(["plugin", "logs", "plug"]);
|
||||
await run(["plugin", "upgrade", "plug"]);
|
||||
await run(["plugin", "config", "plug"]);
|
||||
await run(["plugin", "config:set", "plug", "--payload-json", "{}"]);
|
||||
await run(["plugin", "config:test", "plug", "--payload-json", "{}"]);
|
||||
await run(["plugin", "jobs", "plug"]);
|
||||
await run(["plugin", "job:runs", "plug", "job1"]);
|
||||
await run(["plugin", "job:trigger", "plug", "job1"]);
|
||||
await run(["plugin", "webhook", "plug", "endpoint", "--payload-json", "{}"]);
|
||||
await run(["plugin", "dashboard", "plug"]);
|
||||
await run(["plugin", "bridge:data", "plug", "--payload-json", "{}"]);
|
||||
await run(["plugin", "bridge:action", "plug", "--payload-json", "{}"]);
|
||||
await run(["plugin", "bridge:stream", "plug", "events", "--duration-ms", "1"]);
|
||||
await run(["plugin", "data", "plug", "key", "--payload-json", "{}"]);
|
||||
await run(["plugin", "action", "plug", "key", "--payload-json", "{}"]);
|
||||
await run(["plugin", "local-folders", "plug", "--company-id", COMPANY_ID]);
|
||||
await run(["plugin", "local-folder:status", "plug", "source", "--company-id", COMPANY_ID]);
|
||||
await run(["plugin", "local-folder:validate", "plug", "source", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
await run(["plugin", "local-folder:set", "plug", "source", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", "http://localhost:3100/api/plugins/ui-contributions"],
|
||||
["GET", "http://localhost:3100/api/plugins/tools"],
|
||||
["POST", "http://localhost:3100/api/plugins/tools/execute"],
|
||||
["GET", "http://localhost:3100/api/plugins/plug/health"],
|
||||
["GET", "http://localhost:3100/api/plugins/plug/logs"],
|
||||
["POST", "http://localhost:3100/api/plugins/plug/upgrade"],
|
||||
["GET", "http://localhost:3100/api/plugins/plug/config"],
|
||||
["POST", "http://localhost:3100/api/plugins/plug/config"],
|
||||
["POST", "http://localhost:3100/api/plugins/plug/config/test"],
|
||||
["GET", "http://localhost:3100/api/plugins/plug/jobs"],
|
||||
["GET", "http://localhost:3100/api/plugins/plug/jobs/job1/runs"],
|
||||
["POST", "http://localhost:3100/api/plugins/plug/jobs/job1/trigger"],
|
||||
["POST", "http://localhost:3100/api/plugins/plug/webhooks/endpoint"],
|
||||
["GET", "http://localhost:3100/api/plugins/plug/dashboard"],
|
||||
["POST", "http://localhost:3100/api/plugins/plug/bridge/data"],
|
||||
["POST", "http://localhost:3100/api/plugins/plug/bridge/action"],
|
||||
["GET", "http://localhost:3100/api/plugins/plug/bridge/stream/events"],
|
||||
["POST", "http://localhost:3100/api/plugins/plug/data/key"],
|
||||
["POST", "http://localhost:3100/api/plugins/plug/actions/key"],
|
||||
["GET", `http://localhost:3100/api/plugins/plug/companies/${COMPANY_ID}/local-folders`],
|
||||
["GET", `http://localhost:3100/api/plugins/plug/companies/${COMPANY_ID}/local-folders/source/status`],
|
||||
["POST", `http://localhost:3100/api/plugins/plug/companies/${COMPANY_ID}/local-folders/source/validate`],
|
||||
["PUT", `http://localhost:3100/api/plugins/plug/companies/${COMPANY_ID}/local-folders/source`],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
|
||||
return new Response(JSON.stringify(body), init);
|
||||
}
|
||||
220
cli/src/__tests__/run.test.ts
Normal file
220
cli/src/__tests__/run.test.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerAgentCommands } from "../commands/client/agent.js";
|
||||
import { registerIssueCommands } from "../commands/client/issue.js";
|
||||
import { registerRunCommands } from "../commands/client/run.js";
|
||||
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const RUN_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const ISSUE_ID = "44444444-4444-4444-8444-444444444444";
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({
|
||||
writeOut: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
const run = program.command("run").action(() => {});
|
||||
registerRunCommands(run);
|
||||
registerAgentCommands(program);
|
||||
registerIssueCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
describe("run inspection commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("lists and reads heartbeat runs through run subcommands", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify([
|
||||
{ id: RUN_ID, companyId: COMPANY_ID, agentId: AGENT_ID, status: "running", invocationSource: "on_demand" },
|
||||
]), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: RUN_ID,
|
||||
companyId: COMPANY_ID,
|
||||
agentId: AGENT_ID,
|
||||
status: "running",
|
||||
invocationSource: "on_demand",
|
||||
}), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
text: "hello",
|
||||
offset: 0,
|
||||
nextOffset: 5,
|
||||
}), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ id: RUN_ID, status: "cancelled" }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"run", "list",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--company-id", COMPANY_ID,
|
||||
"--agent-id", AGENT_ID,
|
||||
"--limit", "25",
|
||||
], { from: "user" });
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"run", "get", RUN_ID,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
], { from: "user" });
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"run", "log", RUN_ID,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--offset", "4",
|
||||
"--limit-bytes", "100",
|
||||
"--text",
|
||||
], { from: "user" });
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"run", "cancel", RUN_ID,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
], { from: "user" });
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
`http://localhost:3100/api/companies/${COMPANY_ID}/heartbeat-runs?agentId=${AGENT_ID}&limit=25`,
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}`);
|
||||
expect(fetchMock.mock.calls[2]?.[0]).toBe(
|
||||
`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/log?offset=4&limitBytes=100`,
|
||||
);
|
||||
expect(fetchMock.mock.calls[3]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/cancel`);
|
||||
expect(fetchMock.mock.calls[3]?.[1]?.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("supports run events, issues, workspace operations, and watchdog decisions", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify([
|
||||
{ id: 1, runId: RUN_ID, agentId: AGENT_ID, seq: 1, eventType: "output", message: "hi" },
|
||||
]), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify([
|
||||
{ id: ISSUE_ID, identifier: "PC-1", title: "Fix it", status: "in_progress", priority: "normal" },
|
||||
]), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify([
|
||||
{ id: "55555555-5555-4555-8555-555555555555", status: "succeeded", phase: "workspace_provision" },
|
||||
]), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ text: "workspace" }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ id: "decision-1", decision: "continue" }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"run", "events", RUN_ID,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--after-seq", "7",
|
||||
"--limit", "50",
|
||||
], { from: "user" });
|
||||
await createProgram().parseAsync([
|
||||
"run", "issues", RUN_ID,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
], { from: "user" });
|
||||
await createProgram().parseAsync([
|
||||
"run", "workspace-operations", RUN_ID,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
], { from: "user" });
|
||||
await createProgram().parseAsync([
|
||||
"run", "workspace-log", "55555555-5555-4555-8555-555555555555",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
], { from: "user" });
|
||||
await createProgram().parseAsync([
|
||||
"run", "watchdog-decision", RUN_ID,
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--decision", "continue",
|
||||
"--reason", "operator reviewed",
|
||||
], { from: "user" });
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/events?afterSeq=7&limit=50`,
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/issues`);
|
||||
expect(fetchMock.mock.calls[2]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/workspace-operations`);
|
||||
expect(fetchMock.mock.calls[3]?.[0]).toBe(
|
||||
"http://localhost:3100/api/workspace-operations/55555555-5555-4555-8555-555555555555/log?offset=0",
|
||||
);
|
||||
expect(fetchMock.mock.calls[4]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/watchdog-decisions`);
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[4]?.[1]?.body))).toMatchObject({
|
||||
decision: "continue",
|
||||
reason: "operator reviewed",
|
||||
});
|
||||
});
|
||||
|
||||
it("wakes agents and exposes issue run helpers", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: AGENT_ID,
|
||||
name: "Builder",
|
||||
companyId: COMPANY_ID,
|
||||
urlKey: "builder",
|
||||
}), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: RUN_ID,
|
||||
companyId: COMPANY_ID,
|
||||
agentId: AGENT_ID,
|
||||
status: "queued",
|
||||
}), { status: 202 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify([{ id: RUN_ID, status: "succeeded" }]), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify([{ id: RUN_ID, status: "running" }]), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ id: RUN_ID, status: "running" }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"agent", "wake", "builder",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--company-id", COMPANY_ID,
|
||||
"--reason", "manual check",
|
||||
"--payload", "{\"issueId\":\"PC-1\"}",
|
||||
], { from: "user" });
|
||||
await createProgram().parseAsync([
|
||||
"issue", "runs", "PC-1",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
], { from: "user" });
|
||||
await createProgram().parseAsync([
|
||||
"issue", "live-runs", "PC-1",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
], { from: "user" });
|
||||
await createProgram().parseAsync([
|
||||
"issue", "active-run", "PC-1",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
], { from: "user" });
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/agents/builder?companyId=${COMPANY_ID}`);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}/wakeup`);
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
reason: "manual check",
|
||||
payload: { issueId: "PC-1" },
|
||||
});
|
||||
expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3100/api/issues/PC-1/runs");
|
||||
expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3100/api/issues/PC-1/live-runs");
|
||||
expect(fetchMock.mock.calls[4]?.[0]).toBe("http://localhost:3100/api/issues/PC-1/active-run");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Agent, CompanySecret } from "@paperclipai/shared";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { secretsCheck } from "../checks/secrets-check.js";
|
||||
|
|
@ -7,6 +8,7 @@ import {
|
|||
buildMigratedAgentEnv,
|
||||
collectInlineSecretMigrationCandidates,
|
||||
parseSecretsInclude,
|
||||
registerSecretCommands,
|
||||
toPlainEnvValue,
|
||||
} from "../commands/client/secrets.js";
|
||||
|
||||
|
|
@ -255,3 +257,76 @@ describe("secrets CLI helpers", () => {
|
|||
expect(result.message).toContain("AWS_PROFILE/shared config");
|
||||
});
|
||||
});
|
||||
|
||||
describe("secrets API parity commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("wraps provider config and remote import endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await runSecretCommand(["secrets", "provider-configs", "--company-id", "company-1"]);
|
||||
await runSecretCommand(["secrets", "provider-config:create", "--company-id", "company-1", "--payload-json", "{}"]);
|
||||
await runSecretCommand(["secrets", "provider-config:discovery-preview", "--company-id", "company-1", "--payload-json", "{}"]);
|
||||
await runSecretCommand(["secrets", "provider-config:get", "config-1"]);
|
||||
await runSecretCommand(["secrets", "provider-config:update", "config-1", "--payload-json", "{}"]);
|
||||
await runSecretCommand(["secrets", "provider-config:default", "config-1"]);
|
||||
await runSecretCommand(["secrets", "provider-config:health", "config-1"]);
|
||||
await runSecretCommand(["secrets", "provider-config:delete", "config-1"]);
|
||||
await runSecretCommand(["secrets", "remote-import:preview", "--company-id", "company-1", "--payload-json", "{}"]);
|
||||
await runSecretCommand(["secrets", "remote-import", "--company-id", "company-1", "--payload-json", "{}"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["GET", "http://localhost:3100/api/companies/company-1/secret-provider-configs"],
|
||||
["POST", "http://localhost:3100/api/companies/company-1/secret-provider-configs"],
|
||||
["POST", "http://localhost:3100/api/companies/company-1/secret-provider-configs/discovery/preview"],
|
||||
["GET", "http://localhost:3100/api/secret-provider-configs/config-1"],
|
||||
["PATCH", "http://localhost:3100/api/secret-provider-configs/config-1"],
|
||||
["POST", "http://localhost:3100/api/secret-provider-configs/config-1/default"],
|
||||
["POST", "http://localhost:3100/api/secret-provider-configs/config-1/health"],
|
||||
["DELETE", "http://localhost:3100/api/secret-provider-configs/config-1"],
|
||||
["POST", "http://localhost:3100/api/companies/company-1/secrets/remote-import/preview"],
|
||||
["POST", "http://localhost:3100/api/companies/company-1/secrets/remote-import"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("wraps secret metadata, rotation, usage, access event, and delete endpoints", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await runSecretCommand(["secrets", "update", "secret-1", "--payload-json", "{\"description\":\"updated\"}"]);
|
||||
await runSecretCommand(["secrets", "rotate", "secret-1", "--value", "new-value"]);
|
||||
await runSecretCommand(["secrets", "usage", "secret-1"]);
|
||||
await runSecretCommand(["secrets", "access-events", "secret-1"]);
|
||||
await runSecretCommand(["secrets", "delete", "secret-1", "--yes", "--confirm", "secret-1"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
|
||||
["PATCH", "http://localhost:3100/api/secrets/secret-1"],
|
||||
["POST", "http://localhost:3100/api/secrets/secret-1/rotate"],
|
||||
["GET", "http://localhost:3100/api/secrets/secret-1/usage"],
|
||||
["GET", "http://localhost:3100/api/secrets/secret-1/access-events"],
|
||||
["DELETE", "http://localhost:3100/api/secrets/secret-1"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
async function runSecretCommand(args: string[]): Promise<void> {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
|
||||
registerSecretCommands(program);
|
||||
await program.parseAsync([...args, "--api-base", "http://localhost:3100", "--api-key", "board-token"], { from: "user" });
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
|
||||
return new Response(JSON.stringify(body), init);
|
||||
}
|
||||
|
|
|
|||
132
cli/src/__tests__/token.test.ts
Normal file
132
cli/src/__tests__/token.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerTokenCommands } from "../commands/client/token.js";
|
||||
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({
|
||||
writeOut: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
registerTokenCommands(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
function agentResponse() {
|
||||
return {
|
||||
id: AGENT_ID,
|
||||
companyId: COMPANY_ID,
|
||||
name: "Worker",
|
||||
urlKey: "worker",
|
||||
role: "Engineer",
|
||||
status: "active",
|
||||
};
|
||||
}
|
||||
|
||||
describe("token commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.PAPERCLIP_API_KEY;
|
||||
delete process.env.PAPERCLIP_API_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates an agent token through the generic token command", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(agentResponse()), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: "key-1",
|
||||
name: "external-worker",
|
||||
token: "pcp_plaintext",
|
||||
createdAt: "2026-05-23T00:00:00.000Z",
|
||||
}), { status: 201 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"token",
|
||||
"agent",
|
||||
"create",
|
||||
"--api-base",
|
||||
"http://localhost:3100",
|
||||
"--api-key",
|
||||
"board-token",
|
||||
"--company-id",
|
||||
COMPANY_ID,
|
||||
"--agent",
|
||||
"worker",
|
||||
"--name",
|
||||
"external-worker",
|
||||
"--json",
|
||||
], { from: "user" });
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/agents/worker?companyId=${COMPANY_ID}`);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}/keys`);
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({ name: "external-worker" });
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
|
||||
agentId: AGENT_ID,
|
||||
companyId: COMPANY_ID,
|
||||
key: { id: "key-1", token: "pcp_plaintext" },
|
||||
});
|
||||
});
|
||||
|
||||
it("lists and revokes agent tokens", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(agentResponse()), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify([{ id: "key-1", name: "external-worker", createdAt: "2026-05-23T00:00:00.000Z", revokedAt: null }]), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(agentResponse()), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, keyId: "key-1" }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"token", "agent", "list",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--company-id", COMPANY_ID,
|
||||
"--agent", "worker",
|
||||
], { from: "user" });
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"token", "agent", "revoke", "key-1",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--company-id", COMPANY_ID,
|
||||
"--agent", "worker",
|
||||
], { from: "user" });
|
||||
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}/keys`);
|
||||
expect(fetchMock.mock.calls[3]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}/keys/key-1`);
|
||||
expect(fetchMock.mock.calls[3]?.[1]?.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("resolves agent token commands by agent id without the reference lookup query", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(agentResponse()), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify([{ id: "key-1", name: "external-worker", createdAt: "2026-05-23T00:00:00.000Z", revokedAt: null }]), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"token", "agent", "list",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--company-id", COMPANY_ID,
|
||||
"--agent", AGENT_ID,
|
||||
"--json",
|
||||
], { from: "user" });
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}`);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}/keys`);
|
||||
});
|
||||
});
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
resolveWorktreeReseedSource,
|
||||
resolveWorktreeReseedTargetPaths,
|
||||
resolveGitWorktreeAddArgs,
|
||||
resolvePnpmInstallInvocation,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeRepairCommand,
|
||||
worktreeInitCommand,
|
||||
|
|
@ -150,6 +151,36 @@ describe("worktree helpers", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("reuses the current pnpm executable for worktree dependency installation", () => {
|
||||
expect(
|
||||
resolvePnpmInstallInvocation(
|
||||
{ npm_execpath: "/Users/test/.pnpm/pnpm/9.15.4/bin/pnpm.cjs" },
|
||||
"/usr/local/bin/node",
|
||||
),
|
||||
).toEqual({
|
||||
command: "/usr/local/bin/node",
|
||||
argsPrefix: ["/Users/test/.pnpm/pnpm/9.15.4/bin/pnpm.cjs"],
|
||||
});
|
||||
expect(
|
||||
resolvePnpmInstallInvocation(
|
||||
{ npm_execpath: "/Users/test/.pnpm/pnpm/9.15.4/bin/pnpm" },
|
||||
"/usr/local/bin/node",
|
||||
),
|
||||
).toEqual({
|
||||
command: "/Users/test/.pnpm/pnpm/9.15.4/bin/pnpm",
|
||||
argsPrefix: [],
|
||||
});
|
||||
expect(
|
||||
resolvePnpmInstallInvocation(
|
||||
{ npm_execpath: "/Users/test/.npm/npm-cli.js" },
|
||||
"/usr/local/bin/node",
|
||||
),
|
||||
).toEqual({
|
||||
command: "pnpm",
|
||||
argsPrefix: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("builds git worktree add args for new and existing branches", () => {
|
||||
expect(
|
||||
resolveGitWorktreeAddArgs({
|
||||
|
|
|
|||
|
|
@ -8,11 +8,17 @@ const DEFAULT_PROFILE = "default";
|
|||
export interface ClientContextProfile {
|
||||
apiBase?: string;
|
||||
companyId?: string;
|
||||
persona?: "board" | "agent";
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
apiKeyEnvVarName?: string;
|
||||
tokenName?: string;
|
||||
tokenId?: string;
|
||||
tokenCreatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ClientContext {
|
||||
version: 1;
|
||||
version: 2;
|
||||
currentProfile: string;
|
||||
profiles: Record<string, ClientContextProfile>;
|
||||
}
|
||||
|
|
@ -43,7 +49,7 @@ export function resolveContextPath(overridePath?: string): string {
|
|||
|
||||
export function defaultClientContext(): ClientContext {
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
currentProfile: DEFAULT_PROFILE,
|
||||
profiles: {
|
||||
[DEFAULT_PROFILE]: {},
|
||||
|
|
@ -66,11 +72,20 @@ function toStringOrUndefined(value: unknown): string | undefined {
|
|||
function normalizeProfile(value: unknown): ClientContextProfile {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
||||
const profile = value as Record<string, unknown>;
|
||||
const persona = profile.persona === "board" || profile.persona === "agent"
|
||||
? profile.persona
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
apiBase: toStringOrUndefined(profile.apiBase),
|
||||
companyId: toStringOrUndefined(profile.companyId),
|
||||
persona,
|
||||
agentId: toStringOrUndefined(profile.agentId),
|
||||
agentName: toStringOrUndefined(profile.agentName),
|
||||
apiKeyEnvVarName: toStringOrUndefined(profile.apiKeyEnvVarName),
|
||||
tokenName: toStringOrUndefined(profile.tokenName),
|
||||
tokenId: toStringOrUndefined(profile.tokenId),
|
||||
tokenCreatedAt: toStringOrUndefined(profile.tokenCreatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -80,7 +95,7 @@ function normalizeContext(raw: unknown): ClientContext {
|
|||
}
|
||||
|
||||
const record = raw as Record<string, unknown>;
|
||||
const version = record.version === 1 ? 1 : 1;
|
||||
const version = 2;
|
||||
const currentProfile = toStringOrUndefined(record.currentProfile) ?? DEFAULT_PROFILE;
|
||||
|
||||
const rawProfiles = record.profiles;
|
||||
|
|
@ -134,10 +149,17 @@ export function upsertProfile(
|
|||
): ClientContext {
|
||||
const context = readContext(contextPath);
|
||||
const existing = context.profiles[profileName] ?? {};
|
||||
const merged: ClientContextProfile = {
|
||||
...existing,
|
||||
...patch,
|
||||
};
|
||||
const merged: ClientContextProfile = { ...existing };
|
||||
|
||||
if (patch.apiBase !== undefined) merged.apiBase = patch.apiBase;
|
||||
if (patch.companyId !== undefined) merged.companyId = patch.companyId;
|
||||
if (patch.persona !== undefined) merged.persona = patch.persona;
|
||||
if (patch.agentId !== undefined) merged.agentId = patch.agentId;
|
||||
if (patch.agentName !== undefined) merged.agentName = patch.agentName;
|
||||
if (patch.apiKeyEnvVarName !== undefined) merged.apiKeyEnvVarName = patch.apiKeyEnvVarName;
|
||||
if (patch.tokenName !== undefined) merged.tokenName = patch.tokenName;
|
||||
if (patch.tokenId !== undefined) merged.tokenId = patch.tokenId;
|
||||
if (patch.tokenCreatedAt !== undefined) merged.tokenCreatedAt = patch.tokenCreatedAt;
|
||||
|
||||
if (patch.apiBase !== undefined && patch.apiBase.trim().length === 0) {
|
||||
delete merged.apiBase;
|
||||
|
|
@ -145,9 +167,27 @@ export function upsertProfile(
|
|||
if (patch.companyId !== undefined && patch.companyId.trim().length === 0) {
|
||||
delete merged.companyId;
|
||||
}
|
||||
if (patch.persona === undefined && "persona" in patch) {
|
||||
delete merged.persona;
|
||||
}
|
||||
if (patch.agentId !== undefined && patch.agentId.trim().length === 0) {
|
||||
delete merged.agentId;
|
||||
}
|
||||
if (patch.agentName !== undefined && patch.agentName.trim().length === 0) {
|
||||
delete merged.agentName;
|
||||
}
|
||||
if (patch.apiKeyEnvVarName !== undefined && patch.apiKeyEnvVarName.trim().length === 0) {
|
||||
delete merged.apiKeyEnvVarName;
|
||||
}
|
||||
if (patch.tokenName !== undefined && patch.tokenName.trim().length === 0) {
|
||||
delete merged.tokenName;
|
||||
}
|
||||
if (patch.tokenId !== undefined && patch.tokenId.trim().length === 0) {
|
||||
delete merged.tokenId;
|
||||
}
|
||||
if (patch.tokenCreatedAt !== undefined && patch.tokenCreatedAt.trim().length === 0) {
|
||||
delete merged.tokenCreatedAt;
|
||||
}
|
||||
|
||||
context.profiles[profileName] = merged;
|
||||
context.currentProfile = context.currentProfile || profileName;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,13 @@ export class PaperclipApiClient {
|
|||
}, opts);
|
||||
}
|
||||
|
||||
put<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
|
||||
return this.request<T>(path, {
|
||||
method: "PUT",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}, opts);
|
||||
}
|
||||
|
||||
delete<T>(path: string, opts?: RequestOptions): Promise<T | null> {
|
||||
return this.request<T>(path, { method: "DELETE" }, opts);
|
||||
}
|
||||
|
|
|
|||
519
cli/src/commands/client/access.ts
Normal file
519
cli/src/commands/client/access.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
223
cli/src/commands/client/adapter.ts
Normal file
223
cli/src/commands/client/adapter.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
147
cli/src/commands/client/asset.ts
Normal file
147
cli/src/commands/client/asset.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 || "");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
265
cli/src/commands/client/connect.ts
Normal file
265
cli/src/commands/client/connect.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
167
cli/src/commands/client/cost.ts
Normal file
167
cli/src/commands/client/cost.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
177
cli/src/commands/client/goal.ts
Normal file
177
cli/src/commands/client/goal.ts
Normal 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
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
228
cli/src/commands/client/project.ts
Normal file
228
cli/src/commands/client/project.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
276
cli/src/commands/client/prompt.ts
Normal file
276
cli/src/commands/client/prompt.ts
Normal 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;
|
||||
}
|
||||
154
cli/src/commands/client/routine-api.ts
Normal file
154
cli/src/commands/client/routine-api.ts
Normal 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;
|
||||
}
|
||||
321
cli/src/commands/client/run.ts
Normal file
321
cli/src/commands/client/run.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
150
cli/src/commands/client/skill.ts
Normal file
150
cli/src/commands/client/skill.ts
Normal 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;
|
||||
}
|
||||
244
cli/src/commands/client/token.ts
Normal file
244
cli/src/commands/client/token.ts
Normal 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;
|
||||
}
|
||||
327
cli/src/commands/client/workspace.ts
Normal file
327
cli/src/commands/client/workspace.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { registerContextCommands } from "./commands/client/context.js";
|
|||
import { registerCompanyCommands } from "./commands/client/company.js";
|
||||
import { registerIssueCommands } from "./commands/client/issue.js";
|
||||
import { registerAgentCommands } from "./commands/client/agent.js";
|
||||
import { registerProjectCommands } from "./commands/client/project.js";
|
||||
import { registerGoalCommands } from "./commands/client/goal.js";
|
||||
import { registerApprovalCommands } from "./commands/client/approval.js";
|
||||
import { registerActivityCommands } from "./commands/client/activity.js";
|
||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
|
|
@ -27,6 +29,17 @@ import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
|||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||
import { registerClientAuthCommands } from "./commands/client/auth.js";
|
||||
import { registerConnectCommand } from "./commands/client/connect.js";
|
||||
import { registerTokenCommands } from "./commands/client/token.js";
|
||||
import { registerPromptCommands } from "./commands/client/prompt.js";
|
||||
import { registerRunCommands } from "./commands/client/run.js";
|
||||
import { registerCostCommands } from "./commands/client/cost.js";
|
||||
import { registerWorkspaceCommands } from "./commands/client/workspace.js";
|
||||
import { registerAccessCommands } from "./commands/client/access.js";
|
||||
import { registerRoutineApiCommands } from "./commands/client/routine-api.js";
|
||||
import { registerAdapterCommands } from "./commands/client/adapter.js";
|
||||
import { registerAssetCommands } from "./commands/client/asset.js";
|
||||
import { registerSkillCommands } from "./commands/client/skill.js";
|
||||
import { cliVersion } from "./version.js";
|
||||
|
||||
const program = new Command();
|
||||
|
|
@ -107,7 +120,7 @@ program
|
|||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.action(addAllowedHostname);
|
||||
|
||||
program
|
||||
const run = program
|
||||
.command("run")
|
||||
.description("Bootstrap local setup (onboard + doctor) and run Paperclip")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
|
|
@ -118,6 +131,8 @@ program
|
|||
.option("--no-repair", "Disable automatic repairs during doctor")
|
||||
.action(runCommand);
|
||||
|
||||
registerRunCommands(run);
|
||||
|
||||
const heartbeat = program.command("heartbeat").description("Heartbeat utilities");
|
||||
|
||||
heartbeat
|
||||
|
|
@ -142,12 +157,24 @@ heartbeat
|
|||
.action(heartbeatRun);
|
||||
|
||||
registerContextCommands(program);
|
||||
registerConnectCommand(program);
|
||||
registerCompanyCommands(program);
|
||||
registerIssueCommands(program);
|
||||
registerAgentCommands(program);
|
||||
registerProjectCommands(program);
|
||||
registerGoalCommands(program);
|
||||
registerTokenCommands(program);
|
||||
registerPromptCommands(program);
|
||||
registerApprovalCommands(program);
|
||||
registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerCostCommands(program);
|
||||
registerWorkspaceCommands(program);
|
||||
registerAccessCommands(program);
|
||||
registerRoutineApiCommands(program);
|
||||
registerAdapterCommands(program);
|
||||
registerAssetCommands(program);
|
||||
registerSkillCommands(program);
|
||||
registerRoutineCommands(program);
|
||||
registerFeedbackCommands(program);
|
||||
registerSecretCommands(program);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue