Files
PaperClipAI/cli/src/commands/client/access.ts
T
Aron PrinsandDevin Foley 70b1a9109d 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>
2026-06-02 17:13:29 -07:00

520 lines
21 KiB
TypeScript

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