Improve CLI API parity coverage (#6626)

## Thinking Path

> - Paperclip is a control plane for AI-agent companies, with the CLI
acting as a scriptable operator and agent interface to that control
plane.
> - The REST API surface has grown across companies, agents, issues,
routines, plugins, auth, workspaces, secrets, and operational inspection
commands.
> - The CLI had drifted from that API surface: some commands were
missing, some command shapes differed from docs/reference material, and
several edge cases only failed during end-to-end local-source testing.
> - The local development runbook requires these tests to be disposable
and isolated from a real `~/.paperclip`, `~/.codex`, or `~/.claude`
installation.
> - This pull request adds broad CLI/API parity coverage, fixes the
actionable bugs found during that pass, and records the reproducible
test log under `doc/logs`.
> - The benefit is a more complete, scriptable CLI surface with
regression coverage for the command families exercised by the parity
run.

## What Changed

- Added or expanded CLI command coverage for access/auth, companies,
agents, projects, goals, issues and subresources, routines, plugins,
workspaces, activity/run/cost/dashboard inspection, assets, skills,
secrets, tokens, prompt/wake flows, and local setup helpers.
- Fixed CLI/API parity bugs found during the run, including context
profile patching, issue interaction optional payloads, malformed
tree-hold errors, environment duplicate handling, configure
invalid-section exit codes, worktree pnpm invocation, token agent ID
resolution, plugin tool worker lookup, and routine webhook secret
cleanup.
- Added missing CLI wrappers and route coverage for health/access,
invite resolution URL forwarding, join status normalization, secret
lifecycle commands, LLM docs routes, available-skill isolation, positive
board-claim coverage, and interactive `connect` prompt-flow tests.
- Added a schema-backed `/api/openapi.json` route sufficient for CLI
parity and `paperclipai openapi --json` smoke coverage.
- Added `doc/logs/2026-05-24-cli-api-parity-e2e-log.md` with the
detailed living test/bug log and renamed the log directory from
`doc/bugs` to `doc/logs`.
- Added `doc/plans/2026-05-23-cli-api-parity.md` and the OpenAPI parity
reference used during the pass.

OpenAPI note: this PR intentionally does not try to subsume
`feature/openapi-spec`. The OpenAPI implementation here is schema-backed
and better than the earlier route-inventory stub, but
`feature/openapi-spec` is the fuller/better OpenAPI branch because it
includes exact mounted-route coverage tests and additional current route
coverage. That branch should stay as its own PR and can supersede this
OpenAPI route implementation.

## Verification

Targeted automated checks run:

- `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts`
- `pnpm exec vitest run server/src/__tests__/board-claim.test.ts`
- `pnpm exec vitest run cli/src/__tests__/connect.test.ts`
- `pnpm exec vitest run cli/src/__tests__/agent-lifecycle.test.ts`
- `pnpm exec vitest run server/src/__tests__/plugin-database.test.ts`
- `pnpm exec vitest run server/src/__tests__/routines-service.test.ts`
- `pnpm --dir cli typecheck`
- `pnpm --dir server typecheck`

Manual/local E2E verification:

- Ran the full disposable local-source CLI/API parity pass with isolated
`PAPERCLIP_HOME`, `PAPERCLIP_CONFIG`, `PAPERCLIP_CONTEXT`,
`PAPERCLIP_AUTH_STORE`, `CODEX_HOME`, and `CLAUDE_HOME` under
`tmp/cli-api-parity`.
- Verified `DATABASE_URL` and `DATABASE_MIGRATION_URL` stayed unset for
the scratch server.
- Verified live health and schema-backed OpenAPI responses on
non-default port `3197`.
- Revoked created board/agent tokens and cleaned up temporary plugins,
secrets, non-default environments, and project workspaces.
- See `doc/logs/2026-05-24-cli-api-parity-e2e-log.md` for the full
command-by-command reproduction log.

Not run:

- Full `pnpm test`, `pnpm test:run`, or `pnpm build` were not run after
the entire branch because the branch is broad and the parity pass used
focused test/typecheck verification plus live isolated CLI reruns.

## Risks

- This is a broad PR and touches many CLI command modules, so review
surface is high. The changes are grouped around one theme, but a split
may be easier if maintainers prefer narrower PRs.
- The OpenAPI route in this PR is not the final/best OpenAPI
implementation. `feature/openapi-spec` has stronger exact-route coverage
and should remain the source for the dedicated OpenAPI PR.
- The living log is intentionally detailed and large. It is useful for
reproducibility but adds documentation weight.
- No UI changes are intended; screenshots are not applicable.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent in Codex desktop. Exact served
model/context-window identifier was not exposed in the local app. Work
used shell/Git/GitHub CLI tooling, local source inspection, targeted
test execution, and live isolated Paperclip CLI/API smoke testing.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Devin Foley <devin@devinfoley.com>
This commit is contained in:
Aron Prins
2026-06-03 02:13:29 +02:00
committed by GitHub
parent 68401f82f3
commit 70b1a9109d
74 changed files with 18175 additions and 111 deletions
+132 -13
View File
@@ -32,6 +32,7 @@ import {
acceptInviteSchema,
createCliAuthChallengeSchema,
claimJoinRequestApiKeySchema,
createBoardApiKeySchema,
createCompanyInviteSchema,
createOpenClawInvitePromptSchema,
listCompanyInvitesQuerySchema,
@@ -43,7 +44,8 @@ import {
archiveCompanyMemberSchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
PERMISSION_KEYS
PERMISSION_KEYS,
isUuidLike,
} from "@paperclipai/shared";
import type { DeploymentExposure, DeploymentMode, HumanCompanyMembershipRole, PermissionKey } from "@paperclipai/shared";
import {
@@ -139,20 +141,17 @@ function buildCliAuthApprovalPath(challengeId: string, token: string) {
function readSkillMarkdown(skillName: string): string | null {
const normalized = skillName.trim().toLowerCase();
if (
normalized !== "paperclip" &&
normalized !== "paperclip-create-agent" &&
normalized !== "paperclip-create-plugin" &&
normalized !== "paperclip-converting-plans-to-tasks" &&
normalized !== "para-memory-files"
)
if (!isSafeSkillName(normalized)) {
return null;
}
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const claudeSkillsDir = resolveClaudeSkillsDir();
const candidates = [
claudeSkillsDir ? path.resolve(claudeSkillsDir, normalized, "SKILL.md") : null,
path.resolve(moduleDir, "../../skills", normalized, "SKILL.md"), // published: dist/routes/ -> <pkg>/skills/
path.resolve(process.cwd(), "skills", normalized, "SKILL.md"), // cwd (e.g. monorepo root)
path.resolve(moduleDir, "../../../skills", normalized, "SKILL.md") // dev: src/routes/ -> repo root/skills/
];
].filter((candidate): candidate is string => Boolean(candidate));
for (const skillPath of candidates) {
try {
return fs.readFileSync(skillPath, "utf8");
@@ -163,6 +162,10 @@ function readSkillMarkdown(skillName: string): string | null {
return null;
}
function isSafeSkillName(skillName: string): boolean {
return /^[a-z0-9][a-z0-9._-]*$/.test(skillName);
}
/** Resolve the Paperclip repo skills directory (built-in / managed skills). */
function resolvePaperclipSkillsDir(): string | null {
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -206,10 +209,17 @@ interface AvailableSkill {
isPaperclipManaged: boolean;
}
/** Discover all available Claude Code skills from ~/.claude/skills/. */
/** Discover all available Claude Code skills from CLAUDE_HOME or ~/.claude. */
function resolveClaudeSkillsDir(): string {
const configuredClaudeHome = process.env.CLAUDE_HOME?.trim();
const claudeHome = configuredClaudeHome
? path.resolve(configuredClaudeHome)
: path.join(process.env.HOME || process.env.USERPROFILE || "", ".claude");
return path.join(claudeHome, "skills");
}
function listAvailableSkills(): AvailableSkill[] {
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
const claudeSkillsDir = path.join(homeDir, ".claude", "skills");
const claudeSkillsDir = resolveClaudeSkillsDir();
const paperclipSkillsDir = resolvePaperclipSkillsDir();
// Build set of Paperclip-managed skill names
@@ -241,7 +251,27 @@ function listAvailableSkills(): AvailableSkill[] {
isPaperclipManaged: paperclipSkillNames.has(entry.name),
});
}
} catch { /* ~/.claude/skills/ doesn't exist */ }
} catch { /* Claude skills directory doesn't exist */ }
if (paperclipSkillsDir) {
const existingNames = new Set(skills.map((skill) => skill.name));
try {
for (const entry of fs.readdirSync(paperclipSkillsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith(".") || existingNames.has(entry.name)) continue;
const skillMdPath = path.join(paperclipSkillsDir, entry.name, "SKILL.md");
let description = "";
try {
const md = fs.readFileSync(skillMdPath, "utf8");
description = parseSkillFrontmatter(md).description;
} catch { /* no SKILL.md or unreadable */ }
skills.push({
name: entry.name,
description,
isPaperclipManaged: true,
});
}
} catch { /* skip Paperclip skills directory */ }
}
skills.sort((a, b) => a.name.localeCompare(b.name));
return skills;
@@ -2610,6 +2640,95 @@ export function accessRoutes(
});
});
router.get("/board-api-keys", async (req, res) => {
if (req.actor.type !== "board" || !req.actor.userId) {
throw unauthorized("Board authentication required");
}
const keys = await boardAuth.listBoardApiKeys(req.actor.userId, {
includeInactive: req.query.includeInactive === "true",
});
res.json(keys);
});
router.post(
"/board-api-keys",
validate(createBoardApiKeySchema),
async (req, res) => {
if (req.actor.type !== "board" || !req.actor.userId) {
throw unauthorized("Board authentication required");
}
if (req.body.requestedCompanyId) {
assertCompanyAccess(req, req.body.requestedCompanyId);
}
const key = await boardAuth.createNamedBoardApiKey({
userId: req.actor.userId,
name: req.body.name,
expiresAt: req.body.expiresAt === undefined ? undefined : req.body.expiresAt,
});
const companyIds = await boardAuth.resolveBoardActivityCompanyIds({
userId: req.actor.userId,
requestedCompanyId: req.body.requestedCompanyId ?? null,
boardApiKeyId: key.id,
});
for (const companyId of companyIds) {
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId,
action: "board_api_key.created",
entityType: "user",
entityId: req.actor.userId,
details: {
boardApiKeyId: key.id,
name: key.name,
requestedCompanyId: req.body.requestedCompanyId ?? null,
expiresAt: key.expiresAt?.toISOString() ?? null,
},
});
}
res.status(201).json(key);
},
);
router.delete("/board-api-keys/:keyId", async (req, res) => {
if (req.actor.type !== "board" || !req.actor.userId) {
throw unauthorized("Board authentication required");
}
const keyId = (req.params.keyId as string).trim();
if (!isUuidLike(keyId)) {
throw badRequest("Invalid board API key ID");
}
const key = await boardAuth.getBoardApiKeyForUser(keyId, req.actor.userId);
if (!key) throw notFound("Board API key not found");
const revoked = await boardAuth.revokeBoardApiKey(key.id);
if (!revoked) throw notFound("Board API key not found");
const companyIds = await boardAuth.resolveBoardActivityCompanyIds({
userId: req.actor.userId,
boardApiKeyId: key.id,
});
for (const companyId of companyIds) {
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId,
action: "board_api_key.revoked",
entityType: "user",
entityId: req.actor.userId,
details: {
boardApiKeyId: key.id,
name: key.name,
revokedVia: "board_api_key_lifecycle",
},
});
}
res.json({ ok: true, keyId: key.id });
});
router.post("/cli-auth/revoke-current", async (req, res) => {
if (req.actor.type !== "board" || req.actor.source !== "board_key") {
throw badRequest("Current board API key context is required");
+15
View File
@@ -350,6 +350,21 @@ export function adapterRoutes() {
}
});
router.get("/adapters/:type", async (req, res) => {
assertBoardOrgAccess(req);
const adapterType = req.params.type;
const adapter = findServerAdapter(adapterType);
if (!adapter) {
res.status(404).json({ error: `Adapter "${adapterType}" is not registered.` });
return;
}
const externalRecord = getAdapterPluginByType(adapterType);
const disabledSet = new Set(getDisabledAdapterTypes());
res.json(buildAdapterInfo(adapter, externalRecord, disabledSet));
});
/**
* PATCH /api/adapters/:type
*
+7 -1
View File
@@ -7,7 +7,7 @@ import {
probeEnvironmentConfigSchema,
updateEnvironmentSchema,
} from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { conflict, forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
import {
accessService,
@@ -196,6 +196,12 @@ export function environmentRoutes(
router.post("/companies/:companyId/environments", validate(createEnvironmentSchema), async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanMutateEnvironments(req, companyId);
if (req.body.driver === "local") {
const existingLocal = await svc.list(companyId, { driver: "local" });
if (existingLocal.length > 0) {
throw conflict("A local environment already exists for this company.");
}
}
const actor = getActorInfo(req);
const input = {
...req.body,
+15 -2
View File
@@ -3,6 +3,7 @@ import type { Request } from "express";
import type { Db } from "@paperclipai/db";
import {
createIssueTreeHoldSchema,
isUuidLike,
previewIssueTreeControlSchema,
releaseIssueTreeHoldSchema,
} from "@paperclipai/shared";
@@ -340,7 +341,13 @@ export function issueTreeControlRoutes(db: Db) {
}
assertCompanyAccess(req, root.companyId);
const hold = await treeControlSvc.getHold(root.companyId, req.params.holdId as string);
const holdId = req.params.holdId as string;
if (!isUuidLike(holdId)) {
res.status(400).json({ error: "Invalid hold ID" });
return;
}
const hold = await treeControlSvc.getHold(root.companyId, holdId);
if (!hold || hold.rootIssueId !== root.id) {
res.status(404).json({ error: "Issue tree hold not found" });
return;
@@ -360,8 +367,14 @@ export function issueTreeControlRoutes(db: Db) {
}
assertCompanyAccess(req, root.companyId);
const holdId = req.params.holdId as string;
if (!isUuidLike(holdId)) {
res.status(400).json({ error: "Invalid hold ID" });
return;
}
const actor = getActorInfo(req);
const hold = await treeControlSvc.releaseHold(root.companyId, root.id, req.params.holdId as string, {
const hold = await treeControlSvc.releaseHold(root.companyId, root.id, holdId, {
...req.body,
actor: {
actorType: actor.actorType,
File diff suppressed because it is too large Load Diff