fix: fail closed on unusable Codex auth during sandbox auth merge

When reusing a Codex sandbox, the workspace-restore step now performs
an auth-merge check before adopting the sandbox's auth.json. The merge
fails closed when:

- host or sandbox Codex auth is unusable (missing token or account)
- the subscription payload shapes differ between host and sandbox
- subscription account identities do not match

Additional hardening: node absence on PATH during the embedded decision
script now exits non-zero instead of silently falling through. Co-change
comments link parseAuth (the embedded script) to hasUsableAuthPayload
(codex-home.ts) so a future auth-format change cannot silently diverge
between the two implementations.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Harold KimandPaperclip committed 2026-07-09 10:08:57 +00:00
commit 98df214917
3422 files changed
+1614966

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
# paperclipai
## 0.3.1
### Patch Changes
- Stable release preparation for 0.3.1
- Updated dependencies
- @paperclipai/adapter-utils@0.3.1
- @paperclipai/adapter-claude-local@0.3.1
- @paperclipai/adapter-codex-local@0.3.1
- @paperclipai/adapter-cursor-local@0.3.1
- @paperclipai/adapter-gemini-local@0.3.1
- @paperclipai/adapter-openclaw-gateway@0.3.1
- @paperclipai/adapter-opencode-local@0.3.1
- @paperclipai/adapter-pi-local@0.3.1
- @paperclipai/db@0.3.1
- @paperclipai/shared@0.3.1
- @paperclipai/server@0.3.1
## 0.3.0
### Minor Changes
- Stable release preparation for 0.3.0
### Patch Changes
- Updated dependencies [6077ae6]
- Updated dependencies
- @paperclipai/shared@0.3.0
- @paperclipai/adapter-utils@0.3.0
- @paperclipai/adapter-claude-local@0.3.0
- @paperclipai/adapter-codex-local@0.3.0
- @paperclipai/adapter-cursor-local@0.3.0
- @paperclipai/adapter-openclaw-gateway@0.3.0
- @paperclipai/adapter-opencode-local@0.3.0
- @paperclipai/adapter-pi-local@0.3.0
- @paperclipai/db@0.3.0
- @paperclipai/server@0.3.0
## 0.2.7
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.7
- @paperclipai/adapter-utils@0.2.7
- @paperclipai/db@0.2.7
- @paperclipai/adapter-claude-local@0.2.7
- @paperclipai/adapter-codex-local@0.2.7
- @paperclipai/adapter-openclaw@0.2.7
- @paperclipai/server@0.2.7
## 0.2.6
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.6
- @paperclipai/adapter-utils@0.2.6
- @paperclipai/db@0.2.6
- @paperclipai/adapter-claude-local@0.2.6
- @paperclipai/adapter-codex-local@0.2.6
- @paperclipai/adapter-openclaw@0.2.6
- @paperclipai/server@0.2.6
## 0.2.5
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.5
- @paperclipai/adapter-utils@0.2.5
- @paperclipai/db@0.2.5
- @paperclipai/adapter-claude-local@0.2.5
- @paperclipai/adapter-codex-local@0.2.5
- @paperclipai/adapter-openclaw@0.2.5
- @paperclipai/server@0.2.5
## 0.2.4
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.4
- @paperclipai/adapter-utils@0.2.4
- @paperclipai/db@0.2.4
- @paperclipai/adapter-claude-local@0.2.4
- @paperclipai/adapter-codex-local@0.2.4
- @paperclipai/adapter-openclaw@0.2.4
- @paperclipai/server@0.2.4
## 0.2.3
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.3
- @paperclipai/adapter-utils@0.2.3
- @paperclipai/db@0.2.3
- @paperclipai/adapter-claude-local@0.2.3
- @paperclipai/adapter-codex-local@0.2.3
- @paperclipai/adapter-openclaw@0.2.3
- @paperclipai/server@0.2.3
## 0.2.2
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.2
- @paperclipai/adapter-utils@0.2.2
- @paperclipai/db@0.2.2
- @paperclipai/adapter-claude-local@0.2.2
- @paperclipai/adapter-codex-local@0.2.2
- @paperclipai/adapter-openclaw@0.2.2
- @paperclipai/server@0.2.2
## 0.2.1
### Patch Changes
- Version bump (patch)
- Updated dependencies
- @paperclipai/shared@0.2.1
- @paperclipai/adapter-utils@0.2.1
- @paperclipai/db@0.2.1
- @paperclipai/adapter-claude-local@0.2.1
- @paperclipai/adapter-codex-local@0.2.1
- @paperclipai/adapter-openclaw@0.2.1
- @paperclipai/server@0.2.1
+445
View File
@@ -0,0 +1,445 @@
<p align="center">
<img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/banner.jpg" alt="Paperclip is the app people use to manage AI agents for work." width="720" />
</p>
<p align="center">
<a href="#quickstart"><strong>Quickstart</strong></a> &middot;
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> &middot;
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> &middot;
<a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a> &middot;
<a href="https://x.com/papercliping"><strong>Twitter</strong></a> &middot;
<a href="https://paperclip.ing"><strong>Website</strong></a>
</p>
<p align="center">
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/discord/000000000?label=discord" alt="Discord" /></a>
</p>
<br/>
<div align="center">
<video src="https://github.com/user-attachments/assets/773bdfb2-6d1e-4e30-8c5f-3487d5b70c8f" width="600" controls></video>
</div>
<br/>
# Paperclip is the app people use to manage AI agents for work.
Open-source orchestration for teams of AI agents.
**If OpenClaw is an _employee_, Paperclip is the _company_.**
Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track work and costs from one dashboard.
It looks like a task manager. Under the hood: org charts, budgets, governance, goal alignment, and agent coordination.
**Manage business goals, not pull requests.**
| | Step | Example |
| ------ | --------------- | ------------------------------------------------------------------ |
| **01** | Define the goal | _"Build the #1 AI note-taking app to $1M MRR."_ |
| **02** | Hire the team | CEO, CTO, engineers, designers, marketers — any bot, any provider. |
| **03** | Approve and run | Review strategy. Set budgets. Hit go. Monitor from the dashboard. |
<br/>
<div align="center">
<table>
<tr>
<td align="center"><strong>Works<br/>with</strong></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/openclaw.svg" width="32" alt="OpenClaw" /><br/><sub>OpenClaw</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/claude.svg" width="32" alt="Claude" /><br/><sub>Claude Code</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/codex.svg" width="32" alt="Codex" /><br/><sub>Codex</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/cursor.svg" width="32" alt="Cursor" /><br/><sub>Cursor</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/bash.svg" width="32" alt="Bash" /><br/><sub>Bash</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/http.svg" width="32" alt="HTTP" /><br/><sub>HTTP</sub></td>
</tr>
</table>
<em>If it can receive a heartbeat, it's hired.</em>
</div>
<br/>
## Paperclip is right for you if
- ✅ You want to build **autonomous AI companies**
- ✅ You **coordinate many different agents** (OpenClaw, Codex, Claude, Cursor) toward a common goal
- ✅ You have **20 simultaneous Claude Code terminals** open and lose track of what everyone is doing
- ✅ You want agents running **autonomously 24/7**, but still want to audit work and chime in when needed
- ✅ You want to **monitor costs** and enforce budgets
- ✅ You want a process for managing agents that **feels like using a task manager**
- ✅ You want to manage your autonomous businesses **from your phone**
<br/>
## Features
<table>
<tr>
<td align="center" width="33%">
<h3>🔌 Bring Your Own Agent</h3>
Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired.
</td>
<td align="center" width="33%">
<h3>🎯 Goal Alignment</h3>
Every task traces back to the company mission. Agents know <em>what</em> to do and <em>why</em>.
</td>
<td align="center" width="33%">
<h3>💓 Heartbeats</h3>
Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart.
</td>
</tr>
<tr>
<td align="center">
<h3>💰 Cost Control</h3>
Monthly budgets per agent. When they hit the limit, they stop. No runaway costs.
</td>
<td align="center">
<h3>🏢 Multi-Company</h3>
One deployment, many companies. Complete data isolation. One control plane for your portfolio.
</td>
<td align="center">
<h3>🎫 Ticket System</h3>
Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log.
</td>
</tr>
<tr>
<td align="center">
<h3>🛡️ Governance</h3>
Approve hires, override strategy, pause or terminate any agent — at any time.
</td>
<td align="center">
<h3>📊 Org Chart</h3>
Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description.
</td>
<td align="center">
<h3>📱 Mobile Ready</h3>
Monitor and manage your autonomous businesses from anywhere.
</td>
</tr>
</table>
<br/>
## Problems Paperclip solves
| Without Paperclip | With Paperclip |
| ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| ❌ You have 20 Claude Code tabs open and can't track which one does what. On reboot you lose everything. | ✅ Tasks are ticket-based, conversations are threaded, sessions persist across reboots. |
| ❌ You manually gather context from several places to remind your bot what you're actually doing. | ✅ Context flows from the task up through the project and company goals — your agent always knows what to do and why. |
| ❌ Folders of agent configs are disorganized and you're re-inventing task management, communication, and coordination between agents. | ✅ Paperclip gives you org charts, ticketing, delegation, and governance out of the box — so you run a company, not a pile of scripts. |
| ❌ Runaway loops waste hundreds of dollars of tokens and max your quota before you even know what happened. | ✅ Cost tracking surfaces token budgets and throttles agents when they're out. Management prioritizes with budgets. |
| ❌ You have recurring jobs (customer support, social, reports) and have to remember to manually kick them off. | ✅ Heartbeats handle regular work on a schedule. Management supervises. |
| ❌ You have an idea, you have to find your repo, fire up Claude Code, keep a tab open, and babysit it. | ✅ Add a task in Paperclip. Your coding agent works on it until it's done. Management reviews their work. |
<br/>
## Why Paperclip is special
Paperclip handles the hard orchestration details correctly.
| | |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Atomic execution.** | Task checkout and budget enforcement are atomic, so no double-work and no runaway spend. |
| **Persistent agent state.** | Agents resume the same task context across heartbeats instead of restarting from scratch. |
| **Runtime skill injection.** | Agents can learn Paperclip workflows and project context at runtime, without retraining. |
| **Governance with rollback.** | Approval gates are enforced, config changes are revisioned, and bad changes can be rolled back safely. |
| **Goal-aware execution.** | Tasks carry full goal ancestry so agents consistently see the "why," not just a title. |
| **Portable company templates.** | Export/import orgs, agents, and skills with secret scrubbing and collision handling. |
| **True multi-company isolation.** | Every entity is company-scoped, so one deployment can run many companies with separate data and audit trails. |
<br/>
## What's Under the Hood
Paperclip is a full control plane, not a wrapper. Before you build any of this yourself, know that it already exists:
```
┌──────────────────────────────────────────────────────────────┐
│ PAPERCLIP SERVER │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │Identity & │ │ Work & │ │ Heartbeat │ │Governance │ │
│ │ Access │ │ Tasks │ │ Execution │ │& Approvals│ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Org Chart │ │Workspaces │ │ Plugins │ │ Budget │ │
│ │ & Agents │ │ & Runtime │ │ │ │ & Costs │ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Routines │ │ Secrets & │ │ Activity │ │ Company │ │
│ │& Schedules│ │ Storage │ │ & Events │ │Portability│ │
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
└──────────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ Claude │ │ Codex │ │ CLI │ │ HTTP/web │
│ Code │ │ │ │ agents │ │ bots │
└───────────┘ └───────────┘ └───────────┘ └───────────┘
```
### The Systems
<table>
<tr>
<td width="50%">
**Identity & Access** — Two deployment modes (trusted local or authenticated), board users, agent API keys, short-lived run JWTs, company memberships, invite flows, and OpenClaw onboarding. Every mutating request is traced to an actor.
</td>
<td width="50%">
**Org Chart & Agents** — Agents have roles, titles, reporting lines, permissions, and budgets. Adapter examples match the diagram: Claude Code, Codex, CLI agents such as Cursor/Gemini/bash, HTTP/webhook bots such as OpenClaw, and external adapter plugins. If it can receive a heartbeat, it's hired.
</td>
</tr>
<tr>
<td>
**Work & Task System** — Issues carry company/project/goal/parent links, atomic checkout with execution locks, first-class blocker dependencies, comments, documents, attachments, work products, labels, and inbox state. No double-work, no lost context.
</td>
<td>
**Heartbeat Execution** — DB-backed wakeup queue with coalescing, budget checks, workspace resolution, secret injection, skill loading, and adapter invocation. Runs produce structured logs, cost events, session state, and audit trails. Recovery handles orphaned runs automatically.
</td>
</tr>
<tr>
<td>
**Workspaces & Runtime** — Project workspaces, isolated execution workspaces (git worktrees, operator branches), and runtime services (dev servers, preview URLs). Agents work in the right directory with the right context every time.
</td>
<td>
**Governance & Approvals** — Board approval workflows, execution policies with review/approval stages, decision tracking, budget hard-stops, agent pause/resume/terminate, and full audit logging. Nothing ships without your sign-off.
</td>
</tr>
<tr>
<td>
**Budget & Cost Control** — Token and cost tracking by company, agent, project, goal, issue, provider, and model. Scoped budget policies with warning thresholds and hard stops. Overspend pauses agents and cancels queued work automatically.
</td>
<td>
**Routines & Schedules** — Recurring tasks with cron, webhook, and API triggers. Concurrency and catch-up policies. Each routine execution creates a tracked issue and wakes the assigned agent — no manual kick-offs needed.
</td>
</tr>
<tr>
<td>
**Plugins** — Instance-wide plugin system with out-of-process workers, capability-gated host services, job scheduling, tool exposure, and UI contributions. Extend Paperclip without forking it.
</td>
<td>
**Secrets & Storage** — Instance and company secrets, encrypted local storage, provider-backed object storage, attachments, and work products. Sensitive values stay out of prompts unless a scoped run explicitly needs them.
</td>
</tr>
<tr>
<td>
**Activity & Events** — Mutating actions, heartbeat state changes, cost events, approvals, comments, and work products are recorded as durable activity so operators can audit what happened and why.
</td>
<td>
**Company Portability** — Export and import entire organizations — agents, skills, projects, routines, and issues — with secret scrubbing and collision handling. One deployment, many companies, complete data isolation.
</td>
</tr>
</table>
<br/>
## What Paperclip is not
| | |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| **Not a chatbot.** | Agents have jobs, not chat windows. |
| **Not an agent framework.** | We don't tell you how to build agents. We tell you how to run a company made of them. |
| **Not a workflow builder.** | No drag-and-drop pipelines. Paperclip models companies — with org charts, goals, budgets, and governance. |
| **Not a prompt manager.** | Agents bring their own prompts, models, and runtimes. Paperclip manages the organization they work in. |
| **Not a single-agent tool.** | This is for teams. If you have one agent, you probably don't need Paperclip. If you have twenty — you definitely do. |
| **Not a code review tool.** | Paperclip orchestrates work, not pull requests. Bring your own review process. |
<br/>
## Quickstart
Open source. Self-hosted. No Paperclip account required.
```bash
npx paperclipai onboard --yes
```
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
```bash
npx paperclipai onboard --yes --bind lan
# or:
npx paperclipai onboard --yes --bind tailnet
```
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
Or manually:
```bash
git clone https://github.com/paperclipai/paperclip.git
cd paperclip
pnpm install
pnpm dev
```
This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required.
> **Requirements:** Node.js 20+, pnpm 9.15+
<br/>
## FAQ
**What does a typical setup look like?**
Locally, a single Node.js process manages an embedded Postgres and local file storage. For production, point it at your own Postgres and deploy however you like. Configure projects, agents, and goals — the agents take care of the rest.
If you're a solo entrepreneur you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it.
**Can I run multiple companies?**
Yes. A single deployment can run an unlimited number of companies with complete data isolation.
**How is Paperclip different from agents like OpenClaw or Claude Code?**
Paperclip _uses_ those agents. It orchestrates them into a company — with org charts, budgets, goals, governance, and accountability.
**Why should I use Paperclip instead of just pointing my OpenClaw to Asana or Trello?**
Agent orchestration has subtleties in how you coordinate who has work checked out, how to maintain sessions, monitoring costs, establishing governance - Paperclip does this for you.
(Bring-your-own-ticket-system is on the Roadmap)
**Do agents run continuously?**
By default, agents run on scheduled heartbeats and event-based triggers (task assignment, @-mentions). You can also hook in continuous agents like OpenClaw. You bring your agent and Paperclip coordinates.
<br/>
## Paperclip Cloud Sync
Cloud upstream sync is behind the `Cloud Sync` experimental setting. Enable it in Instance Settings before pushing.
```bash
paperclipai cloud connect https://your-stack.paperclip.app
paperclipai cloud connect https://your-stack.paperclip.app --no-browser
paperclipai cloud push --company <local-company-id> --dry-run
paperclipai cloud push --company <local-company-id>
```
`cloud connect` authorizes the local instance against the target stack and stores the upstream token in the local instance secret store. The default path opens a browser for consent; `--no-browser` uses the device-code flow and prints the verification URL and user code.
`cloud push --dry-run` exports the selected local company, sends a preview bundle to the connected Cloud stack, and exits with code `2` when conflicts need user resolution. A schema mismatch exits with code `3`. Running without `--dry-run` stages chunks idempotently, applies the run, and prints the final summary and recent progress events.
## Development
```bash
pnpm dev # Full dev (API + UI, watch mode)
pnpm dev:once # Full dev without file watching
pnpm dev:server # Server only
pnpm build # Build all
pnpm typecheck # Type checking
pnpm test # Cheap default test run (Vitest only)
pnpm test:watch # Vitest watch mode
pnpm test:e2e # Playwright browser suite
pnpm db:generate # Generate DB migration
pnpm db:migrate # Apply migrations
```
`pnpm test` does not run Playwright. Browser suites stay separate and are typically run only when working on those flows or in CI.
See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide.
<br/>
## Roadmap
- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc)
- ✅ Get OpenClaw / claw-style agent employees
- ✅ companies.sh - import and export entire organizations
- ✅ Easy AGENTS.md configurations
- ✅ Skills Manager
- ✅ Scheduled Routines
- ✅ Better Budgeting
- ✅ Agent Reviews and Approvals
- ✅ Multiple Human Users
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b / Novita agents)
- ⚪ Artifacts & Work Products
- ⚪ Memory / Knowledge
- ⚪ Enforced Outcomes
- ⚪ MAXIMIZER MODE
- ⚪ Deep Planning
- ⚪ Work Queues
- ⚪ Self-Organization
- ⚪ Automatic Organizational Learning
- ⚪ CEO Chat
- ⚪ Cloud deployments
- ⚪ Desktop App
This is the short roadmap preview. See the full roadmap in [ROADMAP.md](https://github.com/paperclipai/paperclip/blob/master/ROADMAP.md).
<br/>
## Community & Plugins
Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip)
## Telemetry
Paperclip collects anonymous usage telemetry to help us understand how the product is used and improve it. No personal information, issue content, prompts, file paths, or secrets are ever collected. Private repository references are hashed with a per-install salt before being sent.
Telemetry is **enabled by default** and can be disabled with any of the following:
| Method | How |
| -------------------- | ------------------------------------------------------- |
| Environment variable | `PAPERCLIP_TELEMETRY_DISABLED=1` |
| Standard convention | `DO_NOT_TRACK=1` |
| CI environments | Automatically disabled when `CI=true` |
| Config file | Set `telemetry.enabled: false` in your Paperclip config |
## Contributing
We welcome contributions. See the [contributing guide](https://github.com/paperclipai/paperclip/blob/master/CONTRIBUTING.md) for details.
<br/>
## Community
- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community
- [Twitter / X](https://x.com/papercliping) — Follow updates and announcements
- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests
- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC
<br/>
## License
MIT &copy; 2026 [Paperclip Labs, Inc](https://paperclip.ing)
## Star History
[![Star History Chart](https://api.star-history.com/image?repos=paperclipai/paperclip&type=date&legend=top-left)](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left)
<br/>
---
<p align="center">
<sub>Open source under MIT. Built for people who want to get work done, not babysit agents.</sub>
</p>
+67
View File
@@ -0,0 +1,67 @@
/**
* esbuild configuration for building the paperclipai CLI for npm.
*
* Bundles all workspace packages (@paperclipai/*) into a single file.
* External npm packages remain as regular dependencies.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
// Workspace packages whose code should be bundled into the CLI.
// Note: "server" is excluded — it's published separately and resolved at runtime.
const workspacePaths = [
"cli",
"packages/db",
"packages/shared",
"packages/adapter-utils",
"packages/adapters/claude-local",
"packages/adapters/codex-local",
"packages/adapters/hermes-gateway",
"packages/adapters/hermes",
"packages/adapters/openclaw-gateway",
];
// Workspace packages that should NOT be bundled — they'll be published
// to npm and resolved at runtime (e.g. @paperclipai/server uses dynamic import).
const externalWorkspacePackages = new Set([
"@paperclipai/server",
]);
// Collect all external (non-workspace) npm package names
const externals = new Set();
for (const p of workspacePaths) {
const pkg = JSON.parse(readFileSync(resolve(repoRoot, p, "package.json"), "utf8"));
for (const name of Object.keys(pkg.dependencies || {})) {
if (externalWorkspacePackages.has(name)) {
externals.add(name);
} else if (!name.startsWith("@paperclipai/")) {
externals.add(name);
}
}
for (const name of Object.keys(pkg.optionalDependencies || {})) {
externals.add(name);
}
}
// Also add all published workspace packages as external
for (const name of externalWorkspacePackages) {
externals.add(name);
}
/** @type {import('esbuild').BuildOptions} */
export default {
entryPoints: ["src/index.ts"],
bundle: true,
platform: "node",
target: "node20",
format: "esm",
outfile: "dist/index.js",
banner: { js: "#!/usr/bin/env node" },
external: [...externals].sort(),
treeShaking: true,
sourcemap: true,
};
+65
View File
@@ -0,0 +1,65 @@
{
"name": "paperclipai",
"version": "0.3.1",
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
"type": "module",
"bin": {
"paperclipai": "./dist/index.js"
},
"keywords": [
"paperclip",
"ai",
"agents",
"orchestration",
"cli"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "cli"
},
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "node --input-type=module -e \"import esbuild from 'esbuild'; import config from './esbuild.config.mjs'; await esbuild.build(config);\" && chmod +x dist/index.js",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@clack/prompts": "^0.10.0",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-cloud": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-grok-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/db": "workspace:*",
"@paperclipai/server": "workspace:*",
"@paperclipai/shared": "workspace:*",
"@paperclipai/hermes-paperclip-adapter": "workspace:*",
"drizzle-orm": "0.45.2",
"dotenv": "^17.4.2",
"commander": "^13.1.0",
"embedded-postgres": "^18.1.0-beta.16",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.19.21",
"tsx": "^4.22.4",
"typescript": "^5.7.3"
}
}
+140
View File
@@ -0,0 +1,140 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerAccessCommands } from "../commands/client/access.js";
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
const USER_ID = "33333333-3333-4333-8333-333333333333";
const INVITE_ID = "44444444-4444-4444-8444-444444444444";
const JOIN_ID = "55555555-5555-4555-8555-555555555555";
const MEMBER_ID = "66666666-6666-4666-8666-666666666666";
function createProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
registerAccessCommands(program);
return program;
}
async function run(args: string[]): Promise<void> {
await createProgram().parseAsync([...args, "--api-base", "http://localhost:3100", "--api-key", "board-token"], { from: "user" });
}
describe("access parity commands", () => {
beforeEach(() => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("wraps auth, invites, joins, members, and admin endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run(["health"]);
await run(["whoami"]);
await run(["access", "whoami"]);
await run(["profile", "session"]);
await run(["profile", "get"]);
await run(["profile", "update", "--payload-json", "{}"]);
await run(["invite", "list", "--company-id", COMPANY_ID]);
await run(["invite", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["invite", "revoke", INVITE_ID]);
await run(["invite", "show", "token-1"]);
await run(["invite", "test-resolution", "token-1", "--url", "http://localhost:3100/invite/token-1"]);
await run(["invite", "accept", "token-1"]);
await run(["join", "list", "--company-id", COMPANY_ID, "--status", "pending"]);
await run(["join", "approve", JOIN_ID, "--company-id", COMPANY_ID]);
await run(["join", "reject", JOIN_ID, "--company-id", COMPANY_ID]);
await run(["join", "claim-key", JOIN_ID, "--claim-secret", "secret"]);
await run(["member", "list", "--company-id", COMPANY_ID]);
await run(["member", "update", MEMBER_ID, "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["member", "archive", MEMBER_ID, "--company-id", COMPANY_ID]);
await run(["admin", "user", "list"]);
await run(["admin", "user", "promote", USER_ID]);
await run(["admin", "user", "company-access:update", USER_ID, "--payload-json", "{}"]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["GET", "http://localhost:3100/api/health"],
["GET", "http://localhost:3100/api/cli-auth/me"],
["GET", "http://localhost:3100/api/cli-auth/me"],
["GET", "http://localhost:3100/api/auth/get-session"],
["GET", "http://localhost:3100/api/auth/profile"],
["PATCH", "http://localhost:3100/api/auth/profile"],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/invites`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/invites`],
["POST", `http://localhost:3100/api/invites/${INVITE_ID}/revoke`],
["GET", "http://localhost:3100/api/invites/token-1"],
["GET", "http://localhost:3100/api/invites/token-1/test-resolution?url=http%3A%2F%2Flocalhost%3A3100%2Finvite%2Ftoken-1"],
["POST", "http://localhost:3100/api/invites/token-1/accept"],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/join-requests?status=pending_approval`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/join-requests/${JOIN_ID}/approve`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/join-requests/${JOIN_ID}/reject`],
["POST", `http://localhost:3100/api/join-requests/${JOIN_ID}/claim-api-key`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/members`],
["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}/members/${MEMBER_ID}`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/members/${MEMBER_ID}/archive`],
["GET", "http://localhost:3100/api/admin/users"],
["POST", `http://localhost:3100/api/admin/users/${USER_ID}/promote-instance-admin`],
["PUT", `http://localhost:3100/api/admin/users/${USER_ID}/company-access`],
]);
});
it("wraps instance, sidebar, llm, and openapi endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run(["openapi"]);
await run(["instance", "scheduler-heartbeats"]);
await run(["instance", "settings:general"]);
await run(["instance", "settings:general:update", "--payload-json", "{}"]);
await run(["instance", "database-backup"]);
await run(["sidebar", "preferences"]);
await run(["sidebar", "preferences:update", "--payload-json", "{}"]);
await run(["sidebar", "project-preferences", "--company-id", COMPANY_ID]);
await run(["sidebar", "project-preferences:update", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["sidebar", "badges", "--company-id", COMPANY_ID]);
await run(["inbox", "dismissals", "--company-id", COMPANY_ID]);
await run(["inbox", "dismiss", "--company-id", COMPANY_ID, "--payload-json", "{\"itemKey\":\"run:1\"}"]);
await run(["board-claim", "show", "claim-token"]);
await run(["board-claim", "claim", "claim-token", "--payload-json", "{}"]);
await run(["openclaw", "invite-prompt", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["available-skill", "list"]);
await run(["available-skill", "index"]);
await run(["available-skill", "get", "paperclip"]);
await run(["llm", "agent-configuration"]);
await run(["llm", "agent-configuration:adapter", "codex_local"]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["GET", "http://localhost:3100/api/openapi.json"],
["GET", "http://localhost:3100/api/instance/scheduler-heartbeats"],
["GET", "http://localhost:3100/api/instance/settings/general"],
["PATCH", "http://localhost:3100/api/instance/settings/general"],
["POST", "http://localhost:3100/api/instance/database-backups"],
["GET", "http://localhost:3100/api/sidebar-preferences/me"],
["PUT", "http://localhost:3100/api/sidebar-preferences/me"],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/sidebar-preferences/me`],
["PUT", `http://localhost:3100/api/companies/${COMPANY_ID}/sidebar-preferences/me`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/sidebar-badges`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/inbox-dismissals`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/inbox-dismissals`],
["GET", "http://localhost:3100/api/board-claim/claim-token"],
["POST", "http://localhost:3100/api/board-claim/claim-token/claim"],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/openclaw/invite-prompt`],
["GET", "http://localhost:3100/api/skills/available"],
["GET", "http://localhost:3100/api/skills/index"],
["GET", "http://localhost:3100/api/skills/paperclip"],
["GET", "http://localhost:3100/api/llms/agent-configuration.txt"],
["GET", "http://localhost:3100/api/llms/agent-configuration/codex_local.txt"],
]);
});
});
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
return new Response(JSON.stringify(body), init);
}
+53
View File
@@ -0,0 +1,53 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerActivityCommands } from "../commands/client/activity.js";
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
const ISSUE_ID = "33333333-3333-4333-8333-333333333333";
function createProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
registerActivityCommands(program);
return program;
}
async function run(args: string[]): Promise<void> {
await createProgram().parseAsync([...args, "--api-base", "http://localhost:3100", "--api-key", "board-token"], { from: "user" });
}
describe("activity parity commands", () => {
beforeEach(() => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("wraps activity endpoints", async () => {
const fetchMock = vi
.fn()
.mockImplementationOnce(() => Promise.resolve(jsonResponse([])))
.mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run(["activity", "list", "--company-id", COMPANY_ID, "--agent-id", "agent-1"]);
await run(["activity", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["activity", "issue", ISSUE_ID]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/activity?agentId=agent-1`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/activity`],
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/activity`],
]);
});
});
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
return new Response(JSON.stringify(body), init);
}
@@ -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);
}
+79
View File
@@ -0,0 +1,79 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
ensureAgentJwtSecret,
mergePaperclipEnvEntries,
readAgentJwtSecretFromEnv,
readPaperclipEnvEntries,
resolveAgentJwtEnvFile,
} from "../config/env.js";
import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js";
const ORIGINAL_ENV = { ...process.env };
function tempConfigPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-jwt-env-"));
const configDir = path.join(dir, "custom");
fs.mkdirSync(configDir, { recursive: true });
return path.join(configDir, "config.json");
}
describe("agent jwt env helpers", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("writes .env next to explicit config path", () => {
const configPath = tempConfigPath();
const result = ensureAgentJwtSecret(configPath);
expect(result.created).toBe(true);
const envPath = resolveAgentJwtEnvFile(configPath);
expect(fs.existsSync(envPath)).toBe(true);
const contents = fs.readFileSync(envPath, "utf-8");
expect(contents).toContain("PAPERCLIP_AGENT_JWT_SECRET=");
});
it("loads secret from .env next to explicit config path", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=test-secret\n", { mode: 0o600 });
const loaded = readAgentJwtSecretFromEnv(configPath);
expect(loaded).toBe("test-secret");
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBe("test-secret");
});
it("doctor check passes when secret exists in adjacent .env", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=check-secret\n", { mode: 0o600 });
const result = agentJwtSecretCheck(configPath);
expect(result.status).toBe("pass");
});
it("quotes hash-prefixed env values so dotenv round-trips them", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
mergePaperclipEnvEntries(
{
PAPERCLIP_WORKTREE_COLOR: "#439edb",
},
envPath,
);
const contents = fs.readFileSync(envPath, "utf-8");
expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"');
expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb");
});
});
+117
View File
@@ -0,0 +1,117 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerAgentCommands } from "../commands/client/agent.js";
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
const REVISION_ID = "33333333-3333-4333-8333-333333333333";
function createProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => {},
writeErr: () => {},
});
registerAgentCommands(program);
return program;
}
async function run(args: string[]): Promise<void> {
await createProgram().parseAsync([
...args,
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
}
describe("agent lifecycle commands", () => {
beforeEach(() => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("wraps agent lifecycle and state endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run([
"agent", "create",
"--company-id", COMPANY_ID,
"--payload-json", JSON.stringify({ name: "Builder", adapterType: "codex_local" }),
]);
await run(["agent", "hire", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["agent", "update", AGENT_ID, "--payload-json", JSON.stringify({ title: "Senior Builder" })]);
await run(["agent", "pause", AGENT_ID]);
await run(["agent", "resume", AGENT_ID]);
await run(["agent", "approve", AGENT_ID]);
await run(["agent", "terminate", AGENT_ID]);
await run(["agent", "heartbeat:invoke", AGENT_ID]);
await run(["agent", "claude-login", AGENT_ID]);
await run(["agent", "delete", AGENT_ID, "--yes"]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/agents`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/agent-hires`],
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/pause`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/resume`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/approve`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/terminate`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/heartbeat/invoke`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/claude-login`],
["DELETE", `http://localhost:3100/api/agents/${AGENT_ID}`],
]);
});
it("wraps configuration, runtime, skills, and instructions endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run(["agent", "permissions:update", AGENT_ID, "--payload-json", JSON.stringify({ canCreateAgents: true, canAssignTasks: true })]);
await run(["agent", "configuration", AGENT_ID]);
await run(["agent", "config-revisions", AGENT_ID]);
await run(["agent", "config-revision:get", AGENT_ID, REVISION_ID]);
await run(["agent", "config-revision:rollback", AGENT_ID, REVISION_ID]);
await run(["agent", "runtime-state", AGENT_ID]);
await run(["agent", "runtime-state:reset-session", AGENT_ID, "--task-key", "task-1"]);
await run(["agent", "task-sessions", AGENT_ID]);
await run(["agent", "skills", AGENT_ID]);
await run(["agent", "skills:sync", AGENT_ID, "--desired-skills", "paperclip,github"]);
await run(["agent", "instructions-path:update", AGENT_ID, "--payload-json", JSON.stringify({ path: "/tmp/AGENTS.md" })]);
await run(["agent", "instructions-bundle", AGENT_ID]);
await run(["agent", "instructions-bundle:update", AGENT_ID, "--payload-json", JSON.stringify({ mode: "managed" })]);
await run(["agent", "instructions-file:get", AGENT_ID, "--path", "AGENTS.md"]);
await run(["agent", "instructions-file:put", AGENT_ID, "--path", "AGENTS.md", "--content", "hello"]);
await run(["agent", "instructions-file:delete", AGENT_ID, "--path", "AGENTS.md"]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/permissions`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/configuration`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/config-revisions`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/config-revisions/${REVISION_ID}`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/config-revisions/${REVISION_ID}/rollback`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/runtime-state`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/runtime-state/reset-session`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/task-sessions`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/skills`],
["POST", `http://localhost:3100/api/agents/${AGENT_ID}/skills/sync`],
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-path`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle`],
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle`],
["GET", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle/file?path=AGENTS.md`],
["PUT", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle/file`],
["DELETE", `http://localhost:3100/api/agents/${AGENT_ID}/instructions-bundle/file?path=AGENTS.md`],
]);
});
});
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
return new Response(JSON.stringify(body), init);
}
@@ -0,0 +1,80 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { PaperclipConfig } from "../config/schema.js";
import { addAllowedHostname } from "../commands/allowed-hostname.js";
function createTempConfigPath() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-allowed-hostname-"));
return path.join(dir, "config.json");
}
function writeBaseConfig(configPath: string) {
const base: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
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: "authenticated",
exposure: "private",
host: "0.0.0.0",
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.writeFileSync(configPath, JSON.stringify(base, null, 2));
}
describe("allowed-hostname command", () => {
it("adds and normalizes hostnames", async () => {
const configPath = createTempConfigPath();
writeBaseConfig(configPath);
await addAllowedHostname("https://Dotta-MacBook-Pro:3100", { config: configPath });
await addAllowedHostname("dotta-macbook-pro", { config: configPath });
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8")) as PaperclipConfig;
expect(raw.server.allowedHostnames).toEqual(["dotta-macbook-pro"]);
});
});
@@ -0,0 +1,80 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerClientAuthCommands } from "../commands/client/auth.js";
describe("registerClientAuthCommands", () => {
it("registers auth commands without duplicate company-id flags", () => {
const program = new Command();
const auth = program.command("auth");
expect(() => registerClientAuthCommands(auth)).not.toThrow();
const login = auth.commands.find((command) => command.name() === "login");
expect(login).toBeDefined();
expect(login?.options.filter((option) => option.long === "--company-id")).toHaveLength(1);
});
it("registers the --no-browser flag on login", () => {
const program = new Command();
const auth = program.command("auth");
registerClientAuthCommands(auth);
const login = auth.commands.find((command) => command.name() === "login");
expect(login?.options.some((option) => option.long === "--no-browser")).toBe(true);
});
});
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);
}
+53
View File
@@ -0,0 +1,53 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
getStoredBoardCredential,
readBoardAuthStore,
removeStoredBoardCredential,
setStoredBoardCredential,
} from "../client/board-auth.js";
function createTempAuthPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-auth-"));
return path.join(dir, "auth.json");
}
describe("board auth store", () => {
it("returns an empty store when the file does not exist", () => {
const authPath = createTempAuthPath();
expect(readBoardAuthStore(authPath)).toEqual({
version: 1,
credentials: {},
});
});
it("stores and retrieves credentials by normalized api base", () => {
const authPath = createTempAuthPath();
setStoredBoardCredential({
apiBase: "http://localhost:3100/",
token: "token-123",
userId: "user-1",
storePath: authPath,
});
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toMatchObject({
apiBase: "http://localhost:3100",
token: "token-123",
userId: "user-1",
});
});
it("removes stored credentials", () => {
const authPath = createTempAuthPath();
setStoredBoardCredential({
apiBase: "http://localhost:3100",
token: "token-123",
storePath: authPath,
});
expect(removeStoredBoardCredential("http://localhost:3100", authPath)).toBe(true);
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toBeNull();
});
});
+243
View File
@@ -0,0 +1,243 @@
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 type { CompanyPortabilityExportResult } from "@paperclipai/shared";
import {
assertDiscoveryCompatible,
buildBundleFromLocalCompany,
cloudCommandExitCodes,
connectCloud,
resolveDeviceCodeExpiresAt,
} from "../commands/client/cloud.js";
import {
LocalUpstreamPushCoordinator,
normalizedContentHash,
type LocalUpstreamExportBundle,
} from "../commands/client/cloud-transfer.js";
import { getCloudConnection } from "../commands/client/cloud-store.js";
const originalEnv = { ...process.env };
const originalFetch = globalThis.fetch;
describe("cloud CLI helpers", () => {
let tempHome: string;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cloud-cli-"));
process.env = { ...originalEnv, PAPERCLIP_HOME: tempHome };
});
afterEach(() => {
process.env = { ...originalEnv };
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
fs.rmSync(tempHome, { recursive: true, force: true });
});
it("connects with the device-code flow and stores the resulting cloud connection", async () => {
globalThis.fetch = vi.fn(async (url, init) => {
const requestUrl = String(url);
if (requestUrl.endsWith("/.well-known/paperclip-upstream")) {
return jsonResponse(discovery());
}
if (requestUrl.endsWith("/api/upstream-sync/device-code")) {
expect(JSON.parse(String(init?.body))).toMatchObject({
stackId: "stack-1",
scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"],
});
return jsonResponse({
deviceCode: "device-1",
userCode: "ABCD-EFGH",
verificationUri: "https://cloud.example.test/api/upstream-sync/device-code/approve",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
intervalSeconds: 0,
});
}
if (requestUrl.endsWith("/api/upstream-sync/token")) {
return jsonResponse({
accessToken: "upt_test",
scopes: ["upstream_import:preview"],
token: {
id: "token-1",
companyStackId: "stack-1",
targetOrigin: "https://cloud.example.test",
sourceInstanceId: "paperclip-local-default",
sourceInstanceFingerprint: "sha256:test",
scopes: ["upstream_import:preview"],
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
});
}
return jsonResponse({ error: "not_found" }, 404);
}) as typeof fetch;
const connection = await connectCloud("https://cloud.example.test", { noBrowser: true, json: true });
expect(connection.accessToken).toBe("upt_test");
expect(getCloudConnection("https://cloud.example.test")?.token.id).toBe("token-1");
});
it("hard-blocks incompatible transfer schema versions with the stable schema exit code", () => {
expect(() => assertDiscoveryCompatible(discovery({ supportedSchemaMajor: 99 }))).toThrow(/schema mismatch/i);
expect(cloudCommandExitCodes.schemaMismatch).toBe(3);
});
it("falls back to a bounded device-code expiry when the cloud omits or malforms expiresAt", () => {
const now = Date.UTC(2026, 4, 22, 13, 0, 0);
const validExpiry = "2026-05-22T13:05:00.000Z";
expect(resolveDeviceCodeExpiresAt(validExpiry, now)).toBe(Date.parse(validExpiry));
expect(resolveDeviceCodeExpiresAt(undefined, now)).toBe(now + 15 * 60_000);
expect(resolveDeviceCodeExpiresAt("not-a-date", now)).toBe(now + 15 * 60_000);
});
it("builds deterministic chunks with validated payload hashes", async () => {
const bundle = await buildTestBundle();
expect(bundle.chunks).toHaveLength(2);
expect(bundle.chunks[0]?.sha256).toBe(normalizedContentHash(bundle.chunks[0]?.payload));
expect(bundle.manifest.chunks[0]?.manifestHash).toBe(bundle.manifest.manifestHash);
expect(bundle.manifest.idempotencyKey).toBe((await buildTestBundle()).manifest.idempotencyKey);
});
it("reuses the same manifest and chunk identity when an interrupted apply is retried", async () => {
const bundle = await buildTestBundle();
const calls: Array<{ path: string; body: unknown }> = [];
const coordinator = new LocalUpstreamPushCoordinator({
targetOrigin: "https://cloud.example.test",
paperclipCompanyId: "target-company-1",
fetch: async (url, init) => {
const parsed = new URL(String(url));
const body = init?.body ? JSON.parse(String(init.body)) as unknown : {};
calls.push({ path: parsed.pathname, body });
if (parsed.pathname.endsWith("/runs")) return jsonResponse({ run: { id: "run-1" } });
return jsonResponse({ run: { id: "run-1" }, summary: { create: 0, update: 0, adopt: 0, skip: 2, conflict: 0, staleMapping: 0 } });
},
});
await coordinator.apply(bundle);
await coordinator.apply(bundle);
const runBodies = calls.filter((call) => call.path.endsWith("/runs")).map((call) => call.body as { manifest: { idempotencyKey: string } });
const chunkBodies = calls.filter((call) => call.path.endsWith("/chunks")).map((call) => call.body as { chunkIndex: number; sha256: string });
expect(runBodies).toHaveLength(2);
expect(runBodies[0]?.manifest.idempotencyKey).toBe(runBodies[1]?.manifest.idempotencyKey);
expect(chunkBodies[0]).toEqual(chunkBodies[2]);
expect(chunkBodies[1]).toEqual(chunkBodies[3]);
});
});
async function buildTestBundle(): Promise<LocalUpstreamExportBundle> {
return buildBundleFromLocalCompany({
localCompanyId: "local-company-1",
connection: {
id: "conn-1",
remoteUrl: "https://cloud.example.test",
targetOrigin: "https://cloud.example.test",
targetHost: "cloud.example.test",
stackId: "stack-1",
targetCompanyId: "target-company-1",
accessToken: "upt_test",
token: {
id: "token-1",
companyStackId: "stack-1",
targetOrigin: "https://cloud.example.test",
sourceInstanceId: "paperclip-local-default",
sourceInstanceFingerprint: "sha256:test",
scopes: ["upstream_import:preview"],
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
privateKeyPem: "unused",
sourcePublicKey: "unused",
sourceInstanceId: "paperclip-local-default",
sourceInstanceFingerprint: "sha256:test",
scopes: ["upstream_import:preview"],
createdAt: "2026-05-18T00:00:00.000Z",
updatedAt: "2026-05-18T00:00:00.000Z",
},
discovery: discovery(),
localApi: {
post: async <T>() => portabilityExport() as T,
},
maxEntitiesPerChunk: 1,
mode: "apply",
});
}
function discovery(overrides: Partial<{ supportedSchemaMajor: number }> = {}) {
return {
schema: "paperclip-upstream-discovery-v1",
stack: {
id: "stack-1",
slug: "cloud-test",
displayName: "Cloud Test",
companyId: "target-company-1",
origin: "https://cloud.example.test",
},
auth: {
deviceCode: {
deviceCodeUrl: "https://cloud.example.test/api/upstream-sync/device-code",
verificationUrl: "https://cloud.example.test/api/upstream-sync/device-code/approve",
tokenUrl: "https://cloud.example.test/api/upstream-sync/token",
},
scopes: ["upstream_import:preview", "upstream_import:write", "upstream_import:read"],
},
transfer: {
supportedSchemaMajor: overrides.supportedSchemaMajor ?? 1,
featureFlags: ["cloud_sync"],
},
};
}
function portabilityExport(): CompanyPortabilityExportResult {
return {
rootPath: ".",
paperclipExtensionPath: ".paperclip.yaml",
manifest: {
schemaVersion: 1,
generatedAt: "2026-05-18T00:00:00.000Z",
source: {
companyId: "local-company-1",
companyName: "Local Company",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "company.json",
name: "Local Company",
description: null,
brandColor: null,
logoPath: null,
attachmentMaxBytes: null,
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
},
sidebar: null,
agents: [],
skills: [],
projects: [],
issues: [],
envInputs: [],
},
files: {
"README.md": "Local Company",
},
warnings: [],
};
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
+187
View File
@@ -0,0 +1,187 @@
import fs from "node:fs";
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 { setStoredBoardCredential } from "../client/board-auth.js";
import { inferContentTypeFromPath, resolveApiBase, resolveCommandContext } from "../commands/client/common.js";
const ORIGINAL_ENV = { ...process.env };
function createTempPath(name: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-common-"));
return path.join(dir, name);
}
describe("resolveCommandContext", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
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(() => {
process.env = { ...ORIGINAL_ENV };
});
it("uses profile defaults when options/env are not provided", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 2,
currentProfile: "ops",
profiles: {
ops: {
apiBase: "http://127.0.0.1:9999",
companyId: "company-profile",
apiKeyEnvVarName: "AGENT_KEY",
},
},
},
contextPath,
);
process.env.AGENT_KEY = "key-from-env";
const resolved = resolveCommandContext({ context: contextPath }, { requireCompany: true });
expect(resolved.api.apiBase).toBe("http://127.0.0.1:9999");
expect(resolved.companyId).toBe("company-profile");
expect(resolved.api.apiKey).toBe("key-from-env");
});
it("prefers explicit options over profile values", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 2,
currentProfile: "default",
profiles: {
default: {
apiBase: "http://profile:3100",
companyId: "company-profile",
},
},
},
contextPath,
);
const resolved = resolveCommandContext(
{
context: contextPath,
apiBase: "http://override:3200",
apiKey: "direct-token",
companyId: "company-override",
},
{ requireCompany: true },
);
expect(resolved.api.apiBase).toBe("http://override:3200");
expect(resolved.companyId).toBe("company-override");
expect(resolved.api.apiKey).toBe("direct-token");
});
it("throws when company is required but unresolved", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 2,
currentProfile: "default",
profiles: { default: {} },
},
contextPath,
);
expect(() =>
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");
});
});
describe("inferContentTypeFromPath", () => {
it("maps the issue-attachment file types the server allows", () => {
// Must match server/src/attachment-types.ts DEFAULT_ALLOWED_TYPES exactly.
expect(inferContentTypeFromPath("newsletter.html")).toBe("text/html");
expect(inferContentTypeFromPath("page.htm")).toBe("text/html");
expect(inferContentTypeFromPath("data.csv")).toBe("text/csv");
expect(inferContentTypeFromPath("bundle.zip")).toBe("application/zip");
expect(inferContentTypeFromPath("demo.mp4")).toBe("video/mp4");
expect(inferContentTypeFromPath("clip.webm")).toBe("video/webm");
expect(inferContentTypeFromPath("teaser.m4v")).toBe("video/x-m4v");
expect(inferContentTypeFromPath("walkthrough.mov")).toBe("video/quicktime");
expect(inferContentTypeFromPath("report.pdf")).toBe("application/pdf");
expect(inferContentTypeFromPath("chart.png")).toBe("image/png");
});
it("emits text types with no charset parameter so they match the exact allowlist", () => {
expect(inferContentTypeFromPath("notes.md")).toBe("text/markdown");
expect(inferContentTypeFromPath("log.txt")).toBe("text/plain");
});
it("is case-insensitive and returns undefined for unknown extensions", () => {
expect(inferContentTypeFromPath("/abs/Path/IMAGE.PNG")).toBe("image/png");
expect(inferContentTypeFromPath("archive.unknownext")).toBeUndefined();
expect(inferContentTypeFromPath("noextension")).toBeUndefined();
});
});
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import type { Company } from "@paperclipai/shared";
import { assertDeleteConfirmation, resolveCompanyForDeletion } from "../commands/client/company.js";
function makeCompany(overrides: Partial<Company>): Company {
return {
id: "11111111-1111-1111-1111-111111111111",
name: "Alpha",
description: null,
status: "active",
pauseReason: null,
pausedAt: null,
issuePrefix: "ALP",
issueCounter: 1,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
attachmentMaxBytes: 10 * 1024 * 1024,
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
brandColor: null,
logoAssetId: null,
logoUrl: null,
defaultResponsibleUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
describe("resolveCompanyForDeletion", () => {
const companies: Company[] = [
makeCompany({
id: "11111111-1111-1111-1111-111111111111",
name: "Alpha",
issuePrefix: "ALP",
}),
makeCompany({
id: "22222222-2222-2222-2222-222222222222",
name: "Paperclip",
issuePrefix: "PAP",
}),
];
it("resolves by ID in auto mode", () => {
const result = resolveCompanyForDeletion(companies, "22222222-2222-2222-2222-222222222222", "auto");
expect(result.issuePrefix).toBe("PAP");
});
it("resolves by prefix in auto mode", () => {
const result = resolveCompanyForDeletion(companies, "pap", "auto");
expect(result.id).toBe("22222222-2222-2222-2222-222222222222");
});
it("throws when selector is not found", () => {
expect(() => resolveCompanyForDeletion(companies, "MISSING", "auto")).toThrow(/No company found/);
});
it("respects explicit id mode", () => {
expect(() => resolveCompanyForDeletion(companies, "PAP", "id")).toThrow(/No company found by ID/);
});
it("respects explicit prefix mode", () => {
expect(() => resolveCompanyForDeletion(companies, "22222222-2222-2222-2222-222222222222", "prefix"))
.toThrow(/No company found by shortname/);
});
});
describe("assertDeleteConfirmation", () => {
const company = makeCompany({
id: "22222222-2222-2222-2222-222222222222",
issuePrefix: "PAP",
});
it("requires --yes", () => {
expect(() => assertDeleteConfirmation(company, { confirm: "PAP" })).toThrow(/requires --yes/);
});
it("accepts matching prefix confirmation", () => {
expect(() => assertDeleteConfirmation(company, { yes: true, confirm: "pap" })).not.toThrow();
});
it("accepts matching id confirmation", () => {
expect(() =>
assertDeleteConfirmation(company, {
yes: true,
confirm: "22222222-2222-2222-2222-222222222222",
})).not.toThrow();
});
it("rejects mismatched confirmation", () => {
expect(() => assertDeleteConfirmation(company, { yes: true, confirm: "nope" }))
.toThrow(/does not match target company/);
});
});
@@ -0,0 +1,616 @@
import { execFile, spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { createStoredZipArchive } from "./helpers/zip.js";
const execFileAsync = promisify(execFile);
type ServerProcess = ReturnType<typeof spawn>;
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres company import/export e2e tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) {
const config = {
$meta: {
version: 1,
updatedAt: new Date().toISOString(),
source: "doctor",
},
database: {
mode: "postgres",
connectionString,
embeddedPostgresDataDir: path.join(tempRoot, "embedded-db"),
embeddedPostgresPort: 54329,
backup: {
enabled: false,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(tempRoot, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(tempRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port,
allowedHostnames: [],
serveUi: false,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(tempRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(tempRoot, "secrets", "master.key"),
},
},
};
mkdirSync(path.dirname(configPath), { recursive: true });
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
}
interface TestPaperclipEnv {
configPath: string;
paperclipHome: string;
instanceId: string;
shellHome?: string;
}
function createBasePaperclipEnv(options: TestPaperclipEnv) {
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (key.startsWith("PAPERCLIP_")) {
delete env[key];
}
}
env.PAPERCLIP_CONFIG = options.configPath;
env.PAPERCLIP_HOME = options.paperclipHome;
env.PAPERCLIP_INSTANCE_ID = options.instanceId;
env.PAPERCLIP_CONTEXT = path.join(options.paperclipHome, "context.json");
env.PAPERCLIP_AUTH_STORE = path.join(options.paperclipHome, "auth.json");
if (options.shellHome) {
env.HOME = options.shellHome;
}
return env;
}
function createServerEnv(
configPath: string,
port: number,
connectionString: string,
options: Omit<TestPaperclipEnv, "configPath">,
) {
const env = createBasePaperclipEnv({
configPath,
...options,
});
delete env.DATABASE_URL;
delete env.PORT;
delete env.HOST;
delete env.SERVE_UI;
delete env.HEARTBEAT_SCHEDULER_ENABLED;
env.DATABASE_URL = connectionString;
env.HOST = "127.0.0.1";
env.PORT = String(port);
env.SERVE_UI = "false";
env.PAPERCLIP_DB_BACKUP_ENABLED = "false";
env.HEARTBEAT_SCHEDULER_ENABLED = "false";
env.PAPERCLIP_MIGRATION_AUTO_APPLY = "true";
env.PAPERCLIP_UI_DEV_MIDDLEWARE = "false";
return env;
}
function createCliEnv(options: TestPaperclipEnv) {
const env = createBasePaperclipEnv(options);
delete env.DATABASE_URL;
delete env.PORT;
delete env.HOST;
delete env.SERVE_UI;
delete env.PAPERCLIP_DB_BACKUP_ENABLED;
delete env.HEARTBEAT_SCHEDULER_ENABLED;
delete env.PAPERCLIP_MIGRATION_AUTO_APPLY;
delete env.PAPERCLIP_UI_DEV_MIDDLEWARE;
return env;
}
function collectTextFiles(root: string, current: string, files: Record<string, string>) {
for (const entry of readdirSync(current, { withFileTypes: true })) {
const absolutePath = path.join(current, entry.name);
if (entry.isDirectory()) {
collectTextFiles(root, absolutePath, files);
continue;
}
if (!entry.isFile()) continue;
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
files[relativePath] = readFileSync(absolutePath, "utf8");
}
}
async function stopServerProcess(child: ServerProcess | null) {
if (!child || child.exitCode !== null) return;
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
child.once("exit", () => resolve());
setTimeout(() => {
if (child.exitCode === null) {
child.kill("SIGKILL");
}
}, 5_000);
});
}
async function api<T>(baseUrl: string, pathname: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${baseUrl}${pathname}`, init);
const text = await res.text();
if (!res.ok) {
throw new Error(`Request failed ${res.status} ${pathname}: ${text}`);
}
return text ? JSON.parse(text) as T : (null as T);
}
async function runCliJson<T>(
args: string[],
opts: TestPaperclipEnv & { apiBase?: string; includeConfigArg?: boolean },
) {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const cliArgs = ["--silent", "paperclipai", ...args];
if (opts.apiBase) {
cliArgs.push("--api-base", opts.apiBase);
}
if (opts.includeConfigArg !== false) {
cliArgs.push("--config", opts.configPath);
}
cliArgs.push("--json");
const result = await execFileAsync(
"pnpm",
cliArgs,
{
cwd: repoRoot,
env: createCliEnv(opts),
maxBuffer: 10 * 1024 * 1024,
},
);
const stdout = result.stdout.trim();
const jsonStart = stdout.search(/[\[{]/);
if (jsonStart === -1) {
throw new Error(`CLI did not emit JSON.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
}
return JSON.parse(stdout.slice(jsonStart)) as T;
}
async function waitForServer(
apiBase: string,
child: ServerProcess,
output: { stdout: string[]; stderr: string[] },
) {
const startedAt = Date.now();
while (Date.now() - startedAt < 30_000) {
if (child.exitCode !== null) {
throw new Error(
`paperclipai run exited before healthcheck succeeded.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`,
);
}
try {
const res = await fetch(`${apiBase}/api/health`);
if (res.ok) return;
} catch {
// Server is still starting.
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(
`Timed out waiting for ${apiBase}/api/health.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`,
);
}
describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
let tempRoot = "";
let configPath = "";
let exportDir = "";
let apiBase = "";
let paperclipHome = "";
let cliShellHome = "";
let paperclipInstanceId = "";
let serverProcess: ServerProcess | null = null;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-"));
configPath = path.join(tempRoot, "config", "config.json");
exportDir = path.join(tempRoot, "exported-company");
paperclipHome = path.join(tempRoot, "paperclip-home");
cliShellHome = path.join(tempRoot, "shell-home");
paperclipInstanceId = "company-cli-e2e";
mkdirSync(paperclipHome, { recursive: true });
mkdirSync(cliShellHome, { recursive: true });
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-");
const port = await getAvailablePort();
writeTestConfig(configPath, tempRoot, port, tempDb.connectionString);
apiBase = `http://127.0.0.1:${port}`;
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const output = { stdout: [] as string[], stderr: [] as string[] };
const child = spawn(
"pnpm",
["paperclipai", "run", "--config", configPath],
{
cwd: repoRoot,
env: createServerEnv(configPath, port, tempDb.connectionString, {
paperclipHome,
instanceId: paperclipInstanceId,
shellHome: cliShellHome,
}),
stdio: ["ignore", "pipe", "pipe"],
},
);
serverProcess = child;
child.stdout?.on("data", (chunk) => {
output.stdout.push(String(chunk));
});
child.stderr?.on("data", (chunk) => {
output.stderr.push(String(chunk));
});
await waitForServer(apiBase, child, output);
}, 60_000);
afterAll(async () => {
await stopServerProcess(serverProcess);
await tempDb?.cleanup();
if (tempRoot) {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("exports a company package and imports it into new and existing companies", async () => {
expect(serverProcess).not.toBeNull();
const cliContext = await runCliJson<{
contextPath: string;
profileName: string;
profile: { apiBase?: string };
}>(
["context", "set", "--profile", "isolation-check", "--api-base", "https://example.test"],
{
configPath,
paperclipHome,
instanceId: paperclipInstanceId,
shellHome: cliShellHome,
includeConfigArg: false,
},
);
const expectedContextPath = path.join(paperclipHome, "context.json");
const leakedContextPath = path.join(cliShellHome, ".paperclip", "context.json");
expect(cliContext.contextPath).toBe(expectedContextPath);
expect(cliContext.profileName).toBe("isolation-check");
expect(cliContext.profile.apiBase).toBe("https://example.test");
expect(existsSync(expectedContextPath)).toBe(true);
expect(existsSync(leakedContextPath)).toBe(false);
rmSync(expectedContextPath, { force: true });
expect(existsSync(expectedContextPath)).toBe(false);
const sourceCompany = await api<{ id: string; name: string; issuePrefix: string }>(apiBase, "/api/companies", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: `CLI Export Source ${Date.now()}` }),
});
await api(apiBase, `/api/companies/${sourceCompany.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ requireBoardApprovalForNewAgents: false }),
});
const sourceAgent = await api<{ id: string; name: string }>(
apiBase,
`/api/companies/${sourceCompany.id}/agents`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: "Export Engineer",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {},
instructionsBundle: {
files: {
"AGENTS.md": "You verify company portability.",
},
},
}),
},
);
const sourceProject = await api<{ id: string; name: string }>(
apiBase,
`/api/companies/${sourceCompany.id}/projects`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: "Portability Verification",
status: "in_progress",
}),
},
);
const largeIssueDescription = `Round-trip the company package through the CLI.\n\n${"portable-data ".repeat(12_000)}`;
const sourceIssue = await api<{ id: string; title: string; identifier: string }>(
apiBase,
`/api/companies/${sourceCompany.id}/issues`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: "Validate company import/export",
description: largeIssueDescription,
status: "todo",
projectId: sourceProject.id,
assigneeAgentId: sourceAgent.id,
}),
},
);
const exportResult = await runCliJson<{
ok: boolean;
out: string;
filesWritten: number;
}>(
[
"company",
"export",
sourceCompany.id,
"--out",
exportDir,
"--include",
"company,agents,projects,issues",
],
{
apiBase,
configPath,
paperclipHome,
instanceId: paperclipInstanceId,
shellHome: cliShellHome,
},
);
expect(exportResult.ok).toBe(true);
expect(exportResult.filesWritten).toBeGreaterThan(0);
expect(readFileSync(path.join(exportDir, "COMPANY.md"), "utf8")).toContain(sourceCompany.name);
expect(readFileSync(path.join(exportDir, ".paperclip.yaml"), "utf8")).toContain('schema: "paperclip/v1"');
const importedNew = await runCliJson<{
company: { id: string; name: string; action: string };
agents: Array<{ id: string | null; action: string; name: string }>;
}>(
[
"company",
"import",
exportDir,
"--target",
"new",
"--new-company-name",
`Imported ${sourceCompany.name}`,
"--include",
"company,agents,projects,issues",
"--yes",
],
{
apiBase,
configPath,
paperclipHome,
instanceId: paperclipInstanceId,
shellHome: cliShellHome,
},
);
expect(importedNew.company.action).toBe("created");
expect(importedNew.agents).toHaveLength(1);
expect(importedNew.agents[0]?.action).toBe("created");
const importedAgents = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/agents`,
);
const importedProjects = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/projects`,
);
const importedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/issues`,
);
const importedMatchingIssues = importedIssues.filter((issue) => issue.title === sourceIssue.title);
expect(importedAgents.map((agent) => agent.name)).toContain(sourceAgent.name);
expect(importedProjects.map((project) => project.name)).toContain(sourceProject.name);
expect(importedMatchingIssues).toHaveLength(1);
const previewExisting = await runCliJson<{
errors: string[];
plan: {
companyAction: string;
agentPlans: Array<{ action: string }>;
projectPlans: Array<{ action: string }>;
issuePlans: Array<{ action: string }>;
};
}>(
[
"company",
"import",
exportDir,
"--target",
"existing",
"--company-id",
importedNew.company.id,
"--include",
"company,agents,projects,issues",
"--collision",
"rename",
"--dry-run",
],
{
apiBase,
configPath,
paperclipHome,
instanceId: paperclipInstanceId,
shellHome: cliShellHome,
},
);
expect(previewExisting.errors).toEqual([]);
expect(previewExisting.plan.companyAction).toBe("none");
expect(previewExisting.plan.agentPlans.some((plan) => plan.action === "create")).toBe(true);
expect(previewExisting.plan.projectPlans.some((plan) => plan.action === "create")).toBe(true);
expect(previewExisting.plan.issuePlans.some((plan) => plan.action === "create")).toBe(true);
const importedExisting = await runCliJson<{
company: { id: string; action: string };
agents: Array<{ id: string | null; action: string; name: string }>;
}>(
[
"company",
"import",
exportDir,
"--target",
"existing",
"--company-id",
importedNew.company.id,
"--include",
"company,agents,projects,issues",
"--collision",
"rename",
"--yes",
],
{
apiBase,
configPath,
paperclipHome,
instanceId: paperclipInstanceId,
shellHome: cliShellHome,
},
);
expect(importedExisting.company.action).toBe("unchanged");
expect(importedExisting.agents.some((agent) => agent.action === "created")).toBe(true);
const twiceImportedAgents = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/agents`,
);
const twiceImportedProjects = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/projects`,
);
const twiceImportedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/issues`,
);
const twiceImportedMatchingIssues = twiceImportedIssues.filter((issue) => issue.title === sourceIssue.title);
expect(twiceImportedAgents).toHaveLength(2);
expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2);
expect(twiceImportedProjects).toHaveLength(2);
expect(twiceImportedMatchingIssues).toHaveLength(2);
expect(new Set(twiceImportedMatchingIssues.map((issue) => issue.identifier)).size).toBe(2);
const zipPath = path.join(tempRoot, "exported-company.zip");
const portableFiles: Record<string, string> = {};
collectTextFiles(exportDir, exportDir, portableFiles);
writeFileSync(zipPath, createStoredZipArchive(portableFiles, "paperclip-demo"));
const importedFromZip = await runCliJson<{
company: { id: string; name: string; action: string };
agents: Array<{ id: string | null; action: string; name: string }>;
}>(
[
"company",
"import",
zipPath,
"--target",
"new",
"--new-company-name",
`Zip Imported ${sourceCompany.name}`,
"--include",
"company,agents,projects,issues",
"--yes",
],
{
apiBase,
configPath,
paperclipHome,
instanceId: paperclipInstanceId,
shellHome: cliShellHome,
},
);
expect(importedFromZip.company.action).toBe("created");
expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true);
}, 90_000);
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
isGithubShorthand,
looksLikeRepoUrl,
isHttpUrl,
normalizeGithubImportSource,
} from "../commands/client/company.js";
describe("isHttpUrl", () => {
it("matches http URLs", () => {
expect(isHttpUrl("http://example.com/foo")).toBe(true);
});
it("matches https URLs", () => {
expect(isHttpUrl("https://example.com/foo")).toBe(true);
});
it("rejects local paths", () => {
expect(isHttpUrl("/tmp/my-company")).toBe(false);
expect(isHttpUrl("./relative")).toBe(false);
});
});
describe("looksLikeRepoUrl", () => {
it("matches GitHub URLs", () => {
expect(looksLikeRepoUrl("https://github.com/org/repo")).toBe(true);
});
it("rejects URLs without owner/repo path", () => {
expect(looksLikeRepoUrl("https://example.com/foo")).toBe(false);
});
it("rejects local paths", () => {
expect(looksLikeRepoUrl("/tmp/my-company")).toBe(false);
});
});
describe("isGithubShorthand", () => {
it("matches owner/repo/path shorthands", () => {
expect(isGithubShorthand("paperclipai/companies/gstack")).toBe(true);
expect(isGithubShorthand("paperclipai/companies")).toBe(true);
});
it("rejects local-looking paths", () => {
expect(isGithubShorthand("./exports/acme")).toBe(false);
expect(isGithubShorthand("/tmp/acme")).toBe(false);
expect(isGithubShorthand("C:\\temp\\acme")).toBe(false);
});
});
describe("normalizeGithubImportSource", () => {
it("normalizes shorthand imports to canonical GitHub sources", () => {
expect(normalizeGithubImportSource("paperclipai/companies/gstack")).toBe(
"https://github.com/paperclipai/companies?ref=main&path=gstack",
);
});
it("applies --ref to shorthand imports", () => {
expect(normalizeGithubImportSource("paperclipai/companies/gstack", "feature/demo")).toBe(
"https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack",
);
});
it("applies --ref to existing GitHub tree URLs without losing the package path", () => {
expect(
normalizeGithubImportSource(
"https://github.com/paperclipai/companies/tree/main/gstack",
"release/2026-03-23",
),
).toBe(
"https://github.com/paperclipai/companies?ref=release%2F2026-03-23&path=gstack",
);
});
});
@@ -0,0 +1,44 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveInlineSourceFromPath } from "../commands/client/company.js";
import { createStoredZipArchive } from "./helpers/zip.js";
const tempDirs: string[] = [];
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}
});
describe("resolveInlineSourceFromPath", () => {
it("imports portable files from a zip archive instead of scanning the parent directory", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-company-import-zip-"));
tempDirs.push(tempDir);
const archivePath = path.join(tempDir, "paperclip-demo.zip");
const archive = createStoredZipArchive(
{
"COMPANY.md": "# Company\n",
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/ceo/AGENT.md": "# CEO\n",
"notes/todo.txt": "ignore me\n",
},
"paperclip-demo",
);
await writeFile(archivePath, archive);
const resolved = await resolveInlineSourceFromPath(archivePath);
expect(resolved).toEqual({
rootPath: "paperclip-demo",
files: {
"COMPANY.md": "# Company\n",
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/ceo/AGENT.md": "# CEO\n",
},
});
});
});
+800
View File
@@ -0,0 +1,800 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared";
import {
buildCompanyDashboardUrl,
buildDefaultImportAdapterOverrides,
buildDefaultImportSelectionState,
buildImportSelectionCatalog,
buildSelectedFilesFromImportSelection,
renderCompanyImportPreview,
renderCompanyImportResult,
registerCompanyCommands,
resolveCompanyImportApplyConfirmationMode,
resolveCompanyImportApiPath,
} from "../commands/client/company.js";
const ORIGINAL_ENV = { ...process.env };
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
function makeProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => undefined,
writeErr: () => undefined,
});
registerCompanyCommands(program);
return program;
}
async function runCommand(args: string[]): Promise<void> {
await makeProgram().parseAsync(args, { from: "user" });
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function company(overrides: Record<string, unknown> = {}) {
return {
id: COMPANY_ID,
name: "Paperclip",
description: null,
status: "active",
issuePrefix: "PAP",
issueCounter: 1,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
attachmentMaxBytes: 1073741824,
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
brandColor: "#5c5fff",
logoAssetId: null,
createdAt: "2026-06-04T00:00:00.000Z",
updatedAt: "2026-06-04T00:00:00.000Z",
logoUrl: null,
...overrides,
};
}
describe("company CLI commands", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_COMPANY_ID;
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("gets the current company from an explicit company context without board-wide listing", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(company()));
await runCommand([
"company",
"current",
"--company-id",
COMPANY_ID,
"--api-base",
"http://paperclip.test",
"--api-key",
"agent-token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
`http://paperclip.test/api/companies/${COMPANY_ID}`,
expect.objectContaining({ method: "GET" }),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ id: COMPANY_ID, name: "Paperclip" });
});
it("gets the current company from agent authentication when no company context is set", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ id: "agent-1", companyId: COMPANY_ID }))
.mockResolvedValueOnce(jsonResponse(company()));
await runCommand([
"company",
"current",
"--api-base",
"http://paperclip.test",
"--api-key",
"agent-token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://paperclip.test/api/agents/me",
expect.objectContaining({ method: "GET" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
`http://paperclip.test/api/companies/${COMPANY_ID}`,
expect.objectContaining({ method: "GET" }),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ id: COMPANY_ID, name: "Paperclip" });
});
it("lists the scoped agent company when board-wide company listing is denied", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse({ error: "Board access required" }, 403))
.mockResolvedValueOnce(jsonResponse({ id: "agent-1", companyId: COMPANY_ID }))
.mockResolvedValueOnce(jsonResponse(company()));
await runCommand([
"company",
"list",
"--api-base",
"http://paperclip.test",
"--api-key",
"agent-token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://paperclip.test/api/companies",
expect.objectContaining({ method: "GET" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/agents/me",
expect.objectContaining({ method: "GET" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
`http://paperclip.test/api/companies/${COMPANY_ID}`,
expect.objectContaining({ method: "GET" }),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject([{ id: COMPANY_ID, name: "Paperclip" }]);
});
it("explains that company creation requires board instance-admin authentication under agent auth", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ error: "Board access required" }, 403));
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
throw new Error(`exit:${code ?? 0}`);
}) as typeof process.exit);
await expect(runCommand([
"company",
"create",
"--payload-json",
"{\"name\":\"Disposable\"}",
"--api-base",
"http://paperclip.test",
"--api-key",
"agent-token",
"--json",
])).rejects.toThrow("exit:1");
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies",
expect.objectContaining({ method: "POST" }),
);
const rendered = String(errorSpy.mock.calls[0]?.[0]);
expect(rendered).toContain("Creating companies requires board/instance-admin authentication");
expect(rendered).toContain("company list --json");
});
});
describe("resolveCompanyImportApiPath", () => {
it("uses company-scoped preview route for existing-company dry runs", () => {
expect(
resolveCompanyImportApiPath({
dryRun: true,
targetMode: "existing_company",
companyId: "company-123",
}),
).toBe("/api/companies/company-123/imports/preview");
});
it("uses company-scoped apply route for existing-company imports", () => {
expect(
resolveCompanyImportApiPath({
dryRun: false,
targetMode: "existing_company",
companyId: "company-123",
}),
).toBe("/api/companies/company-123/imports/apply");
});
it("keeps global routes for new-company imports", () => {
expect(
resolveCompanyImportApiPath({
dryRun: true,
targetMode: "new_company",
}),
).toBe("/api/companies/import/preview");
expect(
resolveCompanyImportApiPath({
dryRun: false,
targetMode: "new_company",
}),
).toBe("/api/companies/import");
});
it("throws when an existing-company import is missing a company id", () => {
expect(() =>
resolveCompanyImportApiPath({
dryRun: true,
targetMode: "existing_company",
companyId: " ",
})
).toThrow(/require a companyId/i);
});
});
describe("resolveCompanyImportApplyConfirmationMode", () => {
it("skips confirmation when --yes is set", () => {
expect(
resolveCompanyImportApplyConfirmationMode({
yes: true,
interactive: false,
json: false,
}),
).toBe("skip");
});
it("prompts in interactive text mode when --yes is not set", () => {
expect(
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: true,
json: false,
}),
).toBe("prompt");
});
it("requires --yes for non-interactive apply", () => {
expect(() =>
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: false,
json: false,
})
).toThrow(/non-interactive terminal requires --yes/i);
});
it("requires --yes for json apply", () => {
expect(() =>
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: false,
json: true,
})
).toThrow(/with --json requires --yes/i);
});
});
describe("buildCompanyDashboardUrl", () => {
it("preserves the configured base path when building a dashboard URL", () => {
expect(buildCompanyDashboardUrl("https://paperclip.example/app/", "PAP")).toBe(
"https://paperclip.example/app/PAP/dashboard",
);
});
});
describe("renderCompanyImportPreview", () => {
it("summarizes the preview with counts, selection info, and truncated examples", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
targetCompanyId: "company-123",
targetCompanyName: "Imported Co",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo", "cto", "eng-1", "eng-2", "eng-3", "eng-4", "eng-5"],
plan: {
companyAction: "update",
agentPlans: [
{ slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null },
{ slug: "cto", action: "update", plannedName: "CTO", existingAgentId: "agent-2", reason: "replace strategy" },
{ slug: "eng-1", action: "skip", plannedName: "Engineer 1", existingAgentId: "agent-3", reason: "skip strategy" },
{ slug: "eng-2", action: "create", plannedName: "Engineer 2", existingAgentId: null, reason: null },
{ slug: "eng-3", action: "create", plannedName: "Engineer 3", existingAgentId: null, reason: null },
{ slug: "eng-4", action: "create", plannedName: "Engineer 4", existingAgentId: null, reason: null },
{ slug: "eng-5", action: "create", plannedName: "Engineer 5", existingAgentId: null, reason: null },
],
projectPlans: [
{ slug: "alpha", action: "create", plannedName: "Alpha", existingProjectId: null, reason: null },
],
issuePlans: [
{ slug: "kickoff", action: "create", plannedTitle: "Kickoff", reason: null },
],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T17:00:00.000Z",
source: {
companyId: "company-src",
companyName: "Source Co",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "COMPANY.md",
name: "Source Co",
description: null,
attachmentMaxBytes: null,
brandColor: null,
logoPath: null,
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
},
sidebar: {
agents: ["ceo"],
projects: ["alpha"],
},
agents: [
{
slug: "ceo",
name: "CEO",
path: "agents/ceo/AGENT.md",
skills: [],
role: "ceo",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
reportsToExistingAgentId: null,
reportsToExistingAgentSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [
{
key: "skill-a",
slug: "skill-a",
name: "Skill A",
path: "skills/skill-a/SKILL.md",
description: null,
sourceType: "inline",
sourceLocator: null,
sourceRef: null,
trustLevel: null,
compatibility: null,
metadata: null,
fileInventory: [],
},
],
projects: [
{
slug: "alpha",
name: "Alpha",
path: "projects/alpha/PROJECT.md",
description: null,
ownerAgentSlug: null,
leadAgentSlug: null,
targetDate: null,
color: null,
icon: null,
status: null,
executionWorkspacePolicy: null,
workspaces: [],
env: null,
metadata: null,
},
],
issues: [
{
slug: "kickoff",
identifier: null,
title: "Kickoff",
path: "projects/alpha/issues/kickoff/TASK.md",
projectSlug: "alpha",
projectWorkspaceKey: null,
assigneeAgentSlug: "ceo",
description: null,
recurring: false,
routine: null,
legacyRecurrence: null,
status: null,
priority: null,
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
comments: [],
metadata: null,
},
],
envInputs: [
{
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
projectSlug: null,
kind: "secret",
requirement: "required",
defaultValue: null,
portability: "portable",
},
],
},
files: {
"COMPANY.md": "# Source Co",
},
envInputs: [
{
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
projectSlug: null,
kind: "secret",
requirement: "required",
defaultValue: null,
portability: "portable",
},
],
warnings: ["One warning"],
errors: ["One error"],
};
const rendered = renderCompanyImportPreview(preview, {
sourceLabel: "GitHub: https://github.com/paperclipai/companies/demo",
targetLabel: "Imported Co (company-123)",
infoMessages: ["Using claude-local adapter"],
});
expect(rendered).toContain("Include");
expect(rendered).toContain("company, projects, tasks, agents, skills");
expect(rendered).toContain("7 agents total");
expect(rendered).toContain("1 project total");
expect(rendered).toContain("1 task total");
expect(rendered).toContain("skills: 1 skill packaged");
expect(rendered).toContain("+1 more");
expect(rendered).toContain("Using claude-local adapter");
expect(rendered).toContain("Warnings");
expect(rendered).toContain("Errors");
});
});
describe("renderCompanyImportResult", () => {
it("summarizes import results with created, updated, and skipped counts", () => {
const rendered = renderCompanyImportResult(
{
company: {
id: "company-123",
name: "Imported Co",
action: "updated",
},
agents: [
{ slug: "ceo", id: "agent-1", action: "created", name: "CEO", reason: null },
{ slug: "cto", id: "agent-2", action: "updated", name: "CTO", reason: "replace strategy" },
{ slug: "ops", id: null, action: "skipped", name: "Ops", reason: "skip strategy" },
],
projects: [
{ slug: "app", id: "project-1", action: "created", name: "App", reason: null },
{ slug: "ops", id: "project-2", action: "updated", name: "Operations", reason: "replace strategy" },
{ slug: "archive", id: null, action: "skipped", name: "Archive", reason: "skip strategy" },
],
envInputs: [],
warnings: ["Review API keys"],
},
{
targetLabel: "Imported Co (company-123)",
companyUrl: "https://paperclip.example/PAP/dashboard",
infoMessages: ["Using claude-local adapter"],
},
);
expect(rendered).toContain("Company");
expect(rendered).toContain("https://paperclip.example/PAP/dashboard");
expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)");
expect(rendered).toContain("3 projects total (1 created, 1 updated, 1 skipped)");
expect(rendered).toContain("Agent results");
expect(rendered).toContain("Project results");
expect(rendered).toContain("Using claude-local adapter");
expect(rendered).toContain("Review API keys");
});
});
describe("import selection catalog", () => {
it("defaults to everything and keeps project selection separate from task selection", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
targetCompanyId: "company-123",
targetCompanyName: "Imported Co",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo"],
plan: {
companyAction: "create",
agentPlans: [],
projectPlans: [],
issuePlans: [],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T18:00:00.000Z",
source: {
companyId: "company-src",
companyName: "Source Co",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "COMPANY.md",
name: "Source Co",
description: null,
attachmentMaxBytes: null,
brandColor: null,
logoPath: "images/company-logo.png",
requireBoardApprovalForNewAgents: false,
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
},
sidebar: {
agents: ["ceo"],
projects: ["alpha"],
},
agents: [
{
slug: "ceo",
name: "CEO",
path: "agents/ceo/AGENT.md",
skills: [],
role: "ceo",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
reportsToExistingAgentId: null,
reportsToExistingAgentSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [
{
key: "skill-a",
slug: "skill-a",
name: "Skill A",
path: "skills/skill-a/SKILL.md",
description: null,
sourceType: "inline",
sourceLocator: null,
sourceRef: null,
trustLevel: null,
compatibility: null,
metadata: null,
fileInventory: [{ path: "skills/skill-a/helper.md", kind: "doc" }],
},
],
projects: [
{
slug: "alpha",
name: "Alpha",
path: "projects/alpha/PROJECT.md",
description: null,
ownerAgentSlug: null,
leadAgentSlug: null,
targetDate: null,
color: null,
icon: null,
status: null,
executionWorkspacePolicy: null,
workspaces: [],
env: null,
metadata: null,
},
],
issues: [
{
slug: "kickoff",
identifier: null,
title: "Kickoff",
path: "projects/alpha/issues/kickoff/TASK.md",
projectSlug: "alpha",
projectWorkspaceKey: null,
assigneeAgentSlug: "ceo",
description: null,
recurring: false,
routine: null,
legacyRecurrence: null,
status: null,
priority: null,
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
comments: [],
metadata: null,
},
],
envInputs: [],
},
files: {
"COMPANY.md": "# Source Co",
"README.md": "# Readme",
".paperclip.yaml": "schema: paperclip/v1\n",
"images/company-logo.png": {
encoding: "base64",
data: "",
contentType: "image/png",
},
"projects/alpha/PROJECT.md": "# Alpha",
"projects/alpha/notes.md": "project notes",
"projects/alpha/issues/kickoff/TASK.md": "# Kickoff",
"projects/alpha/issues/kickoff/details.md": "task details",
"agents/ceo/AGENT.md": "# CEO",
"agents/ceo/prompt.md": "prompt",
"skills/skill-a/SKILL.md": "# Skill A",
"skills/skill-a/helper.md": "helper",
},
envInputs: [],
warnings: [],
errors: [],
};
const catalog = buildImportSelectionCatalog(preview);
const state = buildDefaultImportSelectionState(catalog);
expect(state.company).toBe(true);
expect(state.projects.has("alpha")).toBe(true);
expect(state.issues.has("kickoff")).toBe(true);
expect(state.agents.has("ceo")).toBe(true);
expect(state.skills.has("skill-a")).toBe(true);
state.company = false;
state.issues.clear();
state.agents.clear();
state.skills.clear();
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
expect(selectedFiles).toContain(".paperclip.yaml");
expect(selectedFiles).toContain("projects/alpha/PROJECT.md");
expect(selectedFiles).toContain("projects/alpha/notes.md");
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/TASK.md");
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/details.md");
});
});
describe("default adapter overrides", () => {
it("maps process-only imported agents to claude_local", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: false,
agents: true,
projects: false,
issues: false,
skills: false,
},
targetCompanyId: null,
targetCompanyName: null,
collisionStrategy: "rename",
selectedAgentSlugs: ["legacy-agent", "explicit-agent"],
plan: {
companyAction: "none",
agentPlans: [],
projectPlans: [],
issuePlans: [],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T18:20:00.000Z",
source: null,
includes: {
company: false,
agents: true,
projects: false,
issues: false,
skills: false,
},
company: null,
sidebar: null,
agents: [
{
slug: "legacy-agent",
name: "Legacy Agent",
path: "agents/legacy-agent/AGENT.md",
skills: [],
role: "agent",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
reportsToExistingAgentId: null,
reportsToExistingAgentSlug: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
{
slug: "explicit-agent",
name: "Explicit Agent",
path: "agents/explicit-agent/AGENT.md",
skills: [],
role: "agent",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
reportsToExistingAgentId: null,
reportsToExistingAgentSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [],
projects: [],
issues: [],
envInputs: [],
},
files: {},
envInputs: [],
warnings: [],
errors: [],
};
expect(buildDefaultImportAdapterOverrides(preview)).toEqual({
"legacy-agent": {
adapterType: "claude_local",
},
});
});
});
+99
View File
@@ -0,0 +1,99 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { configure } from "../commands/configure.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_EXIT_CODE = process.exitCode;
afterEach(() => {
process.exitCode = ORIGINAL_EXIT_CODE;
});
function writeBaseConfig(configPath: string) {
const base: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: "2026-01-01T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "/tmp/paperclip-db",
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: "/tmp/paperclip-backups",
},
},
logging: {
mode: "file",
logDir: "/tmp/paperclip-logs",
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
bind: "loopback",
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
telemetry: {
enabled: true,
},
storage: {
provider: "local_disk",
localDisk: { baseDir: "/tmp/paperclip-storage" },
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: { keyFilePath: "/tmp/paperclip-secrets/master.key" },
},
};
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify(base, null, 2));
}
describe("configure command", () => {
it("sets a failing exit code for unknown sections", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-configure-"));
const configPath = path.join(root, "config.json");
writeBaseConfig(configPath);
try {
await configure({ config: configPath, section: "invalid-section" });
expect(process.exitCode).toBe(1);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
it("sets a failing exit code when no config exists", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-configure-missing-"));
const configPath = path.join(root, "missing.json");
try {
await configure({ config: configPath, section: "server" });
expect(process.exitCode).toBe(1);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
+197
View File
@@ -0,0 +1,197 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as prompts from "@clack/prompts";
import { registerConnectCommand } from "../commands/client/connect.js";
import { loginBoardCli } from "../client/board-auth.js";
vi.mock("@clack/prompts", () => ({
intro: vi.fn(),
outro: vi.fn(),
cancel: vi.fn(),
isCancel: vi.fn(() => false),
text: vi.fn(),
select: vi.fn(),
}));
vi.mock("../client/board-auth.js", () => ({
loginBoardCli: vi.fn(),
}));
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
const AGENT_ID = "33333333-3333-4333-8333-333333333333";
const API_BASE = "http://127.0.0.1:3197";
function createProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
registerConnectCommand(program);
return program;
}
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
return new Response(JSON.stringify(body), init);
}
function createTempContextPath() {
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-connect-test-")), "context.json");
}
function readContext(filePath: string) {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as {
currentProfile: string;
profiles: Record<string, Record<string, unknown>>;
};
}
describe("connect command", () => {
let originalStdinIsTTY: boolean | undefined;
let originalStdoutIsTTY: boolean | undefined;
beforeEach(() => {
originalStdinIsTTY = process.stdin.isTTY;
originalStdoutIsTTY = process.stdout.isTTY;
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
vi.restoreAllMocks();
vi.mocked(loginBoardCli).mockResolvedValue({
token: "board-login-token",
approvalUrl: `${API_BASE}/cli-auth/challenge-1`,
userId: "user-1",
});
vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
Object.defineProperty(process.stdin, "isTTY", { value: originalStdinIsTTY, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: originalStdoutIsTTY, configurable: true });
vi.restoreAllMocks();
});
it("drives the interactive board profile flow through prompts and context writes", async () => {
const contextPath = createTempContextPath();
vi.mocked(prompts.text).mockResolvedValue(API_BASE);
vi.mocked(prompts.select).mockResolvedValue(COMPANY_ID);
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input));
if (url.pathname === "/api/health") return jsonResponse({ status: "ok" });
if (url.pathname === "/api/companies") {
return jsonResponse([{ id: COMPANY_ID, name: "Connect Co" }]);
}
if (url.pathname === "/api/board-api-keys" && init?.method === "POST") {
return jsonResponse({
id: "board-key-1",
name: "connect-board-token",
token: "pcp_board_created",
createdAt: "2026-05-24T12:00:00.000Z",
expiresAt: null,
});
}
return jsonResponse({ error: `Unexpected ${init?.method ?? "GET"} ${url.pathname}` }, { status: 500 });
});
vi.stubGlobal("fetch", fetchMock);
await createProgram().parseAsync([
"connect",
"--persona",
"board",
"--profile",
"cli-board",
"--token-name",
"connect-board-token",
"--context",
contextPath,
"--api-base",
API_BASE,
"--json",
], { from: "user" });
expect(loginBoardCli).toHaveBeenCalledWith(expect.objectContaining({
apiBase: API_BASE,
requestedAccess: "board",
command: "paperclipai connect",
}));
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", new URL(String(call[0])).pathname])).toEqual([
["GET", "/api/health"],
["GET", "/api/companies"],
["POST", "/api/board-api-keys"],
]);
expect(readContext(contextPath)).toMatchObject({
currentProfile: "cli-board",
profiles: {
"cli-board": {
apiBase: API_BASE,
companyId: COMPANY_ID,
persona: "board",
tokenId: "board-key-1",
tokenName: "connect-board-token",
},
},
});
});
it("drives the interactive agent profile flow through prompts and context writes", async () => {
const contextPath = createTempContextPath();
vi.mocked(prompts.text).mockResolvedValue(API_BASE);
vi.mocked(prompts.select).mockResolvedValue(AGENT_ID);
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input));
if (url.pathname === "/api/health") return jsonResponse({ status: "ok" });
if (url.pathname === "/api/companies") {
return jsonResponse([{ id: COMPANY_ID, name: "Connect Co" }]);
}
if (url.pathname === `/api/companies/${COMPANY_ID}/agents`) {
return jsonResponse([{ id: AGENT_ID, name: "Connect Agent", role: "Operator" }]);
}
if (url.pathname === `/api/agents/${AGENT_ID}/keys` && init?.method === "POST") {
return jsonResponse({
id: "agent-key-1",
name: "connect-agent-token",
token: "pcp_agent_created",
createdAt: "2026-05-24T12:00:00.000Z",
});
}
return jsonResponse({ error: `Unexpected ${init?.method ?? "GET"} ${url.pathname}` }, { status: 500 });
});
vi.stubGlobal("fetch", fetchMock);
await createProgram().parseAsync([
"connect",
"--persona",
"agent",
"--profile",
"cli-agent",
"--token-name",
"connect-agent-token",
"--context",
contextPath,
"--api-base",
API_BASE,
"--json",
], { from: "user" });
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", new URL(String(call[0])).pathname])).toEqual([
["GET", "/api/health"],
["GET", "/api/companies"],
["GET", `/api/companies/${COMPANY_ID}/agents`],
["POST", `/api/agents/${AGENT_ID}/keys`],
]);
expect(readContext(contextPath)).toMatchObject({
currentProfile: "cli-agent",
profiles: {
"cli-agent": {
apiBase: API_BASE,
companyId: COMPANY_ID,
persona: "agent",
agentId: AGENT_ID,
agentName: "Connect Agent",
tokenId: "agent-key-1",
tokenName: "connect-agent-token",
},
},
});
});
});
+133
View File
@@ -0,0 +1,133 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
defaultClientContext,
readContext,
setCurrentProfile,
upsertProfile,
writeContext,
} from "../client/context.js";
function createTempContextPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-context-"));
return path.join(dir, "context.json");
}
describe("client context store", () => {
it("returns default context when file does not exist", () => {
const contextPath = createTempContextPath();
const context = readContext(contextPath);
expect(context).toEqual(defaultClientContext());
});
it("upserts profile values and switches current profile", () => {
const contextPath = createTempContextPath();
upsertProfile(
"work",
{
apiBase: "http://localhost:3100",
companyId: "company-123",
persona: "agent",
agentId: "agent-123",
agentName: "Agent One",
apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN",
},
contextPath,
);
setCurrentProfile("work", contextPath);
const context = readContext(contextPath);
expect(context.currentProfile).toBe("work");
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: 2,
currentProfile: "x",
profiles: {
x: {
apiBase: " ",
companyId: " ",
apiKeyEnvVarName: " ",
},
},
},
contextPath,
);
const context = readContext(contextPath);
expect(context.currentProfile).toBe("x");
expect(context.profiles.x).toEqual({});
});
});
+79
View File
@@ -0,0 +1,79 @@
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { applyDataDirOverride } from "../config/data-dir.js";
const ORIGINAL_ENV = { ...process.env };
describe("applyDataDirOverride", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_HOME;
delete process.env.PAPERCLIP_CONFIG;
delete process.env.PAPERCLIP_CONTEXT;
delete process.env.PAPERCLIP_INSTANCE_ID;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("sets PAPERCLIP_HOME and isolated default config/context paths", () => {
const home = applyDataDirOverride({
dataDir: "~/paperclip-data",
config: undefined,
context: undefined,
}, { hasConfigOption: true, hasContextOption: true });
const expectedHome = path.resolve(os.homedir(), "paperclip-data");
expect(home).toBe(expectedHome);
expect(process.env.PAPERCLIP_HOME).toBe(expectedHome);
expect(process.env.PAPERCLIP_CONFIG).toBe(
path.resolve(expectedHome, "instances", "default", "config.json"),
);
expect(process.env.PAPERCLIP_CONTEXT).toBe(path.resolve(expectedHome, "context.json"));
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("default");
});
it("uses the provided instance id when deriving default config path", () => {
const home = applyDataDirOverride({
dataDir: "/tmp/paperclip-alt",
instance: "dev_1",
config: undefined,
context: undefined,
}, { hasConfigOption: true, hasContextOption: true });
expect(home).toBe(path.resolve("/tmp/paperclip-alt"));
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("dev_1");
expect(process.env.PAPERCLIP_CONFIG).toBe(
path.resolve("/tmp/paperclip-alt", "instances", "dev_1", "config.json"),
);
});
it("does not override explicit config/context settings", () => {
process.env.PAPERCLIP_CONFIG = "/env/config.json";
process.env.PAPERCLIP_CONTEXT = "/env/context.json";
applyDataDirOverride({
dataDir: "/tmp/paperclip-alt",
config: "/flag/config.json",
context: "/flag/context.json",
}, { hasConfigOption: true, hasContextOption: true });
expect(process.env.PAPERCLIP_CONFIG).toBe("/env/config.json");
expect(process.env.PAPERCLIP_CONTEXT).toBe("/env/context.json");
});
it("only applies defaults for options supported by the command", () => {
applyDataDirOverride(
{
dataDir: "/tmp/paperclip-alt",
},
{ hasConfigOption: false, hasContextOption: false },
);
expect(process.env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-alt"));
expect(process.env.PAPERCLIP_CONFIG).toBeUndefined();
expect(process.env.PAPERCLIP_CONTEXT).toBeUndefined();
});
});
+102
View File
@@ -0,0 +1,102 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { doctor } from "../commands/doctor.js";
import { writeConfig } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_ENV = { ...process.env };
function createTempConfig(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-"));
const configPath = path.join(root, ".paperclip", "config.json");
const runtimeRoot = path.join(root, "runtime");
const config: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: "2026-03-10T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
embeddedPostgresPort: 55432,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(runtimeRoot, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(runtimeRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3199,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
telemetry: {
enabled: true,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(runtimeRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
},
},
};
writeConfig(config, configPath);
return configPath;
}
describe("doctor", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("re-runs repairable checks so repaired failures do not remain blocking", async () => {
const configPath = createTempConfig();
const summary = await doctor({
config: configPath,
repair: true,
yes: true,
});
expect(summary.failed).toBe(0);
expect(summary.warned).toBe(0);
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy();
});
});
+24
View File
@@ -0,0 +1,24 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { collectEnvLabDoctorStatus, resolveEnvLabSshStatePath } from "../commands/env-lab.js";
describe("env-lab command", () => {
it("resolves the default SSH fixture state path under the instance root", () => {
const statePath = resolveEnvLabSshStatePath("fixture-test");
expect(statePath).toContain(
path.join("instances", "fixture-test", "env-lab", "ssh-fixture", "state.json"),
);
});
it("reports doctor status for an instance without a running fixture", async () => {
const status = await collectEnvLabDoctorStatus({ instance: "fixture-test-missing" });
expect(status.statePath).toContain(
path.join("instances", "fixture-test-missing", "env-lab", "ssh-fixture", "state.json"),
);
expect(typeof status.ssh.supported).toBe("boolean");
expect(status.ssh.running).toBe(false);
expect(status.ssh.environment).toBeNull();
});
});
+177
View File
@@ -0,0 +1,177 @@
import os from "node:os";
import path from "node:path";
import { mkdtemp, readFile } from "node:fs/promises";
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import type { FeedbackTrace } from "@paperclipai/shared";
import { readZipArchive } from "../commands/client/zip.js";
import {
buildFeedbackTraceQuery,
registerFeedbackCommands,
renderFeedbackReport,
summarizeFeedbackTraces,
writeFeedbackExportBundle,
} from "../commands/client/feedback.js";
function makeTrace(overrides: Partial<FeedbackTrace> = {}): FeedbackTrace {
return {
id: "trace-12345678",
companyId: "company-123",
feedbackVoteId: "vote-12345678",
issueId: "issue-123",
projectId: "project-123",
issueIdentifier: "PAP-123",
issueTitle: "Fix the feedback command",
authorUserId: "user-123",
targetType: "issue_comment",
targetId: "comment-123",
vote: "down",
status: "pending",
destination: "paperclip_labs_feedback_v1",
exportId: null,
consentVersion: "feedback-data-sharing-v1",
schemaVersion: "1",
bundleVersion: "1",
payloadVersion: "1",
payloadDigest: null,
payloadSnapshot: {
vote: {
value: "down",
reason: "Needed more detail",
},
},
targetSummary: {
label: "Comment",
excerpt: "The first answer was too vague.",
authorAgentId: "agent-123",
authorUserId: null,
createdAt: new Date("2026-03-31T12:00:00.000Z"),
documentKey: null,
documentTitle: null,
revisionNumber: null,
},
redactionSummary: null,
attemptCount: 0,
lastAttemptedAt: null,
exportedAt: null,
failureReason: null,
createdAt: new Date("2026-03-31T12:01:00.000Z"),
updatedAt: new Date("2026-03-31T12:02:00.000Z"),
...overrides,
};
}
describe("registerFeedbackCommands", () => {
it("registers the top-level feedback commands", () => {
const program = new Command();
expect(() => registerFeedbackCommands(program)).not.toThrow();
const feedback = program.commands.find((command) => command.name() === "feedback");
expect(feedback).toBeDefined();
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);
});
});
describe("buildFeedbackTraceQuery", () => {
it("encodes all supported filters", () => {
expect(
buildFeedbackTraceQuery({
targetType: "issue_comment",
vote: "down",
status: "pending",
projectId: "project-123",
issueId: "issue-123",
from: "2026-03-31T00:00:00.000Z",
to: "2026-03-31T23:59:59.999Z",
sharedOnly: true,
}),
).toBe(
"?targetType=issue_comment&vote=down&status=pending&projectId=project-123&issueId=issue-123&from=2026-03-31T00%3A00%3A00.000Z&to=2026-03-31T23%3A59%3A59.999Z&sharedOnly=true&includePayload=true",
);
});
});
describe("renderFeedbackReport", () => {
it("includes summary counts and the optional reason", () => {
const traces = [
makeTrace(),
makeTrace({
id: "trace-87654321",
feedbackVoteId: "vote-87654321",
vote: "up",
status: "local_only",
payloadSnapshot: {
vote: {
value: "up",
reason: null,
},
},
}),
];
const report = renderFeedbackReport({
apiBase: "http://127.0.0.1:3100",
companyId: "company-123",
traces,
summary: summarizeFeedbackTraces(traces),
includePayloads: false,
});
expect(report).toContain("Paperclip Feedback Report");
expect(report).toContain("thumbs up");
expect(report).toContain("thumbs down");
expect(report).toContain("Needed more detail");
});
});
describe("writeFeedbackExportBundle", () => {
it("writes votes, traces, a manifest, and a zip archive", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-feedback-export-"));
const outputDir = path.join(tempDir, "feedback-export");
const traces = [
makeTrace(),
makeTrace({
id: "trace-abcdef12",
feedbackVoteId: "vote-abcdef12",
issueIdentifier: "PAP-124",
issueId: "issue-124",
vote: "up",
status: "local_only",
payloadSnapshot: {
vote: {
value: "up",
reason: null,
},
},
}),
];
const exported = await writeFeedbackExportBundle({
apiBase: "http://127.0.0.1:3100",
companyId: "company-123",
traces,
outputDir,
});
expect(exported.manifest.summary.total).toBe(2);
expect(exported.manifest.summary.withReason).toBe(1);
const manifest = JSON.parse(await readFile(path.join(outputDir, "index.json"), "utf8")) as {
files: { votes: string[]; traces: string[]; zip: string };
};
expect(manifest.files.votes).toHaveLength(2);
expect(manifest.files.traces).toHaveLength(2);
const archive = await readFile(exported.zipPath);
const zip = await readZipArchive(archive);
expect(Object.keys(zip.files)).toEqual(
expect.arrayContaining([
"index.json",
`votes/${manifest.files.votes[0]}`,
`traces/${manifest.files.traces[0]}`,
]),
);
});
});
@@ -0,0 +1,6 @@
export {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
type EmbeddedPostgresTestDatabase,
type EmbeddedPostgresTestSupport,
} from "@paperclipai/db";
+87
View File
@@ -0,0 +1,87 @@
function writeUint16(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
}
function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
target[offset + 2] = (value >>> 16) & 0xff;
target[offset + 3] = (value >>> 24) & 0xff;
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
export function createStoredZipArchive(files: Record<string, string>, rootPath: string) {
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
let entryCount = 0;
for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
const body = encoder.encode(content);
const checksum = crc32(body);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, 0);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, body.length);
writeUint32(localHeader, 22, body.length);
writeUint16(localHeader, 26, fileName.length);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, 0);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, body.length);
writeUint32(centralHeader, 24, body.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, body);
centralChunks.push(centralHeader);
localOffset += localHeader.length + body.length;
entryCount += 1;
}
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
);
let offset = 0;
for (const chunk of localChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
const centralDirectoryOffset = offset;
for (const chunk of centralChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
writeUint32(archive, offset, 0x06054b50);
writeUint16(archive, offset + 8, entryCount);
writeUint16(archive, offset + 10, entryCount);
writeUint32(archive, offset + 12, centralDirectoryLength);
writeUint32(archive, offset + 16, centralDirectoryOffset);
return archive;
}
+46
View File
@@ -0,0 +1,46 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
describeLocalInstancePaths,
expandHomePrefix,
resolvePaperclipHomeDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
const ORIGINAL_ENV = { ...process.env };
describe("home path resolution", () => {
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("defaults to ~/.paperclip and default instance", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-home-paths-"));
process.env.PAPERCLIP_HOME = home;
delete process.env.PAPERCLIP_INSTANCE_ID;
const paths = describeLocalInstancePaths();
expect(paths.homeDir).toBe(home);
expect(paths.instanceId).toBe("default");
expect(paths.configPath).toBe(path.resolve(home, "instances", "default", "config.json"));
});
it("supports PAPERCLIP_HOME and explicit instance ids", () => {
process.env.PAPERCLIP_HOME = "~/paperclip-home";
const home = resolvePaperclipHomeDir();
expect(home).toBe(path.resolve(os.homedir(), "paperclip-home"));
expect(resolvePaperclipInstanceId("dev_1")).toBe("dev_1");
});
it("rejects invalid instance ids", () => {
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid PAPERCLIP_INSTANCE_ID/);
});
it("expands ~ prefixes", () => {
expect(expandHomePrefix("~")).toBe(os.homedir());
expect(expandHomePrefix("~/x/y")).toBe(path.resolve(os.homedir(), "x/y"));
});
});
+106
View File
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiConnectionError, ApiRequestError, PaperclipApiClient } from "../client/http.js";
describe("PaperclipApiClient", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("adds authorization and run-id headers", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({
apiBase: "http://localhost:3100",
apiKey: "token-123",
runId: "run-abc",
});
await client.post("/api/test", { hello: "world" });
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as [string, RequestInit];
expect(call[0]).toContain("/api/test");
const headers = call[1].headers as Record<string, string>;
expect(headers.authorization).toBe("Bearer token-123");
expect(headers["x-paperclip-run-id"]).toBe("run-abc");
expect(headers["content-type"]).toBe("application/json");
});
it("returns null on ignoreNotFound", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "Not found" }), { status: 404 }),
);
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
const result = await client.get("/api/missing", { ignoreNotFound: true });
expect(result).toBeNull();
});
it("throws ApiRequestError with details", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ error: "Issue checkout conflict", details: { issueId: "1" } }),
{ status: 409 },
),
);
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
await expect(client.post("/api/issues/1/checkout", {})).rejects.toMatchObject({
status: 409,
message: "Issue checkout conflict",
details: { issueId: "1" },
} satisfies Partial<ApiRequestError>);
});
it("throws ApiConnectionError with recovery guidance when fetch fails", async () => {
const fetchMock = vi.fn().mockRejectedValue(new TypeError("fetch failed"));
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
await expect(client.post("/api/companies/import/preview", {})).rejects.toBeInstanceOf(ApiConnectionError);
await expect(client.post("/api/companies/import/preview", {})).rejects.toMatchObject({
url: "http://localhost:3100/api/companies/import/preview",
method: "POST",
causeMessage: "fetch failed",
} satisfies Partial<ApiConnectionError>);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/Could not reach the Paperclip API\./,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/curl http:\/\/localhost:3100\/api\/health/,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/pnpm dev|pnpm paperclipai run/,
);
});
it("retries once after interactive auth recovery", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Board access required" }), { status: 403 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const recoverAuth = vi.fn().mockResolvedValue("board-token-123");
const client = new PaperclipApiClient({
apiBase: "http://localhost:3100",
recoverAuth,
});
const result = await client.post<{ ok: boolean }>("/api/test", { hello: "world" });
expect(result).toEqual({ ok: true });
expect(recoverAuth).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledTimes(2);
const retryHeaders = fetchMock.mock.calls[1]?.[1]?.headers as Record<string, string>;
expect(retryHeaders.authorization).toBe("Bearer board-token-123");
});
});
@@ -0,0 +1,262 @@
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:accept", ISSUE_ID, INTERACTION_ID, "--selected-option-ids", "file-a,file-b"]);
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}/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`],
]);
expect(JSON.parse(String(fetchMock.mock.calls[4]?.[1]?.body))).toEqual({
selectedOptionIds: ["file-a", "file-b"],
});
});
it("forwards the agent run-id header and inferred content-type on attachment:upload", async () => {
// Regression: the multipart upload uses a hand-rolled fetch (not the JSON
// client), so it must forward X-Paperclip-Run-Id itself — otherwise an
// agent-authenticated upload is rejected with "401 Agent run id required".
const RUN_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const tmp = await mkdtemp(join(tmpdir(), "paperclip-cli-test-"));
const filePath = join(tmp, "deliverable.html");
await writeFile(filePath, "<html><body>hi</body></html>", "utf8");
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
try {
await run([
"issue", "attachment:upload", ISSUE_ID,
"--company-id", COMPANY_ID,
"--file", filePath,
"--run-id", RUN_ID,
]);
} finally {
await rm(tmp, { recursive: true, force: true });
}
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/issues/${ISSUE_ID}/attachments`);
expect(init.method).toBe("POST");
const headers = init.headers as Record<string, string>;
expect(headers["x-paperclip-run-id"]).toBe(RUN_ID);
expect(headers.authorization).toBe("Bearer board-token");
const file = (init.body as FormData).get("file") as File;
expect(file.type).toBe("text/html");
});
});
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
return new Response(JSON.stringify(body), init);
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared";
import { buildPresetServerConfig } from "../config/server-bind.js";
const ORIGINAL_PATH = process.env.PATH;
describe("network bind helpers", () => {
it("rejects non-loopback bind modes in local_trusted", () => {
expect(
validateConfiguredBindMode({
deploymentMode: "local_trusted",
deploymentExposure: "private",
bind: "lan",
host: "0.0.0.0",
}),
).toContain("local_trusted requires server.bind=loopback");
});
it("resolves tailnet bind using the detected tailscale address", () => {
const resolved = resolveRuntimeBind({
bind: "tailnet",
host: "127.0.0.1",
tailnetBindHost: "100.64.0.8",
});
expect(resolved.errors).toEqual([]);
expect(resolved.host).toBe("100.64.0.8");
});
it("requires a custom bind host when bind=custom", () => {
const resolved = resolveRuntimeBind({
bind: "custom",
host: "127.0.0.1",
});
expect(resolved.errors).toContain("server.customBindHost is required when server.bind=custom");
});
it("stores the detected tailscale address for tailnet presets", () => {
process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8";
const preset = buildPresetServerConfig("tailnet", {
port: 3100,
allowedHostnames: [],
serveUi: true,
});
expect(preset.server.host).toBe("100.64.0.8");
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
});
it("falls back to loopback when no tailscale address is available for tailnet presets", () => {
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
process.env.PATH = "";
try {
const preset = buildPresetServerConfig("tailnet", {
port: 3100,
allowedHostnames: [],
serveUi: true,
});
expect(preset.server.host).toBe("127.0.0.1");
} finally {
process.env.PATH = ORIGINAL_PATH;
}
});
});
+202
View File
@@ -0,0 +1,202 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { onboard } from "../commands/onboard.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_ENV = { ...process.env };
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_PATH = process.env.PATH;
function createExistingConfigFixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-"));
const runtimeRoot = path.join(root, "runtime");
const configPath = path.join(root, ".paperclip", "config.json");
const config: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: "2026-03-29T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(runtimeRoot, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(runtimeRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
telemetry: {
enabled: true,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(runtimeRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
},
},
};
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
return { configPath, configText: fs.readFileSync(configPath, "utf8") };
}
function createFreshConfigPath() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-fresh-"));
return path.join(root, ".paperclip", "config.json");
}
describe("onboard", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
delete process.env.PAPERCLIP_HOME;
delete process.env.PAPERCLIP_CONFIG;
delete process.env.PAPERCLIP_INSTANCE_ID;
delete process.env.PAPERCLIP_BIND;
delete process.env.PAPERCLIP_BIND_HOST;
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
delete process.env.HOST;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
process.chdir(ORIGINAL_CWD);
});
it("preserves an existing config when rerun without flags", async () => {
const fixture = createExistingConfigFixture();
await onboard({ config: fixture.configPath });
expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText);
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
});
it("preserves an existing config when rerun with --yes", async () => {
const fixture = createExistingConfigFixture();
await onboard({ config: fixture.configPath, yes: true, invokedByRun: true });
expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText);
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
});
it("keeps --yes onboarding on local trusted loopback defaults", async () => {
const configPath = createFreshConfigPath();
process.env.HOST = "0.0.0.0";
process.env.PAPERCLIP_BIND = "lan";
await onboard({ config: configPath, yes: true, invokedByRun: true });
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
expect(raw.server.deploymentMode).toBe("local_trusted");
expect(raw.server.exposure).toBe("private");
expect(raw.server.bind).toBe("loopback");
expect(raw.server.host).toBe("127.0.0.1");
});
it("creates instance-root config and data paths for a fresh PAPERCLIP_HOME", async () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-home-"));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-cwd-"));
process.chdir(cwd);
process.env.PAPERCLIP_HOME = home;
await onboard({ yes: true, invokedByRun: true });
const instanceRoot = path.join(home, "instances", "default");
const configPath = path.join(instanceRoot, "config.json");
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
expect(raw.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db"));
expect(raw.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups"));
expect(raw.logging.logDir).toBe(path.join(instanceRoot, "logs"));
expect(raw.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage"));
expect(raw.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key"));
expect(fs.existsSync(path.join(instanceRoot, ".env"))).toBe(true);
expect(fs.existsSync(path.join(instanceRoot, "secrets", "master.key"))).toBe(true);
});
it("supports authenticated/private quickstart bind presets", async () => {
const configPath = createFreshConfigPath();
process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8";
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
expect(raw.server.deploymentMode).toBe("authenticated");
expect(raw.server.exposure).toBe("private");
expect(raw.server.bind).toBe("tailnet");
expect(raw.server.host).toBe("100.64.0.8");
});
it("keeps tailnet quickstart on loopback until tailscale is available", async () => {
const configPath = createFreshConfigPath();
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
process.env.PATH = "";
try {
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
} finally {
process.env.PATH = ORIGINAL_PATH;
}
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
expect(raw.server.deploymentMode).toBe("authenticated");
expect(raw.server.exposure).toBe("private");
expect(raw.server.bind).toBe("tailnet");
expect(raw.server.host).toBe("127.0.0.1");
});
it("ignores deployment env overrides during --yes quickstart", async () => {
const configPath = createFreshConfigPath();
process.env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated";
await onboard({ config: configPath, yes: true, invokedByRun: true });
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
expect(raw.server.deploymentMode).toBe("local_trusted");
expect(raw.server.exposure).toBe("private");
expect(raw.server.bind).toBe("loopback");
expect(raw.server.host).toBe("127.0.0.1");
});
});
+57
View File
@@ -0,0 +1,57 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
// Lets each test decide what the next spawned child does, while the
// node:child_process mock below is hoisted above the board-auth import.
const mocks = vi.hoisted(() => ({ spawn: vi.fn() }));
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return { ...actual, spawn: mocks.spawn };
});
import { openUrl } from "../client/board-auth.js";
function fakeChild(): EventEmitter & { unref: () => void } {
const child = new EventEmitter() as EventEmitter & { unref: () => void };
child.unref = () => {};
return child;
}
describe("openUrl", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("resolves true when the browser opener launches", async () => {
mocks.spawn.mockImplementation(() => {
const child = fakeChild();
queueMicrotask(() => child.emit("spawn"));
return child;
});
await expect(openUrl("https://example.com")).resolves.toBe(true);
});
it("resolves false instead of crashing when the opener is missing", async () => {
// Headless container case: e.g. no `xdg-open`. spawn reports this via an
// async 'error' event, not a thrown exception — openUrl must swallow it and
// resolve false rather than letting an unhandled 'error' abort the process.
mocks.spawn.mockImplementation(() => {
const child = fakeChild();
const error = Object.assign(new Error("spawn xdg-open ENOENT"), { code: "ENOENT" });
queueMicrotask(() => child.emit("error", error));
return child;
});
await expect(openUrl("https://example.com")).resolves.toBe(false);
});
it("resolves false when spawn throws synchronously", async () => {
mocks.spawn.mockImplementation(() => {
throw new Error("synchronous spawn failure");
});
await expect(openUrl("https://example.com")).resolves.toBe(false);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerCostCommands } from "../commands/client/cost.js";
import { registerWorkspaceCommands } from "../commands/client/workspace.js";
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
const ISSUE_ID = "44444444-4444-4444-8444-444444444444";
const WORKSPACE_ID = "55555555-5555-4555-8555-555555555555";
const PROJECT_ID = "66666666-6666-4666-8666-666666666666";
const PROJECT_WORKSPACE_ID = "77777777-7777-4777-8777-777777777777";
const ENV_ID = "88888888-8888-4888-8888-888888888888";
const INCIDENT_ID = "99999999-9999-4999-8999-999999999999";
function createProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
registerCostCommands(program);
registerWorkspaceCommands(program);
return program;
}
async function run(args: string[]): Promise<void> {
await createProgram().parseAsync([
...args,
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
}
describe("operations parity commands", () => {
beforeEach(() => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("wraps cost, finance, and budget endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run(["cost", "summary", "--company-id", COMPANY_ID]);
await run(["cost", "by-agent", "--company-id", COMPANY_ID]);
await run(["cost", "by-project", "--company-id", COMPANY_ID]);
await run(["cost", "issue", ISSUE_ID]);
await run(["cost", "event:create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["finance", "event:create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["finance", "summary", "--company-id", COMPANY_ID]);
await run(["budget", "overview", "--company-id", COMPANY_ID]);
await run(["budget", "policy:upsert", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["budget", "company:update", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["budget", "agent:update", AGENT_ID, "--payload-json", "{}"]);
await run(["budget", "incident:resolve", INCIDENT_ID, "--company-id", COMPANY_ID]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/costs/summary`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/costs/by-agent`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/costs/by-project`],
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/cost-summary`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/cost-events`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/finance-events`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/costs/finance-summary`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/budgets/overview`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/budgets/policies`],
["PATCH", `http://localhost:3100/api/companies/${COMPANY_ID}/budgets`],
["PATCH", `http://localhost:3100/api/agents/${AGENT_ID}/budgets`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/budget-incidents/${INCIDENT_ID}/resolve`],
]);
});
it("wraps org, execution workspace, environment, and project workspace endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(jsonResponse()));
vi.stubGlobal("fetch", fetchMock);
await run(["org", "get", "--company-id", COMPANY_ID]);
await run(["org", "svg", "--company-id", COMPANY_ID]);
await run(["agent-config", "list", "--company-id", COMPANY_ID]);
await run(["workspace", "list", "--company-id", COMPANY_ID]);
await run(["workspace", "get", WORKSPACE_ID]);
await run(["workspace", "close-readiness", WORKSPACE_ID]);
await run(["workspace", "operations", WORKSPACE_ID]);
await run(["workspace", "update", WORKSPACE_ID, "--payload-json", "{}"]);
await run(["workspace", "runtime-service", WORKSPACE_ID, "restart", "--payload-json", "{}"]);
await run(["environment", "list", "--company-id", COMPANY_ID]);
await run(["environment", "capabilities", "--company-id", COMPANY_ID]);
await run(["environment", "create", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["environment", "get", ENV_ID]);
await run(["environment", "leases", ENV_ID]);
await run(["environment", "update", ENV_ID, "--payload-json", "{}"]);
await run(["environment", "delete", ENV_ID]);
await run(["environment", "probe", ENV_ID]);
await run(["environment", "probe-config", "--company-id", COMPANY_ID, "--payload-json", "{}"]);
await run(["project-workspace", "list", PROJECT_ID]);
await run(["project-workspace", "create", PROJECT_ID, "--payload-json", "{}"]);
await run(["project-workspace", "update", PROJECT_ID, PROJECT_WORKSPACE_ID, "--payload-json", "{}"]);
await run(["project-workspace", "runtime-command", PROJECT_ID, PROJECT_WORKSPACE_ID, "run", "--payload-json", "{}"]);
await run(["project-workspace", "delete", PROJECT_ID, PROJECT_WORKSPACE_ID]);
expect(fetchMock.mock.calls.map((call) => [call[1]?.method ?? "GET", call[0]])).toEqual([
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/org`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/org.svg`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/agent-configurations`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/execution-workspaces`],
["GET", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}`],
["GET", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}/close-readiness`],
["GET", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}/workspace-operations`],
["PATCH", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}`],
["POST", `http://localhost:3100/api/execution-workspaces/${WORKSPACE_ID}/runtime-services/restart`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/environments`],
["GET", `http://localhost:3100/api/companies/${COMPANY_ID}/environments/capabilities`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/environments`],
["GET", `http://localhost:3100/api/environments/${ENV_ID}`],
["GET", `http://localhost:3100/api/environments/${ENV_ID}/leases`],
["PATCH", `http://localhost:3100/api/environments/${ENV_ID}`],
["DELETE", `http://localhost:3100/api/environments/${ENV_ID}`],
["POST", `http://localhost:3100/api/environments/${ENV_ID}/probe`],
["POST", `http://localhost:3100/api/companies/${COMPANY_ID}/environments/probe-config`],
["GET", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces`],
["POST", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces`],
["PATCH", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces/${PROJECT_WORKSPACE_ID}`],
["POST", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces/${PROJECT_WORKSPACE_ID}/runtime-commands/run`],
["DELETE", `http://localhost:3100/api/projects/${PROJECT_ID}/workspaces/${PROJECT_WORKSPACE_ID}`],
]);
});
});
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
return new Response(JSON.stringify(body), init);
}
+126
View File
@@ -0,0 +1,126 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerPipelineCommands } from "../commands/pipelines.js";
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
const CASE_ID = "11111111-1111-4111-8111-111111111111";
function createProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => {},
writeErr: () => {},
});
registerPipelineCommands(program);
return program;
}
async function run(args: string[]): Promise<void> {
await createProgram().parseAsync([
...args,
"--api-base",
"http://localhost:3100",
"--api-key",
"board-token",
"--company-id",
COMPANY_ID,
], { from: "user" });
}
describe("pipeline CLI 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("sends request-changes review decisions", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ case: { id: CASE_ID } }));
vi.stubGlobal("fetch", fetchMock);
await run([
"pipelines",
"case",
"review",
CASE_ID,
"--request-changes",
"--reason",
"Needs edits",
"--expected-version",
"2",
]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/cases/${CASE_ID}/review`);
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
decision: "request_changes",
reason: "Needs edits",
expectedVersion: 2,
});
});
it("passes request_changes rows through review-bulk", async () => {
const dir = await mkdtemp(join(tmpdir(), "paperclip-pipeline-cli-"));
const file = join(dir, "review-bulk.json");
await writeFile(file, JSON.stringify([
{ caseId: CASE_ID, decision: "request_changes", reason: "Needs edits", expectedVersion: 2 },
]));
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ results: [] }));
vi.stubGlobal("fetch", fetchMock);
try {
await run(["pipelines", "review-bulk", "--file", file]);
} finally {
await rm(dir, { recursive: true, force: true });
}
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/companies/${COMPANY_ID}/review-cases/bulk`);
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
items: [{ caseId: CASE_ID, decision: "request_changes", reason: "Needs edits", expectedVersion: 2 }],
});
});
it("passes blockedByCaseKeys rows through ingest-batch", async () => {
const dir = await mkdtemp(join(tmpdir(), "paperclip-pipeline-cli-"));
const file = join(dir, "ingest-batch.json");
await writeFile(file, JSON.stringify([
{ caseKey: "tweet", title: "Tweet", blockedByCaseKeys: ["image", "post"] },
{ caseKey: "image", title: "Image" },
{ caseKey: "post", title: "Post" },
]));
const fetchMock = vi.fn()
.mockResolvedValueOnce(jsonResponse([{ id: "33333333-3333-4333-8333-333333333333", key: "content", name: "Content" }]))
.mockResolvedValueOnce(jsonResponse([]));
vi.stubGlobal("fetch", fetchMock);
try {
await run(["pipelines", "ingest-batch", "content", "--file", file]);
} finally {
await rm(dir, { recursive: true, force: true });
}
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3100/api/pipelines/33333333-3333-4333-8333-333333333333/cases/batch");
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({
items: [
{ caseKey: "tweet", title: "Tweet", blockedByCaseKeys: ["image", "post"] },
{ caseKey: "image", title: "Image" },
{ caseKey: "post", title: "Post" },
],
});
});
});
function jsonResponse(body: unknown = { ok: true }, init: ResponseInit = { status: 200 }): Response {
return new Response(JSON.stringify(body), init);
}
+227
View File
@@ -0,0 +1,227 @@
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";
const mocks = vi.hoisted(() => ({
scaffoldPluginProject: vi.fn((options: { outputDir: string }) => options.outputDir),
}));
vi.mock("../../../packages/plugins/create-paperclip-plugin/src/index.js", async () => {
const actual =
await vi.importActual<typeof import("../../../packages/plugins/create-paperclip-plugin/src/index.js")>(
"../../../packages/plugins/create-paperclip-plugin/src/index.js",
);
return {
...actual,
scaffoldPluginProject: mocks.scaffoldPluginProject,
};
});
import {
buildPluginInstallRequest,
buildPluginInitNextCommands,
buildPluginInitScaffoldOptions,
formatTargetDiagnostics,
probeTargetDiagnostics,
registerPluginCommands,
} from "../commands/client/plugin.js";
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-plugin-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("plugin init", () => {
beforeEach(() => {
mocks.scaffoldPluginProject.mockClear();
});
it("maps package name and flags to scaffolder options", () => {
const cwd = path.resolve("/tmp/paperclip-cli-test");
const options = buildPluginInitScaffoldOptions(
"@acme/plugin-linear",
{
output: "plugins",
template: "connector",
category: "automation",
displayName: "Linear Bridge",
description: "Syncs Linear issues",
author: "Acme",
sdkPath: "../paperclip/packages/plugins/sdk",
},
cwd,
);
expect(options).toEqual({
pluginName: "@acme/plugin-linear",
outputDir: path.resolve(cwd, "plugins", "plugin-linear"),
template: "connector",
category: "automation",
displayName: "Linear Bridge",
description: "Syncs Linear issues",
author: "Acme",
sdkPath: "../paperclip/packages/plugins/sdk",
});
});
it("builds exact next commands using the scaffold path", () => {
expect(buildPluginInitNextCommands("/tmp/acme plugin")).toEqual([
"cd '/tmp/acme plugin'",
"pnpm install",
"pnpm dev",
"paperclipai plugin install '/tmp/acme plugin'",
]);
});
it("registers the CLI wrapper and invokes the existing scaffolder", async () => {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeOut: () => {}, writeErr: () => {} });
registerPluginCommands(program);
await program.parseAsync(
[
"plugin",
"init",
"demo-plugin",
"--output",
"/tmp/paperclip-init-output",
"--template",
"workspace",
"--category",
"workspace",
"--display-name",
"Demo Plugin",
"--description",
"Demo description",
"--author",
"Paperclip",
"--sdk-path",
"/repo/packages/plugins/sdk",
],
{ from: "user" },
);
expect(mocks.scaffoldPluginProject).toHaveBeenCalledTimes(1);
expect(mocks.scaffoldPluginProject).toHaveBeenCalledWith({
pluginName: "demo-plugin",
outputDir: path.resolve("/tmp/paperclip-init-output", "demo-plugin"),
template: "workspace",
category: "workspace",
displayName: "Demo Plugin",
description: "Demo description",
author: "Paperclip",
sdkPath: "/repo/packages/plugins/sdk",
});
});
});
describe("plugin install", () => {
it("resolves an existing relative local path to an absolute local install request", () => {
const cwd = makeTempDir();
const pluginDir = path.join(cwd, "demo-plugin");
fs.mkdirSync(pluginDir);
expect(buildPluginInstallRequest("demo-plugin", {}, { cwd })).toEqual({
packageName: pluginDir,
version: undefined,
isLocalPath: true,
});
});
it("keeps an absolute local path absolute and marks it as local", () => {
const pluginDir = path.join(makeTempDir(), "demo-plugin");
fs.mkdirSync(pluginDir);
expect(buildPluginInstallRequest(pluginDir, {}, { cwd: "/" })).toEqual({
packageName: pluginDir,
version: undefined,
isLocalPath: true,
});
});
it("preserves npm package installs when no local path exists", () => {
expect(
buildPluginInstallRequest("@acme/plugin-linear", { version: "1.2.3" }, {
cwd: makeTempDir(),
}),
).toEqual({
packageName: "@acme/plugin-linear",
version: "1.2.3",
isLocalPath: false,
});
});
});
describe("plugin target diagnostics", () => {
it("probes /api/health and reports the resolved api base on success", async () => {
const get = vi.fn(async () => ({
status: "ok",
version: "1.2.3",
deploymentMode: "local_trusted",
deploymentExposure: "private",
}));
const diag = await probeTargetDiagnostics({ apiBase: "http://127.0.0.1:3100", get });
expect(get).toHaveBeenCalledWith("/api/health");
expect(diag).toEqual({
apiBase: "http://127.0.0.1:3100",
reachable: true,
health: {
status: "ok",
version: "1.2.3",
deploymentMode: "local_trusted",
deploymentExposure: "private",
},
});
});
it("marks the target unreachable when the health probe throws", async () => {
const get = vi.fn(async () => {
throw new Error("Could not reach the Paperclip API.\nRequest: GET ...");
});
const diag = await probeTargetDiagnostics({ apiBase: "http://other-host:9999", get });
expect(diag.apiBase).toBe("http://other-host:9999");
expect(diag.reachable).toBe(false);
expect(diag.error).toContain("Could not reach the Paperclip API.");
});
it("formats reachable diagnostics with version and mode", () => {
const rendered = formatTargetDiagnostics({
apiBase: "http://127.0.0.1:3100",
reachable: true,
health: { status: "ok", version: "9.9.9", deploymentMode: "local_trusted" },
});
expect(rendered).toContain("http://127.0.0.1:3100");
expect(rendered).toContain("version=9.9.9");
expect(rendered).toContain("mode=local_trusted");
});
it("formats unreachable diagnostics with a remediation hint", () => {
const rendered = formatTargetDiagnostics({
apiBase: "http://127.0.0.1:3100",
reachable: false,
error: "ECONNREFUSED",
});
expect(rendered).toContain("unreachable");
expect(rendered).toContain("--api-base");
expect(rendered).toContain("PAPERCLIP_API_URL");
});
});
+156
View File
@@ -0,0 +1,156 @@
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;
delete process.env.PAPERCLIP_COMPANY_ID;
});
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
View File
@@ -0,0 +1,102 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { writeContext } from "../client/context.js";
import { runAgentPrompt } from "../commands/client/prompt.js";
const ORIGINAL_ENV = { ...process.env };
function createTempContextPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-prompt-"));
return path.join(dir, "context.json");
}
function agent(overrides: Record<string, unknown> = {}) {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "22222222-2222-4222-8222-222222222222",
name: "Worker",
urlKey: "worker",
role: "Engineer",
status: "active",
...overrides,
};
}
describe("prompt handoff", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_API_KEY;
vi.restoreAllMocks();
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
vi.restoreAllMocks();
});
it("fails when an agent prompt uses a board persona profile", async () => {
const contextPath = createTempContextPath();
writeContext(
{
version: 2,
currentProfile: "board",
profiles: {
board: {
apiBase: "http://localhost:3100",
persona: "board",
},
},
},
contextPath,
);
await expect(runAgentPrompt("worker", "Do the work", { context: contextPath, apiKey: "agent-token" }))
.rejects
.toThrow(/persona=board/);
});
it("fails when the supplied agent key belongs to a different agent", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(agent()), { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock);
await expect(runAgentPrompt("other-agent", "Do the work", {
apiBase: "http://localhost:3100",
apiKey: "agent-token",
})).rejects.toThrow(/Agent key belongs to Worker/);
});
it("creates an assigned issue and wakes the authenticated agent", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(agent()), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: "issue-1",
companyId: "22222222-2222-4222-8222-222222222222",
title: "Investigate queue lag",
status: "todo",
priority: "medium",
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
}), { status: 201 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ id: "run-1", status: "queued" }), { status: 202 }));
vi.stubGlobal("fetch", fetchMock);
const result = await runAgentPrompt("worker", "Investigate queue lag", {
apiBase: "http://localhost:3100",
apiKey: "agent-token",
});
expect(result.mode).toBe("issue");
expect(result.agent.id).toBe("11111111-1111-4111-8111-111111111111");
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3100/api/companies/22222222-2222-4222-8222-222222222222/issues");
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({
title: "Investigate queue lag",
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
});
expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3100/api/agents/11111111-1111-4111-8111-111111111111/wakeup");
});
});
@@ -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);
}
+249
View File
@@ -0,0 +1,249 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import {
agents,
companies,
createDb,
projects,
routines,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { disableAllRoutinesInConfig } from "../commands/routines.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres routines CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
function writeTestConfig(configPath: string, tempRoot: string, connectionString: string) {
const config = {
$meta: {
version: 1,
updatedAt: new Date().toISOString(),
source: "doctor" as const,
},
database: {
mode: "postgres" as const,
connectionString,
embeddedPostgresDataDir: path.join(tempRoot, "embedded-db"),
embeddedPostgresPort: 54329,
backup: {
enabled: false,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(tempRoot, "backups"),
},
},
logging: {
mode: "file" as const,
logDir: path.join(tempRoot, "logs"),
},
server: {
deploymentMode: "local_trusted" as const,
exposure: "private" as const,
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],
serveUi: false,
},
auth: {
baseUrlMode: "auto" as const,
disableSignUp: false,
},
storage: {
provider: "local_disk" as const,
localDisk: {
baseDir: path.join(tempRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted" as const,
strictMode: false,
localEncrypted: {
keyFilePath: path.join(tempRoot, "secrets", "master.key"),
},
},
};
mkdirSync(path.dirname(configPath), { recursive: true });
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
}
describeEmbeddedPostgres("disableAllRoutinesInConfig", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let tempRoot = "";
let configPath = "";
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-cli-db-");
db = createDb(tempDb.connectionString);
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-cli-config-"));
configPath = path.join(tempRoot, "config.json");
writeTestConfig(configPath, tempRoot, tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(routines);
await db.delete(projects);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
if (tempRoot) {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("pauses only non-archived routines for the selected company", async () => {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const projectId = randomUUID();
const otherProjectId = randomUUID();
const agentId = randomUUID();
const otherAgentId = randomUUID();
const activeRoutineId = randomUUID();
const pausedRoutineId = randomUUID();
const archivedRoutineId = randomUUID();
const otherCompanyRoutineId = randomUUID();
await db.insert(companies).values([
{
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
{
id: otherCompanyId,
name: "Other company",
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
},
]);
await db.insert(agents).values([
{
id: agentId,
companyId,
name: "Coder",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: otherAgentId,
companyId: otherCompanyId,
name: "Other coder",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(projects).values([
{
id: projectId,
companyId,
name: "Project",
status: "in_progress",
},
{
id: otherProjectId,
companyId: otherCompanyId,
name: "Other project",
status: "in_progress",
},
]);
await db.insert(routines).values([
{
id: activeRoutineId,
companyId,
projectId,
assigneeAgentId: agentId,
title: "Active routine",
status: "active",
},
{
id: pausedRoutineId,
companyId,
projectId,
assigneeAgentId: agentId,
title: "Paused routine",
status: "paused",
},
{
id: archivedRoutineId,
companyId,
projectId,
assigneeAgentId: agentId,
title: "Archived routine",
status: "archived",
},
{
id: otherCompanyRoutineId,
companyId: otherCompanyId,
projectId: otherProjectId,
assigneeAgentId: otherAgentId,
title: "Other company routine",
status: "active",
},
]);
const result = await disableAllRoutinesInConfig({
config: configPath,
companyId,
});
expect(result).toMatchObject({
companyId,
totalRoutines: 3,
pausedCount: 1,
alreadyPausedCount: 1,
archivedCount: 1,
});
const companyRoutines = await db
.select({
id: routines.id,
status: routines.status,
})
.from(routines)
.where(eq(routines.companyId, companyId));
const statusById = new Map(companyRoutines.map((routine) => [routine.id, routine.status]));
expect(statusById.get(activeRoutineId)).toBe("paused");
expect(statusById.get(pausedRoutineId)).toBe("paused");
expect(statusById.get(archivedRoutineId)).toBe("archived");
const otherCompanyRoutine = await db
.select({
status: routines.status,
})
.from(routines)
.where(eq(routines.id, otherCompanyRoutineId));
expect(otherCompanyRoutine[0]?.status).toBe("active");
});
});
+220
View File
@@ -0,0 +1,220 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerAgentCommands } from "../commands/client/agent.js";
import { registerIssueCommands } from "../commands/client/issue.js";
import { registerRunCommands } from "../commands/client/run.js";
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
const RUN_ID = "33333333-3333-4333-8333-333333333333";
const ISSUE_ID = "44444444-4444-4444-8444-444444444444";
function createProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => {},
writeErr: () => {},
});
const run = program.command("run").action(() => {});
registerRunCommands(run);
registerAgentCommands(program);
registerIssueCommands(program);
return program;
}
describe("run inspection commands", () => {
beforeEach(() => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("lists and reads heartbeat runs through run subcommands", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify([
{ id: RUN_ID, companyId: COMPANY_ID, agentId: AGENT_ID, status: "running", invocationSource: "on_demand" },
]), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: RUN_ID,
companyId: COMPANY_ID,
agentId: AGENT_ID,
status: "running",
invocationSource: "on_demand",
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
text: "hello",
offset: 0,
nextOffset: 5,
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ id: RUN_ID, status: "cancelled" }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
await createProgram().parseAsync([
"run", "list",
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
"--company-id", COMPANY_ID,
"--agent-id", AGENT_ID,
"--limit", "25",
], { from: "user" });
await createProgram().parseAsync([
"run", "get", RUN_ID,
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
await createProgram().parseAsync([
"run", "log", RUN_ID,
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
"--offset", "4",
"--limit-bytes", "100",
"--text",
], { from: "user" });
await createProgram().parseAsync([
"run", "cancel", RUN_ID,
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
expect(fetchMock.mock.calls[0]?.[0]).toBe(
`http://localhost:3100/api/companies/${COMPANY_ID}/heartbeat-runs?agentId=${AGENT_ID}&limit=25`,
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}`);
expect(fetchMock.mock.calls[2]?.[0]).toBe(
`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/log?offset=4&limitBytes=100`,
);
expect(fetchMock.mock.calls[3]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/cancel`);
expect(fetchMock.mock.calls[3]?.[1]?.method).toBe("POST");
});
it("supports run events, issues, workspace operations, and watchdog decisions", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify([
{ id: 1, runId: RUN_ID, agentId: AGENT_ID, seq: 1, eventType: "output", message: "hi" },
]), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify([
{ id: ISSUE_ID, identifier: "PC-1", title: "Fix it", status: "in_progress", priority: "normal" },
]), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify([
{ id: "55555555-5555-4555-8555-555555555555", status: "succeeded", phase: "workspace_provision" },
]), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ text: "workspace" }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ id: "decision-1", decision: "continue" }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(console, "log").mockImplementation(() => {});
await createProgram().parseAsync([
"run", "events", RUN_ID,
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
"--after-seq", "7",
"--limit", "50",
], { from: "user" });
await createProgram().parseAsync([
"run", "issues", RUN_ID,
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
await createProgram().parseAsync([
"run", "workspace-operations", RUN_ID,
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
await createProgram().parseAsync([
"run", "workspace-log", "55555555-5555-4555-8555-555555555555",
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
await createProgram().parseAsync([
"run", "watchdog-decision", RUN_ID,
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
"--decision", "continue",
"--reason", "operator reviewed",
], { from: "user" });
expect(fetchMock.mock.calls[0]?.[0]).toBe(
`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/events?afterSeq=7&limit=50`,
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/issues`);
expect(fetchMock.mock.calls[2]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/workspace-operations`);
expect(fetchMock.mock.calls[3]?.[0]).toBe(
"http://localhost:3100/api/workspace-operations/55555555-5555-4555-8555-555555555555/log?offset=0",
);
expect(fetchMock.mock.calls[4]?.[0]).toBe(`http://localhost:3100/api/heartbeat-runs/${RUN_ID}/watchdog-decisions`);
expect(JSON.parse(String(fetchMock.mock.calls[4]?.[1]?.body))).toMatchObject({
decision: "continue",
reason: "operator reviewed",
});
});
it("wakes agents and exposes issue run helpers", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
id: AGENT_ID,
name: "Builder",
companyId: COMPANY_ID,
urlKey: "builder",
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: RUN_ID,
companyId: COMPANY_ID,
agentId: AGENT_ID,
status: "queued",
}), { status: 202 }))
.mockResolvedValueOnce(new Response(JSON.stringify([{ id: RUN_ID, status: "succeeded" }]), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify([{ id: RUN_ID, status: "running" }]), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ id: RUN_ID, status: "running" }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(console, "log").mockImplementation(() => {});
await createProgram().parseAsync([
"agent", "wake", "builder",
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
"--company-id", COMPANY_ID,
"--reason", "manual check",
"--payload", "{\"issueId\":\"PC-1\"}",
], { from: "user" });
await createProgram().parseAsync([
"issue", "runs", "PC-1",
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
await createProgram().parseAsync([
"issue", "live-runs", "PC-1",
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
await createProgram().parseAsync([
"issue", "active-run", "PC-1",
"--api-base", "http://localhost:3100",
"--api-key", "board-token",
], { from: "user" });
expect(fetchMock.mock.calls[0]?.[0]).toBe(`http://localhost:3100/api/agents/builder?companyId=${COMPANY_ID}`);
expect(fetchMock.mock.calls[1]?.[0]).toBe(`http://localhost:3100/api/agents/${AGENT_ID}/wakeup`);
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({
source: "on_demand",
triggerDetail: "manual",
reason: "manual check",
payload: { issueId: "PC-1" },
});
expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3100/api/issues/PC-1/runs");
expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3100/api/issues/PC-1/live-runs");
expect(fetchMock.mock.calls[4]?.[0]).toBe("http://localhost:3100/api/issues/PC-1/active-run");
});
});
+335
View File
@@ -0,0 +1,335 @@
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";
import {
buildInlineMigrationSecretName,
buildMigratedAgentEnv,
collectInlineSecretMigrationCandidates,
parseSecretsInclude,
registerSecretCommands,
toPlainEnvValue,
} from "../commands/client/secrets.js";
function agent(partial: Partial<Agent>): Agent {
return {
id: "agent-12345678",
companyId: "company-1",
name: "Coder",
urlKey: "coder",
role: "engineer",
title: null,
icon: null,
status: "idle",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: {
canCreateAgents: false,
},
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-04-26T00:00:00.000Z"),
updatedAt: new Date("2026-04-26T00:00:00.000Z"),
...partial,
};
}
function secret(partial: Partial<CompanySecret>): CompanySecret {
return {
id: "secret-1",
companyId: "company-1",
scope: "company",
ownerUserId: null,
userSecretDefinitionId: null,
key: "agent_agent-12_anthropic_api_key",
name: "agent_agent-12_anthropic_api_key",
provider: "local_encrypted",
status: "active",
managedMode: "paperclip_managed",
externalRef: null,
providerConfigId: null,
providerMetadata: null,
latestVersion: 1,
description: null,
lastResolvedAt: null,
lastRotatedAt: null,
deletedAt: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date("2026-04-26T00:00:00.000Z"),
updatedAt: new Date("2026-04-26T00:00:00.000Z"),
...partial,
};
}
function configWithSecretsProvider(provider: PaperclipConfig["secrets"]["provider"]): PaperclipConfig {
return {
$meta: {
version: 1,
updatedAt: "2026-05-02T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "/tmp/paperclip/db",
embeddedPostgresPort: 55432,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: "/tmp/paperclip/backups",
},
},
logging: {
mode: "file",
logDir: "/tmp/paperclip/logs",
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
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,
strictMode: true,
localEncrypted: {
keyFilePath: "/tmp/paperclip/secrets/master.key",
},
},
};
}
describe("secrets CLI helpers", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.PAPERCLIP_SECRETS_AWS_REGION;
delete process.env.AWS_REGION;
delete process.env.AWS_DEFAULT_REGION;
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("parses declaration include filters", () => {
expect(parseSecretsInclude("agents,projects,tasks")).toEqual({
company: false,
agents: true,
projects: true,
issues: true,
skills: false,
});
});
it("detects inline sensitive env values that need migration", () => {
const rows = collectInlineSecretMigrationCandidates(
[
agent({
id: "agent-12345678",
adapterConfig: {
env: {
ANTHROPIC_API_KEY: "sk-ant-test",
GH_TOKEN: {
type: "plain",
value: "ghp-test",
},
PATH: {
type: "plain",
value: "/usr/bin",
},
OPENAI_API_KEY: {
type: "secret_ref",
secretId: "secret-existing",
},
},
},
}),
],
[
secret({
id: "secret-gh-token",
name: buildInlineMigrationSecretName("agent-12345678", "GH_TOKEN"),
}),
],
);
expect(rows).toEqual([
{
agentId: "agent-12345678",
agentName: "Coder",
envKey: "ANTHROPIC_API_KEY",
secretName: "agent_agent-12_anthropic_api_key",
existingSecretId: null,
},
{
agentId: "agent-12345678",
agentName: "Coder",
envKey: "GH_TOKEN",
secretName: "agent_agent-12_gh_token",
existingSecretId: "secret-gh-token",
},
]);
});
it("builds migrated env bindings without preserving secret values", () => {
const next = buildMigratedAgentEnv(
{
ANTHROPIC_API_KEY: "sk-ant-test",
NODE_ENV: {
type: "plain",
value: "development",
},
},
new Map([["ANTHROPIC_API_KEY", "secret-1"]]),
);
expect(next).toEqual({
ANTHROPIC_API_KEY: {
type: "secret_ref",
secretId: "secret-1",
version: "latest",
},
NODE_ENV: {
type: "plain",
value: "development",
},
});
expect(JSON.stringify(next)).not.toContain("sk-ant-test");
});
it("reads only explicit plain env values", () => {
expect(toPlainEnvValue("plain-value")).toBe("plain-value");
expect(toPlainEnvValue({ type: "plain", value: "wrapped" })).toBe("wrapped");
expect(toPlainEnvValue({ type: "secret_ref", secretId: "secret-1" })).toBeNull();
});
it("reports the AWS bootstrap config required by doctor", () => {
const result = secretsCheck(configWithSecretsProvider("aws_secrets_manager"));
expect(result.status).toBe("fail");
expect(result.message).toContain("PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID");
expect(result.repairHint).toContain("AWS SDK default credential chain");
expect(result.repairHint).toContain("Do not store AWS root credentials");
});
it("passes AWS doctor checks when non-secret provider config is present", () => {
process.env.PAPERCLIP_SECRETS_AWS_REGION = "us-east-1";
process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID = "prod-us-1";
process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID =
"arn:aws:kms:us-east-1:123456789012:key/test";
process.env.AWS_PROFILE = "paperclip-prod";
const result = secretsCheck(configWithSecretsProvider("aws_secrets_manager"));
expect(result.status).toBe("pass");
expect(result.message).toContain("prod-us-1");
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);
}
+506
View File
@@ -0,0 +1,506 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerSkillsCommands } from "../commands/client/skills.js";
import { resolveCompanySkillReference } from "../commands/client/skills.js";
const ORIGINAL_ENV = { ...process.env };
function makeProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => undefined,
writeErr: () => undefined,
});
registerSkillsCommands(program);
return program;
}
async function runCommand(args: string[]): Promise<void> {
await makeProgram().parseAsync(args, { from: "user" });
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function skill(overrides: Record<string, unknown> = {}) {
return {
id: "11111111-1111-1111-1111-111111111111",
companyId: "company-1",
key: "paperclip/review-prs",
slug: "review-prs",
name: "Review PRs",
description: "Review pull requests",
markdown: "# Review PRs",
sourceType: "local_path",
sourceLocator: null,
sourceRef: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
metadata: null,
createdAt: "2026-05-26T00:00:00.000Z",
updatedAt: "2026-05-26T00:00:00.000Z",
attachedAgentCount: 2,
editable: true,
editableReason: null,
sourceLabel: null,
sourceBadge: "local",
sourcePath: null,
...overrides,
};
}
function catalogSkill(overrides: Record<string, unknown> = {}) {
return {
id: "paperclipai:bundled:software-development:github-pr-workflow",
key: "paperclipai/bundled/software-development/github-pr-workflow",
kind: "bundled",
category: "software-development",
slug: "github-pr-workflow",
name: "github-pr-workflow",
description: "Prepare pull requests, review responses, and verification notes.",
path: "catalog/bundled/software-development/github-pr-workflow",
entrypoint: "SKILL.md",
trustLevel: "markdown_only",
compatibility: "compatible",
defaultInstall: false,
recommendedForRoles: ["engineer"],
requires: [],
tags: ["github", "pull-requests"],
files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 128, sha256: "sha256:abc" }],
contentHash: "sha256:catalog",
...overrides,
};
}
function agent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-1",
companyId: "company-1",
name: "Coder",
role: "engineer",
status: "active",
reportsTo: null,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
createdAt: "2026-05-26T00:00:00.000Z",
updatedAt: "2026-05-26T00:00:00.000Z",
...overrides,
};
}
describe("skills CLI helpers", () => {
it("resolves skill refs by id, key, or unique normalized slug", () => {
const rows = [
skill({ id: "skill-a", key: "paperclip/a", slug: "alpha", name: "Alpha" }),
skill({ id: "skill-b", key: "paperclip/b", slug: "beta-skill", name: "Beta" }),
];
expect(resolveCompanySkillReference(rows, "skill-a").key).toBe("paperclip/a");
expect(resolveCompanySkillReference(rows, "paperclip/b").id).toBe("skill-b");
expect(resolveCompanySkillReference(rows, "Beta Skill").id).toBe("skill-b");
});
it("rejects ambiguous slug refs", () => {
const rows = [
skill({ id: "skill-a", key: "paperclip/a", slug: "same", name: "A" }),
skill({ id: "skill-b", key: "paperclip/b", slug: "same", name: "B" }),
];
expect(() => resolveCompanySkillReference(rows, "same")).toThrow(/Ambiguous skill slug/);
});
});
describe("skills CLI commands", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let writeChunks: unknown[];
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_COMPANY_ID;
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
writeChunks = [];
vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
writeChunks.push(chunk);
return true;
});
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("lists company skills as JSON through the shared client context", async () => {
const rows = [skill()];
fetchMock.mockResolvedValueOnce(jsonResponse(rows));
await runCommand([
"skills",
"list",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/company-1/skills",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ authorization: "Bearer token" }),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(rows);
});
it("resolves a skill slug before reading detail", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([skill()]))
.mockResolvedValueOnce(jsonResponse({ ...skill(), usedByAgents: [] }));
await runCommand([
"skills",
"show",
"Review PRs",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111",
expect.objectContaining({ method: "GET" }),
);
});
it("prints skill files as raw pipeable content in human mode", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([skill()]))
.mockResolvedValueOnce(jsonResponse({
skillId: "11111111-1111-1111-1111-111111111111",
path: "SKILL.md",
kind: "skill",
content: "# Review PRs",
language: "markdown",
markdown: true,
editable: true,
}));
await runCommand([
"skills",
"file",
"review-prs",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
]);
expect(logSpy).not.toHaveBeenCalled();
expect(writeChunks.join("")).toBe("# Review PRs\n");
});
it("browses catalog skills with filters in table output", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse([catalogSkill()]));
await runCommand([
"skills",
"browse",
"--kind",
"bundled",
"--category",
"software-development",
"--query",
"github",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/skills/catalog?kind=bundled&category=software-development&q=github",
expect.objectContaining({ method: "GET" }),
);
const rendered = logSpy.mock.calls.map((call: unknown[]) => String(call[0])).join("\n");
expect(rendered).toContain("id");
expect(rendered).toContain("paperclipai:bundled:software-development:github-pr-workflow");
expect(rendered).toContain("roles");
});
it("searches catalog skills as JSON", async () => {
const rows = [catalogSkill()];
fetchMock.mockResolvedValueOnce(jsonResponse(rows));
await runCommand([
"skills",
"search",
"pull requests",
"--kind",
"bundled",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/skills/catalog?kind=bundled&q=pull+requests",
expect.objectContaining({ method: "GET" }),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(rows);
});
it("inspects catalog skill detail by query ref so keys with slashes work", async () => {
const detail = catalogSkill();
fetchMock.mockResolvedValueOnce(jsonResponse(detail));
await runCommand([
"skills",
"inspect",
"paperclipai/bundled/software-development/github-pr-workflow",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/skills/catalog/ref?ref=paperclipai%2Fbundled%2Fsoftware-development%2Fgithub-pr-workflow",
expect.objectContaining({ method: "GET" }),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(detail);
});
it("installs catalog skills into the company library without agent sync", async () => {
const result = {
action: "created",
skill: skill({
key: "paperclipai/bundled/software-development/github-pr-workflow",
slug: "pr-flow",
sourceType: "catalog",
}),
catalogSkill: catalogSkill(),
warnings: [],
};
fetchMock.mockResolvedValueOnce(jsonResponse(result, 201));
await runCommand([
"skills",
"install",
"github-pr-workflow",
"--as",
"pr-flow",
"--force",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/company-1/skills/install-catalog",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
catalogSkillId: "github-pr-workflow",
slug: "pr-flow",
force: true,
}),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(result);
});
it("passes force to skill updates", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([skill()]))
.mockResolvedValueOnce(jsonResponse(skill({ sourceRef: "sha256:new" })));
await runCommand([
"skills",
"update",
"review-prs",
"--force",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111/install-update",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ force: true }),
}),
);
});
it("audits installed skill bytes through the server", async () => {
const audit = {
skillId: "11111111-1111-1111-1111-111111111111",
installedHash: "sha256:installed",
originHash: "sha256:origin",
verdict: "warning",
codes: ["network_reference"],
findings: [{
code: "network_reference",
severity: "warning",
message: "Skill content references network-capable commands or URLs.",
path: "SKILL.md",
}],
scannedAt: "2026-05-26T00:00:00.000Z",
scanVersion: "skills-audit-v1",
};
fetchMock
.mockResolvedValueOnce(jsonResponse([skill()]))
.mockResolvedValueOnce(jsonResponse(audit));
await runCommand([
"skills",
"audit",
"review-prs",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111/audit",
expect.objectContaining({
method: "POST",
body: JSON.stringify({}),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(audit);
});
it("requires confirmation for reset and sends force when confirmed", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([skill({ sourceType: "catalog" })]))
.mockResolvedValueOnce(jsonResponse(skill({ sourceType: "catalog" })));
await runCommand([
"skills",
"reset",
"review-prs",
"--yes",
"--force",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/skills/11111111-1111-1111-1111-111111111111/reset",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ force: true }),
}),
);
});
it("syncs desired company skill refs to an agent and returns the runtime snapshot", async () => {
const snapshot = {
adapterType: "codex_local",
supported: true,
mode: "persistent",
desiredSkills: ["paperclip/review-prs"],
entries: [
{
key: "paperclip/review-prs",
runtimeName: "review-prs",
desired: true,
managed: true,
required: false,
state: "installed",
origin: "company_managed",
detail: null,
},
],
warnings: [],
};
fetchMock
.mockResolvedValueOnce(jsonResponse(agent()))
.mockResolvedValueOnce(jsonResponse(snapshot));
await runCommand([
"skills",
"agent",
"sync",
"coder",
"--skill",
"review-prs",
"--skill",
"paperclip/qa",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://paperclip.test/api/agents/coder?companyId=company-1",
expect.objectContaining({ method: "GET" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/agents/agent-1/skills/sync",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ desiredSkills: ["review-prs", "paperclip/qa"] }),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(snapshot);
});
});
+581
View File
@@ -0,0 +1,581 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerTeamCommands } from "../commands/client/teams.js";
const ORIGINAL_ENV = { ...process.env };
function makeProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => undefined,
writeErr: () => undefined,
});
registerTeamCommands(program);
return program;
}
async function runCommand(args: string[]): Promise<void> {
await makeProgram().parseAsync(args, { from: "user" });
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function catalogTeam(overrides: Record<string, unknown> = {}) {
return {
id: "paperclipai:bundled:software-development:product-engineering",
key: "paperclipai/bundled/software-development/product-engineering",
kind: "bundled",
category: "software-development",
slug: "product-engineering",
name: "Product Engineering",
description: "A software development team with CTO, coder, and QA roles.",
path: "catalog/bundled/software-development/product-engineering",
entrypoint: "TEAM.md",
schema: "agentcompanies/v1",
defaultInstall: true,
recommendedForCompanyTypes: ["software"],
tags: ["engineering"],
counts: { agents: 3, projects: 1, tasks: 1, routines: 0, localSkills: 0, catalogSkills: 1, externalSkillSources: 0 },
rootAgentSlugs: ["cto"],
agentSlugs: ["cto", "senior-coder", "qa"],
projectSlugs: ["product-engineering"],
requiredSkills: [],
envInputs: [],
sourceRefs: [],
files: [{ path: "TEAM.md", kind: "team", sizeBytes: 128, sha256: "sha256:team" }],
trustLevel: "markdown_only",
compatibility: "compatible",
contentHash: "sha256:catalog-team",
...overrides,
};
}
function installedCatalogTeam(overrides: Record<string, unknown> = {}) {
return {
catalogId: "paperclipai:bundled:software-development:product-engineering",
catalogKey: "paperclipai/bundled/software-development/product-engineering",
present: true,
currentContentHash: "sha256:catalog-team",
installedOriginHashes: ["sha256:catalog-team"],
agentCount: 3,
outOfDate: false,
...overrides,
};
}
describe("teams CLI commands", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_COMPANY_ID;
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("browses catalog teams with filters in table output", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse([catalogTeam()]));
await runCommand([
"teams",
"browse",
"--kind",
"bundled",
"--category",
"software-development",
"--query",
"engineering",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/teams/catalog?kind=bundled&category=software-development&q=engineering",
expect.objectContaining({ method: "GET" }),
);
const rendered = logSpy.mock.calls.map((call: unknown[]) => String(call[0])).join("\n");
expect(rendered).toContain("id");
expect(rendered).toContain("paperclipai:bundled:software-development:product-engineering");
});
it("searches catalog teams as JSON", async () => {
const rows = [catalogTeam()];
fetchMock.mockResolvedValueOnce(jsonResponse(rows));
await runCommand([
"teams",
"search",
"engineering",
"--kind",
"bundled",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/teams/catalog?kind=bundled&q=engineering",
expect.objectContaining({ method: "GET" }),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(rows);
});
it("lists catalog teams with installed status for a company", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([
catalogTeam(),
catalogTeam({
id: "paperclipai:optional:content:content-machine",
key: "paperclipai/optional/content/content-machine",
kind: "optional",
category: "content",
slug: "content-machine",
name: "Content Machine",
contentHash: "sha256:content-current",
}),
]))
.mockResolvedValueOnce(jsonResponse([
installedCatalogTeam({
currentContentHash: "sha256:catalog-team",
installedOriginHashes: ["sha256:older"],
outOfDate: true,
}),
]));
await runCommand([
"teams",
"list",
"--kind",
"bundled",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://paperclip.test/api/teams/catalog?kind=bundled",
expect.objectContaining({ method: "GET" }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/teams/catalog/installed",
expect.objectContaining({ method: "GET" }),
);
const rendered = logSpy.mock.calls.map((call: unknown[]) => String(call[0])).join("\n");
expect(rendered).toContain("installedStatus");
expect(rendered).toContain("out_of_date");
expect(rendered).toContain("not_installed");
});
it("lists installed status as JSON including removed catalog teams", async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse([catalogTeam()]))
.mockResolvedValueOnce(jsonResponse([
installedCatalogTeam(),
installedCatalogTeam({
catalogId: "paperclipai:removed:team",
catalogKey: "paperclipai/removed/team",
present: false,
currentContentHash: null,
installedOriginHashes: ["sha256:removed"],
agentCount: 2,
}),
]));
await runCommand([
"teams",
"list",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
const rows = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(rows).toMatchObject([
{
catalogId: "paperclipai:bundled:software-development:product-engineering",
catalogKey: "paperclipai/bundled/software-development/product-engineering",
installedStatus: "installed",
installedAgentCount: 3,
outOfDate: false,
},
{
catalogId: "paperclipai:removed:team",
catalogKey: "paperclipai/removed/team",
installedStatus: "installed_missing",
installedAgentCount: 2,
present: false,
},
]);
});
it("inspects catalog team detail by query ref so keys with slashes work", async () => {
const detail = catalogTeam();
fetchMock.mockResolvedValueOnce(jsonResponse(detail));
await runCommand([
"teams",
"inspect",
"paperclipai/bundled/software-development/product-engineering",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/teams/catalog/ref?ref=paperclipai%2Fbundled%2Fsoftware-development%2Fproduct-engineering",
expect.objectContaining({ method: "GET" }),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(detail);
});
it("previews catalog team installs with trust policy flags", async () => {
const result = {
team: catalogTeam(),
portabilityPreview: {
plan: { companyAction: "none", agentPlans: [], projectPlans: [], issuePlans: [] },
warnings: [],
errors: [],
},
skillPreparations: [],
warnings: [],
errors: [],
};
fetchMock.mockResolvedValueOnce(jsonResponse(result));
await runCommand([
"teams",
"preview",
"product-engineering",
"--target-manager-slug",
"engineering-lead",
"--allow-external-sources",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/company-1/teams/catalog/ref/preview?ref=product-engineering",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
targetManagerSlug: "engineering-lead",
sourcePolicy: { allowExternalSources: true },
}),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(result);
});
it("installs catalog teams and passes selection options", async () => {
const result = {
team: catalogTeam(),
portabilityImport: {
company: { id: "company-1", name: "Paperclip", action: "unchanged" },
agents: [],
projects: [],
envInputs: [],
warnings: [],
},
skillPreparations: [],
warnings: [],
};
fetchMock.mockResolvedValueOnce(jsonResponse(result, 201));
await runCommand([
"teams",
"install",
"product-engineering",
"--agent",
"cto",
"--selected-file",
"agents/cto/AGENTS.md",
"--collision-strategy",
"skip",
"--secret-value",
"agent:cto:OPENAI_API_KEY=sk-test",
"--adapter-override",
"cto=opencode_local",
"--company-id",
"company/1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/company%2F1/teams/catalog/ref/install?ref=product-engineering",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
agents: ["cto"],
collisionStrategy: "skip",
selectedFiles: ["agents/cto/AGENTS.md"],
adapterOverrides: { cto: { adapterType: "opencode_local" } },
secretValues: { "agent:cto:OPENAI_API_KEY": "sk-test" },
}),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(result);
});
it("requests board approval when agent-run install lacks create-agent permission", async () => {
const approval = {
id: "approval-1",
companyId: "company-1",
type: "request_board_approval",
requestedByAgentId: "agent-1",
requestedByUserId: null,
status: "pending",
payload: {},
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
createdAt: "2026-06-04T00:00:00.000Z",
updatedAt: "2026-06-04T00:00:00.000Z",
};
fetchMock
.mockResolvedValueOnce(jsonResponse({ error: "Missing permission: can create agents" }, 403))
.mockResolvedValueOnce(jsonResponse(approval, 201));
await runCommand([
"teams",
"install",
"product-engineering",
"--target-manager-agent-id",
"manager-1",
"--collision-strategy",
"rename",
"--secret-value",
"agent:cto:OPENAI_API_KEY=sk-live-secret",
"--company-id",
"company-1",
"--request-approval-on-forbidden",
"--approval-issue-id",
"11111111-1111-4111-8111-111111111111",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://paperclip.test/api/companies/company-1/teams/catalog/ref/install?ref=product-engineering",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
targetManagerAgentId: "manager-1",
collisionStrategy: "rename",
secretValues: { "agent:cto:OPENAI_API_KEY": "sk-live-secret" },
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/approvals",
expect.objectContaining({
method: "POST",
body: expect.any(String),
}),
);
const approvalPayload = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body));
expect(approvalPayload).toMatchObject({
type: "request_board_approval",
issueIds: ["11111111-1111-4111-8111-111111111111"],
payload: {
title: "Approve catalog team install: product-engineering",
installAttempt: {
companyId: "company-1",
catalogRef: "product-engineering",
deniedReason: "Missing permission: can create agents",
options: {
targetManagerAgentId: "manager-1",
collisionStrategy: "rename",
},
},
},
});
const rendered = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(rendered).toMatchObject({
status: "approval_requested",
approval,
installAttempt: {
companyId: "company-1",
catalogRef: "product-engineering",
deniedReason: "Missing permission: can create agents",
options: {
targetManagerAgentId: "manager-1",
collisionStrategy: "rename",
secretValues: { "agent:cto:OPENAI_API_KEY": "[redacted]" },
},
},
});
expect(JSON.stringify(approvalPayload)).not.toContain("sk-live-secret");
expect(String(logSpy.mock.calls[0]?.[0])).not.toContain("sk-live-secret");
expect(errorSpy).not.toHaveBeenCalled();
});
it("auto-requests board approval for forbidden installs inside a Paperclip task run", async () => {
process.env.PAPERCLIP_TASK_ID = "11111111-1111-4111-8111-111111111111";
const approval = {
id: "approval-2",
companyId: "company-1",
type: "request_board_approval",
requestedByAgentId: "agent-1",
requestedByUserId: null,
status: "pending",
payload: {},
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
createdAt: "2026-06-04T00:00:00.000Z",
updatedAt: "2026-06-04T00:00:00.000Z",
};
fetchMock
.mockResolvedValueOnce(jsonResponse({ error: "Missing permission: can create agents" }, 403))
.mockResolvedValueOnce(jsonResponse(approval, 201));
await runCommand([
"teams",
"install",
"product-engineering",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"http://paperclip.test/api/companies/company-1/approvals",
expect.objectContaining({
method: "POST",
body: expect.any(String),
}),
);
const approvalPayload = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body));
expect(approvalPayload).toMatchObject({
type: "request_board_approval",
issueIds: ["11111111-1111-4111-8111-111111111111"],
payload: {
installAttempt: {
companyId: "company-1",
catalogRef: "product-engineering",
deniedReason: "Missing permission: can create agents",
},
},
});
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
status: "approval_requested",
approval,
});
expect(errorSpy).not.toHaveBeenCalled();
});
it("does not request board approval for unrelated forbidden install errors", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ error: "Agent key cannot access another company" }, 403));
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
throw new Error(`exit:${code ?? 0}`);
}) as typeof process.exit);
await expect(runCommand([
"teams",
"install",
"product-engineering",
"--company-id",
"company-1",
"--request-approval-on-forbidden",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
])).rejects.toThrow("exit:1");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/company-1/teams/catalog/ref/install?ref=product-engineering",
expect.objectContaining({
method: "POST",
body: JSON.stringify({}),
}),
);
expect(String(errorSpy.mock.calls[0]?.[0])).toContain("Agent key cannot access another company");
});
it("surfaces server blocks for unsafe local-path catalog team sources", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({
error: 'Local path skill source "../unsafe" is development-only and is not allowed for catalog team install.',
}, 422));
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
throw new Error(`exit:${code ?? 0}`);
}) as typeof process.exit);
await expect(runCommand([
"teams",
"install",
"unsafe-local-team",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
])).rejects.toThrow("exit:1");
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/company-1/teams/catalog/ref/install?ref=unsafe-local-team",
expect.objectContaining({
method: "POST",
body: JSON.stringify({}),
}),
);
expect(String(errorSpy.mock.calls[0]?.[0])).toContain("API error 422");
expect(String(errorSpy.mock.calls[0]?.[0])).toContain("development-only");
});
});
+117
View File
@@ -0,0 +1,117 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_ENV = { ...process.env };
const CI_ENV_VARS = ["CI", "CONTINUOUS_INTEGRATION", "BUILD_NUMBER", "GITHUB_ACTIONS", "GITLAB_CI"];
function makeConfigPath(root: string, enabled: boolean): string {
const configPath = path.join(root, ".paperclip", "config.json");
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
$meta: {
version: 1,
updatedAt: "2026-03-31T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(root, "runtime", "db"),
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(root, "runtime", "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(root, "runtime", "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
telemetry: {
enabled,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(root, "runtime", "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(root, "runtime", "secrets", "master.key"),
},
},
}, null, 2));
return configPath;
}
describe("cli telemetry", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
for (const key of CI_ENV_VARS) {
delete process.env[key];
}
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true })));
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
vi.unstubAllGlobals();
vi.resetModules();
});
it("respects telemetry.enabled=false from the config file", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-telemetry-"));
const configPath = makeConfigPath(root, false);
process.env.PAPERCLIP_HOME = path.join(root, "home");
process.env.PAPERCLIP_INSTANCE_ID = "telemetry-test";
const { initTelemetryFromConfigFile } = await import("../telemetry.js");
const client = initTelemetryFromConfigFile(configPath);
expect(client).toBeNull();
expect(fs.existsSync(path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json"))).toBe(false);
});
it("creates telemetry state only after the first event is tracked", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-telemetry-"));
process.env.PAPERCLIP_HOME = path.join(root, "home");
process.env.PAPERCLIP_INSTANCE_ID = "telemetry-test";
const { initTelemetry, flushTelemetry } = await import("../telemetry.js");
const client = initTelemetry({ enabled: true });
const statePath = path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json");
expect(client).not.toBeNull();
expect(fs.existsSync(statePath)).toBe(false);
client!.track("install.started", { setupMode: "quickstart" });
expect(fs.existsSync(statePath)).toBe(true);
await flushTelemetry();
});
});
+135
View File
@@ -0,0 +1,135 @@
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",
scope: { kind: "standard" },
});
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`);
});
});
@@ -0,0 +1,492 @@
import { describe, expect, it } from "vitest";
import { buildWorktreeMergePlan, parseWorktreeMergeScopes } from "../commands/worktree-merge-history-lib.js";
function makeIssue(overrides: Record<string, unknown> = {}) {
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: "goal-1",
parentId: null,
title: "Issue",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "local-board",
issueNumber: 1,
identifier: "PAP-1",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeComment(overrides: Record<string, unknown> = {}) {
return {
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "local-board",
body: "hello",
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeIssueDocument(overrides: Record<string, unknown> = {}) {
return {
id: "issue-document-1",
companyId: "company-1",
issueId: "issue-1",
documentId: "document-1",
key: "plan",
linkCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
linkUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
title: "Plan",
format: "markdown",
latestBody: "# Plan",
latestRevisionId: "revision-1",
latestRevisionNumber: 1,
createdByAgentId: null,
createdByUserId: "local-board",
updatedByAgentId: null,
updatedByUserId: "local-board",
documentCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
documentUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeDocumentRevision(overrides: Record<string, unknown> = {}) {
return {
id: "revision-1",
companyId: "company-1",
documentId: "document-1",
revisionNumber: 1,
body: "# Plan",
changeSummary: null,
createdByAgentId: null,
createdByUserId: "local-board",
createdAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeAttachment(overrides: Record<string, unknown> = {}) {
return {
id: "attachment-1",
companyId: "company-1",
issueId: "issue-1",
issueCommentId: null,
assetId: "asset-1",
provider: "local_disk",
objectKey: "company-1/issues/issue-1/2026/03/20/asset.png",
contentType: "image/png",
byteSize: 12,
sha256: "deadbeef",
originalFilename: "asset.png",
createdByAgentId: null,
createdByUserId: "local-board",
assetCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
assetUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
attachmentCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
attachmentUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeProject(overrides: Record<string, unknown> = {}) {
return {
id: "project-1",
companyId: "company-1",
goalId: null,
name: "Project",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#22c55e",
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
archivedAt: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeProjectWorkspace(overrides: Record<string, unknown> = {}) {
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: "https://github.com/example/project.git",
repoRef: "main",
defaultRef: "main",
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
isPrimary: true,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
describe("worktree merge history planner", () => {
it("parses default scopes", () => {
expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]);
expect(parseWorktreeMergeScopes("issues")).toEqual(["issues"]);
});
it("dedupes nested worktree issues by preserved source uuid", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10", title: "Shared" });
const branchOneIssue = makeIssue({
id: "issue-b",
identifier: "PAP-22",
title: "Branch one issue",
createdAt: new Date("2026-03-20T01:00:00.000Z"),
});
const branchTwoIssue = makeIssue({
id: "issue-c",
identifier: "PAP-23",
title: "Branch two issue",
createdAt: new Date("2026-03-20T02:00:00.000Z"),
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 500,
scopes: ["issues", "comments"],
sourceIssues: [sharedIssue, branchOneIssue, branchTwoIssue],
targetIssues: [sharedIssue, branchOneIssue],
sourceComments: [],
targetComments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.issuesToInsert).toBe(1);
expect(plan.issuePlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual(["issue-c"]);
expect(plan.issuePlans.find((item) => item.source.id === "issue-c" && item.action === "insert")).toMatchObject({
previewIdentifier: "PAP-501",
});
});
it("clears missing references and coerces in_progress without an assignee", () => {
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [
makeIssue({
id: "issue-x",
identifier: "PAP-99",
status: "in_progress",
assigneeAgentId: "agent-missing",
projectId: "project-missing",
projectWorkspaceId: "workspace-missing",
goalId: "goal-missing",
}),
],
targetIssues: [],
sourceComments: [],
targetComments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [],
});
const insert = plan.issuePlans[0] as any;
expect(insert.targetStatus).toBe("todo");
expect(insert.targetAssigneeAgentId).toBeNull();
expect(insert.targetProjectId).toBeNull();
expect(insert.targetProjectWorkspaceId).toBeNull();
expect(insert.targetGoalId).toBeNull();
expect(insert.adjustments).toEqual([
"clear_assignee_agent",
"clear_project",
"clear_project_workspace",
"clear_goal",
"coerce_in_progress_to_todo",
]);
});
it("applies an explicit project mapping override instead of clearing the project", () => {
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [
makeIssue({
id: "issue-project-map",
identifier: "PAP-77",
projectId: "source-project-1",
projectWorkspaceId: "source-workspace-1",
}),
],
targetIssues: [],
sourceComments: [],
targetComments: [],
targetAgents: [],
targetProjects: [{ id: "target-project-1", name: "Mapped project", status: "in_progress" }] as any,
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
projectIdOverrides: {
"source-project-1": "target-project-1",
},
});
const insert = plan.issuePlans[0] as any;
expect(insert.targetProjectId).toBe("target-project-1");
expect(insert.projectResolution).toBe("mapped");
expect(insert.mappedProjectName).toBe("Mapped project");
expect(insert.targetProjectWorkspaceId).toBeNull();
expect(insert.adjustments).toEqual(["clear_project_workspace"]);
});
it("plans selected project imports and preserves project workspace links", () => {
const sourceProject = makeProject({
id: "source-project-1",
name: "Paperclip Evals",
goalId: "goal-1",
});
const sourceWorkspace = makeProjectWorkspace({
id: "source-workspace-1",
projectId: "source-project-1",
cwd: "/Users/dotta/paperclip-evals",
repoUrl: "https://github.com/paperclipai/paperclip-evals.git",
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [
makeIssue({
id: "issue-project-import",
identifier: "PAP-88",
projectId: "source-project-1",
projectWorkspaceId: "source-workspace-1",
}),
],
targetIssues: [],
sourceComments: [],
targetComments: [],
sourceProjects: [sourceProject],
sourceProjectWorkspaces: [sourceWorkspace],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
importProjectIds: ["source-project-1"],
});
expect(plan.counts.projectsToImport).toBe(1);
expect(plan.projectImports[0]).toMatchObject({
source: { id: "source-project-1", name: "Paperclip Evals" },
targetGoalId: "goal-1",
workspaces: [{ id: "source-workspace-1" }],
});
const insert = plan.issuePlans[0] as any;
expect(insert.targetProjectId).toBe("source-project-1");
expect(insert.targetProjectWorkspaceId).toBe("source-workspace-1");
expect(insert.projectResolution).toBe("imported");
expect(insert.mappedProjectName).toBe("Paperclip Evals");
expect(insert.adjustments).toEqual([]);
});
it("imports comments onto shared or newly imported issues while skipping existing comments", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
const newIssue = makeIssue({
id: "issue-b",
identifier: "PAP-11",
createdAt: new Date("2026-03-20T01:00:00.000Z"),
});
const existingComment = makeComment({ id: "comment-existing", issueId: "issue-a" });
const sharedIssueComment = makeComment({ id: "comment-shared", issueId: "issue-a" });
const newIssueComment = makeComment({
id: "comment-new-issue",
issueId: "issue-b",
authorAgentId: "missing-agent",
createdAt: new Date("2026-03-20T01:05:00.000Z"),
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues", "comments"],
sourceIssues: [sharedIssue, newIssue],
targetIssues: [sharedIssue],
sourceComments: [existingComment, sharedIssueComment, newIssueComment],
targetComments: [existingComment],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.commentsToInsert).toBe(2);
expect(plan.counts.commentsExisting).toBe(1);
expect(plan.commentPlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual([
"comment-shared",
"comment-new-issue",
]);
expect(plan.adjustments.clear_author_agent).toBe(1);
});
it("merges document revisions onto an existing shared document and renumbers conflicts", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
const sourceDocument = makeIssueDocument({
issueId: "issue-a",
documentId: "document-a",
latestBody: "# Branch plan",
latestRevisionId: "revision-branch-2",
latestRevisionNumber: 2,
documentUpdatedAt: new Date("2026-03-20T02:00:00.000Z"),
linkUpdatedAt: new Date("2026-03-20T02:00:00.000Z"),
});
const targetDocument = makeIssueDocument({
issueId: "issue-a",
documentId: "document-a",
latestBody: "# Main plan",
latestRevisionId: "revision-main-2",
latestRevisionNumber: 2,
documentUpdatedAt: new Date("2026-03-20T01:00:00.000Z"),
linkUpdatedAt: new Date("2026-03-20T01:00:00.000Z"),
});
const sourceRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" });
const sourceRevisionTwo = makeDocumentRevision({
documentId: "document-a",
id: "revision-branch-2",
revisionNumber: 2,
body: "# Branch plan",
createdAt: new Date("2026-03-20T02:00:00.000Z"),
});
const targetRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" });
const targetRevisionTwo = makeDocumentRevision({
documentId: "document-a",
id: "revision-main-2",
revisionNumber: 2,
body: "# Main plan",
createdAt: new Date("2026-03-20T01:00:00.000Z"),
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues", "comments"],
sourceIssues: [sharedIssue],
targetIssues: [sharedIssue],
sourceComments: [],
targetComments: [],
sourceDocuments: [sourceDocument],
targetDocuments: [targetDocument],
sourceDocumentRevisions: [sourceRevisionOne, sourceRevisionTwo],
targetDocumentRevisions: [targetRevisionOne, targetRevisionTwo],
sourceAttachments: [],
targetAttachments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.documentsToMerge).toBe(1);
expect(plan.counts.documentRevisionsToInsert).toBe(1);
expect(plan.documentPlans[0]).toMatchObject({
action: "merge_existing",
latestRevisionId: "revision-branch-2",
latestRevisionNumber: 3,
});
const mergePlan = plan.documentPlans[0] as any;
expect(mergePlan.revisionsToInsert).toHaveLength(1);
expect(mergePlan.revisionsToInsert[0]).toMatchObject({
source: { id: "revision-branch-2" },
targetRevisionNumber: 3,
});
});
it("imports attachments while clearing missing comment and author references", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
const attachment = makeAttachment({
issueId: "issue-a",
issueCommentId: "comment-missing",
createdByAgentId: "agent-missing",
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [sharedIssue],
targetIssues: [sharedIssue],
sourceComments: [],
targetComments: [],
sourceDocuments: [],
targetDocuments: [],
sourceDocumentRevisions: [],
targetDocumentRevisions: [],
sourceAttachments: [attachment],
targetAttachments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.attachmentsToInsert).toBe(1);
expect(plan.adjustments.clear_attachment_agent).toBe(1);
expect(plan.attachmentPlans[0]).toMatchObject({
action: "insert",
targetIssueCommentId: null,
targetCreatedByAgentId: null,
});
});
});
File diff suppressed because it is too large. Load diff
+4
View File
@@ -0,0 +1,4 @@
export function printHttpStdoutEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
if (line) console.log(line);
}
+7
View File
@@ -0,0 +1,7 @@
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
import { printHttpStdoutEvent } from "./format-event.js";
export const httpCLIAdapter: CLIAdapterModule = {
type: "http",
formatStdoutEvent: printHttpStdoutEvent,
};
+2
View File
@@ -0,0 +1,2 @@
export { getCLIAdapter } from "./registry.js";
export type { CLIAdapterModule } from "@paperclipai/adapter-utils";
+4
View File
@@ -0,0 +1,4 @@
export function printProcessStdoutEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
if (line) console.log(line);
}
+7
View File
@@ -0,0 +1,7 @@
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
import { printProcessStdoutEvent } from "./format-event.js";
export const processCLIAdapter: CLIAdapterModule = {
type: "process",
formatStdoutEvent: printProcessStdoutEvent,
};
+91
View File
@@ -0,0 +1,91 @@
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
import { printCursorCloudEvent } from "@paperclipai/adapter-cursor-cloud/cli";
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
import { printGrokStreamEvent } from "@paperclipai/adapter-grok-local/cli";
import { formatStdoutEvent as printHermesGatewayStreamEvent } from "@paperclipai/hermes-paperclip-adapter/gateway/cli";
import { printHermesStreamEvent } from "@paperclipai/hermes-paperclip-adapter/cli";
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
import { processCLIAdapter } from "./process/index.js";
import { httpCLIAdapter } from "./http/index.js";
const claudeLocalCLIAdapter: CLIAdapterModule = {
type: "claude_local",
formatStdoutEvent: printClaudeStreamEvent,
};
const codexLocalCLIAdapter: CLIAdapterModule = {
type: "codex_local",
formatStdoutEvent: printCodexStreamEvent,
};
const openCodeLocalCLIAdapter: CLIAdapterModule = {
type: "opencode_local",
formatStdoutEvent: printOpenCodeStreamEvent,
};
const piLocalCLIAdapter: CLIAdapterModule = {
type: "pi_local",
formatStdoutEvent: printPiStreamEvent,
};
const cursorLocalCLIAdapter: CLIAdapterModule = {
type: "cursor",
formatStdoutEvent: printCursorStreamEvent,
};
const cursorCloudCLIAdapter: CLIAdapterModule = {
type: "cursor_cloud",
formatStdoutEvent: printCursorCloudEvent,
};
const geminiLocalCLIAdapter: CLIAdapterModule = {
type: "gemini_local",
formatStdoutEvent: printGeminiStreamEvent,
};
const grokLocalCLIAdapter: CLIAdapterModule = {
type: "grok_local",
formatStdoutEvent: printGrokStreamEvent,
};
const hermesGatewayCLIAdapter: CLIAdapterModule = {
type: "hermes_gateway",
formatStdoutEvent: printHermesGatewayStreamEvent,
};
const hermesLocalCLIAdapter: CLIAdapterModule = {
type: "hermes_local",
formatStdoutEvent: printHermesStreamEvent,
};
const openclawGatewayCLIAdapter: CLIAdapterModule = {
type: "openclaw_gateway",
formatStdoutEvent: printOpenClawGatewayStreamEvent,
};
const adaptersByType = new Map<string, CLIAdapterModule>(
[
claudeLocalCLIAdapter,
codexLocalCLIAdapter,
openCodeLocalCLIAdapter,
piLocalCLIAdapter,
cursorLocalCLIAdapter,
cursorCloudCLIAdapter,
geminiLocalCLIAdapter,
grokLocalCLIAdapter,
hermesGatewayCLIAdapter,
hermesLocalCLIAdapter,
openclawGatewayCLIAdapter,
processCLIAdapter,
httpCLIAdapter,
].map((a) => [a.type, a]),
);
export function getCLIAdapter(type: string): CLIAdapterModule {
return adaptersByType.get(type) ?? processCLIAdapter;
}
+40
View File
@@ -0,0 +1,40 @@
import {
ensureAgentJwtSecret,
readAgentJwtSecretFromEnv,
readAgentJwtSecretFromEnvFile,
resolveAgentJwtEnvFile,
} from "../config/env.js";
import type { CheckResult } from "./index.js";
export function agentJwtSecretCheck(configPath?: string): CheckResult {
if (readAgentJwtSecretFromEnv(configPath)) {
return {
name: "Agent JWT secret",
status: "pass",
message: "PAPERCLIP_AGENT_JWT_SECRET is set in environment",
};
}
const envPath = resolveAgentJwtEnvFile(configPath);
const fileSecret = readAgentJwtSecretFromEnvFile(envPath);
if (fileSecret) {
return {
name: "Agent JWT secret",
status: "warn",
message: `PAPERCLIP_AGENT_JWT_SECRET is present in ${envPath} but not loaded into environment`,
repairHint: `Set the value from ${envPath} in your shell before starting the Paperclip server`,
};
}
return {
name: "Agent JWT secret",
status: "fail",
message: `PAPERCLIP_AGENT_JWT_SECRET missing from environment and ${envPath}`,
canRepair: true,
repair: () => {
ensureAgentJwtSecret(configPath);
},
repairHint: `Run with --repair to create ${envPath} containing PAPERCLIP_AGENT_JWT_SECRET`,
};
}
+33
View File
@@ -0,0 +1,33 @@
import { readConfig, configExists, resolveConfigPath } from "../config/store.js";
import type { CheckResult } from "./index.js";
export function configCheck(configPath?: string): CheckResult {
const filePath = resolveConfigPath(configPath);
if (!configExists(configPath)) {
return {
name: "Config file",
status: "fail",
message: `Config file not found at ${filePath}`,
canRepair: false,
repairHint: "Run `paperclipai onboard` to create one",
};
}
try {
readConfig(configPath);
return {
name: "Config file",
status: "pass",
message: `Valid config at ${filePath}`,
};
} catch (err) {
return {
name: "Config file",
status: "fail",
message: `Invalid config: ${err instanceof Error ? err.message : String(err)}`,
canRepair: false,
repairHint: "Run `paperclipai configure --section database` (or `paperclipai onboard` to recreate)",
};
}
}
+59
View File
@@ -0,0 +1,59 @@
import fs from "node:fs";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
import { resolveRuntimeLikePath } from "./path-resolver.js";
export async function databaseCheck(config: PaperclipConfig, configPath?: string): Promise<CheckResult> {
if (config.database.mode === "postgres") {
if (!config.database.connectionString) {
return {
name: "Database",
status: "fail",
message: "PostgreSQL mode selected but no connection string configured",
canRepair: false,
repairHint: "Run `paperclipai configure --section database`",
};
}
try {
const { createDb } = await import("@paperclipai/db");
const db = createDb(config.database.connectionString);
await db.execute("SELECT 1");
return {
name: "Database",
status: "pass",
message: "PostgreSQL connection successful",
};
} catch (err) {
return {
name: "Database",
status: "fail",
message: `Cannot connect to PostgreSQL: ${err instanceof Error ? err.message : String(err)}`,
canRepair: false,
repairHint: "Check your connection string and ensure PostgreSQL is running",
};
}
}
if (config.database.mode === "embedded-postgres") {
const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath);
const reportedPath = dataDir;
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(reportedPath, { recursive: true });
}
return {
name: "Database",
status: "pass",
message: `Embedded PostgreSQL configured at ${dataDir} (port ${config.database.embeddedPostgresPort})`,
};
}
return {
name: "Database",
status: "fail",
message: `Unknown database mode: ${String(config.database.mode)}`,
canRepair: false,
repairHint: "Run `paperclipai configure --section database`",
};
}
+88
View File
@@ -0,0 +1,88 @@
import { inferBindModeFromHost } from "@paperclipai/shared";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
const mode = config.server.deploymentMode;
const exposure = config.server.exposure;
const auth = config.auth;
const bind = config.server.bind ?? inferBindModeFromHost(config.server.host);
if (mode === "local_trusted") {
if (bind !== "loopback") {
return {
name: "Deployment/auth mode",
status: "fail",
message: `local_trusted requires loopback binding (found ${bind})`,
canRepair: false,
repairHint: "Run `paperclipai configure --section server` and choose Local trusted / loopback reachability",
};
}
return {
name: "Deployment/auth mode",
status: "pass",
message: "local_trusted mode is configured for loopback-only access",
};
}
const secret =
process.env.BETTER_AUTH_SECRET?.trim() ??
process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim();
if (!secret) {
return {
name: "Deployment/auth mode",
status: "fail",
message: "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET)",
canRepair: false,
repairHint: "Set BETTER_AUTH_SECRET before starting Paperclip",
};
}
if (auth.baseUrlMode === "explicit" && !auth.publicBaseUrl) {
return {
name: "Deployment/auth mode",
status: "fail",
message: "auth.baseUrlMode=explicit requires auth.publicBaseUrl",
canRepair: false,
repairHint: "Run `paperclipai configure --section server` and provide a base URL",
};
}
if (exposure === "public") {
if (auth.baseUrlMode !== "explicit" || !auth.publicBaseUrl) {
return {
name: "Deployment/auth mode",
status: "fail",
message: "authenticated/public requires explicit auth.publicBaseUrl",
canRepair: false,
repairHint: "Run `paperclipai configure --section server` and select public exposure",
};
}
try {
const url = new URL(auth.publicBaseUrl);
if (url.protocol !== "https:") {
return {
name: "Deployment/auth mode",
status: "warn",
message: "Public exposure should use an https:// auth.publicBaseUrl",
canRepair: false,
repairHint: "Use HTTPS in production for secure session cookies",
};
}
} catch {
return {
name: "Deployment/auth mode",
status: "fail",
message: "auth.publicBaseUrl is not a valid URL",
canRepair: false,
repairHint: "Run `paperclipai configure --section server` and provide a valid URL",
};
}
}
return {
name: "Deployment/auth mode",
status: "pass",
message: `Mode ${mode}/${exposure} with bind ${bind} and auth URL mode ${auth.baseUrlMode}`,
};
}
+18
View File
@@ -0,0 +1,18 @@
export interface CheckResult {
name: string;
status: "pass" | "warn" | "fail";
message: string;
canRepair?: boolean;
repair?: () => void | Promise<void>;
repairHint?: string;
}
export { agentJwtSecretCheck } from "./agent-jwt-secret-check.js";
export { configCheck } from "./config-check.js";
export { deploymentAuthCheck } from "./deployment-auth-check.js";
export { databaseCheck } from "./database-check.js";
export { llmCheck } from "./llm-check.js";
export { logCheck } from "./log-check.js";
export { portCheck } from "./port-check.js";
export { secretsCheck } from "./secrets-check.js";
export { storageCheck } from "./storage-check.js";
+82
View File
@@ -0,0 +1,82 @@
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
export async function llmCheck(config: PaperclipConfig): Promise<CheckResult> {
if (!config.llm) {
return {
name: "LLM provider",
status: "pass",
message: "No LLM provider configured (optional)",
};
}
if (!config.llm.apiKey) {
return {
name: "LLM provider",
status: "pass",
message: `${config.llm.provider} configured but no API key set (optional)`,
};
}
try {
if (config.llm.provider === "claude") {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": config.llm.apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4-5-20250929",
max_tokens: 1,
messages: [{ role: "user", content: "hi" }],
}),
});
if (res.ok || res.status === 400) {
return { name: "LLM provider", status: "pass", message: "Claude API key is valid" };
}
if (res.status === 401) {
return {
name: "LLM provider",
status: "fail",
message: "Claude API key is invalid (401)",
canRepair: false,
repairHint: "Run `paperclipai configure --section llm`",
};
}
return {
name: "LLM provider",
status: "warn",
message: `Claude API returned status ${res.status}`,
};
} else {
const res = await fetch("https://api.openai.com/v1/models", {
headers: { Authorization: `Bearer ${config.llm.apiKey}` },
});
if (res.ok) {
return { name: "LLM provider", status: "pass", message: "OpenAI API key is valid" };
}
if (res.status === 401) {
return {
name: "LLM provider",
status: "fail",
message: "OpenAI API key is invalid (401)",
canRepair: false,
repairHint: "Run `paperclipai configure --section llm`",
};
}
return {
name: "LLM provider",
status: "warn",
message: `OpenAI API returned status ${res.status}`,
};
}
} catch {
return {
name: "LLM provider",
status: "warn",
message: "Could not reach API to validate key",
};
}
}
+30
View File
@@ -0,0 +1,30 @@
import fs from "node:fs";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
import { resolveRuntimeLikePath } from "./path-resolver.js";
export function logCheck(config: PaperclipConfig, configPath?: string): CheckResult {
const logDir = resolveRuntimeLikePath(config.logging.logDir, configPath);
const reportedDir = logDir;
if (!fs.existsSync(logDir)) {
fs.mkdirSync(reportedDir, { recursive: true });
}
try {
fs.accessSync(reportedDir, fs.constants.W_OK);
return {
name: "Log directory",
status: "pass",
message: `Log directory is writable: ${reportedDir}`,
};
} catch {
return {
name: "Log directory",
status: "fail",
message: `Log directory is not writable: ${logDir}`,
canRepair: false,
repairHint: "Check file permissions on the log directory",
};
}
}
+1
View File
@@ -0,0 +1 @@
export { resolveRuntimeLikePath } from "../utils/path-resolver.js";
+24
View File
@@ -0,0 +1,24 @@
import type { PaperclipConfig } from "../config/schema.js";
import { checkPort } from "../utils/net.js";
import type { CheckResult } from "./index.js";
export async function portCheck(config: PaperclipConfig): Promise<CheckResult> {
const port = config.server.port;
const result = await checkPort(port);
if (result.available) {
return {
name: "Server port",
status: "pass",
message: `Port ${port} is available`,
};
}
return {
name: "Server port",
status: "warn",
message: result.error ?? `Port ${port} is not available`,
canRepair: false,
repairHint: `Check what's using port ${port} with: lsof -i :${port}`,
};
}
+240
View File
@@ -0,0 +1,240 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
import { resolveRuntimeLikePath } from "./path-resolver.js";
const AWS_CREDENTIAL_SOURCE_HINT =
"Provide AWS runtime credentials through the AWS SDK default credential chain: IAM role/workload identity, AWS_PROFILE/SSO/shared credentials, web identity, container/instance metadata, or short-lived shell credentials";
function decodeMasterKey(raw: string): Buffer | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^[A-Fa-f0-9]{64}$/.test(trimmed)) {
return Buffer.from(trimmed, "hex");
}
try {
const decoded = Buffer.from(trimmed, "base64");
if (decoded.length === 32) return decoded;
} catch {
// ignored
}
if (Buffer.byteLength(trimmed, "utf8") === 32) {
return Buffer.from(trimmed, "utf8");
}
return null;
}
function withStrictModeNote(
base: Pick<CheckResult, "name" | "status" | "message" | "canRepair" | "repair" | "repairHint">,
config: PaperclipConfig,
): CheckResult {
const strictModeDisabledInDeployedSetup =
config.database.mode === "postgres" && config.secrets.strictMode === false;
if (!strictModeDisabledInDeployedSetup) return base;
if (base.status === "fail") return base;
return {
...base,
status: "warn",
message: `${base.message}; strict secret mode is disabled for postgres deployment`,
repairHint: base.repairHint
? `${base.repairHint}. Consider enabling secrets.strictMode`
: "Consider enabling secrets.strictMode",
};
}
export function secretsCheck(config: PaperclipConfig, configPath?: string): CheckResult {
const provider = config.secrets.provider;
if (provider === "aws_secrets_manager") {
return withStrictModeNote(awsSecretsManagerCheck(), config);
}
if (provider !== "local_encrypted") {
return {
name: "Secrets adapter",
status: "fail",
message: `${provider} is configured, but this build only supports local_encrypted and aws_secrets_manager`,
canRepair: false,
repairHint: "Run `paperclipai configure --section secrets` and choose local_encrypted or aws_secrets_manager",
};
}
const envMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
if (envMasterKey && envMasterKey.trim().length > 0) {
if (!decodeMasterKey(envMasterKey)) {
return {
name: "Secrets adapter",
status: "fail",
message:
"PAPERCLIP_SECRETS_MASTER_KEY is invalid (expected 32-byte base64, 64-char hex, or raw 32-char string)",
canRepair: false,
repairHint: "Set PAPERCLIP_SECRETS_MASTER_KEY to a valid key or unset it to use a key file",
};
}
return withStrictModeNote(
{
name: "Secrets adapter",
status: "pass",
message: "Local encrypted provider configured via PAPERCLIP_SECRETS_MASTER_KEY",
},
config,
);
}
const keyFileOverride = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const configuredPath =
keyFileOverride && keyFileOverride.trim().length > 0
? keyFileOverride.trim()
: config.secrets.localEncrypted.keyFilePath;
const keyFilePath = resolveRuntimeLikePath(configuredPath, configPath);
if (!fs.existsSync(keyFilePath)) {
return withStrictModeNote(
{
name: "Secrets adapter",
status: "warn",
message: `Secrets key file does not exist yet: ${keyFilePath}`,
canRepair: true,
repair: () => {
fs.mkdirSync(path.dirname(keyFilePath), { recursive: true });
fs.writeFileSync(keyFilePath, randomBytes(32).toString("base64"), {
encoding: "utf8",
mode: 0o600,
});
try {
fs.chmodSync(keyFilePath, 0o600);
} catch {
// best effort
}
},
repairHint: "Run with --repair to create a local encrypted secrets key file",
},
config,
);
}
let raw: string;
try {
raw = fs.readFileSync(keyFilePath, "utf8");
} catch (err) {
return {
name: "Secrets adapter",
status: "fail",
message: `Could not read secrets key file: ${err instanceof Error ? err.message : String(err)}`,
canRepair: false,
repairHint: "Check file permissions or set PAPERCLIP_SECRETS_MASTER_KEY",
};
}
if (!decodeMasterKey(raw)) {
return {
name: "Secrets adapter",
status: "fail",
message: `Invalid key material in ${keyFilePath}`,
canRepair: false,
repairHint: "Replace with valid key material or delete it and run doctor --repair",
};
}
const keyMode = fs.statSync(keyFilePath).mode & 0o777;
const permissionWarning =
(keyMode & 0o077) !== 0
? `; key file permissions are ${keyMode.toString(8)} (run chmod 600 ${keyFilePath})`
: "";
return withStrictModeNote(
{
name: "Secrets adapter",
status: permissionWarning ? "warn" : "pass",
message: `Local encrypted provider configured with key file ${keyFilePath}${permissionWarning}`,
repairHint: permissionWarning
? "Restrict the local encrypted secrets key file to owner read/write permissions"
: undefined,
},
config,
);
}
function awsSecretsManagerCheck(): CheckResult {
const missingConfig = missingAwsSecretsManagerConfig();
if (missingConfig.length > 0) {
return {
name: "Secrets adapter",
status: "fail",
message: `AWS Secrets Manager provider is missing non-secret config: ${missingConfig.join(", ")}`,
canRepair: false,
repairHint:
`Set ${missingConfig.join(", ")} in the Paperclip server runtime. ${AWS_CREDENTIAL_SOURCE_HINT}. Do not store AWS root credentials or long-lived IAM user keys in Paperclip secrets.`,
};
}
const staticEnvCredentials =
process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim();
const credentialSource = detectedAwsCredentialSources().join(", ");
const message =
`AWS Secrets Manager provider configured for deployment ${process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID}; ` +
`runtime credentials source: ${credentialSource || "AWS SDK default credential chain"}`;
if (staticEnvCredentials) {
return {
name: "Secrets adapter",
status: "warn",
message,
canRepair: false,
repairHint:
"AWS static environment credentials are visible. Use only short-lived shell credentials locally; prefer IAM role/workload identity for hosted deployments and never store AWS access keys in Paperclip company secrets.",
};
}
return {
name: "Secrets adapter",
status: "pass",
message,
};
}
function missingAwsSecretsManagerConfig(): string[] {
const missing: string[] = [];
if (
!(
process.env.PAPERCLIP_SECRETS_AWS_REGION?.trim() ||
process.env.AWS_REGION?.trim() ||
process.env.AWS_DEFAULT_REGION?.trim()
)
) {
missing.push("PAPERCLIP_SECRETS_AWS_REGION or AWS_REGION/AWS_DEFAULT_REGION");
}
if (!process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID?.trim()) {
missing.push("PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID");
}
if (!process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID?.trim()) {
missing.push("PAPERCLIP_SECRETS_AWS_KMS_KEY_ID");
}
return missing;
}
function detectedAwsCredentialSources(): string[] {
const sources: string[] = [];
if (process.env.AWS_PROFILE?.trim()) sources.push("AWS_PROFILE/shared config");
if (process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim()) {
sources.push("temporary AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY environment credentials");
}
if (process.env.AWS_WEB_IDENTITY_TOKEN_FILE?.trim() && process.env.AWS_ROLE_ARN?.trim()) {
sources.push("AWS web identity token");
}
if (
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI?.trim() ||
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI?.trim()
) {
sources.push("AWS container credentials endpoint");
}
if (process.env.AWS_SHARED_CREDENTIALS_FILE?.trim() || process.env.AWS_CONFIG_FILE?.trim()) {
sources.push("custom AWS shared credentials/config file");
}
return sources;
}
+51
View File
@@ -0,0 +1,51 @@
import fs from "node:fs";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
import { resolveRuntimeLikePath } from "./path-resolver.js";
export function storageCheck(config: PaperclipConfig, configPath?: string): CheckResult {
if (config.storage.provider === "local_disk") {
const baseDir = resolveRuntimeLikePath(config.storage.localDisk.baseDir, configPath);
if (!fs.existsSync(baseDir)) {
fs.mkdirSync(baseDir, { recursive: true });
}
try {
fs.accessSync(baseDir, fs.constants.W_OK);
return {
name: "Storage",
status: "pass",
message: `Local disk storage is writable: ${baseDir}`,
};
} catch {
return {
name: "Storage",
status: "fail",
message: `Local storage directory is not writable: ${baseDir}`,
canRepair: false,
repairHint: "Check file permissions for storage.localDisk.baseDir",
};
}
}
const bucket = config.storage.s3.bucket.trim();
const region = config.storage.s3.region.trim();
if (!bucket || !region) {
return {
name: "Storage",
status: "fail",
message: "S3 storage requires non-empty bucket and region",
canRepair: false,
repairHint: "Run `paperclipai configure --section storage`",
};
}
return {
name: "Storage",
status: "warn",
message: `S3 storage configured (bucket=${bucket}, region=${region}). Reachability check is skipped in doctor.`,
canRepair: false,
repairHint: "Verify credentials and endpoint in deployment environment",
};
}
+302
View File
@@ -0,0 +1,302 @@
import { spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import pc from "picocolors";
import { buildCliCommandLabel } from "./command-label.js";
import { resolveDefaultCliAuthPath } from "../config/home.js";
type RequestedAccess = "board" | "instance_admin_required";
interface BoardAuthCredential {
apiBase: string;
token: string;
createdAt: string;
updatedAt: string;
userId?: string | null;
}
interface BoardAuthStore {
version: 1;
credentials: Record<string, BoardAuthCredential>;
}
interface CreateChallengeResponse {
id: string;
token: string;
boardApiToken: string;
approvalPath: string;
approvalUrl: string | null;
pollPath: string;
expiresAt: string;
suggestedPollIntervalMs: number;
}
interface ChallengeStatusResponse {
id: string;
status: "pending" | "approved" | "cancelled" | "expired";
command: string;
clientName: string | null;
requestedAccess: RequestedAccess;
requestedCompanyId: string | null;
requestedCompanyName: string | null;
approvedAt: string | null;
cancelledAt: string | null;
expiresAt: string;
approvedByUser: { id: string; name: string; email: string } | null;
}
function defaultBoardAuthStore(): BoardAuthStore {
return {
version: 1,
credentials: {},
};
}
function toStringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function normalizeApiBase(apiBase: string): string {
return apiBase.trim().replace(/\/+$/, "");
}
function isTruthyEnv(value: string | undefined): boolean {
const v = value?.trim().toLowerCase();
return v === "1" || v === "true" || v === "yes";
}
export function resolveBoardAuthStorePath(overridePath?: string): string {
if (overridePath?.trim()) return path.resolve(overridePath.trim());
if (process.env.PAPERCLIP_AUTH_STORE?.trim()) return path.resolve(process.env.PAPERCLIP_AUTH_STORE.trim());
return resolveDefaultCliAuthPath();
}
export function readBoardAuthStore(storePath?: string): BoardAuthStore {
const filePath = resolveBoardAuthStorePath(storePath);
if (!fs.existsSync(filePath)) return defaultBoardAuthStore();
const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<BoardAuthStore> | null;
const credentials = raw?.credentials && typeof raw.credentials === "object" ? raw.credentials : {};
const normalized: Record<string, BoardAuthCredential> = {};
for (const [key, value] of Object.entries(credentials)) {
if (typeof value !== "object" || value === null) continue;
const record = value as unknown as Record<string, unknown>;
const apiBase = toStringOrNull(record.apiBase);
const token = toStringOrNull(record.token);
const createdAt = toStringOrNull(record.createdAt);
const updatedAt = toStringOrNull(record.updatedAt);
if (!apiBase || !token || !createdAt || !updatedAt) continue;
normalized[normalizeApiBase(key)] = {
apiBase,
token,
createdAt,
updatedAt,
userId: toStringOrNull(record.userId),
};
}
return {
version: 1,
credentials: normalized,
};
}
export function writeBoardAuthStore(store: BoardAuthStore, storePath?: string): void {
const filePath = resolveBoardAuthStorePath(storePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
}
export function getStoredBoardCredential(apiBase: string, storePath?: string): BoardAuthCredential | null {
const store = readBoardAuthStore(storePath);
return store.credentials[normalizeApiBase(apiBase)] ?? null;
}
export function setStoredBoardCredential(input: {
apiBase: string;
token: string;
userId?: string | null;
storePath?: string;
}): BoardAuthCredential {
const normalizedApiBase = normalizeApiBase(input.apiBase);
const store = readBoardAuthStore(input.storePath);
const now = new Date().toISOString();
const existing = store.credentials[normalizedApiBase];
const credential: BoardAuthCredential = {
apiBase: normalizedApiBase,
token: input.token.trim(),
createdAt: existing?.createdAt ?? now,
updatedAt: now,
userId: input.userId ?? existing?.userId ?? null,
};
store.credentials[normalizedApiBase] = credential;
writeBoardAuthStore(store, input.storePath);
return credential;
}
export function removeStoredBoardCredential(apiBase: string, storePath?: string): boolean {
const normalizedApiBase = normalizeApiBase(apiBase);
const store = readBoardAuthStore(storePath);
if (!store.credentials[normalizedApiBase]) return false;
delete store.credentials[normalizedApiBase];
writeBoardAuthStore(store, storePath);
return true;
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers ?? undefined);
if (init?.body !== undefined && !headers.has("content-type")) {
headers.set("content-type", "application/json");
}
if (!headers.has("accept")) {
headers.set("accept", "application/json");
}
const response = await fetch(url, {
...init,
headers,
});
if (!response.ok) {
const body = await response.json().catch(() => null);
const message =
body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string"
? (body as { error: string }).error
: `Request failed: ${response.status}`;
throw new Error(message);
}
return response.json() as Promise<T>;
}
export async function openUrl(url: string): Promise<boolean> {
const { command, args } =
process.platform === "darwin"
? { command: "open", args: [url] }
: process.platform === "win32"
? { command: "cmd", args: ["/c", "start", "", url] }
: { command: "xdg-open", args: [url] };
return new Promise<boolean>((resolve) => {
let child: ChildProcess;
try {
child = spawn(command, args, { detached: true, stdio: "ignore" });
} catch {
resolve(false);
return;
}
child.once("error", () => resolve(false));
child.once("spawn", () => {
child.unref();
resolve(true);
});
});
}
export async function loginBoardCli(params: {
apiBase: string;
requestedAccess: RequestedAccess;
requestedCompanyId?: string | null;
clientName?: string | null;
command?: string;
storePath?: string;
print?: boolean;
openBrowser?: boolean;
publicBaseUrl?: string;
}): Promise<{ token: string; approvalUrl: string; userId?: string | null }> {
const apiBase = normalizeApiBase(params.apiBase);
const createUrl = `${apiBase}/api/cli-auth/challenges`;
const command = params.command?.trim() || buildCliCommandLabel();
const challenge = await requestJson<CreateChallengeResponse>(createUrl, {
method: "POST",
body: JSON.stringify({
command,
clientName: params.clientName?.trim() || "paperclipai cli",
requestedAccess: params.requestedAccess,
requestedCompanyId: params.requestedCompanyId?.trim() || null,
}),
});
const publicBase = params.publicBaseUrl?.trim() || process.env.PAPERCLIP_PUBLIC_URL?.trim();
const approvalUrl = publicBase
? `${normalizeApiBase(publicBase)}${challenge.approvalPath}`
: challenge.approvalUrl ?? `${apiBase}${challenge.approvalPath}`;
if (params.print !== false) {
console.error(pc.bold("Board authentication required"));
console.error(`Open this URL in your browser to approve CLI access:\n${approvalUrl}`);
}
const wantBrowser = params.openBrowser !== false && !isTruthyEnv(process.env.PAPERCLIP_NO_BROWSER);
const opened = wantBrowser ? await openUrl(approvalUrl) : false;
if (params.print !== false) {
const browserMessage = !wantBrowser
? "Browser open skipped — open the URL above to approve."
: opened
? "Opened the approval page in your browser."
: "Couldn't open a browser automatically — open the URL above to approve.";
console.error(pc.dim(browserMessage));
}
const expiresAtMs = Date.parse(challenge.expiresAt);
const pollMs = Math.max(500, challenge.suggestedPollIntervalMs || 1000);
while (Number.isFinite(expiresAtMs) ? Date.now() < expiresAtMs : true) {
const status = await requestJson<ChallengeStatusResponse>(
`${apiBase}/api${challenge.pollPath}?token=${encodeURIComponent(challenge.token)}`,
);
if (status.status === "approved") {
const me = await requestJson<{ userId: string; user?: { id: string } | null }>(
`${apiBase}/api/cli-auth/me`,
{
headers: {
authorization: `Bearer ${challenge.boardApiToken}`,
},
},
);
setStoredBoardCredential({
apiBase,
token: challenge.boardApiToken,
userId: me.userId ?? me.user?.id ?? null,
storePath: params.storePath,
});
return {
token: challenge.boardApiToken,
approvalUrl,
userId: me.userId ?? me.user?.id ?? null,
};
}
if (status.status === "cancelled") {
throw new Error("CLI auth challenge was cancelled.");
}
if (status.status === "expired") {
throw new Error("CLI auth challenge expired before approval.");
}
await sleep(pollMs);
}
throw new Error("CLI auth challenge expired before approval.");
}
export async function revokeStoredBoardCredential(params: {
apiBase: string;
token: string;
}): Promise<void> {
const apiBase = normalizeApiBase(params.apiBase);
await requestJson<{ revoked: boolean }>(`${apiBase}/api/cli-auth/revoke-current`, {
method: "POST",
headers: {
authorization: `Bearer ${params.token}`,
},
body: JSON.stringify({}),
});
}
+4
View File
@@ -0,0 +1,4 @@
export function buildCliCommandLabel(): string {
const args = process.argv.slice(2);
return args.length > 0 ? `paperclipai ${args.join(" ")}` : "paperclipai";
}
+215
View File
@@ -0,0 +1,215 @@
import fs from "node:fs";
import path from "node:path";
import { resolveDefaultContextPath } from "../config/home.js";
const DEFAULT_CONTEXT_BASENAME = "context.json";
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: 2;
currentProfile: string;
profiles: Record<string, ClientContextProfile>;
}
function findContextFileFromAncestors(startDir: string): string | null {
const absoluteStartDir = path.resolve(startDir);
let currentDir = absoluteStartDir;
while (true) {
const candidate = path.resolve(currentDir, ".paperclip", DEFAULT_CONTEXT_BASENAME);
if (fs.existsSync(candidate)) {
return candidate;
}
const nextDir = path.resolve(currentDir, "..");
if (nextDir === currentDir) break;
currentDir = nextDir;
}
return null;
}
export function resolveContextPath(overridePath?: string): string {
if (overridePath) return path.resolve(overridePath);
if (process.env.PAPERCLIP_CONTEXT) return path.resolve(process.env.PAPERCLIP_CONTEXT);
return findContextFileFromAncestors(process.cwd()) ?? resolveDefaultContextPath();
}
export function defaultClientContext(): ClientContext {
return {
version: 2,
currentProfile: DEFAULT_PROFILE,
profiles: {
[DEFAULT_PROFILE]: {},
},
};
}
function parseJson(filePath: string): unknown {
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch (err) {
throw new Error(`Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
}
}
function toStringOrUndefined(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : 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),
};
}
function normalizeContext(raw: unknown): ClientContext {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
return defaultClientContext();
}
const record = raw as Record<string, unknown>;
const version = 2;
const currentProfile = toStringOrUndefined(record.currentProfile) ?? DEFAULT_PROFILE;
const rawProfiles = record.profiles;
const profiles: Record<string, ClientContextProfile> = {};
if (typeof rawProfiles === "object" && rawProfiles !== null && !Array.isArray(rawProfiles)) {
for (const [name, profile] of Object.entries(rawProfiles as Record<string, unknown>)) {
if (!name.trim()) continue;
profiles[name] = normalizeProfile(profile);
}
}
if (!profiles[currentProfile]) {
profiles[currentProfile] = {};
}
if (Object.keys(profiles).length === 0) {
profiles[DEFAULT_PROFILE] = {};
}
return {
version,
currentProfile,
profiles,
};
}
export function readContext(contextPath?: string): ClientContext {
const filePath = resolveContextPath(contextPath);
if (!fs.existsSync(filePath)) {
return defaultClientContext();
}
const raw = parseJson(filePath);
return normalizeContext(raw);
}
export function writeContext(context: ClientContext, contextPath?: string): void {
const filePath = resolveContextPath(contextPath);
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
const normalized = normalizeContext(context);
fs.writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
}
export function upsertProfile(
profileName: string,
patch: Partial<ClientContextProfile>,
contextPath?: string,
): ClientContext {
const context = readContext(contextPath);
const existing = context.profiles[profileName] ?? {};
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;
}
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;
writeContext(context, contextPath);
return context;
}
export function setCurrentProfile(profileName: string, contextPath?: string): ClientContext {
const context = readContext(contextPath);
if (!context.profiles[profileName]) {
context.profiles[profileName] = {};
}
context.currentProfile = profileName;
writeContext(context, contextPath);
return context;
}
export function resolveProfile(
context: ClientContext,
profileName?: string,
): { name: string; profile: ClientContextProfile } {
const name = profileName?.trim() || context.currentProfile || DEFAULT_PROFILE;
const profile = context.profiles[name] ?? {};
return { name, profile };
}
+262
View File
@@ -0,0 +1,262 @@
import { URL } from "node:url";
export class ApiRequestError extends Error {
status: number;
details?: unknown;
body?: unknown;
constructor(status: number, message: string, details?: unknown, body?: unknown) {
super(message);
this.status = status;
this.details = details;
this.body = body;
}
}
export class ApiConnectionError extends Error {
url: string;
method: string;
causeMessage?: string;
constructor(input: {
apiBase: string;
path: string;
method: string;
cause?: unknown;
}) {
const url = buildUrl(input.apiBase, input.path);
const causeMessage = formatConnectionCause(input.cause);
super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage }));
this.url = url;
this.method = input.method;
this.causeMessage = causeMessage;
}
}
interface RequestOptions {
ignoreNotFound?: boolean;
}
interface RecoverAuthInput {
path: string;
method: string;
error: ApiRequestError;
}
interface ApiClientOptions {
apiBase: string;
apiKey?: string;
runId?: string;
recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
}
export class PaperclipApiClient {
readonly apiBase: string;
apiKey?: string;
readonly runId?: string;
readonly recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
constructor(opts: ApiClientOptions) {
this.apiBase = opts.apiBase.replace(/\/+$/, "");
this.apiKey = opts.apiKey?.trim() || undefined;
this.runId = opts.runId?.trim() || undefined;
this.recoverAuth = opts.recoverAuth;
}
get<T>(path: string, opts?: RequestOptions): Promise<T | null> {
return this.request<T>(path, { method: "GET" }, opts);
}
post<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
return this.request<T>(path, {
method: "POST",
body: body === undefined ? undefined : JSON.stringify(body),
}, opts);
}
patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
return this.request<T>(path, {
method: "PATCH",
body: body === undefined ? undefined : JSON.stringify(body),
}, 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);
}
setApiKey(apiKey: string | undefined) {
this.apiKey = apiKey?.trim() || undefined;
}
private async request<T>(
path: string,
init: RequestInit,
opts?: RequestOptions,
hasRetriedAuth = false,
): Promise<T | null> {
const url = buildUrl(this.apiBase, path);
const method = String(init.method ?? "GET").toUpperCase();
const headers: Record<string, string> = {
accept: "application/json",
...toStringRecord(init.headers),
};
if (init.body !== undefined) {
headers["content-type"] = headers["content-type"] ?? "application/json";
}
if (this.apiKey) {
headers.authorization = `Bearer ${this.apiKey}`;
}
if (this.runId) {
headers["x-paperclip-run-id"] = this.runId;
}
let response: Response;
try {
response = await fetch(url, {
...init,
headers,
});
} catch (error) {
throw new ApiConnectionError({
apiBase: this.apiBase,
path,
method,
cause: error,
});
}
if (opts?.ignoreNotFound && response.status === 404) {
return null;
}
if (!response.ok) {
const apiError = await toApiError(response);
if (!hasRetriedAuth && this.recoverAuth) {
const recoveredToken = await this.recoverAuth({
path,
method,
error: apiError,
});
if (recoveredToken) {
this.setApiKey(recoveredToken);
return this.request<T>(path, init, opts, true);
}
}
throw apiError;
}
if (response.status === 204) {
return null;
}
const text = await response.text();
if (!text.trim()) {
return null;
}
return safeParseJson(text) as T;
}
}
function buildUrl(apiBase: string, path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const [pathname, query] = normalizedPath.split("?");
const url = new URL(apiBase);
url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`;
if (query) url.search = query;
return url.toString();
}
function safeParseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function toApiError(response: Response): Promise<ApiRequestError> {
const text = await response.text();
const parsed = safeParseJson(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
const body = parsed as Record<string, unknown>;
const message =
(typeof body.error === "string" && body.error.trim()) ||
(typeof body.message === "string" && body.message.trim()) ||
`Request failed with status ${response.status}`;
return new ApiRequestError(response.status, message, body.details, parsed);
}
return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed);
}
function buildConnectionErrorMessage(input: {
apiBase: string;
url: string;
method: string;
causeMessage?: string;
}): string {
const healthUrl = buildHealthCheckUrl(input.url);
const lines = [
"Could not reach the Paperclip API.",
"",
`Request: ${input.method} ${input.url}`,
];
if (input.causeMessage) {
lines.push(`Cause: ${input.causeMessage}`);
}
lines.push(
"",
"This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
"",
"Try:",
"- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
`- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
);
return lines.join("\n");
}
function buildHealthCheckUrl(requestUrl: string): string {
const url = new URL(requestUrl);
url.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/api(?:\/.*)?$/, "")}/api/health`;
url.search = "";
url.hash = "";
return url.toString();
}
function formatConnectionCause(error: unknown): string | undefined {
if (!error) return undefined;
if (error instanceof Error) {
return error.message.trim() || error.name;
}
const message = String(error).trim();
return message || undefined;
}
function toStringRecord(headers: HeadersInit | undefined): Record<string, string> {
if (!headers) return {};
if (Array.isArray(headers)) {
return Object.fromEntries(headers.map(([key, value]) => [key, String(value)]));
}
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key, String(value)]),
);
}
+40
View File
@@ -0,0 +1,40 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import { normalizeHostnameInput } from "../config/hostnames.js";
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
export async function addAllowedHostname(host: string, opts: { config?: string }): Promise<void> {
const configPath = resolveConfigPath(opts.config);
const config = readConfig(opts.config);
if (!config) {
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
return;
}
const normalized = normalizeHostnameInput(host);
const current = new Set((config.server.allowedHostnames ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean));
const existed = current.has(normalized);
current.add(normalized);
config.server.allowedHostnames = Array.from(current).sort();
config.$meta.updatedAt = new Date().toISOString();
config.$meta.source = "configure";
writeConfig(config, opts.config);
if (existed) {
p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`);
} else {
p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
p.log.message(
pc.dim("Restart the Paperclip server for this change to take effect."),
);
}
if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) {
p.log.message(
pc.dim("Note: allowed hostnames are enforced only in authenticated/private mode."),
);
}
}
+138
View File
@@ -0,0 +1,138 @@
import { createHash, randomBytes } from "node:crypto";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { and, eq, gt, isNull } from "drizzle-orm";
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
import { inferBindModeFromHost } from "@paperclipai/shared";
import { loadPaperclipEnvFile } from "../config/env.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
function createInviteToken() {
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
}
function resolveDbUrl(configPath?: string, explicitDbUrl?: string) {
if (explicitDbUrl) return explicitDbUrl;
const config = readConfig(configPath);
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
if (config?.database.mode === "postgres" && config.database.connectionString) {
return config.database.connectionString;
}
if (config?.database.mode === "embedded-postgres") {
const port = config.database.embeddedPostgresPort ?? 54329;
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
}
return null;
}
function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
if (explicitBaseUrl) return explicitBaseUrl.replace(/\/+$/, "");
const fromEnv =
process.env.PAPERCLIP_PUBLIC_URL ??
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
process.env.BETTER_AUTH_URL ??
process.env.BETTER_AUTH_BASE_URL;
if (fromEnv?.trim()) return fromEnv.trim().replace(/\/+$/, "");
const config = readConfig(configPath);
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
return config.auth.publicBaseUrl.replace(/\/+$/, "");
}
const bind = config?.server.bind ?? inferBindModeFromHost(config?.server.host);
const host =
bind === "custom"
? config?.server.customBindHost ?? config?.server.host ?? "localhost"
: config?.server.host ?? "localhost";
const port = config?.server.port ?? 3100;
const publicHost = host === "0.0.0.0" || bind === "lan" ? "localhost" : host;
return `http://${publicHost}:${port}`;
}
export async function bootstrapCeoInvite(opts: {
config?: string;
force?: boolean;
expiresHours?: number;
baseUrl?: string;
dbUrl?: string;
}) {
const configPath = resolveConfigPath(opts.config);
loadPaperclipEnvFile(configPath);
const config = readConfig(configPath);
if (!config) {
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
return;
}
if (config.server.deploymentMode !== "authenticated") {
p.log.info("Deployment mode is local_trusted. Bootstrap CEO invite is only required for authenticated mode.");
return;
}
const dbUrl = resolveDbUrl(configPath, opts.dbUrl);
if (!dbUrl) {
p.log.error(
"Could not resolve database connection for bootstrap.",
);
return;
}
const db = createDb(dbUrl);
const closableDb = db as typeof db & {
$client?: {
end?: (options?: { timeout?: number }) => Promise<void>;
};
};
try {
const existingAdminCount = await db
.select()
.from(instanceUserRoles)
.where(eq(instanceUserRoles.role, "instance_admin"))
.then((rows) => rows.length);
if (existingAdminCount > 0 && !opts.force) {
p.log.info("Instance already has an admin user. Use --force to generate a new bootstrap invite.");
return;
}
const now = new Date();
await db
.update(invites)
.set({ revokedAt: now, updatedAt: now })
.where(
and(
eq(invites.inviteType, "bootstrap_ceo"),
isNull(invites.revokedAt),
isNull(invites.acceptedAt),
gt(invites.expiresAt, now),
),
);
const token = createInviteToken();
const expiresHours = Math.max(1, Math.min(24 * 30, opts.expiresHours ?? 72));
const created = await db
.insert(invites)
.values({
inviteType: "bootstrap_ceo",
tokenHash: hashToken(token),
allowedJoinTypes: "human",
expiresAt: new Date(Date.now() + expiresHours * 60 * 60 * 1000),
invitedByUserId: "system",
})
.returning()
.then((rows) => rows[0]);
const baseUrl = resolveBaseUrl(configPath, opts.baseUrl);
const inviteUrl = `${baseUrl}/invite/${token}`;
p.log.success("Created bootstrap CEO invite.");
p.log.message(`Invite URL: ${pc.cyan(inviteUrl)}`);
p.log.message(`Expires: ${pc.dim(created.expiresAt.toISOString())}`);
} catch (err) {
p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`);
p.log.info("If using embedded-postgres, start the Paperclip server and run this command again.");
} finally {
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}
+519
View File
@@ -0,0 +1,519 @@
import { Command } from "commander";
import {
addCommonClientOptions,
apiPath,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface CompanyOptions extends BaseClientOptions {
companyId?: string;
}
interface JsonPayloadOptions extends CompanyOptions {
payloadJson?: string;
}
interface QueryOptions extends CompanyOptions {
query?: string;
status?: string;
requestType?: string;
url?: string;
}
export function registerAccessCommands(program: Command): void {
addWhoamiCommand(program);
addCommonClientOptions(
program
.command("health")
.description("Check API health")
.action(async (opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get("/api/health"), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
const access = program.command("access").description("Access and auth inspection operations");
addWhoamiCommand(access);
addCommonClientOptions(
program
.command("openapi")
.description("Print the OpenAPI document")
.action(async (opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get("/api/openapi.json"), { json: true });
} catch (err) {
handleCommandError(err);
}
}),
);
const profile = program.command("profile").description("Current user profile operations");
addSimpleGet(profile, "session", "Get auth session", "/api/auth/get-session");
addSimpleGet(profile, "get", "Get current auth profile", "/api/auth/profile");
addJsonPatch(profile, "update", "Update current auth profile", "/api/auth/profile");
addCommonClientOptions(
profile
.command("company-user")
.description("Get a user profile within a company")
.argument("<userSlug>", "User slug")
.option("-C, --company-id <id>", "Company ID")
.action(async (userSlug: string, opts: CompanyOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(await ctx.api.get(apiPath`/api/companies/${ctx.companyId}/users/${userSlug}/profile`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
const invite = program.command("invite").description("Invite operations");
addCompanyList(invite, "list", "List company invites", "invites");
addCompanyPost(invite, "create", "Create an invite", "invites");
addCommonClientOptions(
invite
.command("revoke")
.description("Revoke an invite")
.argument("<inviteId>", "Invite ID")
.action(async (inviteId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.post(apiPath`/api/invites/${inviteId}/revoke`, {}), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
for (const [name, suffix] of [
["show", ""],
["logo", "logo"],
["onboarding", "onboarding"],
["onboarding:text", "onboarding.txt"],
["skills:index", "skills/index"],
] as const) {
addCommonClientOptions(
invite
.command(name)
.description(`Get invite ${name}`)
.argument("<token>", "Invite token")
.action(async (token: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const path = `${apiPath`/api/invites/${token}`}${suffix ? `/${suffix}` : ""}`;
printOutput(await ctx.api.get(path), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
addCommonClientOptions(
invite
.command("test-resolution")
.description("Test invite URL resolution")
.argument("<token>", "Invite token")
.requiredOption("--url <url>", "URL to test")
.action(async (token: string, opts: QueryOptions) => {
try {
const ctx = resolveCommandContext(opts);
const query = new URLSearchParams({ url: opts.url ?? "" });
printOutput(await ctx.api.get(`${apiPath`/api/invites/${token}/test-resolution`}?${query.toString()}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
invite
.command("skill")
.description("Get invite skill markdown")
.argument("<token>", "Invite token")
.argument("<skillName>", "Skill name")
.action(async (token: string, skillName: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get(apiPath`/api/invites/${token}/skills/${skillName}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
invite
.command("accept")
.description("Accept an invite")
.argument("<token>", "Invite token")
.option("--payload-json <json>", "Invite accept JSON payload", "{}")
.action(async (token: string, opts: JsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.post(apiPath`/api/invites/${token}/accept`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
const join = program.command("join").description("Join request operations");
addCommonClientOptions(
join
.command("list")
.description("List join requests")
.option("-C, --company-id <id>", "Company ID")
.option("--status <status>", "Filter by status (pending_approval, approved, rejected; pending alias accepted)")
.option("--request-type <type>", "Filter by request type")
.action(async (opts: QueryOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const params = new URLSearchParams();
const status = normalizeJoinStatus(opts.status);
if (status) params.set("status", status);
if (opts.requestType) params.set("requestType", opts.requestType);
const query = params.toString();
printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}/join-requests`}${query ? `?${query}` : ""}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addJoinAction(join, "approve");
addJoinAction(join, "reject");
addCommonClientOptions(
join
.command("claim-key")
.description("Claim an agent API key for an approved join request")
.argument("<requestId>", "Join request ID")
.requiredOption("--claim-secret <secret>", "Claim secret")
.action(async (requestId: string, opts: BaseClientOptions & { claimSecret: string }) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.post(apiPath`/api/join-requests/${requestId}/claim-api-key`, { claimSecret: opts.claimSecret }), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
const member = program.command("member").description("Company member operations");
addCompanyList(member, "list", "List company members", "members");
addCompanyList(member, "user-directory", "List company user directory", "user-directory");
addMemberPatch(member, "update", "members");
addMemberPatch(member, "role-and-grants", "members", "role-and-grants");
addMemberPatch(member, "permissions", "members", "permissions");
addMemberPost(member, "archive", "members", "archive");
const admin = program.command("admin").description("Instance admin operations");
const user = admin.command("user").description("Admin user operations");
addCommonClientOptions(
user
.command("list")
.description("List users")
.option("--query <text>", "Search query")
.action(async (opts: QueryOptions) => {
try {
const ctx = resolveCommandContext(opts);
const query = opts.query ? `?${new URLSearchParams({ query: opts.query }).toString()}` : "";
printOutput(await ctx.api.get(`/api/admin/users${query}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addAdminUserPost(user, "promote", "promote-instance-admin");
addAdminUserPost(user, "demote", "demote-instance-admin");
addCommonClientOptions(
user
.command("company-access")
.description("Get user company access")
.argument("<userId>", "User ID")
.action(async (userId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get(apiPath`/api/admin/users/${userId}/company-access`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
user
.command("company-access:update")
.description("Update user company access")
.argument("<userId>", "User ID")
.requiredOption("--payload-json <json>", "UpdateUserCompanyAccess JSON payload")
.action(async (userId: string, opts: JsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.put(apiPath`/api/admin/users/${userId}/company-access`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
const instance = program.command("instance").description("Instance operations");
addSimpleGet(instance, "scheduler-heartbeats", "List scheduler heartbeat agents", "/api/instance/scheduler-heartbeats");
addSimpleGet(instance, "settings:general", "Get general instance settings", "/api/instance/settings/general");
addJsonPatch(instance, "settings:general:update", "Update general instance settings", "/api/instance/settings/general");
addSimpleGet(instance, "settings:experimental", "Get experimental instance settings", "/api/instance/settings/experimental");
addJsonPatch(instance, "settings:experimental:update", "Update experimental instance settings", "/api/instance/settings/experimental");
addCommonClientOptions(
instance
.command("database-backup")
.description("Create a database backup")
.action(async (opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.post("/api/instance/database-backups", {}), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
const sidebar = program.command("sidebar").description("Sidebar preference and badge operations");
addSimpleGet(sidebar, "preferences", "Get current sidebar preferences", "/api/sidebar-preferences/me");
addJsonPut(sidebar, "preferences:update", "Update current sidebar preferences", "/api/sidebar-preferences/me");
addCompanyList(sidebar, "project-preferences", "Get current project sidebar preferences", "sidebar-preferences/me");
addCompanyPut(sidebar, "project-preferences:update", "Update current project sidebar preferences", "sidebar-preferences/me");
addCompanyList(sidebar, "badges", "Get sidebar badges", "sidebar-badges");
const inbox = program.command("inbox").description("Board inbox operations");
addCompanyList(inbox, "dismissals", "List dismissed inbox items", "inbox-dismissals");
addCompanyPost(inbox, "dismiss", "Dismiss an inbox item", "inbox-dismissals");
const boardClaim = program.command("board-claim").description("Board claim token operations");
addCommonClientOptions(
boardClaim
.command("show")
.description("Inspect a board claim token")
.argument("<token>", "Claim token")
.action(async (token: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get(apiPath`/api/board-claim/${token}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
boardClaim
.command("claim")
.description("Claim a board claim token")
.argument("<token>", "Claim token")
.option("--payload-json <json>", "Claim JSON payload", "{}")
.action(async (token: string, opts: JsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.post(apiPath`/api/board-claim/${token}/claim`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
const openclaw = program.command("openclaw").description("OpenClaw integration helpers");
addCompanyPost(openclaw, "invite-prompt", "Create an OpenClaw invite prompt", "openclaw/invite-prompt");
const publicSkills = program.command("available-skill").description("Public skill catalog operations");
addSimpleGet(publicSkills, "list", "List available skills", "/api/skills/available");
addSimpleGet(publicSkills, "index", "Get available skill index", "/api/skills/index");
addCommonClientOptions(
publicSkills
.command("get")
.description("Get available skill markdown")
.argument("<skillName>", "Skill name")
.action(async (skillName: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get(apiPath`/api/skills/${skillName}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
const llm = program.command("llm").description("LLM prompt documentation");
addSimpleGet(llm, "agent-configuration", "Get agent configuration prompt docs", "/api/llms/agent-configuration.txt");
addSimpleGet(llm, "agent-icons", "Get agent icon prompt docs", "/api/llms/agent-icons.txt");
addCommonClientOptions(
llm
.command("agent-configuration:adapter")
.description("Get adapter-specific agent configuration prompt docs")
.argument("<adapterType>", "Adapter type")
.action(async (adapterType: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get(`${apiPath`/api/llms/agent-configuration/${adapterType}`}.txt`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
function addWhoamiCommand(parent: Command): void {
addCommonClientOptions(
parent
.command("whoami")
.description("Show current CLI auth identity")
.action(async (opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get("/api/cli-auth/me"), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
function normalizeJoinStatus(status: string | undefined): string | undefined {
if (status === "pending") return "pending_approval";
return status;
}
function addSimpleGet(parent: Command, name: string, description: string, path: string): void {
addCommonClientOptions(parent.command(name).description(description).action(async (opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get(path), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}));
}
function addJsonPatch(parent: Command, name: string, description: string, path: string): void {
addCommonClientOptions(parent.command(name).description(description).requiredOption("--payload-json <json>", "JSON payload").action(async (opts: JsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.patch(path, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}));
}
function addJsonPut(parent: Command, name: string, description: string, path: string): void {
addCommonClientOptions(parent.command(name).description(description).requiredOption("--payload-json <json>", "JSON payload").action(async (opts: JsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.put(path, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}));
}
function addCompanyList(parent: Command, name: string, description: string, path: string): void {
addCommonClientOptions(
parent.command(name).description(description).option("-C, --company-id <id>", "Company ID").action(async (opts: CompanyOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function addCompanyPut(parent: Command, name: string, description: string, path: string): void {
addCommonClientOptions(
parent.command(name).description(description).option("-C, --company-id <id>", "Company ID").requiredOption("--payload-json <json>", "JSON payload").action(async (opts: JsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(await ctx.api.put(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function addCompanyPost(parent: Command, name: string, description: string, path: string): void {
addCommonClientOptions(
parent.command(name).description(description).option("-C, --company-id <id>", "Company ID").requiredOption("--payload-json <json>", "JSON payload").action(async (opts: JsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function addJoinAction(parent: Command, action: "approve" | "reject"): void {
addCommonClientOptions(
parent.command(action).description(`${action} a join request`).argument("<requestId>", "Join request ID").option("-C, --company-id <id>", "Company ID").action(async (requestId: string, opts: CompanyOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}/join-requests/${requestId}`}/${action}`, {}), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function addMemberPatch(parent: Command, name: string, path: string, suffix?: string): void {
addCommonClientOptions(
parent.command(name).description(`${name} a member`).argument("<memberId>", "Member ID").option("-C, --company-id <id>", "Company ID").requiredOption("--payload-json <json>", "JSON payload").action(async (memberId: string, opts: JsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const route = `${apiPath`/api/companies/${ctx.companyId}`}/${path}/${encodeURIComponent(memberId)}${suffix ? `/${suffix}` : ""}`;
printOutput(await ctx.api.patch(route, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function addMemberPost(parent: Command, name: string, path: string, suffix: string): void {
addCommonClientOptions(
parent.command(name).description(`${name} a member`).argument("<memberId>", "Member ID").option("-C, --company-id <id>", "Company ID").option("--payload-json <json>", "JSON payload", "{}").action(async (memberId: string, opts: JsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}/${encodeURIComponent(memberId)}/${suffix}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function addAdminUserPost(parent: Command, name: string, suffix: string): void {
addCommonClientOptions(parent.command(name).description(`${name} instance admin`).argument("<userId>", "User ID").action(async (userId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.post(`${apiPath`/api/admin/users/${userId}`}/${suffix}`, {}), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}));
}
function parseJson(value: string): unknown {
return JSON.parse(value) as unknown;
}
+110
View File
@@ -0,0 +1,110 @@
import { Command } from "commander";
import type { ActivityEvent } from "@paperclipai/shared";
import {
addCommonClientOptions,
apiPath,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface ActivityListOptions extends BaseClientOptions {
companyId?: string;
agentId?: string;
entityType?: string;
entityId?: string;
payloadJson?: string;
}
export function registerActivityCommands(program: Command): void {
const activity = program.command("activity").description("Activity log operations");
addCommonClientOptions(
activity
.command("list")
.description("List company activity log entries")
.requiredOption("-C, --company-id <id>", "Company ID")
.option("--agent-id <id>", "Filter by agent ID")
.option("--entity-type <type>", "Filter by entity type")
.option("--entity-id <id>", "Filter by entity ID")
.action(async (opts: ActivityListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const params = new URLSearchParams();
if (opts.agentId) params.set("agentId", opts.agentId);
if (opts.entityType) params.set("entityType", opts.entityType);
if (opts.entityId) params.set("entityId", opts.entityId);
const query = params.toString();
const path = `${apiPath`/api/companies/${ctx.companyId}/activity`}${query ? `?${query}` : ""}`;
const rows = (await ctx.api.get<ActivityEvent[]>(path)) ?? [];
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,
action: row.action,
actorType: row.actorType,
actorId: row.actorId,
entityType: row.entityType,
entityId: row.entityId,
createdAt: String(row.createdAt),
}),
);
}
} catch (err) {
handleCommandError(err);
}
}),
{ 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
View File
@@ -0,0 +1,223 @@
import { Command } from "commander";
import {
addCommonClientOptions,
apiPath,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface AdapterOptions extends BaseClientOptions {
companyId?: string;
payloadJson?: string;
refresh?: boolean;
environmentId?: string;
}
export function registerAdapterCommands(program: Command): void {
const adapter = program.command("adapter").description("Adapter management operations");
addCommonClientOptions(
adapter
.command("list")
.description("List registered adapters")
.action(async (opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get("/api/adapters"), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addJsonPost(adapter, "install", "Install an external adapter", "/api/adapters/install");
addCommonClientOptions(
adapter
.command("get")
.description("Get one adapter")
.argument("<type>", "Adapter type")
.action(async (type: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get(apiPath`/api/adapters/${type}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addAdapterPatch(adapter, "update", "Update adapter settings", "");
addAdapterPatch(adapter, "override", "Pause or resume a built-in adapter override", "/override");
addAdapterPost(adapter, "reload", "Reload an adapter", "/reload");
addAdapterPost(adapter, "reinstall", "Reinstall an adapter", "/reinstall");
addCommonClientOptions(
adapter
.command("delete")
.description("Delete an external adapter registration")
.argument("<type>", "Adapter type")
.action(async (type: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.delete(apiPath`/api/adapters/${type}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
adapter
.command("config-schema")
.description("Get adapter config schema")
.argument("<type>", "Adapter type")
.action(async (type: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get(apiPath`/api/adapters/${type}/config-schema`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
adapter
.command("ui-parser")
.description("Get adapter UI parser JavaScript")
.argument("<type>", "Adapter type")
.action(async (type: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get(apiPath`/api/adapters/${type}/ui-parser.js`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
adapter
.command("models")
.description("List adapter models for a company")
.argument("<type>", "Adapter type")
.option("-C, --company-id <id>", "Company ID")
.option("--refresh", "Refresh provider model list", false)
.option("--environment-id <id>", "Environment ID for environment-aware adapters")
.action(async (type: string, opts: AdapterOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const query = new URLSearchParams();
if (opts.refresh) query.set("refresh", "true");
if (opts.environmentId?.trim()) query.set("environmentId", opts.environmentId.trim());
const suffix = query.size > 0 ? `?${query.toString()}` : "";
printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}/adapters/${type}/models`}${suffix}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCompanyAdapterGet(adapter, "model-profiles", "List adapter model profiles", "model-profiles");
addCompanyAdapterGet(adapter, "detect-model", "Detect adapter model", "detect-model");
addCompanyAdapterPost(adapter, "test-environment", "Test adapter environment configuration", "test-environment");
}
function addJsonPost(parent: Command, name: string, description: string, path: string): void {
addCommonClientOptions(
parent.command(name).description(description).requiredOption("--payload-json <json>", "JSON payload").action(async (opts: AdapterOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.post(path, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
function addAdapterPatch(parent: Command, name: string, description: string, suffix: string): void {
addCommonClientOptions(
parent
.command(name)
.description(description)
.argument("<type>", "Adapter type")
.requiredOption("--payload-json <json>", "JSON payload")
.action(async (type: string, opts: AdapterOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.patch(`${apiPath`/api/adapters/${type}`}${suffix}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
function addAdapterPost(parent: Command, name: string, description: string, suffix: string): void {
addCommonClientOptions(
parent
.command(name)
.description(description)
.argument("<type>", "Adapter type")
.option("--payload-json <json>", "JSON payload", "{}")
.action(async (type: string, opts: AdapterOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.post(`${apiPath`/api/adapters/${type}`}${suffix}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
function addCompanyAdapterGet(parent: Command, name: string, description: string, suffix: string): void {
addCommonClientOptions(
parent
.command(name)
.description(description)
.argument("<type>", "Adapter type")
.option("-C, --company-id <id>", "Company ID")
.action(async (type: string, opts: AdapterOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}/adapters/${type}`}/${suffix}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function addCompanyAdapterPost(parent: Command, name: string, description: string, suffix: string): void {
addCommonClientOptions(
parent
.command(name)
.description(description)
.argument("<type>", "Adapter type")
.option("-C, --company-id <id>", "Company ID")
.option("--payload-json <json>", "JSON payload", "{}")
.action(async (type: string, opts: AdapterOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(
await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}/adapters/${type}`}/${suffix}`, parseJson(opts.payloadJson ?? "{}")),
{ json: ctx.json },
);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function parseJson(value: string): unknown {
return JSON.parse(value) as unknown;
}
+870
View File
@@ -0,0 +1,870 @@
import { Command } from "commander";
import {
agentSkillSyncSchema,
createAgentSchema,
resetAgentSessionSchema,
updateAgentInstructionsBundleSchema,
updateAgentInstructionsPathSchema,
updateAgentPermissionsSchema,
updateAgentSchema,
upsertAgentInstructionsFileSchema,
wakeAgentSchema,
type Agent,
type AgentWakeupResponse,
type Issue,
} from "@paperclipai/shared";
import {
removeMaintainerOnlySkillSymlinks,
resolvePaperclipSkillsDir,
} from "@paperclipai/adapter-utils/server-utils";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
addCommonClientOptions,
apiPath,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface AgentListOptions extends BaseClientOptions {
companyId?: string;
}
interface AgentLocalCliOptions extends BaseClientOptions {
companyId?: string;
keyName?: string;
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;
token: string;
createdAt: string;
}
interface SkillsInstallSummary {
tool: "codex" | "claude";
target: string;
linked: string[];
removed: string[];
skipped: string[];
failed: Array<{ name: string; error: string }>;
}
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
function codexSkillsHome(): string {
const fromEnv = process.env.CODEX_HOME?.trim();
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex");
return path.join(base, "skills");
}
function claudeSkillsHome(): string {
const fromEnv = process.env.CLAUDE_HOME?.trim();
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude");
return path.join(base, "skills");
}
async function installSkillsForTarget(
sourceSkillsDir: string,
targetSkillsDir: string,
tool: "codex" | "claude",
): Promise<SkillsInstallSummary> {
const summary: SkillsInstallSummary = {
tool,
target: targetSkillsDir,
linked: [],
removed: [],
skipped: [],
failed: [],
};
await fs.mkdir(targetSkillsDir, { recursive: true });
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
summary.removed = await removeMaintainerOnlySkillSymlinks(
targetSkillsDir,
entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name),
);
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(sourceSkillsDir, entry.name);
const target = path.join(targetSkillsDir, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) {
if (existing.isSymbolicLink()) {
let linkedPath: string | null = null;
try {
linkedPath = await fs.readlink(target);
} catch (err) {
await fs.unlink(target);
try {
await fs.symlink(source, target);
summary.linked.push(entry.name);
continue;
} catch (linkErr) {
summary.failed.push({
name: entry.name,
error:
err instanceof Error && linkErr instanceof Error
? `${err.message}; then ${linkErr.message}`
: err instanceof Error
? err.message
: `Failed to recover broken symlink: ${String(err)}`,
});
continue;
}
}
const resolvedLinkedPath = path.isAbsolute(linkedPath)
? linkedPath
: path.resolve(path.dirname(target), linkedPath);
const linkedTargetExists = await fs
.stat(resolvedLinkedPath)
.then(() => true)
.catch(() => false);
if (!linkedTargetExists) {
await fs.unlink(target);
} else {
summary.skipped.push(entry.name);
continue;
}
} else {
summary.skipped.push(entry.name);
continue;
}
}
try {
await fs.symlink(source, target);
summary.linked.push(entry.name);
} catch (err) {
summary.failed.push({
name: entry.name,
error: err instanceof Error ? err.message : String(err),
});
}
}
return summary;
}
function buildAgentEnvExports(input: {
apiBase: string;
companyId: string;
agentId: string;
apiKey: string;
}): string {
const escaped = (value: string) => value.replace(/'/g, "'\"'\"'");
return [
`export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`,
`export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`,
`export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`,
`export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`,
].join("\n");
}
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")
.description("List agents for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.action(async (opts: AgentListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const rows = (await ctx.api.get<Agent[]>(apiPath`/api/companies/${ctx.companyId}/agents`)) ?? [];
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,
role: row.role,
status: row.status,
reportsTo: row.reportsTo,
budgetMonthlyCents: row.budgetMonthlyCents,
spentMonthlyCents: row.spentMonthlyCents,
}),
);
}
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
agent
.command("get")
.description("Get one agent")
.argument("<agentId>", "Agent ID")
.action(async (agentId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const row = await ctx.api.get<Agent>(apiPath`/api/agents/${agentId}`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
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")
.description(
"Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports",
)
.argument("<agentRef>", "Agent ID or shortname/url-key")
.requiredOption("-C, --company-id <id>", "Company ID")
.option("--key-name <name>", "API key label", "local-cli")
.option(
"--no-install-skills",
"Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills",
)
.action(async (agentRef: string, opts: AgentLocalCliOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const query = new URLSearchParams({ companyId: ctx.companyId ?? "" });
const agentRow = await ctx.api.get<Agent>(
`${apiPath`/api/agents/${agentRef}`}?${query.toString()}`,
);
if (!agentRow) {
throw new Error(`Agent not found: ${agentRef}`);
}
const now = new Date().toISOString().replaceAll(":", "-");
const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`;
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");
}
const installSummaries: SkillsInstallSummary[] = [];
if (opts.installSkills !== false) {
const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [path.resolve(process.cwd(), "skills")]);
if (!skillsDir) {
throw new Error(
"Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.",
);
}
installSummaries.push(
await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"),
await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"),
);
}
const exportsText = buildAgentEnvExports({
apiBase: ctx.api.apiBase,
companyId: agentRow.companyId,
agentId: agentRow.id,
apiKey: key.token,
});
if (ctx.json) {
printOutput(
{
agent: {
id: agentRow.id,
name: agentRow.name,
urlKey: agentRow.urlKey,
companyId: agentRow.companyId,
},
key: {
id: key.id,
name: key.name,
createdAt: key.createdAt,
token: key.token,
},
skills: installSummaries,
exports: exportsText,
},
{ json: true },
);
return;
}
console.log(`Agent: ${agentRow.name} (${agentRow.id})`);
console.log(`API key created: ${key.name} (${key.id})`);
if (installSummaries.length > 0) {
for (const summary of installSummaries) {
console.log(
`${summary.tool}: linked=${summary.linked.length} removed=${summary.removed.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
);
for (const failed of summary.failed) {
console.log(` failed ${failed.name}: ${failed.error}`);
}
}
}
console.log("");
console.log("# Run this in your shell before launching codex/claude:");
console.log(exportsText);
} catch (err) {
handleCommandError(err);
}
}),
{ 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);
}
+260
View File
@@ -0,0 +1,260 @@
import { Command } from "commander";
import {
createApprovalSchema,
requestApprovalRevisionSchema,
resolveApprovalSchema,
resubmitApprovalSchema,
type Approval,
type ApprovalComment,
} from "@paperclipai/shared";
import {
addCommonClientOptions,
apiPath,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface ApprovalListOptions extends BaseClientOptions {
companyId?: string;
status?: string;
}
interface ApprovalDecisionOptions extends BaseClientOptions {
decisionNote?: string;
decidedByUserId?: string;
}
interface ApprovalCreateOptions extends BaseClientOptions {
companyId?: string;
type: string;
requestedByAgentId?: string;
payload: string;
issueIds?: string;
}
interface ApprovalResubmitOptions extends BaseClientOptions {
payload?: string;
}
interface ApprovalCommentOptions extends BaseClientOptions {
body: string;
}
export function registerApprovalCommands(program: Command): void {
const approval = program.command("approval").description("Approval operations");
addCommonClientOptions(
approval
.command("list")
.description("List approvals for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.option("--status <status>", "Status filter")
.action(async (opts: ApprovalListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const params = new URLSearchParams();
if (opts.status) params.set("status", opts.status);
const query = params.toString();
const rows =
(await ctx.api.get<Approval[]>(
`${apiPath`/api/companies/${ctx.companyId}/approvals`}${query ? `?${query}` : ""}`,
)) ?? [];
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,
type: row.type,
status: row.status,
requestedByAgentId: row.requestedByAgentId,
requestedByUserId: row.requestedByUserId,
}),
);
}
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
approval
.command("get")
.description("Get one approval")
.argument("<approvalId>", "Approval ID")
.action(async (approvalId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const row = await ctx.api.get<Approval>(apiPath`/api/approvals/${approvalId}`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
approval
.command("create")
.description("Create an approval request")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("--type <type>", "Approval type (hire_agent|approve_ceo_strategy)")
.requiredOption("--payload <json>", "Approval payload as JSON object")
.option("--requested-by-agent-id <id>", "Requesting agent ID")
.option("--issue-ids <csv>", "Comma-separated linked issue IDs")
.action(async (opts: ApprovalCreateOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const payloadJson = parseJsonObject(opts.payload, "payload");
const payload = createApprovalSchema.parse({
type: opts.type,
payload: payloadJson,
requestedByAgentId: opts.requestedByAgentId,
issueIds: parseCsv(opts.issueIds),
});
const created = await ctx.api.post<Approval>(apiPath`/api/companies/${ctx.companyId}/approvals`, payload);
printOutput(created, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
approval
.command("approve")
.description("Approve an approval request")
.argument("<approvalId>", "Approval ID")
.option("--decision-note <text>", "Decision note")
.option("--decided-by-user-id <id>", "Decision actor user ID")
.action(async (approvalId: string, opts: ApprovalDecisionOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = resolveApprovalSchema.parse({
decisionNote: opts.decisionNote,
decidedByUserId: opts.decidedByUserId,
});
const updated = await ctx.api.post<Approval>(apiPath`/api/approvals/${approvalId}/approve`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
approval
.command("reject")
.description("Reject an approval request")
.argument("<approvalId>", "Approval ID")
.option("--decision-note <text>", "Decision note")
.option("--decided-by-user-id <id>", "Decision actor user ID")
.action(async (approvalId: string, opts: ApprovalDecisionOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = resolveApprovalSchema.parse({
decisionNote: opts.decisionNote,
decidedByUserId: opts.decidedByUserId,
});
const updated = await ctx.api.post<Approval>(apiPath`/api/approvals/${approvalId}/reject`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
approval
.command("request-revision")
.description("Request revision for an approval")
.argument("<approvalId>", "Approval ID")
.option("--decision-note <text>", "Decision note")
.option("--decided-by-user-id <id>", "Decision actor user ID")
.action(async (approvalId: string, opts: ApprovalDecisionOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = requestApprovalRevisionSchema.parse({
decisionNote: opts.decisionNote,
decidedByUserId: opts.decidedByUserId,
});
const updated = await ctx.api.post<Approval>(apiPath`/api/approvals/${approvalId}/request-revision`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
approval
.command("resubmit")
.description("Resubmit an approval (optionally with new payload)")
.argument("<approvalId>", "Approval ID")
.option("--payload <json>", "Payload JSON object")
.action(async (approvalId: string, opts: ApprovalResubmitOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = resubmitApprovalSchema.parse({
payload: opts.payload ? parseJsonObject(opts.payload, "payload") : undefined,
});
const updated = await ctx.api.post<Approval>(apiPath`/api/approvals/${approvalId}/resubmit`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
approval
.command("comment")
.description("Add comment to an approval")
.argument("<approvalId>", "Approval ID")
.requiredOption("--body <text>", "Comment body")
.action(async (approvalId: string, opts: ApprovalCommentOptions) => {
try {
const ctx = resolveCommandContext(opts);
const created = await ctx.api.post<ApprovalComment>(apiPath`/api/approvals/${approvalId}/comments`, {
body: opts.body,
});
printOutput(created, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
function parseCsv(value: string | undefined): string[] | undefined {
if (!value) return undefined;
const rows = value.split(",").map((v) => v.trim()).filter(Boolean);
return rows.length > 0 ? rows : undefined;
}
function parseJsonObject(value: string, name: string): Record<string, unknown> {
try {
const parsed = JSON.parse(value) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`${name} must be a JSON object`);
}
return parsed as Record<string, unknown>;
} catch (err) {
throw new Error(`Invalid ${name} JSON: ${err instanceof Error ? err.message : String(err)}`);
}
}
+147
View File
@@ -0,0 +1,147 @@
import { readFile, writeFile } from "node:fs/promises";
import { Command } from "commander";
import { ApiRequestError } from "../../client/http.js";
import {
addCommonClientOptions,
apiPath,
handleCommandError,
inferContentTypeFromPath,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface AssetOptions extends BaseClientOptions {
companyId?: string;
file?: string;
namespace?: string;
alt?: string;
title?: string;
out?: string;
}
export function registerAssetCommands(program: Command): void {
const asset = program.command("asset").description("Asset operations");
addCommonClientOptions(
asset
.command("image:upload")
.description("Upload a company image asset")
.requiredOption("--file <path>", "Image file path")
.option("-C, --company-id <id>", "Company ID")
.option("--namespace <value>", "Asset namespace suffix")
.option("--alt <text>", "Alt text metadata")
.option("--title <text>", "Title metadata")
.action(async (opts: AssetOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const result = await uploadAsset(ctx.api.apiBase, ctx.api.apiKey, apiPath`/api/companies/${ctx.companyId}/assets/images`, opts);
printOutput(result, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
asset
.command("logo:upload")
.description("Upload a company logo")
.requiredOption("--file <path>", "Logo file path")
.option("-C, --company-id <id>", "Company ID")
.action(async (opts: AssetOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const result = await uploadAsset(ctx.api.apiBase, ctx.api.apiKey, apiPath`/api/companies/${ctx.companyId}/logo`, opts);
printOutput(result, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
asset
.command("content")
.description("Download asset content")
.argument("<assetId>", "Asset ID")
.option("--out <path>", "Write content to a file instead of stdout")
.action(async (assetId: string, opts: AssetOptions) => {
try {
const ctx = resolveCommandContext(opts);
const bytes = await downloadAsset(ctx.api.apiBase, ctx.api.apiKey, assetId);
if (opts.out?.trim()) {
await writeFile(opts.out, bytes);
printOutput({ ok: true, out: opts.out, bytes: bytes.length }, { json: ctx.json });
return;
}
process.stdout.write(bytes);
} catch (err) {
handleCommandError(err);
}
}),
);
}
async function uploadAsset(
apiBase: string,
apiKey: string | undefined,
path: string,
opts: AssetOptions,
): Promise<unknown> {
if (!opts.file?.trim()) {
throw new Error("--file is required");
}
const bytes = await readFile(opts.file);
const form = new FormData();
form.set("file", new Blob([bytes], { type: inferContentTypeFromPath(opts.file) }), opts.file.split(/[\\/]/).pop() ?? "asset");
if (opts.namespace?.trim()) form.set("namespace", opts.namespace.trim());
if (opts.alt?.trim()) form.set("alt", opts.alt.trim());
if (opts.title?.trim()) form.set("title", opts.title.trim());
const response = await fetch(buildApiUrl(apiBase, path), {
method: "POST",
headers: apiKey ? { authorization: `Bearer ${apiKey}` } : undefined,
body: form,
});
return parseFetchResponse(response);
}
async function downloadAsset(apiBase: string, apiKey: string | undefined, assetId: string): Promise<Buffer> {
const response = await fetch(buildApiUrl(apiBase, apiPath`/api/assets/${assetId}/content`), {
headers: apiKey ? { authorization: `Bearer ${apiKey}` } : undefined,
});
if (!response.ok) {
await parseFetchResponse(response);
}
return Buffer.from(await response.arrayBuffer());
}
async function parseFetchResponse(response: Response): Promise<unknown> {
const text = await response.text();
const parsed = text.trim() ? safeJson(text) : null;
if (!response.ok) {
const message =
typeof parsed === "object" && parsed !== null && "error" in parsed && typeof parsed.error === "string"
? parsed.error
: `Request failed with status ${response.status}`;
throw new ApiRequestError(response.status, message, undefined, parsed);
}
return parsed;
}
function buildApiUrl(apiBase: string, path: string): string {
const url = new URL(apiBase);
url.pathname = `${url.pathname.replace(/\/+$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
return url.toString();
}
function safeJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
+203
View File
@@ -0,0 +1,203 @@
import type { Command } from "commander";
import {
getStoredBoardCredential,
loginBoardCli,
removeStoredBoardCredential,
revokeStoredBoardCredential,
} from "../../client/board-auth.js";
import {
addCommonClientOptions,
apiPath,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface AuthLoginOptions extends BaseClientOptions {
instanceAdmin?: boolean;
browser?: boolean;
}
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(
auth
.command("login")
.description("Authenticate the CLI for board-user access")
.option("--instance-admin", "Request instance-admin approval instead of plain board access", false)
.option("--no-browser", "Don't try to open a browser; just print the approval URL")
.action(async (opts: AuthLoginOptions) => {
try {
const ctx = resolveCommandContext(opts);
const login = await loginBoardCli({
apiBase: ctx.api.apiBase,
requestedAccess: opts.instanceAdmin ? "instance_admin_required" : "board",
requestedCompanyId: ctx.companyId ?? null,
command: "paperclipai auth login",
openBrowser: opts.browser,
});
printOutput(
{
ok: true,
apiBase: ctx.api.apiBase,
userId: login.userId ?? null,
approvalUrl: login.approvalUrl,
},
{ json: ctx.json },
);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: true },
);
addCommonClientOptions(
auth
.command("logout")
.description("Remove the stored board-user credential for this API base")
.action(async (opts: AuthLogoutOptions) => {
try {
const ctx = resolveCommandContext(opts);
const credential = getStoredBoardCredential(ctx.api.apiBase);
if (!credential) {
printOutput({ ok: true, apiBase: ctx.api.apiBase, revoked: false, removedLocalCredential: false }, { json: ctx.json });
return;
}
let revoked = false;
try {
await revokeStoredBoardCredential({
apiBase: ctx.api.apiBase,
token: credential.token,
});
revoked = true;
} catch {
// Remove the local credential even if the server-side revoke fails.
}
const removedLocalCredential = removeStoredBoardCredential(ctx.api.apiBase);
printOutput(
{
ok: true,
apiBase: ctx.api.apiBase,
revoked,
removedLocalCredential,
},
{ json: ctx.json },
);
} catch (err) {
handleCommandError(err);
}
}),
);
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")
.description("Show the current board-user identity for this API base")
.action(async (opts: AuthWhoamiOptions) => {
try {
const ctx = resolveCommandContext(opts);
const me = await ctx.api.get<{
user: { id: string; name: string; email: string } | null;
userId: string;
isInstanceAdmin: boolean;
companyIds: string[];
source: string;
keyId: string | null;
}>("/api/cli-auth/me");
printOutput(me, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
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.");
}
+177
View File
@@ -0,0 +1,177 @@
import fs from "node:fs";
import path from "node:path";
import { resolvePaperclipInstanceRoot } from "../../config/home.js";
export interface CloudConnectionTokenRecord {
id: string;
companyStackId: string;
targetOrigin: string;
sourceInstanceId: string;
sourceInstanceFingerprint: string;
scopes: string[];
expiresAt: string;
[key: string]: unknown;
}
export interface CloudConnection {
id: string;
remoteUrl: string;
targetOrigin: string;
targetHost: string;
stackId: string;
stackSlug?: string | null;
stackDisplayName?: string | null;
targetCompanyId: string;
accessToken: string;
token: CloudConnectionTokenRecord;
privateKeyPem: string;
sourcePublicKey: string;
sourceInstanceId: string;
sourceInstanceFingerprint: string;
scopes: string[];
createdAt: string;
updatedAt: string;
}
interface CloudConnectionStore {
version: 1;
connections: Record<string, CloudConnection>;
currentConnectionId?: string;
}
function defaultStore(): CloudConnectionStore {
return {
version: 1,
connections: {},
};
}
export function resolveCloudConnectionStorePath(): string {
return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "cloud-upstream-connections.json");
}
export function readCloudConnectionStore(storePath = resolveCloudConnectionStorePath()): CloudConnectionStore {
if (!fs.existsSync(storePath)) return defaultStore();
const raw = JSON.parse(fs.readFileSync(storePath, "utf8")) as Partial<CloudConnectionStore> | null;
const connections: Record<string, CloudConnection> = {};
if (raw?.connections && typeof raw.connections === "object") {
for (const [id, value] of Object.entries(raw.connections)) {
const normalized = normalizeConnection(value);
if (normalized) connections[id] = normalized;
}
}
const currentConnectionId =
typeof raw?.currentConnectionId === "string" && connections[raw.currentConnectionId]
? raw.currentConnectionId
: Object.values(connections).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0]?.id;
return {
version: 1,
connections,
currentConnectionId,
};
}
export function writeCloudConnectionStore(
store: CloudConnectionStore,
storePath = resolveCloudConnectionStorePath(),
): void {
fs.mkdirSync(path.dirname(storePath), { recursive: true });
fs.writeFileSync(storePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
}
export function upsertCloudConnection(
connection: CloudConnection,
storePath = resolveCloudConnectionStorePath(),
): CloudConnection {
const store = readCloudConnectionStore(storePath);
const existing = store.connections[connection.id];
const now = new Date().toISOString();
const next = {
...connection,
createdAt: existing?.createdAt ?? connection.createdAt ?? now,
updatedAt: now,
};
store.connections[next.id] = next;
store.currentConnectionId = next.id;
writeCloudConnectionStore(store, storePath);
return next;
}
export function getCloudConnection(
remoteUrlOrOrigin?: string,
storePath = resolveCloudConnectionStorePath(),
): CloudConnection | null {
const store = readCloudConnectionStore(storePath);
if (remoteUrlOrOrigin?.trim()) {
const needle = normalizeRemoteLookup(remoteUrlOrOrigin);
return Object.values(store.connections).find((connection) =>
normalizeRemoteLookup(connection.remoteUrl) === needle ||
normalizeRemoteLookup(connection.targetOrigin) === needle
) ?? null;
}
return store.currentConnectionId ? store.connections[store.currentConnectionId] ?? null : null;
}
function normalizeRemoteLookup(value: string): string {
try {
const url = new URL(value);
return url.origin.replace(/\/+$/u, "");
} catch {
return value.trim().replace(/\/+$/u, "");
}
}
function normalizeConnection(value: unknown): CloudConnection | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const id = stringValue(record.id);
const remoteUrl = stringValue(record.remoteUrl);
const targetOrigin = stringValue(record.targetOrigin);
const targetHost = stringValue(record.targetHost);
const stackId = stringValue(record.stackId);
const targetCompanyId = stringValue(record.targetCompanyId);
const accessToken = stringValue(record.accessToken);
const token = typeof record.token === "object" && record.token !== null && !Array.isArray(record.token)
? record.token as CloudConnectionTokenRecord
: null;
const privateKeyPem = stringValue(record.privateKeyPem);
const sourcePublicKey = stringValue(record.sourcePublicKey);
const sourceInstanceId = stringValue(record.sourceInstanceId);
const sourceInstanceFingerprint = stringValue(record.sourceInstanceFingerprint);
const createdAt = stringValue(record.createdAt);
const updatedAt = stringValue(record.updatedAt);
if (
!id || !remoteUrl || !targetOrigin || !targetHost || !stackId || !targetCompanyId ||
!accessToken || !token || !privateKeyPem || !sourcePublicKey || !sourceInstanceId ||
!sourceInstanceFingerprint || !createdAt || !updatedAt
) {
return null;
}
return {
id,
remoteUrl,
targetOrigin,
targetHost,
stackId,
stackSlug: stringValue(record.stackSlug),
stackDisplayName: stringValue(record.stackDisplayName),
targetCompanyId,
accessToken,
token,
privateKeyPem,
sourcePublicKey,
sourceInstanceId,
sourceInstanceFingerprint,
scopes: stringArray(record.scopes),
createdAt,
updatedAt,
};
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
}
+297
View File
@@ -0,0 +1,297 @@
import { createHash } from "node:crypto";
export const upstreamTransferSchema = {
family: "paperclip-upstream-transfer",
version: "1.0.0",
major: 1,
minor: 0,
} as const;
export type NormalizedSha256 = `sha256:${string}`;
export interface SourceEntityKey {
sourceInstanceId: string;
sourceCompanyId: string;
sourceEntityType: string;
sourceEntityId: string;
sourceNaturalKey?: string;
}
export interface UpstreamTransferWarning {
code: string;
severity: "info" | "warning" | "blocker";
message: string;
entity?: SourceEntityKey;
}
export interface UpstreamTransferEntityRecord {
key: SourceEntityKey;
contentHash: NormalizedSha256;
dependencies: SourceEntityKey[];
warnings: UpstreamTransferWarning[];
}
export interface UpstreamTransferManifestSource {
sourceInstanceId: string;
sourceCompanyId: string;
sourceInstanceKeyFingerprint: string;
exporterVersion: string;
sourceSchemaVersion: string;
}
export interface UpstreamTransferManifestTarget {
targetStackId: string;
targetCompanyId: string;
targetOrigin: string;
supportedSchemaMajor: number;
}
export interface UpstreamTransferChunk {
chunkIndex: number;
totalChunks: number;
byteLength: number;
sha256: NormalizedSha256;
manifestHash: NormalizedSha256;
}
export interface UpstreamTransferManifest {
schema: typeof upstreamTransferSchema;
source: UpstreamTransferManifestSource;
target: UpstreamTransferManifestTarget;
runId: string;
idempotencyKey: string;
generatedAt: string;
entityCount: number;
entities: UpstreamTransferEntityRecord[];
chunks: UpstreamTransferChunk[];
warnings: UpstreamTransferWarning[];
featureFlags: string[];
manifestHash: NormalizedSha256;
}
export interface LocalUpstreamExportEntityInput {
key: SourceEntityKey;
body: Record<string, unknown>;
dependencies?: SourceEntityKey[];
warnings?: UpstreamTransferWarning[];
conflictKeys?: string[];
}
export interface LocalUpstreamExportEntity {
record: UpstreamTransferEntityRecord;
body: Record<string, unknown>;
conflictKeys?: string[];
}
export interface LocalUpstreamExportChunk {
chunkIndex: number;
totalChunks: number;
byteLength: number;
sha256: NormalizedSha256;
payload: {
entityKeys: SourceEntityKey[];
};
}
export interface LocalUpstreamExportBundle {
manifest: UpstreamTransferManifest;
entities: LocalUpstreamExportEntity[];
chunks: LocalUpstreamExportChunk[];
}
export interface BuildLocalUpstreamExportBundleInput {
source: UpstreamTransferManifestSource;
target: UpstreamTransferManifestTarget;
runId: string;
idempotencyKey: string;
entities: LocalUpstreamExportEntityInput[];
warnings?: UpstreamTransferWarning[];
featureFlags?: string[];
maxEntitiesPerChunk?: number;
}
export interface LocalUpstreamPushCoordinatorOptions {
targetOrigin: string;
paperclipCompanyId: string;
fetch?: typeof fetch;
headers?: (input: { method: string; path: string }) => HeadersInit | Promise<HeadersInit>;
}
export class UpstreamImportRequestError extends Error {
readonly status: number;
readonly body: unknown;
constructor(status: number, message: string, body: unknown) {
super(message);
this.status = status;
this.body = body;
}
}
export class LocalUpstreamPushCoordinator {
readonly #targetOrigin: string;
readonly #paperclipCompanyId: string;
readonly #fetch: typeof fetch;
readonly #headers: NonNullable<LocalUpstreamPushCoordinatorOptions["headers"]>;
constructor(options: LocalUpstreamPushCoordinatorOptions) {
this.#targetOrigin = options.targetOrigin.replace(/\/+$/u, "");
this.#paperclipCompanyId = options.paperclipCompanyId;
this.#fetch = options.fetch ?? fetch;
this.#headers = options.headers ?? (() => ({}));
}
async preview(bundle: LocalUpstreamExportBundle): Promise<unknown> {
return this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/preview`, {
manifest: bundle.manifest,
entities: bundle.entities,
});
}
async apply(bundle: LocalUpstreamExportBundle): Promise<unknown> {
const run = await this.post(`/api/companies/${encodeURIComponent(this.#paperclipCompanyId)}/upstream-imports/runs`, {
mode: "apply",
manifest: bundle.manifest,
entities: bundle.entities,
}) as { run?: { id?: unknown } };
const runId = typeof run.run?.id === "string" ? run.run.id : undefined;
if (!runId) {
throw new Error("Remote upstream importer did not return a run id");
}
for (const chunk of bundle.chunks) {
await this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/chunks`, chunk);
}
return this.post(`/api/upstream-import-runs/${encodeURIComponent(runId)}/apply`, {});
}
async events(runId: string): Promise<unknown> {
return this.get(`/api/upstream-import-runs/${encodeURIComponent(runId)}/events`);
}
private async get(path: string): Promise<unknown> {
const response = await this.#fetch(`${this.#targetOrigin}${path}`, {
method: "GET",
headers: await this.#headers({ method: "GET", path }),
});
return parseCoordinatorResponse(response);
}
private async post(path: string, body: unknown): Promise<unknown> {
const response = await this.#fetch(`${this.#targetOrigin}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(await this.#headers({ method: "POST", path })),
},
body: JSON.stringify(body),
});
return parseCoordinatorResponse(response);
}
}
export function buildLocalUpstreamExportBundle(
input: BuildLocalUpstreamExportBundleInput,
): LocalUpstreamExportBundle {
const entities = input.entities.map<LocalUpstreamExportEntity>((entity) => ({
record: {
key: entity.key,
contentHash: normalizedContentHash(entity.body),
dependencies: entity.dependencies ?? [],
warnings: entity.warnings ?? [],
},
body: entity.body,
conflictKeys: entity.conflictKeys,
}));
const chunks = buildLocalChunks(entities, input.maxEntitiesPerChunk ?? 100);
const manifestWithoutHash = {
schema: upstreamTransferSchema,
source: input.source,
target: input.target,
runId: input.runId,
idempotencyKey: input.idempotencyKey,
generatedAt: new Date(0).toISOString(),
entityCount: entities.length,
entities: entities.map((entity) => entity.record),
chunks: chunks.map(({ payload: _payload, ...chunk }) => chunk),
warnings: input.warnings ?? [],
featureFlags: (input.featureFlags ?? ["cloud_sync"]).slice().sort(),
};
const manifestHash = normalizedContentHash(manifestWithoutHash);
return {
manifest: {
...manifestWithoutHash,
chunks: manifestWithoutHash.chunks.map((chunk) => ({ ...chunk, manifestHash })),
manifestHash,
},
entities,
chunks,
};
}
export function normalizedContentHash(value: unknown): NormalizedSha256 {
return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
}
export function canonicalJson(value: unknown): string {
return JSON.stringify(sortJson(value));
}
function buildLocalChunks(
entities: LocalUpstreamExportEntity[],
maxEntitiesPerChunk: number,
): LocalUpstreamExportChunk[] {
if (!Number.isInteger(maxEntitiesPerChunk) || maxEntitiesPerChunk < 1) {
throw new Error("maxEntitiesPerChunk must be a positive integer");
}
if (entities.length === 0) return [];
const groups: LocalUpstreamExportEntity[][] = [];
for (let index = 0; index < entities.length; index += maxEntitiesPerChunk) {
groups.push(entities.slice(index, index + maxEntitiesPerChunk));
}
return groups.map((group, index) => {
const payload = {
entityKeys: group.map((entity) => entity.record.key),
};
return {
chunkIndex: index,
totalChunks: groups.length,
byteLength: Buffer.byteLength(canonicalJson(payload)),
sha256: normalizedContentHash(payload),
payload,
};
});
}
function sortJson(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortJson);
if (typeof value !== "object" || value === null) return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => [key, sortJson(entry)]),
);
}
async function parseCoordinatorResponse(response: Response): Promise<unknown> {
const text = await response.text();
const parsed = text.trim() ? safeParseJson(text) : {};
if (!response.ok) {
const message = typeof parsed === "object" && parsed !== null && "error" in parsed
? String((parsed as { error: unknown }).error)
: `Upstream importer request failed with ${response.status}`;
throw new UpstreamImportRequestError(response.status, message, parsed);
}
return parsed;
}
function safeParseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
+722
View File
@@ -0,0 +1,722 @@
import { createHash, generateKeyPairSync, randomBytes, randomUUID, sign } from "node:crypto";
import { createServer, type Server } from "node:http";
import { URL } from "node:url";
import { Command } from "commander";
import pc from "picocolors";
import type {
CompanyPortabilityExportResult,
CompanyPortabilityFileEntry,
InstanceExperimentalSettings,
} from "@paperclipai/shared";
import { openUrl } from "../../client/board-auth.js";
import { resolvePaperclipInstanceId } from "../../config/home.js";
import {
addCommonClientOptions,
apiPath,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
import {
buildLocalUpstreamExportBundle,
LocalUpstreamPushCoordinator,
normalizedContentHash,
upstreamTransferSchema,
UpstreamImportRequestError,
type LocalUpstreamExportBundle,
type LocalUpstreamExportEntityInput,
type SourceEntityKey,
type UpstreamTransferManifestSource,
type UpstreamTransferManifestTarget,
type UpstreamTransferWarning,
} from "./cloud-transfer.js";
import {
getCloudConnection,
upsertCloudConnection,
type CloudConnection,
type CloudConnectionTokenRecord,
} from "./cloud-store.js";
const CLOUD_SYNC_CONFLICT_EXIT_CODE = 2;
const CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE = 3;
const CLOUD_SYNC_SCOPES = ["upstream_import:preview", "upstream_import:write", "upstream_import:read"];
const DEVICE_CODE_FALLBACK_EXPIRES_MS = 15 * 60_000;
interface CloudConnectOptions extends BaseClientOptions {
noBrowser?: boolean;
}
interface CloudPushOptions extends BaseClientOptions {
company?: string;
remoteUrl?: string;
dryRun?: boolean;
maxEntitiesPerChunk?: number;
}
interface UpstreamDiscovery {
schema: string;
stack: {
id: string;
slug?: string;
displayName?: string;
companyId: string;
origin: string;
};
auth: {
pkce?: {
authorizeUrl: string;
tokenUrl: string;
codeChallengeMethod: string;
};
deviceCode?: {
deviceCodeUrl: string;
verificationUrl: string;
tokenUrl: string;
};
scopes?: string[];
};
transfer: {
supportedSchemaMajor: number;
featureFlags?: string[];
};
}
interface TokenResponse {
accessToken: string;
token: CloudConnectionTokenRecord;
scopes?: string[];
expiresAt?: string;
}
class CloudAuthRequestError extends Error {
readonly status: number;
readonly body: unknown;
constructor(status: number, message: string, body: unknown) {
super(message);
this.status = status;
this.body = body;
}
}
export function registerCloudCommands(program: Command): void {
const cloud = program.command("cloud").description("Paperclip Cloud upstream sync commands");
addCommonClientOptions(
cloud
.command("connect")
.description("Authorize this local instance to push into a Paperclip Cloud stack")
.argument("<remote-url>", "Paperclip Cloud stack URL")
.option("--no-browser", "Use the device-code flow instead of opening a browser", false)
.action(async (remoteUrl: string, opts: CloudConnectOptions) => {
try {
await connectCloud(remoteUrl, opts);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
cloud
.command("push")
.description("Preview or apply a local company push into the connected Paperclip Cloud stack")
.requiredOption("--company <local-company-id>", "Local company ID to export")
.option("--remote-url <remote-url>", "Use a specific stored cloud connection")
.option("--dry-run", "Preview without applying", false)
.option("--max-entities-per-chunk <count>", "Chunk size for upstream uploads", (value) => Number(value), 100)
.action(async (opts: CloudPushOptions) => {
try {
await pushCloud(opts);
} catch (err) {
if (isSchemaMismatchError(err)) {
console.error(pc.red(err instanceof Error ? err.message : String(err)));
process.exitCode = CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE;
return;
}
handleCommandError(err);
}
}),
);
}
export async function connectCloud(remoteUrl: string, opts: CloudConnectOptions = {}): Promise<CloudConnection> {
const ctx = resolveCommandContext(opts);
const discovery = await discoverUpstream(remoteUrl);
assertDiscoveryCompatible(discovery);
const source = createSourceIdentity();
const token = await authorizeConnection(discovery, source, {
noBrowser: Boolean(opts.noBrowser),
});
const targetOrigin = discovery.stack.origin.replace(/\/+$/u, "");
const targetHost = new URL(targetOrigin).host;
const now = new Date().toISOString();
const connection = upsertCloudConnection({
id: connectionId(targetOrigin),
remoteUrl,
targetOrigin,
targetHost,
stackId: discovery.stack.id,
stackSlug: discovery.stack.slug ?? null,
stackDisplayName: discovery.stack.displayName ?? null,
targetCompanyId: discovery.stack.companyId,
accessToken: token.accessToken,
token: token.token,
privateKeyPem: source.privateKeyPem,
sourcePublicKey: source.sourcePublicKey,
sourceInstanceId: source.sourceInstanceId,
sourceInstanceFingerprint: source.sourceInstanceFingerprint,
scopes: token.scopes ?? token.token.scopes ?? CLOUD_SYNC_SCOPES,
createdAt: now,
updatedAt: now,
});
if (ctx.json) {
printOutput(redactConnection(connection), { json: true });
} else {
console.log(pc.bold("Connected to Paperclip Cloud"));
console.log(`stack=${connection.stackDisplayName ?? connection.stackSlug ?? connection.stackId}`);
console.log(`origin=${connection.targetOrigin}`);
console.log(`company=${connection.targetCompanyId}`);
}
return connection;
}
export async function pushCloud(opts: CloudPushOptions): Promise<unknown> {
const ctx = resolveCommandContext(opts, { requireCompany: false });
const localCompanyId = requiredString(opts.company, "--company");
await assertCloudSyncEnabled(ctx.api.get<InstanceExperimentalSettings>("/api/instance/settings/experimental"));
const connection = getCloudConnection(opts.remoteUrl);
if (!connection) {
throw new Error("No cloud connection found. Run `paperclipai cloud connect <remote-url>` first.");
}
const discovery = await discoverUpstream(connection.targetOrigin);
assertDiscoveryCompatible(discovery);
const bundle = await buildBundleFromLocalCompany({
localCompanyId,
connection,
discovery,
localApi: ctx.api,
maxEntitiesPerChunk: opts.maxEntitiesPerChunk,
mode: opts.dryRun ? "preview" : "apply",
});
const coordinator = new LocalUpstreamPushCoordinator({
targetOrigin: connection.targetOrigin,
paperclipCompanyId: connection.targetCompanyId,
headers: ({ method, path }) => cloudProofHeaders(connection, method, path),
});
const result = opts.dryRun ? await coordinator.preview(bundle) : await coordinator.apply(bundle);
const runId = getRunId(result);
const events = !opts.dryRun && runId ? await coordinator.events(runId).catch(() => null) : null;
const summary = summarizeResult(result);
const conflictCount = summary.conflict + summary.staleMapping;
if (ctx.json) {
printOutput({ result, events }, { json: true });
} else {
console.log(pc.bold(opts.dryRun ? "Cloud Push Preview" : "Cloud Push Applied"));
console.log(`run=${runId ?? "-"}`);
console.log(`manifest=${bundle.manifest.manifestHash}`);
console.log(
`create=${summary.create} update=${summary.update} adopt=${summary.adopt} ` +
`skip=${summary.skip} conflict=${summary.conflict} staleMapping=${summary.staleMapping}`,
);
printWarnings(result);
printConflicts(result);
printEvents(events);
}
if (conflictCount > 0) {
process.exitCode = CLOUD_SYNC_CONFLICT_EXIT_CODE;
}
return result;
}
export async function discoverUpstream(remoteUrl: string): Promise<UpstreamDiscovery> {
const base = new URL(remoteUrl);
const discoveryUrl = new URL("/.well-known/paperclip-upstream", base);
return requestCloudJson<UpstreamDiscovery>(discoveryUrl.toString(), { method: "GET" });
}
export function assertDiscoveryCompatible(discovery: UpstreamDiscovery): void {
if (discovery.schema !== "paperclip-upstream-discovery-v1") {
throw new Error("Remote URL is not a Paperclip Cloud upstream target.");
}
if (discovery.transfer.supportedSchemaMajor !== upstreamTransferSchema.major) {
throw new Error(
`Cloud upstream schema mismatch: local major ${upstreamTransferSchema.major}, remote supports ${discovery.transfer.supportedSchemaMajor}.`,
);
}
if (!discovery.transfer.featureFlags?.includes("cloud_sync")) {
throw new Error("Remote Paperclip Cloud stack does not advertise the cloud_sync transfer flag.");
}
}
export function resolveDeviceCodeExpiresAt(expiresAt: string | undefined, nowMs = Date.now()): number {
const parsed = typeof expiresAt === "string" ? Date.parse(expiresAt) : NaN;
return Number.isFinite(parsed) ? parsed : nowMs + DEVICE_CODE_FALLBACK_EXPIRES_MS;
}
export async function buildBundleFromLocalCompany(input: {
localCompanyId: string;
connection: CloudConnection;
discovery: UpstreamDiscovery;
localApi: {
post<T>(path: string, body?: unknown): Promise<T | null>;
};
maxEntitiesPerChunk?: number;
mode: "preview" | "apply";
}): Promise<LocalUpstreamExportBundle> {
const exported = await input.localApi.post<CompanyPortabilityExportResult>(
apiPath`/api/companies/${input.localCompanyId}/export`,
{
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
expandReferencedSkills: true,
},
);
if (!exported) throw new Error("Local company export returned no data.");
const sourceHash = normalizedContentHash({
manifest: exported.manifest,
files: exported.files,
});
const source: UpstreamTransferManifestSource = {
sourceInstanceId: input.connection.sourceInstanceId,
sourceCompanyId: input.localCompanyId,
sourceInstanceKeyFingerprint: input.connection.sourceInstanceFingerprint,
exporterVersion: "paperclipai-cli-cloud-v1",
sourceSchemaVersion: "paperclip-local-portability-v1",
};
const target: UpstreamTransferManifestTarget = {
targetStackId: input.discovery.stack.id,
targetCompanyId: input.discovery.stack.companyId,
targetOrigin: input.discovery.stack.origin,
supportedSchemaMajor: input.discovery.transfer.supportedSchemaMajor,
};
const entities = buildEntitiesFromPortableExport(input.localCompanyId, input.connection.sourceInstanceId, exported);
const idempotencyKey = [
input.mode,
input.connection.sourceInstanceId,
input.localCompanyId,
input.discovery.stack.id,
sourceHash,
].join(":");
return buildLocalUpstreamExportBundle({
source,
target,
runId: `local-${input.mode}-${shortHash(idempotencyKey)}`,
idempotencyKey,
entities,
warnings: exported.warnings.map((message): UpstreamTransferWarning => ({
code: "local_company_export_warning",
severity: "warning",
message,
})),
featureFlags: ["cloud_sync"],
maxEntitiesPerChunk: input.maxEntitiesPerChunk,
});
}
async function authorizeConnection(
discovery: UpstreamDiscovery,
source: ReturnType<typeof createSourceIdentity>,
opts: { noBrowser: boolean },
): Promise<TokenResponse> {
if (!opts.noBrowser && canOpenBrowser() && discovery.auth.pkce) {
try {
return await authorizeWithBrowser(discovery, source);
} catch (error) {
console.error(pc.yellow(`Browser authorization failed; falling back to device-code flow. ${errorMessage(error)}`));
}
}
if (!discovery.auth.deviceCode) {
throw new Error("Remote Paperclip Cloud stack does not support device-code authorization.");
}
return authorizeWithDeviceCode(discovery, source, { openBrowser: !opts.noBrowser && canOpenBrowser() });
}
async function authorizeWithBrowser(
discovery: UpstreamDiscovery,
source: ReturnType<typeof createSourceIdentity>,
): Promise<TokenResponse> {
const pkce = discovery.auth.pkce;
if (!pkce) throw new Error("Remote did not advertise PKCE authorization.");
const callback = await startPkceCallbackServer();
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const state = randomUUID();
const authorizeUrl = new URL(pkce.authorizeUrl);
authorizeUrl.searchParams.set("redirectUri", callback.redirectUri);
authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("codeChallenge", challenge);
authorizeUrl.searchParams.set("codeChallengeMethod", "S256");
authorizeUrl.searchParams.set("sourceInstanceId", source.sourceInstanceId);
authorizeUrl.searchParams.set("sourceInstanceFingerprint", source.sourceInstanceFingerprint);
authorizeUrl.searchParams.set("sourcePublicKey", source.sourcePublicKey);
authorizeUrl.searchParams.set("scopes", CLOUD_SYNC_SCOPES.join(" "));
try {
console.error(`Open this URL to approve cloud sync:\n${authorizeUrl.toString()}`);
if (!(await openUrl(authorizeUrl.toString()))) {
throw new Error("Could not open a browser.");
}
const code = await callback.waitForCode(state);
return requestCloudJson<TokenResponse>(pkce.tokenUrl, {
method: "POST",
body: JSON.stringify({
grantType: "authorization_code",
code,
redirectUri: callback.redirectUri,
codeVerifier: verifier,
}),
});
} finally {
await callback.close();
}
}
async function authorizeWithDeviceCode(
discovery: UpstreamDiscovery,
source: ReturnType<typeof createSourceIdentity>,
opts: { openBrowser: boolean },
): Promise<TokenResponse> {
const device = discovery.auth.deviceCode;
if (!device) throw new Error("Remote did not advertise device-code authorization.");
const response = await requestCloudJson<{
deviceCode: string;
userCode: string;
verificationUri: string;
expiresAt?: string;
intervalSeconds?: number;
}>(device.deviceCodeUrl, {
method: "POST",
body: JSON.stringify({
stackId: discovery.stack.id,
sourceInstanceId: source.sourceInstanceId,
sourceInstanceFingerprint: source.sourceInstanceFingerprint,
sourcePublicKey: source.sourcePublicKey,
scopes: CLOUD_SYNC_SCOPES,
}),
});
console.error(pc.bold("Cloud device authorization required"));
console.error(`Open: ${response.verificationUri}`);
console.error(`Code: ${response.userCode}`);
if (opts.openBrowser) await openUrl(response.verificationUri);
const expiresAt = resolveDeviceCodeExpiresAt(response.expiresAt);
const intervalMs = Math.max(500, (response.intervalSeconds ?? 5) * 1000);
while (Date.now() < expiresAt) {
await sleep(intervalMs);
try {
return await requestCloudJson<TokenResponse>(device.tokenUrl, {
method: "POST",
body: JSON.stringify({
grantType: "device_code",
deviceCode: response.deviceCode,
}),
});
} catch (error) {
if (error instanceof CloudAuthRequestError && error.body && typeof error.body === "object") {
const code = (error.body as { error?: unknown }).error;
if (code === "authorization_pending") continue;
}
throw error;
}
}
throw new Error("Device-code authorization expired before it was approved.");
}
function buildEntitiesFromPortableExport(
localCompanyId: string,
sourceInstanceId: string,
exported: CompanyPortabilityExportResult,
): LocalUpstreamExportEntityInput[] {
const companyKey: SourceEntityKey = {
sourceInstanceId,
sourceCompanyId: localCompanyId,
sourceEntityType: "company",
sourceEntityId: localCompanyId,
sourceNaturalKey: exported.manifest.company?.name ?? localCompanyId,
};
const entities: LocalUpstreamExportEntityInput[] = [
{
key: companyKey,
body: {
kind: "paperclip_company_portability_manifest",
manifest: exported.manifest,
rootPath: exported.rootPath,
paperclipExtensionPath: exported.paperclipExtensionPath,
fileCount: Object.keys(exported.files).length,
},
conflictKeys: [`company:${companyKey.sourceNaturalKey ?? localCompanyId}`],
},
];
for (const [filePath, entry] of Object.entries(exported.files).sort(([left], [right]) => left.localeCompare(right))) {
entities.push({
key: {
sourceInstanceId,
sourceCompanyId: localCompanyId,
sourceEntityType: "company_setting",
sourceEntityId: shortHash(filePath),
sourceNaturalKey: filePath,
},
body: {
kind: "paperclip_portable_file",
path: filePath,
entry: normalizePortableFileEntry(entry),
},
dependencies: [companyKey],
conflictKeys: [`portable_file:${filePath}`],
});
}
return entities;
}
function normalizePortableFileEntry(entry: CompanyPortabilityFileEntry): Record<string, unknown> {
if (typeof entry === "string") {
return { encoding: "utf8", data: entry };
}
return { ...entry };
}
async function assertCloudSyncEnabled(settingsPromise: Promise<InstanceExperimentalSettings | null>): Promise<void> {
const settings = await settingsPromise;
if (settings?.enableCloudSync !== true) {
throw new Error(
"Cloud sync is disabled. Enable the cloud sync experimental setting before running `paperclipai cloud push`.",
);
}
}
function cloudProofHeaders(connection: CloudConnection, method: string, pathAndSearch: string): Record<string, string> {
const timestamp = new Date().toISOString();
const nonce = randomUUID();
const payload = [
method,
connection.targetHost.toLowerCase(),
pathAndSearch,
connection.token.id,
connection.sourceInstanceId,
timestamp,
nonce,
].join("\n");
return {
Authorization: `Bearer ${connection.accessToken}`,
"X-Paperclip-Upstream-Source-Instance-Id": connection.sourceInstanceId,
"X-Paperclip-Upstream-Proof-Timestamp": timestamp,
"X-Paperclip-Upstream-Proof-Nonce": nonce,
"X-Paperclip-Upstream-Proof-Signature": sign(
null,
Buffer.from(payload, "utf8"),
connection.privateKeyPem,
).toString("base64url"),
};
}
async function requestCloudJson<T>(url: string, init: RequestInit): Promise<T> {
const headers = new Headers(init.headers);
headers.set("accept", "application/json");
if (init.body !== undefined && !headers.has("content-type")) {
headers.set("content-type", "application/json");
}
const response = await fetch(url, { ...init, headers });
const text = await response.text();
const parsed = text.trim() ? JSON.parse(text) as unknown : {};
if (!response.ok) {
const message = typeof parsed === "object" && parsed !== null && "error" in parsed
? String((parsed as { error: unknown }).error)
: `Cloud request failed with ${response.status}`;
throw new CloudAuthRequestError(response.status, message, parsed);
}
return parsed as T;
}
function createSourceIdentity() {
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
const sourcePublicKey = publicKey.export({ type: "spki", format: "pem" }).toString();
const sourceInstanceFingerprint = `sha256:${createHash("sha256")
.update(publicKey.export({ type: "spki", format: "der" }))
.digest("hex")}`;
return {
sourceInstanceId: `paperclip-local-${resolvePaperclipInstanceId()}`,
sourceInstanceFingerprint,
sourcePublicKey,
privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
};
}
async function startPkceCallbackServer(): Promise<{
redirectUri: string;
waitForCode: (state: string) => Promise<string>;
close: () => Promise<void>;
}> {
let resolveCode: ((code: string) => void) | null = null;
let rejectCode: ((error: Error) => void) | null = null;
let expectedState = "";
const codePromise = new Promise<string>((resolve, reject) => {
resolveCode = resolve;
rejectCode = reject;
});
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code || state !== expectedState) {
res.writeHead(400, { "Content-Type": "text/plain" });
res.end("Paperclip Cloud authorization failed. You can close this tab.");
rejectCode?.(new Error("Authorization callback was missing a valid code or state."));
return;
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Paperclip Cloud authorization complete. You can close this tab.");
resolveCode?.(code);
});
await listenOnLoopback(server);
const address = server.address();
if (typeof address !== "object" || !address?.port) {
throw new Error("Failed to start local authorization callback server.");
}
return {
redirectUri: `http://127.0.0.1:${address.port}/cloud/callback`,
waitForCode: (state: string) => {
expectedState = state;
return codePromise;
},
close: () => closeServer(server),
};
}
function listenOnLoopback(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
}
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
});
}
function canOpenBrowser(): boolean {
if (process.platform === "darwin" || process.platform === "win32") return true;
return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
}
function summarizeResult(result: unknown): {
create: number;
update: number;
adopt: number;
skip: number;
conflict: number;
staleMapping: number;
} {
const summary = asRecord(asRecord(result)?.summary);
return {
create: numberValue(summary?.create),
update: numberValue(summary?.update),
adopt: numberValue(summary?.adopt),
skip: numberValue(summary?.skip),
conflict: numberValue(summary?.conflict),
staleMapping: numberValue(summary?.staleMapping),
};
}
function printWarnings(result: unknown): void {
const warnings = Array.isArray(asRecord(result)?.warnings) ? asRecord(result)?.warnings as unknown[] : [];
for (const warning of warnings) {
const record = asRecord(warning);
console.log(pc.yellow(`warning=${record?.code ?? "warning"} ${record?.message ?? ""}`.trim()));
}
}
function printConflicts(result: unknown): void {
const conflicts = Array.isArray(asRecord(result)?.conflicts) ? asRecord(result)?.conflicts as unknown[] : [];
for (const conflict of conflicts.slice(0, 10)) {
const record = asRecord(conflict);
console.log(pc.red(`conflict=${record?.conflictKind ?? "target_conflict"} target=${record?.targetEntityId ?? "-"}`));
}
if (conflicts.length > 10) console.log(pc.red(`conflicts_truncated=${conflicts.length - 10}`));
}
function printEvents(events: unknown): void {
const rows = Array.isArray(asRecord(events)?.events) ? asRecord(events)?.events as unknown[] : [];
for (const row of rows.slice(-10)) {
const event = asRecord(row);
console.log(pc.dim(`event=${event?.action ?? "-"} target=${event?.targetEntityId ?? "-"}`));
}
}
function getRunId(result: unknown): string | null {
const run = asRecord(asRecord(result)?.run);
return typeof run?.id === "string" ? run.id : null;
}
function redactConnection(connection: CloudConnection): Record<string, unknown> {
return {
id: connection.id,
remoteUrl: connection.remoteUrl,
targetOrigin: connection.targetOrigin,
stackId: connection.stackId,
targetCompanyId: connection.targetCompanyId,
scopes: connection.scopes,
expiresAt: connection.token.expiresAt,
};
}
function connectionId(targetOrigin: string): string {
return `cloud-${shortHash(targetOrigin)}`;
}
function shortHash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
function requiredString(value: unknown, label: string): string {
if (typeof value === "string" && value.trim()) return value.trim();
throw new Error(`${label} is required.`);
}
function numberValue(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function isSchemaMismatchError(error: unknown): boolean {
if (error instanceof UpstreamImportRequestError) {
return JSON.stringify(error.body).toLowerCase().includes("schema");
}
return error instanceof Error && error.message.toLowerCase().includes("schema mismatch");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const cloudCommandExitCodes = {
conflict: CLOUD_SYNC_CONFLICT_EXIT_CODE,
schemaMismatch: CLOUD_SYNC_SCHEMA_MISMATCH_EXIT_CODE,
} as const;
+298
View File
@@ -0,0 +1,298 @@
import pc from "picocolors";
import type { Command } from "commander";
import { getStoredBoardCredential, loginBoardCli } from "../../client/board-auth.js";
import { buildCliCommandLabel } from "../../client/command-label.js";
import { readConfig } from "../../config/store.js";
import { readContext, resolveProfile, type ClientContextProfile } from "../../client/context.js";
import { ApiRequestError, PaperclipApiClient } from "../../client/http.js";
export interface BaseClientOptions {
config?: string;
dataDir?: string;
context?: string;
profile?: string;
apiBase?: string;
apiKey?: string;
runId?: string;
companyId?: string;
json?: boolean;
}
export interface ResolvedClientContext {
api: PaperclipApiClient;
companyId?: string;
profileName: string;
profile: ClientContextProfile;
json: boolean;
authSource: "explicit" | "env" | "profile_env" | "stored_board" | "none";
}
export function addCommonClientOptions(command: Command, opts?: { includeCompany?: boolean }): Command {
command
.option("-c, --config <path>", "Path to Paperclip config file")
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "Path to CLI context file")
.option("--profile <name>", "CLI context profile name")
.option("--api-base <url>", "Base URL for the Paperclip API")
.option("--api-key <token>", "Bearer token for agent-authenticated calls")
.option("--run-id <id>", "Heartbeat run id for agent-authenticated mutations (checkout/release/interactions/in-progress update); falls back to $PAPERCLIP_RUN_ID")
.option("--json", "Output raw JSON");
if (opts?.includeCompany) {
command.option("-C, --company-id <id>", "Company ID (overrides context default)");
}
return command;
}
export function resolveCommandContext(
options: BaseClientOptions,
opts?: { requireCompany?: boolean },
): ResolvedClientContext {
const context = readContext(options.context);
const { name: profileName, profile } = resolveProfile(context, options.profile);
const apiBase = resolveApiBase(options, profile);
const resolvedApiKey = resolveApiKey(options, profile);
const explicitApiKey = resolvedApiKey.value;
const storedBoardCredential = explicitApiKey ? null : getStoredBoardCredential(apiBase);
const apiKey = explicitApiKey || storedBoardCredential?.token;
const companyId =
options.companyId?.trim() ||
process.env.PAPERCLIP_COMPANY_ID?.trim() ||
profile.companyId;
if (opts?.requireCompany && !companyId) {
throw new Error(
"Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or set context profile companyId via `paperclipai context set`.",
);
}
// Agent-authenticated mutations (checkout, release, interactions, PATCH of an
// in-progress issue) require the X-Paperclip-Run-Id header (the server returns
// "401 Agent run id required" without it). Source it from --run-id, else the
// PAPERCLIP_RUN_ID env the adapter/embodiment context already exports.
const runId = options.runId?.trim() || process.env.PAPERCLIP_RUN_ID?.trim() || undefined;
const api = new PaperclipApiClient({
apiBase,
apiKey,
runId,
recoverAuth: explicitApiKey || !canAttemptInteractiveBoardAuth()
? undefined
: async ({ error }) => {
const requestedAccess = error.message.includes("Instance admin required")
? "instance_admin_required"
: "board";
if (!shouldRecoverBoardAuth(error)) {
return null;
}
const login = await loginBoardCli({
apiBase,
requestedAccess,
requestedCompanyId: companyId ?? null,
command: buildCliCommandLabel(),
});
return login.token;
},
});
return {
api,
companyId,
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;
// These MIME strings are matched against the server's issue-attachment
// allowlist (server/src/attachment-types.ts DEFAULT_ALLOWED_TYPES) by EXACT
// string, so text types must carry no "; charset=..." parameter or the upload
// is rejected with "422 Unsupported attachment content type". Keep this set in
// sync with that allowlist (plus svg/avif, accepted by the asset routes).
return {
avif: "image/avif",
csv: "text/csv",
gif: "image/gif",
htm: "text/html",
html: "text/html",
jpeg: "image/jpeg",
jpg: "image/jpeg",
json: "application/json",
m4v: "video/x-m4v",
md: "text/markdown",
mov: "video/quicktime",
mp4: "video/mp4",
pdf: "application/pdf",
png: "image/png",
qt: "video/quicktime",
svg: "image/svg+xml",
txt: "text/plain",
webm: "video/webm",
webp: "image/webp",
zip: "application/zip",
}[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;
return error.message.includes("Board access required") || error.message.includes("Instance admin required");
}
function canAttemptInteractiveBoardAuth(): boolean {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}
export function printOutput(data: unknown, opts: { json?: boolean; label?: string } = {}): void {
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
return;
}
if (opts.label) {
console.log(pc.bold(opts.label));
}
if (Array.isArray(data)) {
if (data.length === 0) {
console.log(pc.dim("(empty)"));
return;
}
for (const item of data) {
if (typeof item === "object" && item !== null) {
console.log(formatInlineRecord(item as Record<string, unknown>));
} else {
console.log(String(item));
}
}
return;
}
if (typeof data === "object" && data !== null) {
console.log(JSON.stringify(data, null, 2));
return;
}
if (data === undefined || data === null) {
console.log(pc.dim("(null)"));
return;
}
console.log(String(data));
}
export function formatInlineRecord(record: Record<string, unknown>): string {
const keyOrder = ["identifier", "id", "name", "status", "priority", "title", "action"];
const seen = new Set<string>();
const parts: string[] = [];
for (const key of keyOrder) {
if (!(key in record)) continue;
parts.push(`${key}=${renderValue(record[key])}`);
seen.add(key);
}
for (const [key, value] of Object.entries(record)) {
if (seen.has(key)) continue;
if (typeof value === "object") continue;
parts.push(`${key}=${renderValue(value)}`);
}
return parts.join(" ");
}
function renderValue(value: unknown): string {
if (value === null || value === undefined) return "-";
if (typeof value === "string") {
const compact = value.replace(/\s+/g, " ").trim();
return compact.length > 90 ? `${compact.slice(0, 87)}...` : compact;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "[object]";
}
export function inferApiBaseFromConfig(configPath?: string): string {
const envHost = process.env.PAPERCLIP_SERVER_HOST?.trim() || "localhost";
let port = Number(process.env.PAPERCLIP_SERVER_PORT || "");
if (!Number.isFinite(port) || port <= 0) {
try {
const config = readConfig(configPath);
port = Number(config?.server?.port ?? 3100);
} catch {
port = 3100;
}
}
if (!Number.isFinite(port) || port <= 0) {
port = 3100;
}
return `http://${envHost}:${port}`;
}
function readKeyFromProfileEnv(profile: ClientContextProfile): string | undefined {
if (!profile.apiKeyEnvVarName) return undefined;
return process.env[profile.apiKeyEnvVarName]?.trim() || undefined;
}
export function handleCommandError(error: unknown): never {
if (error instanceof ApiRequestError) {
const detailSuffix = error.details !== undefined ? ` details=${JSON.stringify(error.details)}` : "";
console.error(pc.red(`API error ${error.status}: ${error.message}${detailSuffix}`));
process.exit(1);
}
const message = error instanceof Error ? error.message : String(error);
console.error(pc.red(message));
process.exit(1);
}
File diff suppressed because it is too large. Load diff
+265
View File
@@ -0,0 +1,265 @@
import { Command } from "commander";
import * as p from "@clack/prompts";
import pc from "picocolors";
import type { Agent, Company } from "@paperclipai/shared";
import { createAgentKeySchema, createBoardApiKeySchema } from "@paperclipai/shared";
import { loginBoardCli } from "../../client/board-auth.js";
import { PaperclipApiClient } from "../../client/http.js";
import { resolveProfile, readContext, setCurrentProfile, upsertProfile } from "../../client/context.js";
import {
addCommonClientOptions,
apiPath,
handleCommandError,
normalizeApiBase,
printOutput,
resolveApiBase,
type BaseClientOptions,
} from "./common.js";
interface ConnectOptions extends BaseClientOptions {
profile?: string;
persona?: "board" | "agent";
apiKeyEnvVarName?: string;
tokenName?: string;
}
interface CreatedAgentKey {
id: string;
name: string;
token: string;
createdAt: string;
}
interface CreatedBoardKey extends CreatedAgentKey {
expiresAt: string | null;
}
export function registerConnectCommand(program: Command): void {
addCommonClientOptions(
program
.command("connect")
.description("Interactively connect the CLI as a board operator or agent")
.option("--persona <persona>", "Persona to configure: board or agent")
.option("--api-key-env-var-name <name>", "Env var name to store in the profile", "PAPERCLIP_API_KEY")
.option("--token-name <name>", "Token label to create")
.action(async (opts: ConnectOptions) => {
try {
const result = await connectWizard(opts);
printOutput(result, { json: opts.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
async function connectWizard(opts: ConnectOptions) {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error("`paperclipai connect` is interactive. For scripts, pass --api-base/--api-key or use context set/token commands.");
}
p.intro(pc.bgCyan(pc.black(" paperclipai connect ")));
const context = readContext(opts.context);
const resolvedProfile = resolveProfile(context, opts.profile);
const initialApiBase = resolveApiBase(opts, resolvedProfile.profile);
const apiBaseInput = await p.text({
message: "Paperclip API base",
initialValue: initialApiBase,
placeholder: "http://localhost:3100",
});
assertNotCancelled(apiBaseInput);
const apiBase = normalizeApiBase(String(apiBaseInput || initialApiBase));
console.log(pc.dim(`Checking ${apiBase}/api/health ...`));
await verifyHealth(apiBase);
const boardLogin = await loginBoardCli({
apiBase,
requestedAccess: "board",
requestedCompanyId: opts.companyId ?? resolvedProfile.profile.companyId ?? null,
command: "paperclipai connect",
});
const boardApi = new PaperclipApiClient({ apiBase, apiKey: boardLogin.token });
const companies = (await boardApi.get<Company[]>("/api/companies")) ?? [];
const persona = await choosePersona(opts.persona);
const profileName = opts.profile?.trim() || await askProfileName(resolvedProfile.name);
const apiKeyEnvVarName = opts.apiKeyEnvVarName?.trim() || "PAPERCLIP_API_KEY";
if (persona === "board") {
const company = await chooseCompany(companies, opts.companyId ?? resolvedProfile.profile.companyId, {
optional: true,
});
const tokenName = opts.tokenName?.trim() || `cli-board-${new Date().toISOString()}`;
const key = await boardApi.post<CreatedBoardKey>("/api/board-api-keys", createBoardApiKeySchema.parse({
name: tokenName,
requestedCompanyId: company?.id ?? null,
}));
if (!key) throw new Error("Failed to create board token");
upsertProfile(profileName, {
apiBase,
companyId: company?.id,
persona: "board",
agentId: "",
agentName: "",
apiKeyEnvVarName,
tokenName: key.name,
tokenId: key.id,
tokenCreatedAt: key.createdAt,
}, opts.context);
setCurrentProfile(profileName, opts.context);
p.outro(pc.green(`Connected profile '${profileName}' as board.`));
return {
ok: true,
profile: profileName,
persona: "board",
apiBase,
companyId: company?.id ?? null,
key: publicKeyResult(key),
exports: buildExports({ apiBase, companyId: company?.id, agentId: undefined, envName: apiKeyEnvVarName, token: key.token }),
};
}
const company = await chooseCompany(companies, opts.companyId ?? resolvedProfile.profile.companyId, {
optional: false,
});
if (!company) throw new Error("Company is required for agent profiles");
const agents = (await boardApi.get<Agent[]>(apiPath`/api/companies/${company.id}/agents`)) ?? [];
if (agents.length === 0) throw new Error(`Company '${company.name}' has no agents to connect.`);
const agent = await chooseAgent(agents, resolvedProfile.profile.agentId);
const tokenName = opts.tokenName?.trim() || `cli-agent-${new Date().toISOString()}`;
const key = await boardApi.post<CreatedAgentKey>(apiPath`/api/agents/${agent.id}/keys`, createAgentKeySchema.parse({ name: tokenName }));
if (!key) throw new Error("Failed to create agent token");
upsertProfile(profileName, {
apiBase,
companyId: company.id,
persona: "agent",
agentId: agent.id,
agentName: agent.name,
apiKeyEnvVarName,
tokenName: key.name,
tokenId: key.id,
tokenCreatedAt: key.createdAt,
}, opts.context);
setCurrentProfile(profileName, opts.context);
p.outro(pc.green(`Connected profile '${profileName}' as ${agent.name}.`));
return {
ok: true,
profile: profileName,
persona: "agent",
apiBase,
companyId: company.id,
agentId: agent.id,
agentName: agent.name,
key: publicKeyResult(key),
exports: buildExports({ apiBase, companyId: company.id, agentId: agent.id, envName: apiKeyEnvVarName, token: key.token }),
};
}
async function verifyHealth(apiBase: string): Promise<void> {
const api = new PaperclipApiClient({ apiBase });
await api.get("/api/health");
}
async function choosePersona(input: string | undefined): Promise<"board" | "agent"> {
if (input === "board" || input === "agent") return input;
const selected = await p.select({
message: "Connect as",
options: [
{ value: "board", label: "Board operator" },
{ value: "agent", label: "Agent in a company" },
],
});
assertNotCancelled(selected);
return selected as "board" | "agent";
}
async function askProfileName(defaultName: string): Promise<string> {
const profile = await p.text({
message: "Profile name",
initialValue: defaultName || "default",
});
assertNotCancelled(profile);
const value = String(profile).trim();
if (!value) throw new Error("Profile name is required");
return value;
}
async function chooseCompany(
companies: Company[],
preferredCompanyId: string | undefined,
opts: { optional: boolean },
): Promise<Company | null> {
if (companies.length === 0) {
if (opts.optional) return null;
throw new Error("No companies are accessible with this board credential.");
}
const preferred = preferredCompanyId ? companies.find((company) => company.id === preferredCompanyId) : null;
if (companies.length === 1 && !opts.optional) return companies[0] ?? null;
const selected = await p.select({
message: opts.optional ? "Default company for this profile" : "Agent company",
initialValue: preferred?.id ?? companies[0]?.id,
options: [
...(opts.optional ? [{ value: "", label: "(none)" }] : []),
...companies.map((company) => ({
value: company.id,
label: company.name,
hint: company.id,
})),
],
});
assertNotCancelled(selected);
if (!selected) return null;
return companies.find((company) => company.id === selected) ?? null;
}
async function chooseAgent(agents: Agent[], preferredAgentId: string | undefined): Promise<Agent> {
const selected = await p.select({
message: "Agent",
initialValue: preferredAgentId && agents.some((agent) => agent.id === preferredAgentId)
? preferredAgentId
: agents[0]?.id,
options: agents.map((agent) => ({
value: agent.id,
label: agent.name,
hint: agent.role,
})),
});
assertNotCancelled(selected);
const agent = agents.find((item) => item.id === selected);
if (!agent) throw new Error("Agent selection failed");
return agent;
}
function buildExports(input: {
apiBase: string;
companyId?: string;
agentId?: string;
envName: string;
token: string;
}): string {
const escaped = (value: string) => value.replace(/'/g, "'\"'\"'");
return [
`export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`,
input.companyId ? `export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'` : null,
input.agentId ? `export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'` : null,
`export ${input.envName}='${escaped(input.token)}'`,
].filter((line): line is string => Boolean(line)).join("\n");
}
function publicKeyResult(key: CreatedAgentKey | CreatedBoardKey) {
return {
id: key.id,
name: key.name,
createdAt: key.createdAt,
token: key.token,
expiresAt: "expiresAt" in key ? key.expiresAt : undefined,
};
}
function assertNotCancelled<T>(value: T | symbol): asserts value is T {
if (p.isCancel(value)) {
p.cancel("Cancelled.");
process.exit(0);
}
}
+158
View File
@@ -0,0 +1,158 @@
import { Command } from "commander";
import pc from "picocolors";
import {
readContext,
resolveContextPath,
resolveProfile,
setCurrentProfile,
upsertProfile,
type ClientContextProfile,
} from "../../client/context.js";
import { printOutput } from "./common.js";
interface ContextOptions {
dataDir?: string;
context?: string;
profile?: string;
json?: boolean;
}
interface ContextSetOptions extends ContextOptions {
apiBase?: string;
companyId?: string;
persona?: "board" | "agent";
agentId?: string;
agentName?: string;
apiKeyEnvVarName?: string;
use?: boolean;
}
export function registerContextCommands(program: Command): void {
const context = program.command("context").description("Manage CLI client context profiles");
context
.command("show")
.description("Show current context and active profile")
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "Path to CLI context file")
.option("--profile <name>", "Profile to inspect")
.option("--json", "Output raw JSON")
.action((opts: ContextOptions) => {
const contextPath = resolveContextPath(opts.context);
const store = readContext(opts.context);
const resolved = resolveProfile(store, opts.profile);
const payload = {
contextPath,
currentProfile: store.currentProfile,
profileName: resolved.name,
profile: resolved.profile,
profiles: store.profiles,
};
printOutput(payload, { json: opts.json });
});
context
.command("list")
.description("List available context profiles")
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "Path to CLI context file")
.option("--json", "Output raw JSON")
.action((opts: ContextOptions) => {
const store = readContext(opts.context);
const rows = Object.entries(store.profiles).map(([name, profile]) => ({
name,
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 });
});
context
.command("use")
.description("Set active context profile")
.argument("<profile>", "Profile name")
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "Path to CLI context file")
.action((profile: string, opts: ContextOptions) => {
setCurrentProfile(profile, opts.context);
console.log(pc.green(`Active profile set to '${profile}'.`));
});
context
.command("set")
.description("Set values on a profile")
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "Path to CLI context file")
.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")
.action((opts: ContextSetOptions) => {
const existing = readContext(opts.context);
const targetProfile = opts.profile?.trim() || existing.currentProfile || "default";
upsertProfile(
targetProfile,
buildContextPatch(opts),
opts.context,
);
if (opts.use) {
setCurrentProfile(targetProfile, opts.context);
}
const updated = readContext(opts.context);
const resolved = resolveProfile(updated, targetProfile);
const payload = {
contextPath: resolveContextPath(opts.context),
currentProfile: updated.currentProfile,
profileName: resolved.name,
profile: resolved.profile,
};
if (!opts.json) {
console.log(pc.green(`Updated profile '${targetProfile}'.`));
if (opts.use) {
console.log(pc.green(`Set '${targetProfile}' as active profile.`));
}
}
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
View File
@@ -0,0 +1,167 @@
import { Command } from "commander";
import {
addCommonClientOptions,
apiPath,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface CompanyOptions extends BaseClientOptions {
companyId?: string;
}
interface JsonPayloadOptions extends CompanyOptions {
payloadJson: string;
}
interface IncidentOptions extends CompanyOptions {
payloadJson?: string;
}
export function registerCostCommands(program: Command): void {
const cost = program.command("cost").description("Cost and finance operations");
for (const [name, path] of [
["summary", "costs/summary"],
["by-agent", "costs/by-agent"],
["by-agent-model", "costs/by-agent-model"],
["by-provider", "costs/by-provider"],
["by-biller", "costs/by-biller"],
["by-project", "costs/by-project"],
["window-spend", "costs/window-spend"],
["quota-windows", "costs/quota-windows"],
] as const) {
addCompanyGet(cost, name, `Get ${name} cost data`, path);
}
addCommonClientOptions(
cost
.command("issue")
.description("Get cost summary for an issue")
.argument("<issueId>", "Issue ID")
.action(async (issueId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const result = await ctx.api.get(apiPath`/api/issues/${issueId}/cost-summary`);
printOutput(result, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCompanyPostJson(cost, "event:create", "Record a cost event", "cost-events");
const finance = program.command("finance").description("Finance event and summary operations");
addCompanyPostJson(finance, "event:create", "Record a finance event", "finance-events");
addCompanyGet(finance, "events", "List finance events", "costs/finance-events");
addCompanyGet(finance, "summary", "Get finance summary", "costs/finance-summary");
addCompanyGet(finance, "by-biller", "Get finance summary by biller", "costs/finance-by-biller");
addCompanyGet(finance, "by-kind", "Get finance summary by kind", "costs/finance-by-kind");
const budget = program.command("budget").description("Budget policy and incident operations");
addCompanyGet(budget, "overview", "Get budget overview", "budgets/overview");
addCompanyPostJson(budget, "policy:upsert", "Create or update a budget policy", "budgets/policies");
addCommonClientOptions(
budget
.command("company:update")
.description("Update company budget")
.option("-C, --company-id <id>", "Company ID")
.requiredOption("--payload-json <json>", "UpdateBudget JSON payload")
.action(async (opts: JsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const result = await ctx.api.patch(apiPath`/api/companies/${ctx.companyId}/budgets`, parseJson(opts.payloadJson));
printOutput(result, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
budget
.command("agent:update")
.description("Update agent budget")
.argument("<agentId>", "Agent ID")
.requiredOption("--payload-json <json>", "UpdateBudget JSON payload")
.action(async (agentId: string, opts: JsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts);
const result = await ctx.api.patch(apiPath`/api/agents/${agentId}/budgets`, parseJson(opts.payloadJson));
printOutput(result, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
budget
.command("incident:resolve")
.description("Resolve a budget incident")
.argument("<incidentId>", "Budget incident ID")
.option("-C, --company-id <id>", "Company ID")
.option("--payload-json <json>", "ResolveBudgetIncident JSON payload", "{}")
.action(async (incidentId: string, opts: IncidentOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const result = await ctx.api.post(
apiPath`/api/companies/${ctx.companyId}/budget-incidents/${incidentId}/resolve`,
parseJson(opts.payloadJson ?? "{}"),
);
printOutput(result, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function addCompanyGet(parent: Command, name: string, description: string, path: string): void {
addCommonClientOptions(
parent
.command(name)
.description(description)
.option("-C, --company-id <id>", "Company ID")
.action(async (opts: CompanyOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const result = await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`);
printOutput(result, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function addCompanyPostJson(parent: Command, name: string, description: string, path: string): void {
addCommonClientOptions(
parent
.command(name)
.description(description)
.option("-C, --company-id <id>", "Company ID")
.requiredOption("--payload-json <json>", "JSON payload")
.action(async (opts: JsonPayloadOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const result = await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson));
printOutput(result, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function parseJson(value: string): unknown {
return JSON.parse(value) as unknown;
}
+35
View File
@@ -0,0 +1,35 @@
import { Command } from "commander";
import type { DashboardSummary } from "@paperclipai/shared";
import {
addCommonClientOptions,
apiPath,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface DashboardGetOptions extends BaseClientOptions {
companyId?: string;
}
export function registerDashboardCommands(program: Command): void {
const dashboard = program.command("dashboard").description("Dashboard summary operations");
addCommonClientOptions(
dashboard
.command("get")
.description("Get dashboard summary for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.action(async (opts: DashboardGetOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const row = await ctx.api.get<DashboardSummary>(apiPath`/api/companies/${ctx.companyId}/dashboard`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
+676
View File
@@ -0,0 +1,676 @@
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import pc from "picocolors";
import { Command } from "commander";
import type { Company, FeedbackTrace, FeedbackTraceBundle } from "@paperclipai/shared";
import {
addCommonClientOptions,
apiPath,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
type ResolvedClientContext,
} from "./common.js";
interface FeedbackFilterOptions extends BaseClientOptions {
targetType?: string;
vote?: string;
status?: string;
projectId?: string;
issueId?: string;
from?: string;
to?: string;
sharedOnly?: boolean;
}
export interface FeedbackTraceQueryOptions {
targetType?: string;
vote?: string;
status?: string;
projectId?: string;
issueId?: string;
from?: string;
to?: string;
sharedOnly?: boolean;
}
interface FeedbackReportOptions extends FeedbackFilterOptions {
payloads?: boolean;
}
interface FeedbackExportOptions extends FeedbackFilterOptions {
out?: string;
}
interface FeedbackSummary {
total: number;
thumbsUp: number;
thumbsDown: number;
withReason: number;
statuses: Record<string, number>;
}
interface FeedbackExportManifest {
exportedAt: string;
serverUrl: string;
companyId: string;
summary: FeedbackSummary & {
uniqueIssues: number;
issues: string[];
};
files: {
votes: string[];
traces: string[];
fullTraces: string[];
zip: string;
};
}
interface FeedbackExportResult {
outputDir: string;
zipPath: string;
manifest: FeedbackExportManifest;
}
export function registerFeedbackCommands(program: Command): void {
const feedback = program.command("feedback").description("Inspect and export local feedback traces");
addCommonClientOptions(
feedback
.command("report")
.description("Render a terminal report for company feedback traces")
.option("-C, --company-id <id>", "Company ID (overrides context default)")
.option("--target-type <type>", "Filter by target type")
.option("--vote <vote>", "Filter by vote value")
.option("--status <status>", "Filter by trace status")
.option("--project-id <id>", "Filter by project ID")
.option("--issue-id <id>", "Filter by issue ID")
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
.option("--shared-only", "Only include traces eligible for sharing/export")
.option("--payloads", "Include raw payload dumps in the terminal report", false)
.action(async (opts: FeedbackReportOptions) => {
try {
const ctx = resolveCommandContext(opts);
const companyId = await resolveFeedbackCompanyId(ctx, opts.companyId);
const traces = await fetchCompanyFeedbackTraces(ctx, companyId, opts);
const summary = summarizeFeedbackTraces(traces);
if (ctx.json) {
printOutput(
{
apiBase: ctx.api.apiBase,
companyId,
summary,
traces,
},
{ json: true },
);
return;
}
console.log(renderFeedbackReport({
apiBase: ctx.api.apiBase,
companyId,
traces,
summary,
includePayloads: Boolean(opts.payloads),
}));
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
feedback
.command("export")
.description("Export feedback votes and raw trace bundles into a folder plus zip archive")
.option("-C, --company-id <id>", "Company ID (overrides context default)")
.option("--target-type <type>", "Filter by target type")
.option("--vote <vote>", "Filter by vote value")
.option("--status <status>", "Filter by trace status")
.option("--project-id <id>", "Filter by project ID")
.option("--issue-id <id>", "Filter by issue ID")
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
.option("--shared-only", "Only include traces eligible for sharing/export")
.option("--out <path>", "Output directory (default: ./feedback-export-<timestamp>)")
.action(async (opts: FeedbackExportOptions) => {
try {
const ctx = resolveCommandContext(opts);
const companyId = await resolveFeedbackCompanyId(ctx, opts.companyId);
const traces = await fetchCompanyFeedbackTraces(ctx, companyId, opts);
const outputDir = path.resolve(opts.out?.trim() || defaultFeedbackExportDirName());
const exported = await writeFeedbackExportBundle({
apiBase: ctx.api.apiBase,
companyId,
traces,
outputDir,
traceBundleFetcher: (trace) => fetchFeedbackTraceBundle(ctx, trace.id),
});
if (ctx.json) {
printOutput(
{
companyId,
outputDir: exported.outputDir,
zipPath: exported.zipPath,
summary: exported.manifest.summary,
},
{ json: true },
);
return;
}
console.log(renderFeedbackExportSummary(exported));
} catch (err) {
handleCommandError(err);
}
}),
{ 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(
ctx: ResolvedClientContext,
explicitCompanyId?: string,
): Promise<string> {
const direct = explicitCompanyId?.trim() || ctx.companyId?.trim();
if (direct) return direct;
const companies = (await ctx.api.get<Company[]>("/api/companies")) ?? [];
const companyId = companies[0]?.id?.trim();
if (!companyId) {
throw new Error(
"Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or configure a CLI context default.",
);
}
return companyId;
}
export function buildFeedbackTraceQuery(opts: FeedbackTraceQueryOptions, includePayload = true): string {
const params = new URLSearchParams();
if (opts.targetType) params.set("targetType", opts.targetType);
if (opts.vote) params.set("vote", opts.vote);
if (opts.status) params.set("status", opts.status);
if (opts.projectId) params.set("projectId", opts.projectId);
if (opts.issueId) params.set("issueId", opts.issueId);
if (opts.from) params.set("from", opts.from);
if (opts.to) params.set("to", opts.to);
if (opts.sharedOnly) params.set("sharedOnly", "true");
if (includePayload) params.set("includePayload", "true");
const query = params.toString();
return query ? `?${query}` : "";
}
export function normalizeFeedbackTraceExportFormat(value: string | undefined): "json" | "ndjson" {
if (!value || value === "ndjson") return "ndjson";
if (value === "json") return "json";
throw new Error(`Unsupported export format: ${value}`);
}
export function serializeFeedbackTraces(traces: FeedbackTrace[], format: string | undefined): string {
if (normalizeFeedbackTraceExportFormat(format) === "json") {
return JSON.stringify(traces, null, 2);
}
return traces.map((trace) => JSON.stringify(trace)).join("\n");
}
export async function fetchCompanyFeedbackTraces(
ctx: ResolvedClientContext,
companyId: string,
opts: FeedbackFilterOptions,
): Promise<FeedbackTrace[]> {
return (
(await ctx.api.get<FeedbackTrace[]>(
`${apiPath`/api/companies/${companyId}/feedback-traces`}${buildFeedbackTraceQuery(opts, true)}`,
)) ?? []
);
}
export async function fetchFeedbackTraceBundle(
ctx: ResolvedClientContext,
traceId: string,
): Promise<FeedbackTraceBundle> {
const bundle = await ctx.api.get<FeedbackTraceBundle>(apiPath`/api/feedback-traces/${traceId}/bundle`);
if (!bundle) {
throw new Error(`Feedback trace bundle ${traceId} not found`);
}
return bundle;
}
export function summarizeFeedbackTraces(traces: FeedbackTrace[]): FeedbackSummary {
const statuses: Record<string, number> = {};
let thumbsUp = 0;
let thumbsDown = 0;
let withReason = 0;
for (const trace of traces) {
if (trace.vote === "up") thumbsUp += 1;
if (trace.vote === "down") thumbsDown += 1;
if (readFeedbackReason(trace)) withReason += 1;
statuses[trace.status] = (statuses[trace.status] ?? 0) + 1;
}
return {
total: traces.length,
thumbsUp,
thumbsDown,
withReason,
statuses,
};
}
export function renderFeedbackReport(input: {
apiBase: string;
companyId: string;
traces: FeedbackTrace[];
summary: FeedbackSummary;
includePayloads: boolean;
}): string {
const lines: string[] = [];
lines.push("");
lines.push(pc.bold(pc.magenta("Paperclip Feedback Report")));
lines.push(pc.dim(new Date().toISOString()));
lines.push(horizontalRule());
lines.push(`${pc.dim("Server:")} ${input.apiBase}`);
lines.push(`${pc.dim("Company:")} ${input.companyId}`);
lines.push("");
if (input.traces.length === 0) {
lines.push(pc.yellow("[!!] No feedback traces found."));
lines.push("");
return lines.join("\n");
}
lines.push(pc.bold(pc.cyan("Summary")));
lines.push(horizontalRule());
lines.push(` ${pc.green(pc.bold(String(input.summary.thumbsUp)))} thumbs up`);
lines.push(` ${pc.red(pc.bold(String(input.summary.thumbsDown)))} thumbs down`);
lines.push(` ${pc.yellow(pc.bold(String(input.summary.withReason)))} downvotes with a reason`);
lines.push(` ${pc.bold(String(input.summary.total))} total traces`);
lines.push("");
lines.push(pc.dim("Export status:"));
for (const status of ["pending", "sent", "local_only", "failed"]) {
lines.push(` ${padRight(status, 10)} ${input.summary.statuses[status] ?? 0}`);
}
lines.push("");
lines.push(pc.bold(pc.cyan("Trace Details")));
lines.push(horizontalRule());
for (const trace of input.traces) {
const voteColor = trace.vote === "up" ? pc.green : pc.red;
const voteIcon = trace.vote === "up" ? "^" : "v";
const issueRef = trace.issueIdentifier ?? trace.issueId;
const label = trace.targetSummary.label?.trim() || trace.targetType;
const excerpt = compactText(trace.targetSummary.excerpt);
const reason = readFeedbackReason(trace);
lines.push(
` ${voteColor(voteIcon)} ${pc.bold(issueRef)} ${pc.dim(compactText(trace.issueTitle, 64))}`,
);
lines.push(
` ${pc.dim("Trace:")} ${trace.id.slice(0, 8)} ${pc.dim("Status:")} ${trace.status} ${pc.dim("Date:")} ${formatTimestamp(trace.createdAt)}`,
);
lines.push(` ${pc.dim("Target:")} ${label}`);
if (excerpt) {
lines.push(` ${pc.dim("Excerpt:")} ${excerpt}`);
}
if (reason) {
lines.push(` ${pc.yellow(pc.bold("Reason:"))} ${pc.yellow(reason)}`);
}
lines.push("");
}
if (input.includePayloads) {
lines.push(pc.bold(pc.cyan("Raw Payloads")));
lines.push(horizontalRule());
for (const trace of input.traces) {
if (!trace.payloadSnapshot) continue;
const issueRef = trace.issueIdentifier ?? trace.issueId;
lines.push(` ${pc.bold(`${issueRef} (${trace.id.slice(0, 8)})`)}`);
const body = JSON.stringify(trace.payloadSnapshot, null, 2)?.split("\n") ?? [];
for (const line of body) {
lines.push(` ${pc.dim(line)}`);
}
lines.push("");
}
}
lines.push(horizontalRule());
lines.push(pc.dim(`Report complete. ${input.traces.length} trace(s) displayed.`));
lines.push("");
return lines.join("\n");
}
export async function writeFeedbackExportBundle(input: {
apiBase: string;
companyId: string;
traces: FeedbackTrace[];
outputDir: string;
traceBundleFetcher?: (trace: FeedbackTrace) => Promise<FeedbackTraceBundle>;
}): Promise<FeedbackExportResult> {
await ensureEmptyOutputDirectory(input.outputDir);
await mkdir(path.join(input.outputDir, "votes"), { recursive: true });
await mkdir(path.join(input.outputDir, "traces"), { recursive: true });
await mkdir(path.join(input.outputDir, "full-traces"), { recursive: true });
const summary = summarizeFeedbackTraces(input.traces);
const voteFiles: string[] = [];
const traceFiles: string[] = [];
const fullTraceDirs: string[] = [];
const fullTraceFiles: string[] = [];
const issueSet = new Set<string>();
for (const trace of input.traces) {
const issueRef = sanitizeFileSegment(trace.issueIdentifier ?? trace.issueId);
const voteRecord = buildFeedbackVoteRecord(trace);
const voteFileName = `${issueRef}-${trace.feedbackVoteId.slice(0, 8)}.json`;
const traceFileName = `${issueRef}-${trace.id.slice(0, 8)}.json`;
voteFiles.push(voteFileName);
traceFiles.push(traceFileName);
issueSet.add(trace.issueIdentifier ?? trace.issueId);
await writeFile(
path.join(input.outputDir, "votes", voteFileName),
`${JSON.stringify(voteRecord, null, 2)}\n`,
"utf8",
);
await writeFile(
path.join(input.outputDir, "traces", traceFileName),
`${JSON.stringify(trace, null, 2)}\n`,
"utf8",
);
if (input.traceBundleFetcher) {
const bundle = await input.traceBundleFetcher(trace);
const bundleDirName = `${issueRef}-${trace.id.slice(0, 8)}`;
const bundleDir = path.join(input.outputDir, "full-traces", bundleDirName);
await mkdir(bundleDir, { recursive: true });
fullTraceDirs.push(bundleDirName);
await writeFile(
path.join(bundleDir, "bundle.json"),
`${JSON.stringify(bundle, null, 2)}\n`,
"utf8",
);
fullTraceFiles.push(path.posix.join("full-traces", bundleDirName, "bundle.json"));
for (const file of bundle.files) {
const targetPath = path.join(bundleDir, file.path);
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(targetPath, file.contents, "utf8");
fullTraceFiles.push(path.posix.join("full-traces", bundleDirName, file.path.replace(/\\/g, "/")));
}
}
}
const zipPath = `${input.outputDir}.zip`;
const manifest: FeedbackExportManifest = {
exportedAt: new Date().toISOString(),
serverUrl: input.apiBase,
companyId: input.companyId,
summary: {
...summary,
uniqueIssues: issueSet.size,
issues: Array.from(issueSet).sort((left, right) => left.localeCompare(right)),
},
files: {
votes: voteFiles.slice().sort((left, right) => left.localeCompare(right)),
traces: traceFiles.slice().sort((left, right) => left.localeCompare(right)),
fullTraces: fullTraceDirs.slice().sort((left, right) => left.localeCompare(right)),
zip: path.basename(zipPath),
},
};
await writeFile(
path.join(input.outputDir, "index.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
const archiveFiles = await collectJsonFilesForArchive(input.outputDir, [
"index.json",
...manifest.files.votes.map((file) => path.posix.join("votes", file)),
...manifest.files.traces.map((file) => path.posix.join("traces", file)),
...fullTraceFiles,
]);
await writeFile(zipPath, createStoredZipArchive(archiveFiles, path.basename(input.outputDir)));
return {
outputDir: input.outputDir,
zipPath,
manifest,
};
}
export function renderFeedbackExportSummary(exported: FeedbackExportResult): string {
const lines: string[] = [];
lines.push("");
lines.push(pc.bold(pc.magenta("Paperclip Feedback Export")));
lines.push(pc.dim(exported.manifest.exportedAt));
lines.push(horizontalRule());
lines.push(`${pc.dim("Company:")} ${exported.manifest.companyId}`);
lines.push(`${pc.dim("Output:")} ${exported.outputDir}`);
lines.push(`${pc.dim("Archive:")} ${exported.zipPath}`);
lines.push("");
lines.push(pc.bold("Export Summary"));
lines.push(horizontalRule());
lines.push(` ${pc.green(pc.bold(String(exported.manifest.summary.thumbsUp)))} thumbs up`);
lines.push(` ${pc.red(pc.bold(String(exported.manifest.summary.thumbsDown)))} thumbs down`);
lines.push(` ${pc.yellow(pc.bold(String(exported.manifest.summary.withReason)))} with reason`);
lines.push(` ${pc.bold(String(exported.manifest.summary.uniqueIssues))} unique issues`);
lines.push("");
lines.push(pc.dim("Files:"));
lines.push(` ${path.join(exported.outputDir, "index.json")}`);
lines.push(` ${path.join(exported.outputDir, "votes")} (${exported.manifest.files.votes.length} files)`);
lines.push(` ${path.join(exported.outputDir, "traces")} (${exported.manifest.files.traces.length} files)`);
lines.push(` ${path.join(exported.outputDir, "full-traces")} (${exported.manifest.files.fullTraces.length} bundles)`);
lines.push(` ${exported.zipPath}`);
lines.push("");
return lines.join("\n");
}
function readFeedbackReason(trace: FeedbackTrace): string | null {
const payload = asRecord(trace.payloadSnapshot);
const vote = asRecord(payload?.vote);
const reason = vote?.reason;
return typeof reason === "string" && reason.trim() ? reason.trim() : null;
}
function buildFeedbackVoteRecord(trace: FeedbackTrace) {
return {
voteId: trace.feedbackVoteId,
traceId: trace.id,
issueId: trace.issueId,
issueIdentifier: trace.issueIdentifier,
issueTitle: trace.issueTitle,
vote: trace.vote,
targetType: trace.targetType,
targetId: trace.targetId,
targetSummary: trace.targetSummary,
status: trace.status,
consentVersion: trace.consentVersion,
createdAt: trace.createdAt,
updatedAt: trace.updatedAt,
reason: readFeedbackReason(trace),
};
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function compactText(value: string | null | undefined, maxLength = 88): string | null {
if (!value) return null;
const compact = value.replace(/\s+/g, " ").trim();
if (!compact) return null;
if (compact.length <= maxLength) return compact;
return `${compact.slice(0, maxLength - 3)}...`;
}
function formatTimestamp(value: unknown): string {
if (value instanceof Date) return value.toISOString().slice(0, 19).replace("T", " ");
if (typeof value === "string") return value.slice(0, 19).replace("T", " ");
return "-";
}
function horizontalRule(): string {
return pc.dim("-".repeat(72));
}
function padRight(value: string, width: number): string {
return `${value}${" ".repeat(Math.max(0, width - value.length))}`;
}
function defaultFeedbackExportDirName(): string {
const iso = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
return `feedback-export-${iso}`;
}
async function ensureEmptyOutputDirectory(outputDir: string): Promise<void> {
try {
const info = await stat(outputDir);
if (!info.isDirectory()) {
throw new Error(`Output path already exists and is not a directory: ${outputDir}`);
}
const entries = await readdir(outputDir);
if (entries.length > 0) {
throw new Error(`Output directory already exists and is not empty: ${outputDir}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (/ENOENT/.test(message)) {
await mkdir(outputDir, { recursive: true });
return;
}
throw error;
}
}
async function collectJsonFilesForArchive(
outputDir: string,
relativePaths: string[],
): Promise<Record<string, string>> {
const files: Record<string, string> = {};
for (const relativePath of relativePaths) {
const normalized = relativePath.replace(/\\/g, "/");
files[normalized] = await readFile(path.join(outputDir, normalized), "utf8");
}
return files;
}
function sanitizeFileSegment(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "feedback";
}
function writeUint16(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
}
function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
target[offset + 2] = (value >>> 16) & 0xff;
target[offset + 3] = (value >>> 24) & 0xff;
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function createStoredZipArchive(files: Record<string, string>, rootPath: string): Uint8Array {
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
let entryCount = 0;
for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
const body = encoder.encode(content);
const checksum = crc32(body);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, 0);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, body.length);
writeUint32(localHeader, 22, body.length);
writeUint16(localHeader, 26, fileName.length);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, 0);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, body.length);
writeUint32(centralHeader, 24, body.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, body);
centralChunks.push(centralHeader);
localOffset += localHeader.length + body.length;
entryCount += 1;
}
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
);
let offset = 0;
for (const chunk of localChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
const centralDirectoryOffset = offset;
for (const chunk of centralChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
writeUint32(archive, offset, 0x06054b50);
writeUint16(archive, offset + 8, entryCount);
writeUint16(archive, offset + 10, entryCount);
writeUint32(archive, offset + 12, centralDirectoryLength);
writeUint32(archive, offset + 16, centralDirectoryOffset);
return archive;
}
+177
View File
@@ -0,0 +1,177 @@
import { Command } from "commander";
import type { Goal } from "@paperclipai/shared";
import { createGoalSchema, updateGoalSchema } from "@paperclipai/shared";
import {
addCommonClientOptions,
apiPath,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface GoalListOptions extends BaseClientOptions {
companyId?: string;
}
interface GoalCreateOptions extends BaseClientOptions {
companyId?: string;
title: string;
description?: string;
level?: string;
status?: string;
parentId?: string;
ownerAgentId?: string;
}
interface GoalUpdateOptions extends BaseClientOptions {
title?: string;
description?: string;
level?: string;
status?: string;
parentId?: string;
ownerAgentId?: string;
}
interface GoalDeleteOptions extends BaseClientOptions {
yes?: boolean;
}
export function registerGoalCommands(program: Command): void {
const goal = program.command("goal").description("Goal operations");
addCommonClientOptions(
goal
.command("list")
.description("List goals for a company")
.option("-C, --company-id <id>", "Company ID")
.action(async (opts: GoalListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const rows = (await ctx.api.get<Goal[]>(apiPath`/api/companies/${ctx.companyId}/goals`)) ?? [];
if (ctx.json) {
printOutput(rows, { json: true });
return;
}
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
for (const row of rows) {
console.log(formatInlineRecord({
id: row.id,
status: row.status,
title: row.title,
level: row.level,
parentId: row.parentId,
ownerAgentId: row.ownerAgentId,
}));
}
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
goal
.command("get")
.description("Get one goal")
.argument("<goalId>", "Goal ID")
.action(async (goalId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const row = await ctx.api.get<Goal>(apiPath`/api/goals/${goalId}`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
goal
.command("create")
.description("Create a goal")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("--title <title>", "Goal title")
.option("--description <text>", "Goal description")
.option("--level <level>", "Goal level")
.option("--status <status>", "Goal status")
.option("--parent-id <id>", "Parent goal ID")
.option("--owner-agent-id <id>", "Owner agent ID")
.action(async (opts: GoalCreateOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const payload = createGoalSchema.parse({
title: opts.title,
description: opts.description,
level: opts.level,
status: opts.status,
parentId: parseNullableString(opts.parentId),
ownerAgentId: parseNullableString(opts.ownerAgentId),
});
const created = await ctx.api.post<Goal>(apiPath`/api/companies/${ctx.companyId}/goals`, payload);
printOutput(created, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
goal
.command("update")
.description("Update a goal")
.argument("<goalId>", "Goal ID")
.option("--title <title>", "Goal title")
.option("--description <text|null>", "Goal description")
.option("--level <level>", "Goal level")
.option("--status <status>", "Goal status")
.option("--parent-id <id|null>", "Parent goal ID")
.option("--owner-agent-id <id|null>", "Owner agent ID")
.action(async (goalId: string, opts: GoalUpdateOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = updateGoalSchema.parse({
title: opts.title,
description: parseNullableString(opts.description),
level: opts.level,
status: opts.status,
parentId: parseNullableString(opts.parentId),
ownerAgentId: parseNullableString(opts.ownerAgentId),
});
const updated = await ctx.api.patch<Goal>(apiPath`/api/goals/${goalId}`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
goal
.command("delete")
.description("Delete a goal")
.argument("<goalId>", "Goal ID")
.option("--yes", "Confirm deletion")
.action(async (goalId: string, opts: GoalDeleteOptions) => {
try {
if (!opts.yes) throw new Error("Deletion requires --yes.");
const ctx = resolveCommandContext(opts);
const deleted = await ctx.api.delete<Goal>(apiPath`/api/goals/${goalId}`);
printOutput(deleted, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
function parseNullableString(value: string | undefined): string | null | undefined {
if (value === undefined) return undefined;
return value.trim().toLowerCase() === "null" ? null : value;
}
File diff suppressed because it is too large. Load diff
+939
View File
@@ -0,0 +1,939 @@
import path from "node:path";
import { existsSync } from "node:fs";
import { Command, Option } from "commander";
import {
scaffoldPluginProject,
shellQuote,
type ScaffoldPluginOptions,
} from "../../../../packages/plugins/create-paperclip-plugin/src/index.js";
import pc from "picocolors";
import {
addCommonClientOptions,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
// ---------------------------------------------------------------------------
// Types mirroring server-side shapes
// ---------------------------------------------------------------------------
interface PluginRecord {
id: string;
pluginKey: string;
packageName: string;
version: string;
status: string;
displayName?: string;
lastError?: string | null;
installedAt: string;
updatedAt: string;
}
/** Subset of `GET /api/health` we surface as install/target diagnostics. */
interface TargetHealth {
status?: string;
version?: string;
deploymentMode?: string;
deploymentExposure?: string;
}
/** Result of probing the Paperclip instance the CLI is about to talk to. */
interface TargetDiagnostics {
apiBase: string;
reachable: boolean;
health?: TargetHealth;
error?: string;
}
// ---------------------------------------------------------------------------
// Option types
// ---------------------------------------------------------------------------
interface PluginListOptions extends BaseClientOptions {
status?: string;
}
interface PluginInstallOptions extends BaseClientOptions {
local?: boolean;
version?: string;
/** When false, skip the pre-install target-host health probe. Defaults true. */
verifyTarget?: boolean;
}
interface PluginInstallRequest {
packageName: string;
version?: string;
isLocalPath: boolean;
}
interface PluginUninstallOptions extends BaseClientOptions {
force?: boolean;
}
interface PluginInitOptions extends BaseClientOptions {
output?: string;
template?: ScaffoldPluginOptions["template"];
category?: ScaffoldPluginOptions["category"];
displayName?: string;
description?: string;
author?: string;
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[];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function expandHomePath(packageArg: string): string {
if (!packageArg.startsWith("~")) return packageArg;
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
}
function hasLocalPathSyntax(packageArg: string): boolean {
return (
path.isAbsolute(packageArg) ||
packageArg.startsWith("./") ||
packageArg.startsWith("../") ||
packageArg.startsWith("~") ||
packageArg.startsWith(".\\") ||
packageArg.startsWith("..\\")
);
}
function isExistingRelativePath(
packageArg: string,
cwd: string,
pathExists: (targetPath: string) => boolean,
): boolean {
if (packageArg.trim() === "") return false;
if (hasLocalPathSyntax(packageArg)) return false;
return pathExists(path.resolve(cwd, packageArg));
}
/**
* Resolve a local path argument to an absolute path so the server can find the
* plugin on disk regardless of where the user ran the CLI.
*/
function resolvePackageArg(packageArg: string, isLocal: boolean, cwd = process.cwd()): string {
if (!isLocal) return packageArg;
if (path.isAbsolute(packageArg)) return packageArg;
if (packageArg.startsWith("~")) return expandHomePath(packageArg);
return path.resolve(cwd, packageArg);
}
export function buildPluginInstallRequest(
packageArg: string,
opts: Pick<PluginInstallOptions, "local" | "version"> = {},
deps: { cwd?: string; existsSync?: (targetPath: string) => boolean } = {},
): PluginInstallRequest {
const cwd = deps.cwd ?? process.cwd();
const pathExists = deps.existsSync ?? existsSync;
const isLocal =
opts.local ||
hasLocalPathSyntax(packageArg) ||
(opts.version ? false : isExistingRelativePath(packageArg, cwd, pathExists));
if (isLocal && opts.version) {
throw new Error("--version is only supported for npm package installs, not local plugin paths.");
}
return {
packageName: resolvePackageArg(packageArg, Boolean(isLocal), cwd),
version: opts.version,
isLocalPath: Boolean(isLocal),
};
}
export function renderLocalPluginInstallHint(packagePath: string): string {
return [
pc.dim("Local plugin installs run trusted local code from your machine."),
pc.dim(`Keep ${pc.cyan("pnpm dev")} running in ${packagePath}; Paperclip watches rebuilt dist output and reloads the plugin worker.`),
].join("\n");
}
/**
* Probe `GET /api/health` on the instance the CLI is configured to talk to so a
* developer can confirm *which* Paperclip they are about to install into. This
* exists because a local-path plugin can otherwise be silently installed into a
* stale control-plane host that does not serve the branch's routes; surfacing
* the API URL plus the server version/status catches that mismatch before the
* plugin is exercised against the wrong runtime.
*/
export async function probeTargetDiagnostics(
api: { apiBase: string; get(path: string): Promise<TargetHealth | null> },
): Promise<TargetDiagnostics> {
try {
const health = await api.get("/api/health");
return {
apiBase: api.apiBase,
reachable: true,
health: health ?? undefined,
};
} catch (err) {
return {
apiBase: api.apiBase,
reachable: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Render the target-host diagnostics as human-readable lines. Pure so it can be
* unit-tested without a live server.
*/
export function formatTargetDiagnostics(diag: TargetDiagnostics): string {
const lines = [pc.dim(`Target Paperclip: ${pc.cyan(diag.apiBase)}`)];
if (!diag.reachable) {
lines.push(pc.yellow(` health: unreachable${diag.error ? ` (${diag.error.split("\n")[0]})` : ""}`));
lines.push(
pc.dim(
` Verify the right instance is running, then pass ${pc.cyan("--api-base <url>")} or set ${pc.cyan("PAPERCLIP_API_URL")} if it lives elsewhere.`,
),
);
return lines.join("\n");
}
const health = diag.health ?? {};
const detailParts: string[] = [];
if (health.status) detailParts.push(`status=${health.status}`);
if (health.version) detailParts.push(`version=${health.version}`);
if (health.deploymentMode) detailParts.push(`mode=${health.deploymentMode}`);
if (health.deploymentExposure) detailParts.push(`exposure=${health.deploymentExposure}`);
lines.push(
pc.dim(` health: ${detailParts.length > 0 ? detailParts.join(" ") : "ok (no details exposed)"}`),
);
return lines.join("\n");
}
function formatPlugin(p: PluginRecord): string {
const statusColor =
p.status === "ready"
? pc.green(p.status)
: p.status === "error"
? pc.red(p.status)
: p.status === "disabled"
? pc.dim(p.status)
: pc.yellow(p.status);
const parts = [
`key=${pc.bold(p.pluginKey)}`,
`status=${statusColor}`,
`version=${p.version}`,
`id=${pc.dim(p.id)}`,
];
if (p.lastError) {
parts.push(`error=${pc.red(p.lastError.slice(0, 80))}`);
}
return parts.join(" ");
}
function packageToDirName(pluginName: string): string {
return pluginName.replace(/^@[^/]+\//, "");
}
export function buildPluginInitScaffoldOptions(
packageName: string,
opts: PluginInitOptions,
cwd = process.cwd(),
): ScaffoldPluginOptions {
const outputRoot = path.resolve(cwd, opts.output ?? ".");
const outputDir = path.resolve(outputRoot, packageToDirName(packageName));
return {
pluginName: packageName,
outputDir,
template: opts.template,
category: opts.category,
displayName: opts.displayName,
description: opts.description,
author: opts.author,
sdkPath: opts.sdkPath,
};
}
export function buildPluginInitNextCommands(outputDir: string): string[] {
const quotedOutputDir = shellQuote(outputDir);
return [
`cd ${quotedOutputDir}`,
"pnpm install",
"pnpm dev",
`paperclipai plugin install ${quotedOutputDir}`,
];
}
export function renderPluginInitSuccess(result: PluginInitResult): string {
return [
pc.green(`✓ Created plugin scaffold at ${result.outputDir}`),
"",
"Next commands:",
...result.nextCommands.map((command) => ` ${pc.cyan(command)}`),
].join("\n");
}
export function runPluginInitCommand(packageName: string, opts: PluginInitOptions): PluginInitResult {
const scaffoldOptions = buildPluginInitScaffoldOptions(packageName, opts);
const outputDir = scaffoldPluginProject(scaffoldOptions);
return {
outputDir,
nextCommands: buildPluginInitNextCommands(outputDir),
};
}
// ---------------------------------------------------------------------------
// Command registration
// ---------------------------------------------------------------------------
export function registerPluginCommands(program: Command): void {
const plugin = program.command("plugin").description("Plugin lifecycle management");
// -------------------------------------------------------------------------
// plugin init <package-name>
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("init <packageName>")
.description("Scaffold a local Paperclip plugin project")
.option("--output <dir>", "Directory to create the plugin folder in")
.addOption(
new Option("--template <template>", "Starter template")
.choices(["default", "connector", "workspace", "environment"])
.default("default"),
)
.addOption(
new Option("--category <category>", "Manifest category")
.choices(["connector", "workspace", "automation", "ui", "environment"]),
)
.option("--display-name <name>", "Manifest display name")
.option("--description <description>", "Manifest description")
.option("--author <author>", "Manifest author")
.option("--sdk-path <path>", "Local @paperclipai/plugin-sdk package path")
.action((packageName: string, opts: PluginInitOptions) => {
try {
const result = runPluginInitCommand(packageName, opts);
if (opts.json) {
printOutput(result, { json: true });
return;
}
console.log(renderPluginInitSuccess(result));
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin list
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("list")
.description("List installed plugins")
.option("--status <status>", "Filter by status (ready, error, disabled, installed, upgrade_pending)")
.action(async (opts: PluginListOptions) => {
try {
const ctx = resolveCommandContext(opts);
const qs = opts.status ? `?status=${encodeURIComponent(opts.status)}` : "";
const plugins = await ctx.api.get<PluginRecord[]>(`/api/plugins${qs}`);
if (ctx.json) {
printOutput(plugins, { json: true });
return;
}
const rows = plugins ?? [];
if (rows.length === 0) {
console.log(pc.dim("No plugins installed."));
return;
}
for (const p of rows) {
console.log(formatPlugin(p));
}
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin install <package-or-path>
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("install <package>")
.description(
"Install a plugin from a local path or npm package.\n" +
" Examples:\n" +
" paperclipai plugin install ./my-plugin # local path\n" +
" paperclipai plugin install @acme/plugin-linear # npm package\n" +
" paperclipai plugin install @acme/plugin-linear@1.2 # pinned version",
)
.option("-l, --local", "Treat <package> as a local filesystem path", false)
.option("--version <version>", "Specific npm version to install (npm packages only)")
.option(
"--no-verify-target",
"Skip the pre-install probe that reports which Paperclip instance the plugin installs into",
)
.action(async (packageArg: string, opts: PluginInstallOptions) => {
try {
const ctx = resolveCommandContext(opts);
const installRequest = buildPluginInstallRequest(packageArg, opts);
// Make the install target explicit before sending the plugin to it. A
// local-path plugin can otherwise be silently installed into a stale
// control-plane host that lacks this branch's routes; printing the API
// URL + server version/health lets the developer catch that mismatch.
let target: TargetDiagnostics | undefined;
if (opts.verifyTarget !== false) {
target = await probeTargetDiagnostics(ctx.api);
if (!ctx.json) {
console.log(formatTargetDiagnostics(target));
}
}
if (!ctx.json) {
console.log(
pc.dim(
installRequest.isLocalPath
? `Installing plugin from local path: ${installRequest.packageName}`
: `Installing plugin: ${installRequest.packageName}${opts.version ? `@${opts.version}` : ""}`,
),
);
}
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", installRequest);
if (ctx.json) {
// Preserve the original flat PluginRecord shape so existing
// automation reading top-level fields (id/pluginKey/version/status)
// keeps working; attach target diagnostics as an additive field.
printOutput({ ...installedPlugin, ...(target ? { target } : {}) }, { json: true });
return;
}
if (!installedPlugin) {
console.log(pc.dim("Install returned no plugin record."));
return;
}
console.log(
pc.green(
`✓ Installed ${pc.bold(installedPlugin.pluginKey)} v${installedPlugin.version} (${installedPlugin.status})`,
),
);
if (installedPlugin.lastError) {
console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
}
if (installRequest.isLocalPath) {
console.log(renderLocalPluginInstallHint(installRequest.packageName));
}
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin target
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("target")
.description(
"Show which Paperclip instance plugin commands will talk to.\n" +
" Reports the resolved API URL plus the server status/version/mode from\n" +
" GET /api/health so you can confirm you are installing into the branch\n" +
" runtime and not a stale control-plane host.",
)
.action(async (opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const diag = await probeTargetDiagnostics(ctx.api);
if (ctx.json) {
printOutput(diag, { json: true });
return;
}
console.log(formatTargetDiagnostics(diag));
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin uninstall <plugin-key-or-id>
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("uninstall <pluginKey>")
.description(
"Uninstall a plugin by its plugin key or database ID.\n" +
" Use --force to hard-purge all state and config.",
)
.option("--force", "Purge all plugin state and config (hard delete)", false)
.action(async (pluginKey: string, opts: PluginUninstallOptions) => {
try {
const ctx = resolveCommandContext(opts);
const purge = opts.force === true;
const qs = purge ? "?purge=true" : "";
if (!ctx.json) {
console.log(
pc.dim(
purge
? `Uninstalling and purging plugin: ${pluginKey}`
: `Uninstalling plugin: ${pluginKey}`,
),
);
}
const result = await ctx.api.delete<PluginRecord | null>(
`/api/plugins/${encodeURIComponent(pluginKey)}${qs}`,
);
if (ctx.json) {
printOutput(result, { json: true });
return;
}
console.log(pc.green(`✓ Uninstalled ${pc.bold(pluginKey)}${purge ? " (purged)" : ""}`));
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin enable <plugin-key-or-id>
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("enable <pluginKey>")
.description("Enable a disabled or errored plugin")
.action(async (pluginKey: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const result = await ctx.api.post<PluginRecord>(
`/api/plugins/${encodeURIComponent(pluginKey)}/enable`,
);
if (ctx.json) {
printOutput(result, { json: true });
return;
}
console.log(pc.green(`✓ Enabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin disable <plugin-key-or-id>
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("disable <pluginKey>")
.description("Disable a running plugin without uninstalling it")
.action(async (pluginKey: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const result = await ctx.api.post<PluginRecord>(
`/api/plugins/${encodeURIComponent(pluginKey)}/disable`,
);
if (ctx.json) {
printOutput(result, { json: true });
return;
}
console.log(pc.dim(`Disabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin inspect <plugin-key-or-id>
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("inspect <pluginKey>")
.description("Show full details for an installed plugin")
.action(async (pluginKey: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const result = await ctx.api.get<PluginRecord>(
`/api/plugins/${encodeURIComponent(pluginKey)}`,
);
if (ctx.json) {
printOutput(result, { json: true });
return;
}
if (!result) {
console.log(pc.red(`Plugin not found: ${pluginKey}`));
process.exit(1);
}
console.log(formatPlugin(result));
if (result.lastError) {
console.log(`\n${pc.red("Last error:")}\n${result.lastError}`);
}
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin examples
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("examples")
.description("List bundled example plugins available for local install")
.action(async (opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const examples = await ctx.api.get<
Array<{
packageName: string;
pluginKey: string;
displayName: string;
description: string;
localPath: string;
tag: string;
}>
>("/api/plugins/examples");
if (ctx.json) {
printOutput(examples, { json: true });
return;
}
const rows = examples ?? [];
if (rows.length === 0) {
console.log(pc.dim("No bundled examples available."));
return;
}
for (const ex of rows) {
console.log(
`${pc.bold(ex.displayName)} ${pc.dim(ex.pluginKey)}\n` +
` ${ex.description}\n` +
` ${pc.cyan(`paperclipai plugin install ${ex.localPath}`)}`,
);
}
} catch (err) {
handleCommandError(err);
}
}),
);
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
View File
@@ -0,0 +1,228 @@
import { Command } from "commander";
import type { Project } from "@paperclipai/shared";
import { createProjectSchema, updateProjectSchema } from "@paperclipai/shared";
import {
addCommonClientOptions,
apiPath,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface ProjectListOptions extends BaseClientOptions {
companyId?: string;
}
interface ProjectCreateOptions extends BaseClientOptions {
companyId?: string;
name: string;
description?: string;
status?: string;
goalId?: string;
goalIds?: string;
leadAgentId?: string;
targetDate?: string;
color?: string;
envJson?: string;
executionWorkspacePolicyJson?: string;
}
interface ProjectUpdateOptions extends BaseClientOptions {
name?: string;
description?: string;
status?: string;
goalId?: string;
goalIds?: string;
leadAgentId?: string;
targetDate?: string;
color?: string;
envJson?: string;
executionWorkspacePolicyJson?: string;
archivedAt?: string;
}
interface ProjectDeleteOptions extends BaseClientOptions {
yes?: boolean;
}
export function registerProjectCommands(program: Command): void {
const project = program.command("project").description("Project operations");
addCommonClientOptions(
project
.command("list")
.description("List projects for a company")
.option("-C, --company-id <id>", "Company ID")
.action(async (opts: ProjectListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const rows = (await ctx.api.get<Project[]>(apiPath`/api/companies/${ctx.companyId}/projects`)) ?? [];
if (ctx.json) {
printOutput(rows, { json: true });
return;
}
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
for (const row of rows) {
console.log(formatInlineRecord({
id: row.id,
name: row.name,
status: row.status,
urlKey: row.urlKey,
goalIds: row.goalIds?.join(",") ?? "",
leadAgentId: row.leadAgentId,
}));
}
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
project
.command("get")
.description("Get one project by ID or shortname")
.argument("<project>", "Project ID or shortname")
.option("-C, --company-id <id>", "Company ID for shortname lookup")
.action(async (projectRef: string, opts: ProjectListOptions) => {
try {
const ctx = resolveCommandContext(opts);
const query = ctx.companyId ? `?${new URLSearchParams({ companyId: ctx.companyId }).toString()}` : "";
const row = await ctx.api.get<Project>(`${apiPath`/api/projects/${projectRef}`}${query}`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
project
.command("create")
.description("Create a project")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("--name <name>", "Project name")
.option("--description <text>", "Project description")
.option("--status <status>", "Project status")
.option("--goal-id <id>", "Deprecated single goal ID")
.option("--goal-ids <csv>", "Comma-separated goal IDs")
.option("--lead-agent-id <id>", "Lead agent ID")
.option("--target-date <date>", "Target date")
.option("--color <value>", "Project color")
.option("--env-json <json>", "Project env binding JSON")
.option("--execution-workspace-policy-json <json>", "Execution workspace policy JSON")
.action(async (opts: ProjectCreateOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const payload = createProjectSchema.parse({
name: opts.name,
description: opts.description,
status: opts.status,
goalId: parseNullableString(opts.goalId),
goalIds: parseCsv(opts.goalIds),
leadAgentId: parseNullableString(opts.leadAgentId),
targetDate: parseNullableString(opts.targetDate),
color: parseNullableString(opts.color),
env: parseOptionalJson(opts.envJson),
executionWorkspacePolicy: parseOptionalJson(opts.executionWorkspacePolicyJson),
});
const created = await ctx.api.post<Project>(apiPath`/api/companies/${ctx.companyId}/projects`, payload);
printOutput(created, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
project
.command("update")
.description("Update a project")
.argument("<project>", "Project ID or shortname")
.option("-C, --company-id <id>", "Company ID for shortname lookup")
.option("--name <name>", "Project name")
.option("--description <text|null>", "Project description")
.option("--status <status>", "Project status")
.option("--goal-id <id|null>", "Deprecated single goal ID")
.option("--goal-ids <csv>", "Comma-separated goal IDs")
.option("--lead-agent-id <id|null>", "Lead agent ID")
.option("--target-date <date|null>", "Target date")
.option("--color <value|null>", "Project color")
.option("--env-json <json|null>", "Project env binding JSON")
.option("--execution-workspace-policy-json <json|null>", "Execution workspace policy JSON")
.option("--archived-at <iso8601|null>", "Archive timestamp or null")
.action(async (projectRef: string, opts: ProjectUpdateOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = updateProjectSchema.parse({
name: opts.name,
description: parseNullableString(opts.description),
status: opts.status,
goalId: parseNullableString(opts.goalId),
goalIds: opts.goalIds === undefined ? undefined : parseCsv(opts.goalIds),
leadAgentId: parseNullableString(opts.leadAgentId),
targetDate: parseNullableString(opts.targetDate),
color: parseNullableString(opts.color),
env: parseOptionalJson(opts.envJson),
executionWorkspacePolicy: parseOptionalJson(opts.executionWorkspacePolicyJson),
archivedAt: parseNullableString(opts.archivedAt),
});
const query = ctx.companyId ? `?${new URLSearchParams({ companyId: ctx.companyId }).toString()}` : "";
const updated = await ctx.api.patch<Project>(`${apiPath`/api/projects/${projectRef}`}${query}`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
project
.command("delete")
.description("Delete a project")
.argument("<project>", "Project ID or shortname")
.option("-C, --company-id <id>", "Company ID for shortname lookup")
.option("--yes", "Confirm deletion")
.action(async (projectRef: string, opts: ProjectDeleteOptions) => {
try {
if (!opts.yes) throw new Error("Deletion requires --yes.");
const ctx = resolveCommandContext(opts);
const query = ctx.companyId ? `?${new URLSearchParams({ companyId: ctx.companyId }).toString()}` : "";
const deleted = await ctx.api.delete<Project>(`${apiPath`/api/projects/${projectRef}`}${query}`);
printOutput(deleted, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function parseCsv(value: string | undefined): string[] | undefined {
if (value === undefined) return undefined;
return value.split(",").map((part) => part.trim()).filter(Boolean);
}
function parseNullableString(value: string | undefined): string | null | undefined {
if (value === undefined) return undefined;
return value.trim().toLowerCase() === "null" ? null : value;
}
function parseOptionalJson(value: string | undefined): unknown {
if (value === undefined) return undefined;
if (value.trim().toLowerCase() === "null") return null;
try {
return JSON.parse(value);
} catch (err) {
throw new Error(`Invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
}
}
+276
View File
@@ -0,0 +1,276 @@
import { Command } from "commander";
import type { Agent, Issue, IssueComment } from "@paperclipai/shared";
import { addIssueCommentSchema, createIssueSchema } from "@paperclipai/shared";
import {
addCommonClientOptions,
apiPath,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface PromptOptions extends BaseClientOptions {
agent?: string;
apiKeyEnv?: string;
issue?: string;
title?: string;
wake?: boolean;
companyId?: string;
}
interface PromptResult {
ok: true;
mode: "issue" | "comment";
actor: "agent" | "board";
apiBase: string;
companyId: string;
agent: {
id: string;
name: string;
urlKey?: string | null;
};
issue?: Issue | null;
comment?: IssueComment | null;
wakeup?: unknown;
}
export function registerPromptCommands(program: Command): void {
addCommonClientOptions(
program
.command("agent-prompt")
.description("Create/update Paperclip work for an agent using an agent API key")
.argument("<agent>", "Agent ID, shortname, or name")
.argument("<agentApiKey>", "Agent API key")
.argument("<prompt...>", "Prompt text")
.option("--issue <issueId>", "Append as a comment to an existing issue")
.option("--title <title>", "Issue title when creating a new issue")
.option("--no-wake", "Do not wake the agent after creating/updating work")
.action(async (agent: string, agentApiKey: string, promptParts: string[], opts: PromptOptions) => {
try {
const result = await runAgentPrompt(agent, promptParts.join(" "), {
...opts,
apiKey: agentApiKey,
wake: opts.wake,
});
printOutput(result, { json: opts.json });
} catch (err) {
handleCommandError(err);
}
}),
);
const agent = program.commands.find((cmd) => cmd.name() === "agent") ?? program.command("agent");
addCommonClientOptions(
agent
.command("prompt")
.description("Create/update Paperclip work using an agent persona")
.argument("<prompt...>", "Prompt text")
.option("--agent <agent>", "Agent ID, shortname, or name; defaults to profile/identity agent")
.option("--api-key-env <name>", "Read the agent API key from this environment variable")
.option("--issue <issueId>", "Append as a comment to an existing issue")
.option("--title <title>", "Issue title when creating a new issue")
.option("--no-wake", "Do not wake the agent after creating/updating work")
.action(async (promptParts: string[], opts: PromptOptions) => {
try {
const apiKey = readApiKeyEnvOption(opts);
const result = await runAgentPrompt(opts.agent, promptParts.join(" "), {
...opts,
apiKey: apiKey ?? opts.apiKey,
wake: opts.wake,
});
printOutput(result, { json: opts.json });
} catch (err) {
handleCommandError(err);
}
}),
);
const board = program.command("board").description("Board operator operations");
addCommonClientOptions(
board
.command("prompt")
.description("Create/update Paperclip work for an agent using board auth")
.requiredOption("--agent <agent>", "Target agent ID, shortname, or name")
.option("-C, --company-id <id>", "Company ID")
.option("--issue <issueId>", "Append as a comment to an existing issue")
.option("--title <title>", "Issue title when creating a new issue")
.option("--no-wake", "Do not wake the agent after creating/updating work")
.argument("<prompt...>", "Prompt text")
.action(async (promptParts: string[], opts: PromptOptions) => {
try {
const result = await runBoardPrompt(opts.agent ?? "", promptParts.join(" "), opts);
printOutput(result, { json: opts.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
export async function runAgentPrompt(
agentRef: string | undefined,
prompt: string,
opts: PromptOptions,
): Promise<PromptResult> {
const ctx = resolveCommandContext(opts);
if (ctx.profile.persona && ctx.profile.persona !== "agent") {
throw new Error(`Profile '${ctx.profileName}' is persona=${ctx.profile.persona}; use an agent profile or board prompt.`);
}
const body = normalizePrompt(prompt);
const me = await ctx.api.get<Agent>("/api/agents/me");
if (!me) throw new Error("Agent authentication failed");
const expectedRef = agentRef?.trim() || ctx.profile.agentId || me.id;
assertAgentMatchesReference(me, expectedRef);
const result = await createOrCommentForAgent({
api: ctx.api,
actor: "agent",
agent: me,
companyId: me.companyId,
prompt: body,
issueId: opts.issue,
title: opts.title,
wake: opts.wake !== false,
});
return result;
}
export async function runBoardPrompt(
agentRef: string,
prompt: string,
opts: PromptOptions,
): Promise<PromptResult> {
const ctx = resolveCommandContext(opts, { requireCompany: true });
if (ctx.profile.persona && ctx.profile.persona !== "board") {
throw new Error(`Profile '${ctx.profileName}' is persona=${ctx.profile.persona}; use an agent prompt command or a board profile.`);
}
const body = normalizePrompt(prompt);
const query = new URLSearchParams({ companyId: ctx.companyId ?? "" });
const agent = await ctx.api.get<Agent>(`${apiPath`/api/agents/${agentRef}`}?${query.toString()}`);
if (!agent) throw new Error(`Agent not found: ${agentRef}`);
return createOrCommentForAgent({
api: ctx.api,
actor: "board",
agent,
companyId: ctx.companyId ?? agent.companyId,
prompt: body,
issueId: opts.issue,
title: opts.title,
wake: opts.wake !== false,
});
}
async function createOrCommentForAgent(input: {
api: {
apiBase: string;
post<T>(path: string, body?: unknown): Promise<T | null>;
};
actor: "agent" | "board";
agent: Agent;
companyId: string;
prompt: string;
issueId?: string;
title?: string;
wake: boolean;
}): Promise<PromptResult> {
if (input.issueId?.trim()) {
const payload = addIssueCommentSchema.parse({
body: input.prompt,
resume: input.wake,
});
const comment = await input.api.post<IssueComment>(apiPath`/api/issues/${input.issueId.trim()}/comments`, payload);
const wakeup = input.wake
? await wakeAgent(input.api, input.agent.id, input.issueId.trim(), "Prompt comment handoff")
: null;
return {
ok: true,
mode: "comment",
actor: input.actor,
apiBase: input.api.apiBase,
companyId: input.companyId,
agent: agentSummary(input.agent),
comment,
wakeup,
};
}
const payload = createIssueSchema.parse({
title: input.title?.trim() || defaultPromptTitle(input.prompt),
description: input.prompt,
status: "todo",
priority: "medium",
assigneeAgentId: input.agent.id,
});
const issue = await input.api.post<Issue>(apiPath`/api/companies/${input.companyId}/issues`, payload);
const wakeup = input.wake && issue?.id
? await wakeAgent(input.api, input.agent.id, issue.id, "Prompt issue handoff")
: null;
return {
ok: true,
mode: "issue",
actor: input.actor,
apiBase: input.api.apiBase,
companyId: input.companyId,
agent: agentSummary(input.agent),
issue,
wakeup,
};
}
function wakeAgent(
api: { post<T>(path: string, body?: unknown): Promise<T | null> },
agentId: string,
issueId: string,
reason: string,
): Promise<unknown> {
return api.post(apiPath`/api/agents/${agentId}/wakeup`, {
source: "on_demand",
triggerDetail: "manual",
reason,
payload: { issueId },
});
}
function normalizePrompt(prompt: string): string {
const normalized = prompt.trim();
if (!normalized) throw new Error("Prompt text is required");
return normalized;
}
function defaultPromptTitle(prompt: string): string {
const firstLine = prompt.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "Prompt handoff";
return firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
}
function assertAgentMatchesReference(agent: Agent, reference: string): void {
const normalized = reference.trim().toLowerCase();
if (!normalized) throw new Error("Agent reference is required");
const matches = [
agent.id,
agent.name,
typeof agent.urlKey === "string" ? agent.urlKey : null,
].some((value) => value?.toLowerCase() === normalized);
if (!matches) {
throw new Error(
`Agent key belongs to ${agent.name} (${agent.id}), not '${reference}'. Use the matching agent or a board prompt.`,
);
}
}
function agentSummary(agent: Agent): PromptResult["agent"] {
return {
id: agent.id,
name: agent.name,
urlKey: typeof agent.urlKey === "string" ? agent.urlKey : null,
};
}
function readApiKeyEnvOption(opts: PromptOptions): string | undefined {
if (!opts.apiKeyEnv?.trim()) return undefined;
const value = process.env[opts.apiKeyEnv.trim()]?.trim();
if (!value) throw new Error(`Environment variable ${opts.apiKeyEnv.trim()} is not set`);
return value;
}
+154
View File
@@ -0,0 +1,154 @@
import { Command } from "commander";
import {
addCommonClientOptions,
apiPath,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface CompanyOptions extends BaseClientOptions {
companyId?: string;
projectId?: string;
}
interface JsonOptions extends CompanyOptions {
payloadJson?: string;
limit?: string;
}
export function registerRoutineApiCommands(program: Command): void {
const routine = program.command("routine").description("Routine API operations");
addCommonClientOptions(
routine
.command("list")
.description("List routines")
.option("-C, --company-id <id>", "Company ID")
.option("--project-id <id>", "Filter by project ID")
.action(async (opts: CompanyOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const query = opts.projectId ? `?${new URLSearchParams({ projectId: opts.projectId }).toString()}` : "";
printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}/routines`}${query}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCompanyPost(routine, "create", "Create a routine", "routines");
addIdGet(routine, "get", "Get a routine", "routines");
addIdPatch(routine, "update", "Update a routine", "routines");
addIdGet(routine, "revisions", "List routine revisions", "routines", "revisions");
addCommonClientOptions(
routine
.command("revision:restore")
.description("Restore a routine revision")
.argument("<routineId>", "Routine ID")
.argument("<revisionId>", "Revision ID")
.action(async (routineId: string, revisionId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.post(apiPath`/api/routines/${routineId}/revisions/${revisionId}/restore`, {}), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
routine
.command("runs")
.description("List routine runs")
.argument("<routineId>", "Routine ID")
.option("--limit <n>", "Maximum runs to return")
.action(async (routineId: string, opts: JsonOptions) => {
try {
const ctx = resolveCommandContext(opts);
const query = opts.limit ? `?${new URLSearchParams({ limit: opts.limit }).toString()}` : "";
printOutput(await ctx.api.get(`${apiPath`/api/routines/${routineId}/runs`}${query}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addIdPost(routine, "run", "Run a routine", "routines", "run");
addIdPost(routine, "trigger:create", "Create a routine trigger", "routines", "triggers");
addIdPatch(routine, "trigger:update", "Update a routine trigger", "routine-triggers");
addIdDelete(routine, "trigger:delete", "Delete a routine trigger", "routine-triggers");
addIdPost(routine, "trigger:rotate-secret", "Rotate a routine trigger secret", "routine-triggers", "rotate-secret");
addCommonClientOptions(
routine
.command("trigger:fire")
.description("Fire a public routine trigger")
.argument("<publicId>", "Public trigger ID")
.option("--payload-json <json>", "Public trigger payload", "{}")
.action(async (publicId: string, opts: JsonOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.post(apiPath`/api/routine-triggers/public/${publicId}/fire`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
function addCompanyPost(parent: Command, name: string, description: string, path: string): void {
addCommonClientOptions(parent.command(name).description(description).option("-C, --company-id <id>", "Company ID").requiredOption("--payload-json <json>", "JSON payload").action(async (opts: JsonOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}), { includeCompany: false });
}
function addIdGet(parent: Command, name: string, description: string, resource: string, suffix?: string): void {
addCommonClientOptions(parent.command(name).description(description).argument("<id>", "ID").action(async (id: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.get(`/api/${resource}/${encodeURIComponent(id)}${suffix ? `/${suffix}` : ""}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}));
}
function addIdPatch(parent: Command, name: string, description: string, resource: string): void {
addCommonClientOptions(parent.command(name).description(description).argument("<id>", "ID").requiredOption("--payload-json <json>", "JSON payload").action(async (id: string, opts: JsonOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.patch(`/api/${resource}/${encodeURIComponent(id)}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}));
}
function addIdPost(parent: Command, name: string, description: string, resource: string, suffix: string): void {
addCommonClientOptions(parent.command(name).description(description).argument("<id>", "ID").option("--payload-json <json>", "JSON payload", "{}").action(async (id: string, opts: JsonOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.post(`/api/${resource}/${encodeURIComponent(id)}/${suffix}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}));
}
function addIdDelete(parent: Command, name: string, description: string, resource: string): void {
addCommonClientOptions(parent.command(name).description(description).argument("<id>", "ID").action(async (id: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.delete(`/api/${resource}/${encodeURIComponent(id)}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}));
}
function parseJson(value: string): unknown {
return JSON.parse(value) as unknown;
}
+321
View File
@@ -0,0 +1,321 @@
import { Command } from "commander";
import type { HeartbeatRun, HeartbeatRunEvent, Issue, WorkspaceOperation } from "@paperclipai/shared";
import {
addCommonClientOptions,
apiPath,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface RunListOptions extends BaseClientOptions {
agentId?: string;
limit?: string;
}
interface RunLiveOptions extends BaseClientOptions {
limit?: string;
minCount?: string;
}
interface RunEventsOptions extends BaseClientOptions {
afterSeq?: string;
limit?: string;
}
interface RunLogOptions extends BaseClientOptions {
offset?: string;
limitBytes?: string;
text?: boolean;
}
interface RunWatchdogOptions extends BaseClientOptions {
decision: string;
reason?: string;
snoozedUntil?: string;
evaluationIssueId?: string;
}
interface RunIssueSummary extends Issue {
runId?: string;
runStatus?: string;
}
export function registerRunCommands(command: Command): void {
addCommonClientOptions(
command
.command("list")
.description("List heartbeat runs for a company")
.option("-C, --company-id <id>", "Company ID")
.option("--agent-id <id>", "Filter by agent ID")
.option("--limit <n>", "Maximum runs to return")
.action(async (opts: RunListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const params = new URLSearchParams();
if (opts.agentId) params.set("agentId", opts.agentId);
if (opts.limit) params.set("limit", opts.limit);
const query = params.toString();
const rows = (await ctx.api.get<HeartbeatRun[]>(
`${apiPath`/api/companies/${ctx.companyId}/heartbeat-runs`}${query ? `?${query}` : ""}`,
)) ?? [];
printRuns(rows, ctx.json);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
command
.command("live")
.description("List queued and running heartbeat runs for a company")
.option("-C, --company-id <id>", "Company ID")
.option("--limit <n>", "Maximum runs to return")
.option("--min-count <n>", "Pad with recent completed runs up to this count")
.action(async (opts: RunLiveOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const params = new URLSearchParams();
if (opts.limit) params.set("limit", opts.limit);
if (opts.minCount) params.set("minCount", opts.minCount);
const query = params.toString();
const rows = (await ctx.api.get<HeartbeatRun[]>(
`${apiPath`/api/companies/${ctx.companyId}/live-runs`}${query ? `?${query}` : ""}`,
)) ?? [];
printRuns(rows, ctx.json);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
command
.command("get")
.description("Get a heartbeat run")
.argument("<runId>", "Heartbeat run ID")
.action(async (runId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const run = await ctx.api.get<HeartbeatRun>(apiPath`/api/heartbeat-runs/${runId}`);
printOutput(run, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
command
.command("cancel")
.description("Cancel a queued or running heartbeat run")
.argument("<runId>", "Heartbeat run ID")
.action(async (runId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const run = await ctx.api.post<HeartbeatRun | null>(apiPath`/api/heartbeat-runs/${runId}/cancel`, {});
printOutput(run, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
command
.command("events")
.description("List heartbeat run events")
.argument("<runId>", "Heartbeat run ID")
.option("--after-seq <n>", "Only return events after this sequence", "0")
.option("--limit <n>", "Maximum events to return", "200")
.action(async (runId: string, opts: RunEventsOptions) => {
try {
const ctx = resolveCommandContext(opts);
const params = new URLSearchParams();
if (opts.afterSeq) params.set("afterSeq", opts.afterSeq);
if (opts.limit) params.set("limit", opts.limit);
const events = (await ctx.api.get<HeartbeatRunEvent[]>(
`${apiPath`/api/heartbeat-runs/${runId}/events`}?${params.toString()}`,
)) ?? [];
if (ctx.json) {
printOutput(events, { json: true });
return;
}
for (const event of events) {
console.log(formatInlineRecord({
seq: event.seq,
eventType: event.eventType,
stream: event.stream,
level: event.level,
message: event.message,
}));
}
if (events.length === 0) printOutput([], { json: false });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
command
.command("log")
.description("Read heartbeat run log bytes")
.argument("<runId>", "Heartbeat run ID")
.option("--offset <bytes>", "Byte offset", "0")
.option("--limit-bytes <bytes>", "Maximum bytes to read")
.option("--text", "Print only the log text when the API returns a text field")
.action(async (runId: string, opts: RunLogOptions) => {
try {
const ctx = resolveCommandContext(opts);
const result = await fetchLog(ctx.api, apiPath`/api/heartbeat-runs/${runId}/log`, opts);
printLogResult(result, { json: ctx.json, text: opts.text });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
command
.command("issues")
.description("List issues associated with a heartbeat run")
.argument("<runId>", "Heartbeat run ID")
.action(async (runId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const rows = (await ctx.api.get<RunIssueSummary[]>(apiPath`/api/heartbeat-runs/${runId}/issues`)) ?? [];
printOutput(rows.map((row) => ({
identifier: row.identifier,
id: row.id,
status: row.status,
priority: row.priority,
title: row.title,
runStatus: row.runStatus,
})), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
command
.command("workspace-operations")
.description("List workspace operations for a heartbeat run")
.argument("<runId>", "Heartbeat run ID")
.action(async (runId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const rows = (await ctx.api.get<WorkspaceOperation[]>(
apiPath`/api/heartbeat-runs/${runId}/workspace-operations`,
)) ?? [];
printOutput(rows.map((row) => ({
id: row.id,
status: row.status,
phase: row.phase,
command: row.command,
cwd: row.cwd,
logBytes: row.logBytes,
})), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
command
.command("workspace-log")
.description("Read a workspace operation log")
.argument("<operationId>", "Workspace operation ID")
.option("--offset <bytes>", "Byte offset", "0")
.option("--limit-bytes <bytes>", "Maximum bytes to read")
.option("--text", "Print only the log text when the API returns a text field")
.action(async (operationId: string, opts: RunLogOptions) => {
try {
const ctx = resolveCommandContext(opts);
const result = await fetchLog(ctx.api, apiPath`/api/workspace-operations/${operationId}/log`, opts);
printLogResult(result, { json: ctx.json, text: opts.text });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
command
.command("watchdog-decision")
.description("Record a watchdog decision for a heartbeat run")
.argument("<runId>", "Heartbeat run ID")
.requiredOption("--decision <decision>", "snooze, continue, or dismissed_false_positive")
.option("--reason <text>", "Decision reason")
.option("--snoozed-until <iso8601>", "Required for snooze decisions")
.option("--evaluation-issue-id <id>", "Related watchdog evaluation issue ID")
.action(async (runId: string, opts: RunWatchdogOptions) => {
try {
const ctx = resolveCommandContext(opts);
const decision = await ctx.api.post(apiPath`/api/heartbeat-runs/${runId}/watchdog-decisions`, {
decision: opts.decision,
reason: opts.reason,
snoozedUntil: opts.snoozedUntil,
evaluationIssueId: opts.evaluationIssueId,
});
printOutput(decision, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
async function fetchLog(
api: { get<T>(path: string): Promise<T | null> },
path: string,
opts: RunLogOptions,
): Promise<unknown> {
const params = new URLSearchParams();
if (opts.offset) params.set("offset", opts.offset);
if (opts.limitBytes) params.set("limitBytes", opts.limitBytes);
return api.get(`${path}?${params.toString()}`);
}
function printRuns(rows: HeartbeatRun[], json: boolean): void {
if (json) {
printOutput(rows, { json: true });
return;
}
for (const row of rows) {
console.log(formatInlineRecord({
id: row.id,
status: row.status,
agentId: row.agentId,
invocationSource: row.invocationSource,
triggerDetail: row.triggerDetail,
startedAt: row.startedAt,
finishedAt: row.finishedAt,
logBytes: row.logBytes,
}));
}
if (rows.length === 0) printOutput([], { json: false });
}
function printLogResult(result: unknown, opts: { json: boolean; text?: boolean }): void {
if (opts.json) {
printOutput(result, { json: true });
return;
}
if (opts.text && typeof result === "object" && result !== null && "text" in result) {
const text = (result as { text?: unknown }).text;
process.stdout.write(typeof text === "string" ? text : String(text ?? ""));
return;
}
printOutput(result, { json: false });
}
+726
View File
@@ -0,0 +1,726 @@
import { Command } from "commander";
import pc from "picocolors";
import type {
Agent,
AgentEnvConfig,
CompanyPortabilityEnvInput,
CompanyPortabilityExportPreviewResult,
CompanyPortabilityInclude,
CompanySecret,
EnvBinding,
SecretProvider,
SecretProviderDescriptor,
} from "@paperclipai/shared";
import {
addCommonClientOptions,
apiPath,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface SecretListOptions extends BaseClientOptions {
companyId?: string;
}
interface SecretDeclarationsOptions extends BaseClientOptions {
companyId?: string;
include?: string;
kind?: "all" | "secret" | "plain";
}
interface SecretCreateOptions extends BaseClientOptions {
companyId?: string;
name?: string;
key?: string;
provider?: SecretProvider;
value?: string;
valueEnv?: string;
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;
key?: string;
provider?: SecretProvider;
externalRef?: string;
providerVersionRef?: string;
description?: string;
}
interface SecretDoctorOptions extends BaseClientOptions {
companyId?: string;
}
interface SecretMigrateInlineEnvOptions extends BaseClientOptions {
companyId?: string;
apply?: boolean;
}
interface SecretJsonOptions extends BaseClientOptions {
companyId?: string;
payloadJson?: string;
}
interface SecretProviderHealth {
provider: SecretProvider;
status: "ok" | "warn" | "error";
message: string;
warnings?: string[];
backupGuidance?: string[];
details?: Record<string, unknown>;
}
interface SecretProviderHealthResponse {
providers: SecretProviderHealth[];
}
export interface InlineSecretMigrationCandidate {
agentId: string;
agentName: string;
envKey: string;
secretName: string;
existingSecretId: string | null;
}
const SENSITIVE_ENV_KEY_RE =
/(^token$|[-_]?token$|api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
const DEFAULT_DECLARATION_INCLUDE: CompanyPortabilityInclude = {
company: true,
agents: true,
projects: true,
issues: false,
skills: false,
};
export function parseSecretsInclude(input: string | undefined): CompanyPortabilityInclude {
if (!input?.trim()) return { ...DEFAULT_DECLARATION_INCLUDE };
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
const include = {
company: values.includes("company"),
agents: values.includes("agents"),
projects: values.includes("projects"),
issues: values.includes("issues") || values.includes("tasks"),
skills: values.includes("skills"),
};
if (!Object.values(include).some(Boolean)) {
throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues,tasks,skills");
}
return include;
}
export function isSensitiveEnvKey(key: string): boolean {
return SENSITIVE_ENV_KEY_RE.test(key);
}
export function toPlainEnvValue(binding: unknown): string | null {
if (typeof binding === "string") return binding;
if (typeof binding !== "object" || binding === null || Array.isArray(binding)) return null;
const record = binding as Record<string, unknown>;
if (record.type === "plain" && typeof record.value === "string") return record.value;
return null;
}
export function buildInlineMigrationSecretName(agentId: string, key: string): string {
return `agent_${agentId.slice(0, 8)}_${key.toLowerCase()}`;
}
export function collectInlineSecretMigrationCandidates(
agents: Agent[],
existingSecrets: CompanySecret[],
): InlineSecretMigrationCandidate[] {
const secretByName = new Map(existingSecrets.map((secret) => [secret.name, secret]));
const candidates: InlineSecretMigrationCandidate[] = [];
for (const agent of agents) {
const env = asRecord(agent.adapterConfig.env);
if (!env) continue;
for (const [envKey, binding] of Object.entries(env)) {
if (!isSensitiveEnvKey(envKey)) continue;
const plain = toPlainEnvValue(binding);
if (plain === null || plain.trim().length === 0) continue;
const secretName = buildInlineMigrationSecretName(agent.id, envKey);
candidates.push({
agentId: agent.id,
agentName: agent.name,
envKey,
secretName,
existingSecretId: secretByName.get(secretName)?.id ?? null,
});
}
}
return candidates;
}
export function buildMigratedAgentEnv(
env: Record<string, unknown>,
secretIdByEnvKey: Map<string, string>,
): AgentEnvConfig {
const next: AgentEnvConfig = { ...(env as Record<string, EnvBinding>) };
for (const [envKey, secretId] of secretIdByEnvKey) {
next[envKey] = {
type: "secret_ref",
secretId,
version: "latest",
};
}
return next;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
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.");
}
if (opts.valueEnv !== undefined) {
const value = process.env[opts.valueEnv];
if (!value) throw new Error(`Environment variable ${opts.valueEnv} is empty or unset.`);
return value;
}
if (opts.value !== undefined) return opts.value;
throw new Error("Secret value is required. Pass --value or --value-env.");
}
function renderDeclaration(input: CompanyPortabilityEnvInput): Record<string, unknown> {
const scope = input.agentSlug
? `agent:${input.agentSlug}`
: input.projectSlug
? `project:${input.projectSlug}`
: "company";
return {
key: input.key,
scope,
kind: input.kind,
requirement: input.requirement,
portability: input.portability,
hasDefault: input.defaultValue !== null && input.defaultValue.length > 0,
description: input.description,
};
}
function renderSecret(secret: CompanySecret): Record<string, unknown> {
return {
id: secret.id,
name: secret.name,
key: secret.key,
provider: secret.provider,
status: secret.status,
managedMode: secret.managedMode,
latestVersion: secret.latestVersion,
externalRef: secret.externalRef ? "yes" : "no",
};
}
function printProviderHealth(rows: SecretProviderHealth[], json: boolean): void {
if (json) {
printOutput(rows, { json: true });
return;
}
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
for (const row of rows) {
console.log(
formatInlineRecord({
id: row.provider,
status: row.status,
message: row.message,
}),
);
for (const warning of row.warnings ?? []) {
console.log(pc.yellow(`warning=${warning}`));
}
const missingConfig = asStringArray(row.details?.missingConfig);
if (missingConfig.length > 0) {
console.log(pc.dim(`missingConfig=${missingConfig.join(",")}`));
}
const credentialSource = typeof row.details?.credentialSource === "string"
? row.details.credentialSource
: null;
if (credentialSource) {
console.log(pc.dim(`credentialSource=${credentialSource}`));
}
const detectedCredentialSources = asStringArray(row.details?.detectedCredentialSources);
if (detectedCredentialSources.length > 0) {
console.log(pc.dim(`detectedCredentialSources=${detectedCredentialSources.join(",")}`));
}
for (const guidance of row.backupGuidance ?? []) {
console.log(pc.dim(`backup=${guidance}`));
}
}
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
: [];
}
async function migrateInlineEnv(opts: SecretMigrateInlineEnvOptions): Promise<void> {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const companyId = ctx.companyId!;
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) {
printOutput(
{
apply: false,
agentsToUpdate: new Set(candidates.map((candidate) => candidate.agentId)).size,
secretsToCreate: candidates.filter((candidate) => !candidate.existingSecretId).length,
secretsToRotate: candidates.filter((candidate) => candidate.existingSecretId).length,
candidates,
},
{ json: ctx.json },
);
if (!ctx.json) {
console.log(pc.dim("Re-run with --apply to create/rotate secrets and update agent env bindings."));
}
return;
}
const createdOrRotated = new Map<string, string>();
let createdSecrets = 0;
let rotatedSecrets = 0;
for (const candidate of candidates) {
const agent = agents.find((row) => row.id === candidate.agentId);
const env = asRecord(agent?.adapterConfig.env);
const value = env ? toPlainEnvValue(env[candidate.envKey]) : null;
if (!value) continue;
if (candidate.existingSecretId) {
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>(apiPath`/api/companies/${companyId}/secrets`, {
name: candidate.secretName,
provider: "local_encrypted",
value,
description: `Migrated from agent ${candidate.agentId} env ${candidate.envKey}`,
});
if (!created) throw new Error(`Secret create returned no data for ${candidate.secretName}`);
createdOrRotated.set(`${candidate.agentId}:${candidate.envKey}`, created.id);
createdSecrets += 1;
}
let updatedAgents = 0;
for (const agent of agents) {
const env = asRecord(agent.adapterConfig.env);
if (!env) continue;
const secretIdByEnvKey = new Map<string, string>();
for (const [key] of Object.entries(env)) {
const secretId = createdOrRotated.get(`${agent.id}:${key}`);
if (secretId) secretIdByEnvKey.set(key, secretId);
}
if (secretIdByEnvKey.size === 0) continue;
const adapterConfig = {
...agent.adapterConfig,
env: buildMigratedAgentEnv(env, secretIdByEnvKey),
};
await ctx.api.patch(apiPath`/api/agents/${agent.id}`, {
adapterConfig,
replaceAdapterConfig: true,
});
updatedAgents += 1;
}
printOutput(
{
apply: true,
updatedAgents,
createdSecrets,
rotatedSecrets,
},
{ json: ctx.json },
);
}
export function registerSecretCommands(program: Command): void {
const secrets = program.command("secrets").description("Secret declaration and provider operations");
addCommonClientOptions(
secrets
.command("list")
.description("List secret metadata for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.action(async (opts: SecretListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
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);
}
}),
);
addCommonClientOptions(
secrets
.command("declarations")
.description("List portable env declarations emitted by company export")
.requiredOption("-C, --company-id <id>", "Company ID")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents,projects")
.option("--kind <kind>", "Filter declarations: all | secret | plain", "all")
.action(async (opts: SecretDeclarationsOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const kind = opts.kind ?? "all";
if (!["all", "secret", "plain"].includes(kind)) {
throw new Error("Invalid --kind value. Use: all, secret, plain");
}
const preview = await ctx.api.post<CompanyPortabilityExportPreviewResult>(
apiPath`/api/companies/${ctx.companyId}/exports/preview`,
{ include: parseSecretsInclude(opts.include) },
);
const declarations = (preview?.manifest.envInputs ?? [])
.filter((entry) => kind === "all" || entry.kind === kind);
printOutput(ctx.json ? declarations : declarations.map(renderDeclaration), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
secrets
.command("create")
.description("Create a Paperclip-managed secret")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("--name <name>", "Secret display name")
.option("--key <key>", "Portable secret key")
.option("--provider <provider>", "Secret provider id")
.option("--value <value>", "Secret value")
.option("--value-env <name>", "Read secret value from an environment variable")
.option("--description <text>", "Description")
.action(async (opts: SecretCreateOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const created = await ctx.api.post<CompanySecret>(apiPath`/api/companies/${ctx.companyId}/secrets`, {
name: opts.name,
key: opts.key,
provider: opts.provider,
value: readValueFromOptions(opts),
description: opts.description,
});
printOutput(ctx.json ? created : renderSecret(created!), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
secrets
.command("link")
.description("Link an external provider-owned secret without storing its value in Paperclip")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("--name <name>", "Secret display name")
.requiredOption("--provider <provider>", "Secret provider id")
.requiredOption("--external-ref <ref>", "Provider secret ARN/name/path/reference")
.option("--key <key>", "Portable secret key")
.option("--provider-version-ref <ref>", "Provider version id or label")
.option("--description <text>", "Description")
.action(async (opts: SecretLinkOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const created = await ctx.api.post<CompanySecret>(apiPath`/api/companies/${ctx.companyId}/secrets`, {
name: opts.name,
key: opts.key,
provider: opts.provider,
managedMode: "external_reference",
externalRef: opts.externalRef,
providerVersionRef: opts.providerVersionRef,
description: opts.description,
});
printOutput(ctx.json ? created : renderSecret(created!), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
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")
.description("Run secret provider health checks through the Paperclip API")
.requiredOption("-C, --company-id <id>", "Company ID")
.action(async (opts: SecretDoctorOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const health = await ctx.api.get<SecretProviderHealthResponse>(
apiPath`/api/companies/${ctx.companyId}/secret-providers/health`,
);
printProviderHealth(health?.providers ?? [], ctx.json);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
secrets
.command("providers")
.description("List configured secret provider descriptors")
.requiredOption("-C, --company-id <id>", "Company ID")
.action(async (opts: SecretDoctorOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const rows = (await ctx.api.get<SecretProviderDescriptor[]>(
apiPath`/api/companies/${ctx.companyId}/secret-providers`,
)) ?? [];
printOutput(rows, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
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")
.description("Migrate inline sensitive agent env values into secret references")
.requiredOption("-C, --company-id <id>", "Company ID")
.option("--apply", "Persist changes; default is a dry run", false)
.action(async (opts: SecretMigrateInlineEnvOptions) => {
try {
await migrateInlineEnv(opts);
} catch (err) {
handleCommandError(err);
}
}),
);
}
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
View File
@@ -0,0 +1,150 @@
import { Command } from "commander";
import {
addCommonClientOptions,
apiPath,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface SkillOptions extends BaseClientOptions {
companyId?: string;
payloadJson?: string;
path?: string;
}
export function registerSkillCommands(program: Command): void {
const skill = program.command("skill").description("Company skill operations");
addCompanyGet(skill, "list", "List company skills", "skills");
addCommonClientOptions(
skill
.command("get")
.description("Get company skill details")
.argument("<skillId>", "Skill ID")
.option("-C, --company-id <id>", "Company ID")
.action(async (skillId: string, opts: SkillOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(await ctx.api.get(apiPath`/api/companies/${ctx.companyId}/skills/${skillId}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
skill
.command("file")
.description("Read a company skill file")
.argument("<skillId>", "Skill ID")
.option("-C, --company-id <id>", "Company ID")
.option("--path <path>", "Skill-relative file path", "SKILL.md")
.action(async (skillId: string, opts: SkillOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const query = new URLSearchParams({ path: opts.path ?? "SKILL.md" });
printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}/skills/${skillId}/files`}?${query.toString()}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCompanyPost(skill, "create", "Create a local company skill", "skills", true);
addCompanyPost(skill, "import", "Import company skills from a source", "skills/import", true);
addCompanyPost(skill, "scan-projects", "Scan project workspaces for company skills", "skills/scan-projects", true);
addCommonClientOptions(
skill
.command("file:update")
.description("Update a company skill file")
.argument("<skillId>", "Skill ID")
.option("-C, --company-id <id>", "Company ID")
.requiredOption("--payload-json <json>", "CompanySkillFileUpdate JSON payload")
.action(async (skillId: string, opts: SkillOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(
await ctx.api.patch(apiPath`/api/companies/${ctx.companyId}/skills/${skillId}/files`, parseJson(opts.payloadJson ?? "{}")),
{ json: ctx.json },
);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addSkillAction(skill, "update-status", "Get company skill update status", "update-status", "GET");
addSkillAction(skill, "install-update", "Install available company skill update", "install-update", "POST");
addSkillAction(skill, "delete", "Delete a company skill", "", "DELETE");
}
function addCompanyGet(parent: Command, name: string, description: string, path: string): void {
addCommonClientOptions(
parent.command(name).description(description).option("-C, --company-id <id>", "Company ID").action(async (opts: SkillOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(await ctx.api.get(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function addCompanyPost(parent: Command, name: string, description: string, path: string, requirePayload = false): void {
const command = parent.command(name).description(description).option("-C, --company-id <id>", "Company ID");
if (requirePayload) {
command.requiredOption("--payload-json <json>", "JSON payload");
} else {
command.option("--payload-json <json>", "JSON payload", "{}");
}
addCommonClientOptions(
command.action(async (opts: SkillOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
printOutput(await ctx.api.post(`${apiPath`/api/companies/${ctx.companyId}`}/${path}`, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function addSkillAction(parent: Command, name: string, description: string, suffix: string, method: "GET" | "POST" | "DELETE"): void {
addCommonClientOptions(
parent
.command(name)
.description(description)
.argument("<skillId>", "Skill ID")
.option("-C, --company-id <id>", "Company ID")
.action(async (skillId: string, opts: SkillOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const path = `${apiPath`/api/companies/${ctx.companyId}/skills/${skillId}`}${suffix ? `/${suffix}` : ""}`;
const result =
method === "GET"
? await ctx.api.get(path)
: method === "POST"
? await ctx.api.post(path, {})
: await ctx.api.delete(path);
printOutput(result, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
function parseJson(value: string): unknown {
return JSON.parse(value) as unknown;
}
File diff suppressed because it is too large. Load diff
+720
View File
@@ -0,0 +1,720 @@
import { Command } from "commander";
import type {
Approval,
CatalogTeam,
CatalogTeamImportPreviewResult,
CatalogTeamInstallResult,
CatalogTeamInstallOptions,
CatalogTeamImportOptions,
CatalogTeamSourcePolicy,
InstalledCatalogTeam,
} from "@paperclipai/shared";
import {
addCommonClientOptions,
apiPath,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
type ResolvedClientContext,
} from "./common.js";
import { ApiRequestError } from "../../client/http.js";
interface TeamBrowseOptions extends BaseClientOptions {
kind?: string;
category?: string;
query?: string;
}
interface TeamListOptions extends TeamBrowseOptions {}
interface TeamPreviewOptions extends BaseClientOptions {
companyId?: string;
targetManagerAgentId?: string;
targetManagerSlug?: string;
agent?: string[];
collisionStrategy?: "rename" | "skip" | "replace";
nameOverride?: string[];
selectedFile?: string[];
allowExternalSources?: boolean;
allowUnpinnedOptionalSources?: boolean;
allowLocalPathSources?: boolean;
}
interface TeamInstallOptions extends TeamPreviewOptions {
requestApprovalOnForbidden?: boolean;
approvalIssueId?: string;
secretValue?: string[];
adapterOverride?: string[];
}
interface TeamInstallApprovalFallbackResult {
status: "approval_requested";
approval: Approval;
installAttempt: {
companyId: string;
catalogRef: string;
options: CatalogTeamInstallOptions;
deniedReason: string;
};
}
export function registerTeamCommands(program: Command): void {
const teams = program.command("teams").description("App-shipped team catalog operations");
addCommonClientOptions(
teams
.command("browse")
.description("Browse app-shipped catalog teams without installing them")
.option("--kind <kind>", "Catalog kind filter (bundled or optional)")
.option("--category <slug>", "Catalog category filter")
.option("--query <text>", "Search catalog text")
.action(async (opts: TeamBrowseOptions) => {
try {
const ctx = resolveCommandContext(opts);
const rows = await listCatalogTeams(ctx, opts);
if (ctx.json) {
printOutput(rows, { json: true });
return;
}
printCatalogTeamRows(rows);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
teams
.command("list")
.description("List app-shipped catalog teams with installed status for a company")
.option("--kind <kind>", "Catalog kind filter (bundled or optional)")
.option("--category <slug>", "Catalog category filter")
.option("--query <text>", "Search catalog text")
.action(async (opts: TeamListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const rows = await listCatalogTeamStatusRows(ctx, opts);
if (ctx.json) {
printOutput(rows, { json: true });
return;
}
printCatalogTeamStatusRows(rows);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: true },
);
addCommonClientOptions(
teams
.command("search")
.description("Search app-shipped catalog teams without installing them")
.argument("<query>", "Search text")
.option("--kind <kind>", "Catalog kind filter (bundled or optional)")
.option("--category <slug>", "Catalog category filter")
.action(async (query: string, opts: TeamBrowseOptions) => {
try {
const ctx = resolveCommandContext(opts);
const rows = await listCatalogTeams(ctx, { ...opts, query });
if (ctx.json) {
printOutput(rows, { json: true });
return;
}
printCatalogTeamRows(rows);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
teams
.command("inspect")
.description("Inspect an app-shipped catalog team before installing it")
.argument("<catalogRef>", "Catalog team ID, key, or unique slug")
.option("--file <path>", "Print a specific catalog team file instead of the manifest detail")
.action(async (catalogRef: string, opts: BaseClientOptions & { file?: string }) => {
try {
const ctx = resolveCommandContext(opts);
if (opts.file?.trim()) {
const file = await getCatalogTeamFile(ctx, catalogRef, opts.file);
if (ctx.json) {
printOutput(file, { json: true });
return;
}
process.stdout.write(file?.content ?? "");
if (file?.content && !file.content.endsWith("\n")) {
process.stdout.write("\n");
}
return;
}
const detail = await getCatalogTeam(ctx, catalogRef);
if (ctx.json) {
printOutput(detail, { json: true });
return;
}
printCatalogTeamDetail(detail);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
teams
.command("preview")
.description("Preview importing a catalog team into a company")
.argument("<catalogRef>", "Catalog team ID, key, or unique slug")
.option("--target-manager-agent-id <id>", "Existing agent ID that catalog root agents should report to")
.option("--target-manager-slug <slug>", "Portable manager slug that catalog root agents should report to")
.option("--agent <slug>", "Only preview selected agent slug; may be repeated", collectOptionValue, [] as string[])
.option("--collision-strategy <strategy>", "Import collision strategy (rename, skip, replace)")
.option("--name-override <slug=name>", "Override an imported entity name; may be repeated", collectOptionValue, [] as string[])
.option("--selected-file <path>", "Restrict import preview to selected portable file; may be repeated", collectOptionValue, [] as string[])
.option("--allow-external-sources", "Allow GitHub, URL, or skills.sh skill sources declared by the catalog team", false)
.option("--allow-unpinned-optional-sources", "Allow optional-team external skill sources that are not pinned to a commit", false)
.option("--allow-local-path-sources", "Development only: allow local-path skill sources declared by the catalog team", false)
.action(async (catalogRef: string, opts: TeamPreviewOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const result = await ctx.api.post<CatalogTeamImportPreviewResult>(
catalogTeamCompanyPath(ctx.companyId, catalogRef, "preview"),
buildTeamOptions(opts),
);
if (ctx.json) {
printOutput(result, { json: true });
return;
}
printCatalogTeamPreview(result);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: true },
);
addCommonClientOptions(
teams
.command("install")
.description("Install a catalog team into a company")
.argument("<catalogRef>", "Catalog team ID, key, or unique slug")
.option("--target-manager-agent-id <id>", "Existing agent ID that catalog root agents should report to")
.option("--target-manager-slug <slug>", "Portable manager slug that catalog root agents should report to")
.option("--agent <slug>", "Only install selected agent slug; may be repeated", collectOptionValue, [] as string[])
.option("--collision-strategy <strategy>", "Import collision strategy (rename, skip, replace)")
.option("--name-override <slug=name>", "Override an imported entity name; may be repeated", collectOptionValue, [] as string[])
.option("--selected-file <path>", "Restrict install to selected portable file; may be repeated", collectOptionValue, [] as string[])
.option("--secret-value <key=value>", "Secret env input value for install; may be repeated", collectOptionValue, [] as string[])
.option("--adapter-override <slug=type>", "Adapter type override for an imported agent slug; may be repeated", collectOptionValue, [] as string[])
.option("--allow-external-sources", "Allow GitHub, URL, or skills.sh skill sources declared by the catalog team", false)
.option("--allow-unpinned-optional-sources", "Allow optional-team external skill sources that are not pinned to a commit", false)
.option("--allow-local-path-sources", "Development only: allow local-path skill sources declared by the catalog team", false)
.option(
"--request-approval-on-forbidden",
"When install is denied by agents:create permissions, create a board approval request instead of exiting with the raw 403",
false,
)
.option("--approval-issue-id <id>", "Issue ID to link to the fallback approval request; defaults to PAPERCLIP_TASK_ID when set")
.action(async (catalogRef: string, opts: TeamInstallOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const installOptions = buildTeamInstallOptions(opts);
const result = await ctx.api.post<CatalogTeamInstallResult>(
catalogTeamCompanyPath(ctx.companyId, catalogRef, "install"),
installOptions,
);
if (ctx.json) {
printOutput(result, { json: true });
return;
}
printCatalogTeamInstall(result);
} catch (err) {
if (shouldRequestInstallApproval(err, opts)) {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const fallback = await requestInstallApproval(ctx, catalogRef, buildTeamInstallOptions(opts), opts, err);
if (ctx.json) {
printOutput(fallback, { json: true });
return;
}
printInstallApprovalRequested(fallback);
return;
} catch (fallbackErr) {
handleCommandError(fallbackErr);
}
}
handleCommandError(err);
}
}),
{ includeCompany: true },
);
}
async function listCatalogTeams(
ctx: ResolvedClientContext,
opts: TeamBrowseOptions,
): Promise<CatalogTeam[]> {
const params = new URLSearchParams();
appendQueryParam(params, "kind", opts.kind);
appendQueryParam(params, "category", opts.category);
appendQueryParam(params, "q", opts.query);
const query = params.toString();
return (await ctx.api.get<CatalogTeam[]>(`/api/teams/catalog${query ? `?${query}` : ""}`)) ?? [];
}
type CatalogTeamInstalledStatus = "not_installed" | "installed" | "out_of_date" | "installed_missing";
interface CatalogTeamStatusRow {
catalogId: string;
catalogKey: string | null;
kind: CatalogTeam["kind"] | null;
category: string | null;
slug: string | null;
name: string;
installedStatus: CatalogTeamInstalledStatus;
installedAgentCount: number;
catalogAgentCount: number | null;
projectCount: number | null;
trustLevel: CatalogTeam["trustLevel"] | null;
present: boolean;
outOfDate: boolean;
currentContentHash: string | null;
installedOriginHashes: string[];
}
async function listCatalogTeamStatusRows(
ctx: ResolvedClientContext,
opts: TeamListOptions,
): Promise<CatalogTeamStatusRow[]> {
if (!ctx.companyId) {
throw new Error("Company ID is required.");
}
const [teams, installed] = await Promise.all([
listCatalogTeams(ctx, opts),
ctx.api.get<InstalledCatalogTeam[]>(
`/api/companies/${encodeURIComponent(ctx.companyId)}/teams/catalog/installed`,
),
]);
const installedByCatalogId = new Map((installed ?? []).map((row) => [row.catalogId, row]));
const rows = teams.map((team) => {
const installedTeam = installedByCatalogId.get(team.id);
if (installedTeam) {
installedByCatalogId.delete(team.id);
}
return buildCatalogTeamStatusRow(team, installedTeam ?? null);
});
for (const installedTeam of installedByCatalogId.values()) {
if (installedTeam.present) continue;
rows.push(buildMissingInstalledTeamStatusRow(installedTeam));
}
return rows;
}
async function getCatalogTeam(ctx: ResolvedClientContext, catalogRef: string): Promise<CatalogTeam> {
const ref = catalogRef.trim();
if (!ref) {
throw new Error("Catalog team reference is required.");
}
const detail = await ctx.api.get<CatalogTeam>(`/api/teams/catalog/ref?ref=${encodeURIComponent(ref)}`);
if (!detail) {
throw new Error(`Catalog team not found: ${catalogRef}`);
}
return detail;
}
async function getCatalogTeamFile(
ctx: ResolvedClientContext,
catalogRef: string,
filePath: string,
): Promise<{ content: string } | null> {
const ref = catalogRef.trim();
const path = filePath.trim();
if (!ref) throw new Error("Catalog team reference is required.");
if (!path) throw new Error("Catalog team file path is required.");
const params = new URLSearchParams({ ref, path });
return ctx.api.get(`/api/teams/catalog/ref/files?${params.toString()}`);
}
function catalogTeamCompanyPath(companyId: string | undefined, catalogRef: string, action: "preview" | "install") {
if (!companyId) throw new Error("Company ID is required.");
const params = new URLSearchParams({ ref: catalogRef.trim() });
return `/api/companies/${encodeURIComponent(companyId)}/teams/catalog/ref/${action}?${params.toString()}`;
}
function buildTeamOptions(opts: TeamPreviewOptions): CatalogTeamImportOptions {
return removeUndefined({
targetManagerAgentId: emptyStringToUndefined(opts.targetManagerAgentId),
targetManagerSlug: emptyStringToUndefined(opts.targetManagerSlug),
agents: opts.agent && opts.agent.length > 0 ? opts.agent : undefined,
collisionStrategy: opts.collisionStrategy,
nameOverrides: parseNameOverrides(opts.nameOverride),
selectedFiles: opts.selectedFile && opts.selectedFile.length > 0 ? opts.selectedFile : undefined,
sourcePolicy: buildSourcePolicy(opts),
});
}
function buildTeamInstallOptions(opts: TeamInstallOptions): CatalogTeamInstallOptions {
return removeUndefined({
...buildTeamOptions(opts),
adapterOverrides: parseAdapterOverrides(opts.adapterOverride),
secretValues: parseSecretValues(opts.secretValue),
});
}
const INSTALL_APPROVAL_FALLBACK_MESSAGES = [
"missing permission: agents:create",
"missing permission: can create agents",
];
const SECRET_VALUE_REDACTION = "[redacted]";
function shouldRequestInstallApproval(error: unknown, opts: TeamInstallOptions): error is ApiRequestError {
if (!(opts.requestApprovalOnForbidden || isPaperclipTaskRun())) return false;
if (!(error instanceof ApiRequestError) || error.status !== 403) return false;
const message = error.message.toLowerCase();
return INSTALL_APPROVAL_FALLBACK_MESSAGES.some((expected) => message.includes(expected));
}
function isPaperclipTaskRun(): boolean {
return Boolean(process.env.PAPERCLIP_TASK_ID?.trim());
}
async function requestInstallApproval(
ctx: ResolvedClientContext,
catalogRef: string,
installOptions: CatalogTeamInstallOptions,
opts: TeamInstallOptions,
error: ApiRequestError,
): Promise<TeamInstallApprovalFallbackResult> {
if (!ctx.companyId) throw new Error("Company ID is required.");
const trimmedRef = catalogRef.trim();
const issueIds = resolveApprovalIssueIds(opts);
const approvalInstallOptions = omitInstallSecretValues(installOptions);
const returnedInstallOptions = redactInstallSecretValues(installOptions);
const payload = {
type: "request_board_approval",
issueIds,
payload: {
title: `Approve catalog team install: ${trimmedRef}`,
summary:
`A Paperclip CLI agent-run attempted to install catalog team "${trimmedRef}" into company "${ctx.companyId}", ` +
`but the API denied the install with: ${error.message}.`,
recommendedAction:
"Approve the catalog team source and rerun the install with a board or agent-creator token, or grant agents:create to the requesting agent and rerun the same command.",
risks: [
"Catalog team installation can create agents, projects, tasks, routines, skills, and secret bindings.",
"Only approve after checking the catalog source, selected files, target manager, and collision strategy.",
],
installAttempt: {
companyId: ctx.companyId,
catalogRef: trimmedRef,
options: approvalInstallOptions,
deniedReason: error.message,
},
},
};
const approval = await ctx.api.post<Approval>(apiPath`/api/companies/${ctx.companyId}/approvals`, payload);
if (!approval) {
throw new Error("Approval request failed.");
}
return {
status: "approval_requested",
approval,
installAttempt: {
companyId: ctx.companyId,
catalogRef: trimmedRef,
options: returnedInstallOptions,
deniedReason: error.message,
},
};
}
function omitInstallSecretValues(options: CatalogTeamInstallOptions): CatalogTeamInstallOptions {
if (!options.secretValues) return options;
const { secretValues: _secretValues, ...safeOptions } = options;
return safeOptions;
}
function redactInstallSecretValues(options: CatalogTeamInstallOptions): CatalogTeamInstallOptions {
if (!options.secretValues) return options;
return {
...options,
secretValues: Object.fromEntries(
Object.keys(options.secretValues).map((key) => [key, SECRET_VALUE_REDACTION]),
),
};
}
function resolveApprovalIssueIds(opts: TeamInstallOptions): string[] | undefined {
const issueId = opts.approvalIssueId?.trim() || process.env.PAPERCLIP_TASK_ID?.trim();
if (!issueId) return undefined;
return isUuidLike(issueId) ? [issueId] : undefined;
}
function isUuidLike(value: string): boolean {
return /^[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(value);
}
function buildSourcePolicy(opts: TeamPreviewOptions): CatalogTeamSourcePolicy | undefined {
const sourcePolicy = removeUndefined({
allowExternalSources: opts.allowExternalSources || undefined,
allowUnpinnedOptionalSources: opts.allowUnpinnedOptionalSources || undefined,
allowLocalPathSources: opts.allowLocalPathSources || undefined,
});
return Object.keys(sourcePolicy).length > 0 ? sourcePolicy : undefined;
}
function parseNameOverrides(values: string[] | undefined): Record<string, string> | undefined {
if (!values || values.length === 0) return undefined;
const result: Record<string, string> = {};
for (const raw of values) {
const [slug, name] = parseKeyValueOption(raw, "--name-override", "slug=name");
if (!slug || !name) {
throw new Error(`Invalid --name-override "${raw}". Use slug=name.`);
}
result[slug] = name;
}
return result;
}
function parseSecretValues(values: string[] | undefined): Record<string, string> | undefined {
if (!values || values.length === 0) return undefined;
const result: Record<string, string> = {};
for (const raw of values) {
const [key, value] = parseKeyValueOption(raw, "--secret-value", "key=value");
if (!key) {
throw new Error(`Invalid --secret-value "${raw}". Use key=value.`);
}
result[key] = value;
}
return result;
}
function parseAdapterOverrides(
values: string[] | undefined,
): CatalogTeamInstallOptions["adapterOverrides"] | undefined {
if (!values || values.length === 0) return undefined;
const result: NonNullable<CatalogTeamInstallOptions["adapterOverrides"]> = {};
for (const raw of values) {
const [slug, adapterType] = parseKeyValueOption(raw, "--adapter-override", "slug=type");
if (!slug || !adapterType) {
throw new Error(`Invalid --adapter-override "${raw}". Use slug=type.`);
}
result[slug] = { adapterType };
}
return result;
}
function parseKeyValueOption(raw: string, flag: string, format: string): [string, string] {
const separator = raw.indexOf("=");
if (separator <= 0) {
throw new Error(`Invalid ${flag} "${raw}". Use ${format}.`);
}
return [raw.slice(0, separator).trim(), raw.slice(separator + 1).trim()];
}
function removeUndefined<T extends Record<string, unknown>>(input: T): T {
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)) as T;
}
function emptyStringToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed || undefined;
}
function collectOptionValue(value: string, previous: string[]): string[] {
return [...previous, value];
}
function appendQueryParam(params: URLSearchParams, key: string, value: string | undefined): void {
const trimmed = value?.trim();
if (trimmed) {
params.set(key, trimmed);
}
}
function printCatalogTeamRows(rows: CatalogTeam[]): void {
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
printTable(rows.map((row) => ({
id: row.id,
key: row.key,
kind: row.kind,
category: row.category,
slug: row.slug,
name: row.name,
trust: row.trustLevel,
agents: row.counts.agents,
projects: row.counts.projects,
})));
}
function buildCatalogTeamStatusRow(
team: CatalogTeam,
installed: InstalledCatalogTeam | null,
): CatalogTeamStatusRow {
return {
catalogId: team.id,
catalogKey: team.key,
kind: team.kind,
category: team.category,
slug: team.slug,
name: team.name,
installedStatus: installed ? (installed.outOfDate ? "out_of_date" : "installed") : "not_installed",
installedAgentCount: installed?.agentCount ?? 0,
catalogAgentCount: team.counts.agents,
projectCount: team.counts.projects,
trustLevel: team.trustLevel,
present: true,
outOfDate: installed?.outOfDate ?? false,
currentContentHash: team.contentHash,
installedOriginHashes: installed?.installedOriginHashes ?? [],
};
}
function buildMissingInstalledTeamStatusRow(installed: InstalledCatalogTeam): CatalogTeamStatusRow {
const name = installed.catalogKey ?? installed.catalogId;
return {
catalogId: installed.catalogId,
catalogKey: installed.catalogKey,
kind: null,
category: null,
slug: null,
name,
installedStatus: "installed_missing",
installedAgentCount: installed.agentCount,
catalogAgentCount: null,
projectCount: null,
trustLevel: null,
present: false,
outOfDate: false,
currentContentHash: installed.currentContentHash,
installedOriginHashes: installed.installedOriginHashes,
};
}
function printCatalogTeamStatusRows(rows: CatalogTeamStatusRow[]): void {
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
printTable(rows.map((row) => ({
id: row.catalogId,
key: row.catalogKey,
kind: row.kind,
category: row.category,
slug: row.slug,
name: row.name,
installedStatus: row.installedStatus,
installedAgents: row.installedAgentCount,
catalogAgents: row.catalogAgentCount,
projects: row.projectCount,
trust: row.trustLevel,
})));
}
function printCatalogTeamDetail(team: CatalogTeam): void {
console.log(
formatInlineRecord({
id: team.id,
key: team.key,
kind: team.kind,
category: team.category,
slug: team.slug,
name: team.name,
trust: team.trustLevel,
compatibility: team.compatibility,
contentHash: team.contentHash,
}),
);
console.log(`description=${team.description || "-"}`);
console.log(`recommendedForCompanyTypes=${team.recommendedForCompanyTypes.join(",") || "-"}`);
console.log(`tags=${team.tags.join(",") || "-"}`);
console.log(
`counts=agents:${team.counts.agents},projects:${team.counts.projects},tasks:${team.counts.tasks},skills:${team.counts.localSkills + team.counts.catalogSkills}`,
);
console.log("files:");
printTable(team.files.map((file) => ({
path: file.path,
kind: file.kind,
sizeBytes: file.sizeBytes,
sha256: file.sha256,
})));
}
function printCatalogTeamPreview(result: CatalogTeamImportPreviewResult | null): void {
if (!result) {
console.log("Catalog team preview returned no result.");
return;
}
const preview = result.portabilityPreview;
console.log(
`Catalog team preview: ${result.team.name} (${result.team.key}) agents=${preview.plan.agentPlans.length} projects=${preview.plan.projectPlans.length} issues=${preview.plan.issuePlans.length} warnings=${result.warnings.length} errors=${result.errors.length}`,
);
for (const warning of result.warnings) console.log(`warning=${warning}`);
for (const error of result.errors) console.log(`error=${error}`);
}
function printCatalogTeamInstall(result: CatalogTeamInstallResult | null): void {
if (!result) {
console.log("Catalog team install returned no result.");
return;
}
console.log(
`Catalog team installed: ${result.team.name} (${result.team.key}) agents=${result.portabilityImport.agents.length} projects=${result.portabilityImport.projects.length} warnings=${result.warnings.length}`,
);
for (const warning of result.warnings) console.log(`warning=${warning}`);
}
function printInstallApprovalRequested(result: TeamInstallApprovalFallbackResult): void {
console.log(
formatInlineRecord({
status: result.status,
approvalId: result.approval.id,
approvalStatus: result.approval.status,
type: result.approval.type,
catalogRef: result.installAttempt.catalogRef,
deniedReason: result.installAttempt.deniedReason,
}),
);
console.log("Install was not performed. The board must approve the request and rerun the install with an authorized token.");
}
function printTable(rows: Array<Record<string, unknown>>): void {
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
const columns = Object.keys(rows[0] ?? {});
const widths = new Map(columns.map((column) => [column, column.length]));
for (const row of rows) {
for (const column of columns) {
widths.set(column, Math.max(widths.get(column) ?? 0, renderTableValue(row[column]).length));
}
}
console.log(columns.map((column) => column.padEnd(widths.get(column) ?? column.length)).join(" "));
console.log(columns.map((column) => "-".repeat(widths.get(column) ?? column.length)).join(" "));
for (const row of rows) {
console.log(
columns
.map((column) => renderTableValue(row[column]).padEnd(widths.get(column) ?? column.length))
.join(" "),
);
}
}
function renderTableValue(value: unknown): string {
if (value === null || value === undefined) return "-";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
Loaded 100 of 3422 files, more files were not shown because too many files have changed in this diff. Show more