diff --git a/.agents/skills/company-creator/SKILL.md b/.agents/skills/company-creator/SKILL.md new file mode 100644 index 00000000000..b2f4432bcdb --- /dev/null +++ b/.agents/skills/company-creator/SKILL.md @@ -0,0 +1,277 @@ +--- +name: company-creator +description: > + Create agent company packages conforming to the Agent Companies specification + (agentcompanies/v1). Use when a user wants to create a new agent company from + scratch, build a company around an existing git repo or skills collection, or + scaffold a team/department of agents. Triggers on: "create a company", "make me + a company", "build a company from this repo", "set up an agent company", + "create a team of agents", "hire some agents", or when given a repo URL and + asked to turn it into a company. Do NOT use for importing an existing company + package (use the CLI import command instead) or for modifying a company that + is already running in Paperclip. +--- + +# Company Creator + +Create agent company packages that conform to the Agent Companies specification. + +Spec references: + +- Normative spec: `docs/companies/companies-spec.md` (read this before generating files) +- Web spec: https://agentcompanies.io/specification +- Protocol site: https://agentcompanies.io/ + +## Two Modes + +### Mode 1: Company From Scratch + +The user describes what they want. Interview them to flesh out the vision, then generate the package. + +### Mode 2: Company From a Repo + +The user provides a git repo URL, local path, or tweet. Analyze the repo, then create a company that wraps it. + +See [references/from-repo-guide.md](references/from-repo-guide.md) for detailed repo analysis steps. + +## Process + +### Step 1: Gather Context + +Determine which mode applies: + +- **From scratch**: What kind of company or team? What domain? What should the agents do? +- **From repo**: Clone/read the repo. Scan for existing skills, agent configs, README, source structure. + +### Step 2: Interview (Use AskUserQuestion) + +Do not skip this step. Use AskUserQuestion to align with the user before writing any files. + +**For from-scratch companies**, ask about: + +- Company purpose and domain (1-2 sentences is fine) +- What agents they need - propose a hiring plan based on what they described +- Whether this is a full company (needs a CEO) or a team/department (no CEO required) +- Any specific skills the agents should have +- How work flows through the organization (see "Workflow" below) +- Whether they want projects and starter tasks + +**For from-repo companies**, present your analysis and ask: + +- Confirm the agents you plan to create and their roles +- Whether to reference or vendor any discovered skills (default: reference) +- Any additional agents or skills beyond what the repo provides +- Company name and any customization +- Confirm the workflow you inferred from the repo (see "Workflow" below) + +**Workflow — how does work move through this company?** + +A company is not just a list of agents with skills. It's an organization that takes ideas and turns them into work products. You need to understand the workflow so each agent knows: + +- Who gives them work and in what form (a task, a branch, a question, a review request) +- What they do with it +- Who they hand off to when they're done, and what that handoff looks like +- What "done" means for their role + +**Not every company is a pipeline.** Infer the right workflow pattern from context: + +- **Pipeline** — sequential stages, each agent hands off to the next. Use when the repo/domain has a clear linear process (e.g. plan → build → review → ship → QA, or content ideation → draft → edit → publish). +- **Hub-and-spoke** — a manager delegates to specialists who report back independently. Use when agents do different kinds of work that don't feed into each other (e.g. a CEO who dispatches to a researcher, a marketer, and an analyst). +- **Collaborative** — agents work together on the same things as peers. Use for small teams where everyone contributes to the same output (e.g. a design studio, a brainstorming team). +- **On-demand** — agents are summoned as needed with no fixed flow. Use when agents are more like a toolbox of specialists the user calls directly. + +For from-scratch companies, propose a workflow pattern based on what they described and ask if it fits. + +For from-repo companies, infer the pattern from the repo's structure. If skills have a clear sequential dependency (like `plan-ceo-review → plan-eng-review → review → ship → qa`), that's a pipeline. If skills are independent capabilities, it's more likely hub-and-spoke or on-demand. State your inference in the interview so the user can confirm or adjust. + +**Key interviewing principles:** + +- Propose a concrete hiring plan. Don't ask open-ended "what agents do you want?" - suggest specific agents based on context and let the user adjust. +- Keep it lean. Most users are new to agent companies. A few agents (3-5) is typical for a startup. Don't suggest 10+ agents unless the scope demands it. +- From-scratch companies should start with a CEO who manages everyone. Teams/departments don't need one. +- Ask 2-3 focused questions per round, not 10. + +### Step 3: Read the Spec + +Before generating any files, read the normative spec: + +``` +docs/companies/companies-spec.md +``` + +Also read the quick reference: [references/companies-spec.md](references/companies-spec.md) + +And the example: [references/example-company.md](references/example-company.md) + +### Step 4: Generate the Package + +Create the directory structure and all files. Follow the spec's conventions exactly. + +**Directory structure:** + +``` +/ +├── COMPANY.md +├── agents/ +│ └── /AGENTS.md +├── teams/ +│ └── /TEAM.md (if teams are needed) +├── projects/ +│ └── /PROJECT.md (if projects are needed) +├── tasks/ +│ └── /TASK.md (if tasks are needed) +├── skills/ +│ └── /SKILL.md (if custom skills are needed) +└── .paperclip.yaml (Paperclip vendor extension) +``` + +**Rules:** + +- Slugs must be URL-safe, lowercase, hyphenated +- COMPANY.md gets `schema: agentcompanies/v1` - other files inherit it +- Agent instructions go in the AGENTS.md body, not in .paperclip.yaml +- Skills referenced by shortname in AGENTS.md resolve to `skills//SKILL.md` +- For external skills, use `sources` with `usage: referenced` (see spec section 12) +- Do not export secrets, machine-local paths, or database IDs +- Omit empty/default fields +- For companies generated from a repo, add a references footer at the bottom of COMPANY.md body: + `Generated from [repo-name](repo-url) with the company-creator skill from [Paperclip](https://github.com/paperclipai/paperclip)` + +**Reporting structure:** + +- Every agent except the CEO should have `reportsTo` set to their manager's slug +- The CEO has `reportsTo: null` +- For teams without a CEO, the top-level agent has `reportsTo: null` + +**Writing workflow-aware agent instructions:** + +Each AGENTS.md body should include not just what the agent does, but how they fit into the organization's workflow. Include: + +1. **Where work comes from** — "You receive feature ideas from the user" or "You pick up tasks assigned to you by the CTO" +2. **What you produce** — "You produce a technical plan with architecture diagrams" or "You produce a reviewed, approved branch ready for shipping" +3. **Who you hand off to** — "When your plan is locked, hand off to the Staff Engineer for implementation" or "When review passes, hand off to the Release Engineer to ship" +4. **What triggers you** — "You are activated when a new feature idea needs product-level thinking" or "You are activated when a branch is ready for pre-landing review" + +This turns a collection of agents into an organization that actually works together. Without workflow context, agents operate in isolation — they do their job but don't know what happens before or after them. + +Add a concise execution contract to every generated working agent: + +- Start actionable work in the same heartbeat and do not stop at a plan unless planning was requested. +- Leave durable progress in comments, documents, or work products with the next action. +- Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes. +- Mark blocked work with the unblock owner and action. +- Respect budget, pause/cancel, approval gates, and company boundaries. + +### Step 5: Confirm Output Location + +Ask the user where to write the package. Common options: + +- A subdirectory in the current repo +- A new directory the user specifies +- The current directory (if it's empty or they confirm) + +### Step 6: Write README.md and LICENSE + +**README.md** — every company package gets a README. It should be a nice, readable introduction that someone browsing GitHub would appreciate. Include: + +- Company name and what it does +- The workflow / how the company operates +- Org chart as a markdown list or table showing agents, titles, reporting structure, and skills +- Brief description of each agent's role +- Citations and references: link to the source repo (if from-repo), link to the Agent Companies spec (https://agentcompanies.io/specification), and link to Paperclip (https://github.com/paperclipai/paperclip) +- A "Getting Started" section explaining how to import: `paperclipai company import --from ` + +**LICENSE** — include a LICENSE file. The copyright holder is the user creating the company, not the upstream repo author (they made the skills, the user is making the company). Use the same license type as the source repo (if from-repo) or ask the user (if from-scratch). Default to MIT if unclear. + +### Step 7: Write Files and Summarize + +Write all files, then give a brief summary: + +- Company name and what it does +- Agent roster with roles and reporting structure +- Skills (custom + referenced) +- Projects and tasks if any +- The output path + +## .paperclip.yaml Guidelines + +The `.paperclip.yaml` file is the Paperclip vendor extension. It configures adapters and env inputs per agent. + +### Adapter Rules + +**Do not specify an adapter unless the repo or user context warrants it.** If you don't know what adapter the user wants, omit the adapter block entirely — Paperclip will use its default. Specifying an unknown adapter type causes an import error. + +Paperclip's supported adapter types (these are the ONLY valid values): +- `claude_local` — Claude Code CLI +- `codex_local` — Codex CLI +- `opencode_local` — OpenCode CLI +- `pi_local` — Pi CLI +- `cursor` — Cursor +- `gemini_local` — Gemini CLI +- `openclaw_gateway` — OpenClaw gateway + +Only set an adapter when: +- The repo or its skills clearly target a specific runtime (e.g. gstack is built for Claude Code, so `claude_local` is appropriate) +- The user explicitly requests a specific adapter +- The agent's role requires a specific runtime capability + +### Env Inputs Rules + +**Do not add boilerplate env variables.** Only add env inputs that the agent actually needs based on its skills or role: +- `GH_TOKEN` for agents that push code, create PRs, or interact with GitHub +- API keys only when a skill explicitly requires them +- Never set `ANTHROPIC_API_KEY` as a default empty env variable — the runtime handles this + +Example with adapter (only when warranted): +```yaml +schema: paperclip/v1 +agents: + release-engineer: + adapter: + type: claude_local + config: + model: claude-sonnet-4-6 + inputs: + env: + GH_TOKEN: + kind: secret + requirement: optional +``` + +Example — only agents with actual overrides appear: +```yaml +schema: paperclip/v1 +agents: + release-engineer: + inputs: + env: + GH_TOKEN: + kind: secret + requirement: optional +``` + +In this example, only `release-engineer` appears because it needs `GH_TOKEN`. The other agents (ceo, cto, etc.) have no overrides, so they are omitted entirely from `.paperclip.yaml`. + +## External Skill References + +When referencing skills from a GitHub repo, always use the references pattern: + +```yaml +metadata: + sources: + - kind: github-file + repo: owner/repo + path: path/to/SKILL.md + commit: + attribution: Owner or Org Name + license: + usage: referenced +``` + +Get the commit SHA with: + +```bash +git ls-remote https://github.com/owner/repo HEAD +``` + +Do NOT copy external skill content into the package unless the user explicitly asks. diff --git a/.agents/skills/company-creator/references/companies-spec.md b/.agents/skills/company-creator/references/companies-spec.md new file mode 100644 index 00000000000..cc8e84e9e03 --- /dev/null +++ b/.agents/skills/company-creator/references/companies-spec.md @@ -0,0 +1,144 @@ +# Agent Companies Specification Reference + +The normative specification lives at: + +- Web: https://agentcompanies.io/specification +- Local: docs/companies/companies-spec.md + +Read the local spec file before generating any package files. The spec defines the canonical format and all frontmatter fields. Below is a quick-reference summary for common authoring tasks. + +## Package Kinds + +| File | Kind | Purpose | +| ---------- | ------- | ------------------------------------------------- | +| COMPANY.md | company | Root entrypoint, org boundary and defaults | +| TEAM.md | team | Reusable org subtree | +| AGENTS.md | agent | One role, instructions, and attached skills | +| PROJECT.md | project | Planned work grouping | +| TASK.md | task | Portable starter task | +| SKILL.md | skill | Agent Skills capability package (do not redefine) | + +## Directory Layout + +``` +company-package/ +├── COMPANY.md +├── agents/ +│ └── /AGENTS.md +├── teams/ +│ └── /TEAM.md +├── projects/ +│ └── / +│ ├── PROJECT.md +│ └── tasks/ +│ └── /TASK.md +├── tasks/ +│ └── /TASK.md +├── skills/ +│ └── /SKILL.md +├── assets/ +├── scripts/ +├── references/ +└── .paperclip.yaml (optional vendor extension) +``` + +## Common Frontmatter Fields + +```yaml +schema: agentcompanies/v1 +kind: company | team | agent | project | task +slug: url-safe-stable-identity +name: Human Readable Name +description: Short description for discovery +version: 0.1.0 +license: MIT +authors: + - name: Jane Doe +tags: [] +metadata: {} +sources: [] +``` + +- `schema` usually appears only at package root +- `kind` is optional when filename makes it obvious +- `slug` must be URL-safe and stable +- exporters should omit empty or default-valued fields + +## COMPANY.md Required Fields + +```yaml +name: Company Name +description: What this company does +slug: company-slug +schema: agentcompanies/v1 +``` + +Optional: `version`, `license`, `authors`, `goals`, `includes`, `requirements.secrets` + +## AGENTS.md Key Fields + +```yaml +name: Agent Name +title: Role Title +reportsTo: +skills: + - skill-shortname +``` + +- Body content is the agent's default instructions +- Skills resolve by shortname: `skills//SKILL.md` +- Do not export machine-specific paths or secrets + +## TEAM.md Key Fields + +```yaml +name: Team Name +description: What this team does +slug: team-slug +manager: ../agent-slug/AGENTS.md +includes: + - ../agent-slug/AGENTS.md + - ../../skills/skill-slug/SKILL.md +``` + +## PROJECT.md Key Fields + +```yaml +name: Project Name +description: What this project delivers +owner: agent-slug +``` + +## TASK.md Key Fields + +```yaml +name: Task Name +assignee: agent-slug +project: project-slug +schedule: + timezone: America/Chicago + startsAt: 2026-03-16T09:00:00-05:00 + recurrence: + frequency: weekly + interval: 1 + weekdays: [monday] + time: { hour: 9, minute: 0 } +``` + +## Source References (for external skills/content) + +```yaml +sources: + - kind: github-file + repo: owner/repo + path: path/to/SKILL.md + commit: + sha256: + attribution: Owner Name + license: MIT + usage: referenced +``` + +Usage modes: `vendored` (bytes included), `referenced` (pointer only), `mirrored` (cached locally) + +Default to `referenced` for third-party content. diff --git a/.agents/skills/company-creator/references/example-company.md b/.agents/skills/company-creator/references/example-company.md new file mode 100644 index 00000000000..cbc10d28f15 --- /dev/null +++ b/.agents/skills/company-creator/references/example-company.md @@ -0,0 +1,191 @@ +# Example Company Package + +A minimal but complete example of an agent company package. + +## Directory Structure + +``` +lean-dev-shop/ +├── COMPANY.md +├── agents/ +│ ├── ceo/AGENTS.md +│ ├── cto/AGENTS.md +│ └── engineer/AGENTS.md +├── teams/ +│ └── engineering/TEAM.md +├── projects/ +│ └── q2-launch/ +│ ├── PROJECT.md +│ └── tasks/ +│ └── monday-review/TASK.md +├── tasks/ +│ └── weekly-standup/TASK.md +├── skills/ +│ └── code-review/SKILL.md +└── .paperclip.yaml +``` + +## COMPANY.md + +```markdown +--- +name: Lean Dev Shop +description: Small engineering-focused AI company that builds and ships software products +slug: lean-dev-shop +schema: agentcompanies/v1 +version: 1.0.0 +license: MIT +authors: + - name: Example Org +goals: + - Build and ship software products + - Maintain high code quality +--- + +Lean Dev Shop is a small, focused engineering company. The CEO oversees strategy and coordinates work. The CTO leads the engineering team. Engineers build and ship code. +``` + +## agents/ceo/AGENTS.md + +```markdown +--- +name: CEO +title: Chief Executive Officer +reportsTo: null +skills: + - paperclip +--- + +You are the CEO of Lean Dev Shop. You oversee company strategy, coordinate work across the team, and ensure projects ship on time. + +Your responsibilities: + +- Review and prioritize work across projects +- Coordinate with the CTO on technical decisions +- Ensure the company goals are being met +``` + +## agents/cto/AGENTS.md + +```markdown +--- +name: CTO +title: Chief Technology Officer +reportsTo: ceo +skills: + - code-review + - paperclip +--- + +You are the CTO of Lean Dev Shop. You lead the engineering team and make technical decisions. + +Your responsibilities: + +- Set technical direction and architecture +- Review code and ensure quality standards +- Mentor engineers and unblock technical challenges +``` + +## agents/engineer/AGENTS.md + +```markdown +--- +name: Engineer +title: Software Engineer +reportsTo: cto +skills: + - code-review + - paperclip +--- + +You are a software engineer at Lean Dev Shop. You write code, fix bugs, and ship features. + +Your responsibilities: + +- Implement features and fix bugs +- Write tests and documentation +- Participate in code reviews + +Execution contract: + +- Start actionable implementation work in the same heartbeat; do not stop at a plan unless planning was requested. +- Leave durable progress with a clear next action. +- Use child issues for long or parallel delegated work instead of polling agents, sessions, or processes. +- Mark blocked work with the unblock owner and action. +``` + +## teams/engineering/TEAM.md + +```markdown +--- +name: Engineering +description: Product and platform engineering team +slug: engineering +schema: agentcompanies/v1 +manager: ../../agents/cto/AGENTS.md +includes: + - ../../agents/engineer/AGENTS.md + - ../../skills/code-review/SKILL.md +tags: + - engineering +--- + +The engineering team builds and maintains all software products. +``` + +## projects/q2-launch/PROJECT.md + +```markdown +--- +name: Q2 Launch +description: Ship the Q2 product launch +slug: q2-launch +owner: cto +--- + +Deliver all features planned for the Q2 launch, including the new dashboard and API improvements. +``` + +## projects/q2-launch/tasks/monday-review/TASK.md + +```markdown +--- +name: Monday Review +assignee: ceo +project: q2-launch +schedule: + timezone: America/Chicago + startsAt: 2026-03-16T09:00:00-05:00 + recurrence: + frequency: weekly + interval: 1 + weekdays: + - monday + time: + hour: 9 + minute: 0 +--- + +Review the status of Q2 Launch project. Check progress on all open tasks, identify blockers, and update priorities for the week. +``` + +## skills/code-review/SKILL.md (with external reference) + +```markdown +--- +name: code-review +description: Thorough code review skill for pull requests and diffs +metadata: + sources: + - kind: github-file + repo: anthropics/claude-code + path: skills/code-review/SKILL.md + commit: abc123def456 + sha256: 3b7e...9a + attribution: Anthropic + license: MIT + usage: referenced +--- + +Review code changes for correctness, style, and potential issues. +``` diff --git a/.agents/skills/company-creator/references/from-repo-guide.md b/.agents/skills/company-creator/references/from-repo-guide.md new file mode 100644 index 00000000000..b9458693b4b --- /dev/null +++ b/.agents/skills/company-creator/references/from-repo-guide.md @@ -0,0 +1,79 @@ +# Creating a Company From an Existing Repository + +When a user provides a git repo (URL, local path, or tweet linking to a repo), analyze it and create a company package that wraps its content. + +## Analysis Steps + +1. **Clone or read the repo** - Use `git clone` for URLs, read directly for local paths +2. **Scan for existing agent/skill files** - Look for SKILL.md, AGENTS.md, CLAUDE.md, .claude/ directories, or similar agent configuration +3. **Understand the repo's purpose** - Read README, package.json, main source files to understand what the project does +4. **Identify natural agent roles** - Based on the repo's structure and purpose, determine what agents would be useful + +## Handling Existing Skills + +Many repos already contain skills (SKILL.md files). When you find them: + +**Default behavior: use references, not copies.** + +Instead of copying skill content into your company package, create a source reference: + +```yaml +metadata: + sources: + - kind: github-file + repo: owner/repo + path: path/to/SKILL.md + commit: + attribution: + license: + usage: referenced +``` + +To get the commit SHA: +```bash +git ls-remote https://github.com/owner/repo HEAD +``` + +Only vendor (copy) skills when: +- The user explicitly asks to copy them +- The skill is very small and tightly coupled to the company +- The source repo is private or may become unavailable + +## Handling Existing Agent Configurations + +If the repo has agent configs (CLAUDE.md, .claude/ directories, codex configs, etc.): +- Use them as inspiration for AGENTS.md instructions +- Don't copy them verbatim - adapt them to the Agent Companies format +- Preserve the intent and key instructions + +## Repo-Only Skills (No Agents) + +When a repo contains only skills and no agents: +- Create agents that would naturally use those skills +- The agents should be minimal - just enough to give the skills a runtime context +- A single agent may use multiple skills from the repo +- Name agents based on the domain the skills cover + +Example: A repo with `code-review`, `testing`, and `deployment` skills might become: +- A "Lead Engineer" agent with all three skills +- Or separate "Reviewer", "QA Engineer", and "DevOps" agents if the skills are distinct enough + +## Common Repo Patterns + +### Developer Tools / CLI repos +- Create agents for the tool's primary use cases +- Reference any existing skills +- Add a project maintainer or lead agent + +### Library / Framework repos +- Create agents for development, testing, documentation +- Skills from the repo become agent capabilities + +### Full Application repos +- Map to departments: engineering, product, QA +- Create a lean team structure appropriate to the project size + +### Skills Collection repos (e.g. skills.sh repos) +- Each skill or skill group gets an agent +- Create a lightweight company or team wrapper +- Keep the agent count proportional to the skill diversity diff --git a/skills/create-agent-adapter/SKILL.md b/.agents/skills/create-agent-adapter/SKILL.md similarity index 98% rename from skills/create-agent-adapter/SKILL.md rename to .agents/skills/create-agent-adapter/SKILL.md index dcd6456ee49..a41318580c8 100644 --- a/skills/create-agent-adapter/SKILL.md +++ b/.agents/skills/create-agent-adapter/SKILL.md @@ -548,7 +548,7 @@ Import from `@paperclipai/adapter-utils/server-utils`: ### Prompt Templates - Support `promptTemplate` for every run - Use `renderTemplate()` with the standard variable set -- Default prompt: `"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work."` +- Default prompt should use `DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE` from `@paperclipai/adapter-utils/server-utils` so local adapters share Paperclip's execution contract: act in the same heartbeat, avoid planning-only exits unless requested, leave durable progress and a next action, use child issues instead of polling, mark blockers with owner/action, and respect governance boundaries. ### Error Handling - Differentiate timeout vs process error vs parse failure diff --git a/.agents/skills/deal-with-security-advisory/SKILL.md b/.agents/skills/deal-with-security-advisory/SKILL.md new file mode 100644 index 00000000000..0ba122366ec --- /dev/null +++ b/.agents/skills/deal-with-security-advisory/SKILL.md @@ -0,0 +1,230 @@ +--- +name: deal-with-security-advisory +description: > + Handle a GitHub Security Advisory response for Paperclip, including + confidential fix development in a temporary private fork, human coordination + on advisory-thread comments, CVE request, synchronized advisory publication, + and immediate security release steps. +--- + +# Security Vulnerability Response Instructions + +## ⚠️ CRITICAL: This is a security vulnerability. Everything about this process is confidential until the advisory is published. Do not mention the vulnerability details in any public commit message, PR title, branch name, or comment. Do not push anything to a public branch. Do not discuss specifics in any public channel. Assume anything on the public repo is visible to attackers who will exploit the window between disclosure and user upgrades. + +*** + +## Context + +A security vulnerability has been reported via GitHub Security Advisory: + +* **Advisory:** {{ghsaId}} (e.g. GHSA-x8hx-rhr2-9rf7) +* **Reporter:** {{reporterHandle}} +* **Severity:** {{severity}} +* **Notes:** {{notes}} + +*** + +## Step 0: Fetch the Advisory Details + +Pull the full advisory so you understand the vulnerability before doing anything else: + +``` +gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}} + +``` + +Read the `description`, `severity`, `cvss`, and `vulnerabilities` fields. Understand the attack vector before writing code. + +## Step 1: Acknowledge the Report + +⚠️ **This step requires a human.** The advisory thread does not have a comment API. Ask the human operator to post a comment on the private advisory thread acknowledging the report. Provide them this template: + +> Thanks for the report, @{{reporterHandle}}. We've confirmed the issue and are working on a fix. We're targeting a patch release within {{timeframe}}. We'll keep you updated here. + +Give your human this template, but still continue + +Below we use `gh` tools - you do have access and credentials outside of your sandbox, so use them. + +## Step 2: Create the Temporary Private Fork + +This is where all fix development happens. Never push to the public repo. + +``` +gh api --method POST \ + repos/paperclipai/paperclip/security-advisories/{{ghsaId}}/forks + +``` + +This returns a repository object for the private fork. Save the `full_name` and `clone_url`. + +Clone it and set up your workspace: + +``` +# Clone the private fork somewhere outside ~/paperclip +git clone ~/security-patch-{{ghsaId}} +cd ~/security-patch-{{ghsaId}} +git checkout -b security-fix + +``` + +**Do not edit `~/paperclip`** — the dev server is running off the `~/paperclip` master branch and we don't want to touch it. All work happens in the private fork clone. + +**TIPS:** + +* Do not commit `pnpm-lock.yaml` — the repo has actions to manage this +* Do not use descriptive branch names that leak the vulnerability (e.g., no `fix-dns-rebinding-rce`). Use something generic like `security-fix` +* All work stays in the private fork until publication +* CI/GitHub Actions will NOT run on the temporary private fork — this is a GitHub limitation by design. You must run tests locally + +## Step 3: Develop and Validate the Fix + +Write the patch. Same content standards as any PR: + +* It must functionally work — **run tests locally** since CI won't run on the private fork +* Consider the whole codebase, not just the narrow vulnerability path. A patch that fixes one vector but opens another is worse than no patch +* Ensure backwards compatibility for the database, or be explicit about what breaks +* Make sure any UI components still look correct if the fix touches them +* The fix should be minimal and focused — don't bundle unrelated changes into a security patch. Reviewers (and the reporter) should be able to read the diff and understand exactly what changed and why + +**Specific to security fixes:** + +* Verify the fix actually closes the attack vector described in the advisory. Reproduce the vulnerability first (using the reporter's description), then confirm the patch prevents it +* Consider adjacent attack vectors — if DNS rebinding is the issue, are there other endpoints or modes with the same class of problem? +* Do not introduce new dependencies unless absolutely necessary — new deps in a security patch raise eyebrows + +Push your fix to the private fork: + +``` +git add -A +git commit -m "Fix security vulnerability" +git push origin security-fix + +``` + +## Step 4: Coordinate with the Reporter + +⚠️ **This step requires a human.** Ask the human operator to post on the advisory thread letting the reporter know the fix is ready and giving them a chance to review. Provide them this template: + +> @{{reporterHandle}} — fix is ready in the private fork if you'd like to review before we publish. Planning to release within {{timeframe}}. + +Proceed + +## Step 5: Request a CVE + +This makes vulnerability scanners (npm audit, Snyk, Dependabot) warn users to upgrade. Without it, nobody gets automated notification. + +``` +gh api --method POST \ + repos/paperclipai/paperclip/security-advisories/{{ghsaId}}/cve + +``` + +GitHub is a CVE Numbering Authority and will assign one automatically. The CVE may take a few hours to propagate after the advisory is published. + +## Step 6: Publish Everything Simultaneously + +This all happens at once — do not stagger these steps. The goal is **zero window** between the vulnerability becoming public knowledge and the fix being available. + +### 6a. Verify reporter credit before publishing + +``` +gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}} --jq '.credits' + +``` + +If the reporter is not credited, add them: + +``` +gh api --method PATCH \ + repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \ + --input - << 'EOF' +{ + "credits": [ + { + "login": "{{reporterHandle}}", + "type": "reporter" + } + ] +} +EOF + +``` + +### 6b. Update the advisory with the patched version and publish + +``` +gh api --method PATCH \ + repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \ + --input - << 'EOF' +{ + "state": "published", + "vulnerabilities": [ + { + "package": { + "ecosystem": "npm", + "name": "paperclip" + }, + "vulnerable_version_range": "< {{patchedVersion}}", + "patched_versions": "{{patchedVersion}}" + } + ] +} +EOF + +``` + +Publishing the advisory simultaneously: + +* Makes the GHSA public +* Merges the temporary private fork into your repo +* Triggers the CVE assignment (if requested in step 5) + +### 6c. Cut a release immediately after merge + +``` +cd ~/paperclip +git pull origin master + +gh release create v{{patchedVersion}} \ + --repo paperclipai/paperclip \ + --title "v{{patchedVersion}} — Security Release" \ + --notes "## Security Release + +This release fixes a critical security vulnerability. + +### What was fixed +{{briefDescription}} (e.g., Remote code execution via DNS rebinding in \`local_trusted\` mode) + +### Advisory +https://github.com/paperclipai/paperclip/security/advisories/{{ghsaId}} + +### Credit +Thanks to @{{reporterHandle}} for responsibly disclosing this vulnerability. + +### Action required +All users running versions prior to {{patchedVersion}} should upgrade immediately." + +``` + +## Step 7: Post-Publication Verification + +``` +# Verify the advisory is published and CVE is assigned +gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \ + --jq '{state: .state, cve_id: .cve_id, published_at: .published_at}' + +# Verify the release exists +gh release view v{{patchedVersion}} --repo paperclipai/paperclip + +``` + +If the CVE hasn't been assigned yet, that's normal — it can take a few hours. + +⚠️ **Human step:** Ask the human operator to post a final comment on the advisory thread confirming publication and thanking the reporter. + +Tell the human operator what you did by posting a comment to this task, including: + +* The published advisory URL: `https://github.com/paperclipai/paperclip/security/advisories/{{ghsaId}}` +* The release URL +* Whether the CVE has been assigned yet +* All URLs to any pull requests or branches diff --git a/.agents/skills/doc-maintenance/SKILL.md b/.agents/skills/doc-maintenance/SKILL.md new file mode 100644 index 00000000000..a597e90ccbc --- /dev/null +++ b/.agents/skills/doc-maintenance/SKILL.md @@ -0,0 +1,201 @@ +--- +name: doc-maintenance +description: > + Audit top-level documentation (README, SPEC, PRODUCT) against recent git + history to find drift — shipped features missing from docs or features + listed as upcoming that already landed. Proposes minimal edits, creates + a branch, and opens a PR. Use when asked to review docs for accuracy, + after major feature merges, or on a periodic schedule. +--- + +# Doc Maintenance Skill + +Detect documentation drift and fix it via PR — no rewrites, no churn. + +## When to Use + +- Periodic doc review (e.g. weekly or after releases) +- After major feature merges +- When asked "are our docs up to date?" +- When asked to audit README / SPEC / PRODUCT accuracy + +## Target Documents + +| Document | Path | What matters | +|----------|------|-------------| +| README | `README.md` | Features table, roadmap, quickstart, "what is" accuracy, "works with" table | +| SPEC | `doc/SPEC.md` | No false "not supported" claims, major model/schema accuracy | +| PRODUCT | `doc/PRODUCT.md` | Core concepts, feature list, principles accuracy | + +Out of scope: DEVELOPING.md, DATABASE.md, CLI.md, doc/plans/, skill files, +release notes. These are dev-facing or ephemeral — lower risk of user-facing +confusion. + +## Workflow + +### Step 1 — Detect what changed + +Find the last review cursor: + +```bash +# Read the last-reviewed commit SHA +CURSOR_FILE=".doc-review-cursor" +if [ -f "$CURSOR_FILE" ]; then + LAST_SHA=$(cat "$CURSOR_FILE" | head -1) +else + # First run: look back 60 days + LAST_SHA=$(git log --format="%H" --after="60 days ago" --reverse | head -1) +fi +``` + +Then gather commits since the cursor: + +```bash +git log "$LAST_SHA"..HEAD --oneline --no-merges +``` + +### Step 2 — Classify changes + +Scan commit messages and changed files. Categorize into: + +- **Feature** — new capabilities (keywords: `feat`, `add`, `implement`, `support`) +- **Breaking** — removed/renamed things (keywords: `remove`, `breaking`, `drop`, `rename`) +- **Structural** — new directories, config changes, new adapters, new CLI commands + +**Ignore:** refactors, test-only changes, CI config, dependency bumps, doc-only +changes, style/formatting commits. These don't affect doc accuracy. + +For borderline cases, check the actual diff — a commit titled "refactor: X" +that adds a new public API is a feature. + +### Step 3 — Build a change summary + +Produce a concise list like: + +``` +Since last review (, ): +- FEATURE: Plugin system merged (runtime, SDK, CLI, slots, event bridge) +- FEATURE: Project archiving added +- BREAKING: Removed legacy webhook adapter +- STRUCTURAL: New .agents/skills/ directory convention +``` + +If there are no notable changes, skip to Step 7 (update cursor and exit). + +### Step 4 — Audit each target doc + +For each target document, read it fully and cross-reference against the change +summary. Check for: + +1. **False negatives** — major shipped features not mentioned at all +2. **False positives** — features listed as "coming soon" / "roadmap" / "planned" + / "not supported" / "TBD" that already shipped +3. **Quickstart accuracy** — install commands, prereqs, and startup instructions + still correct (README only) +4. **Feature table accuracy** — does the features section reflect current + capabilities? (README only) +5. **Works-with accuracy** — are supported adapters/integrations listed correctly? + +Use `references/audit-checklist.md` as the structured checklist. +Use `references/section-map.md` to know where to look for each feature area. + +### Step 5 — Create branch and apply minimal edits + +```bash +# Create a branch for the doc updates +BRANCH="docs/maintenance-$(date +%Y%m%d)" +git checkout -b "$BRANCH" +``` + +Apply **only** the edits needed to fix drift. Rules: + +- **Minimal patches only.** Fix inaccuracies, don't rewrite sections. +- **Preserve voice and style.** Match the existing tone of each document. +- **No cosmetic changes.** Don't fix typos, reformat tables, or reorganize + sections unless they're part of a factual fix. +- **No new sections.** If a feature needs a whole new section, note it in the + PR description as a follow-up — don't add it in a maintenance pass. +- **Roadmap items:** Move shipped features out of Roadmap. Add a brief mention + in the appropriate existing section if there isn't one already. Don't add + long descriptions. + +### Step 6 — Open a PR + +Commit the changes and open a PR: + +```bash +git add README.md doc/SPEC.md doc/PRODUCT.md .doc-review-cursor +git commit -m "docs: update documentation for accuracy + +- [list each fix briefly] + +Co-Authored-By: Paperclip " + +git push -u origin "$BRANCH" + +gh pr create \ + --title "docs: periodic documentation accuracy update" \ + --body "$(cat <<'EOF' +## Summary +Automated doc maintenance pass. Fixes documentation drift detected since +last review. + +### Changes +- [list each fix] + +### Change summary (since last review) +- [list notable code changes that triggered doc updates] + +## Review notes +- Only factual accuracy fixes — no style/cosmetic changes +- Preserves existing voice and structure +- Larger doc additions (new sections, tutorials) noted as follow-ups + +🤖 Generated by doc-maintenance skill +EOF +)" +``` + +### Step 7 — Update the cursor + +After a successful audit (whether or not edits were needed), update the cursor: + +```bash +git rev-parse HEAD > .doc-review-cursor +``` + +If edits were made, this is already committed in the PR branch. If no edits +were needed, commit the cursor update to the current branch. + +## Change Classification Rules + +| Signal | Category | Doc update needed? | +|--------|----------|-------------------| +| `feat:`, `add`, `implement`, `support` in message | Feature | Yes if user-facing | +| `remove`, `drop`, `breaking`, `!:` in message | Breaking | Yes | +| New top-level directory or config file | Structural | Maybe | +| `fix:`, `bugfix` | Fix | No (unless it changes behavior described in docs) | +| `refactor:`, `chore:`, `ci:`, `test:` | Maintenance | No | +| `docs:` | Doc change | No (already handled) | +| Dependency bumps only | Maintenance | No | + +## Patch Style Guide + +- Fix the fact, not the prose +- If removing a roadmap item, don't leave a gap — remove the bullet cleanly +- If adding a feature mention, match the format of surrounding entries + (e.g. if features are in a table, add a table row) +- Keep README changes especially minimal — it shouldn't churn often +- For SPEC/PRODUCT, prefer updating existing statements over adding new ones + (e.g. change "not supported in V1" to "supported via X" rather than adding + a new section) + +## Output + +When the skill completes, report: + +- How many commits were scanned +- How many notable changes were found +- How many doc edits were made (and to which files) +- PR link (if edits were made) +- Any follow-up items that need larger doc work diff --git a/.agents/skills/doc-maintenance/references/audit-checklist.md b/.agents/skills/doc-maintenance/references/audit-checklist.md new file mode 100644 index 00000000000..9c13a437d54 --- /dev/null +++ b/.agents/skills/doc-maintenance/references/audit-checklist.md @@ -0,0 +1,85 @@ +# Doc Maintenance Audit Checklist + +Use this checklist when auditing each target document. For each item, compare +against the change summary from git history. + +## README.md + +### Features table +- [ ] Each feature card reflects a shipped capability +- [ ] No feature cards for things that don't exist yet +- [ ] No major shipped features missing from the table + +### Roadmap +- [ ] Nothing listed as "planned" or "coming soon" that already shipped +- [ ] No removed/cancelled items still listed +- [ ] Items reflect current priorities (cross-check with recent PRs) + +### Quickstart +- [ ] `npx paperclipai onboard` command is correct +- [ ] Manual install steps are accurate (clone URL, commands) +- [ ] Prerequisites (Node version, pnpm version) are current +- [ ] Server URL and port are correct + +### "What is Paperclip" section +- [ ] High-level description is accurate +- [ ] Step table (Define goal / Hire team / Approve and run) is correct + +### "Works with" table +- [ ] All supported adapters/runtimes are listed +- [ ] No removed adapters still listed +- [ ] Logos and labels match current adapter names + +### "Paperclip is right for you if" +- [ ] Use cases are still accurate +- [ ] No claims about capabilities that don't exist + +### "Why Paperclip is special" +- [ ] Technical claims are accurate (atomic execution, governance, etc.) +- [ ] No features listed that were removed or significantly changed + +### FAQ +- [ ] Answers are still correct +- [ ] No references to removed features or outdated behavior + +### Development section +- [ ] Commands are accurate (`pnpm dev`, `pnpm build`, etc.) +- [ ] Link to DEVELOPING.md is correct + +## doc/SPEC.md + +### Company Model +- [ ] Fields match current schema +- [ ] Governance model description is accurate + +### Agent Model +- [ ] Adapter types match what's actually supported +- [ ] Agent configuration description is accurate +- [ ] No features described as "not supported" or "not V1" that shipped + +### Task Model +- [ ] Task hierarchy description is accurate +- [ ] Status values match current implementation + +### Extensions / Plugins +- [ ] If plugins are shipped, no "not in V1" or "future" language +- [ ] Plugin model description matches implementation + +### Open Questions +- [ ] Resolved questions removed or updated +- [ ] No "TBD" items that have been decided + +## doc/PRODUCT.md + +### Core Concepts +- [ ] Company, Employees, Task Management descriptions accurate +- [ ] Agent Execution modes described correctly +- [ ] No missing major concepts + +### Principles +- [ ] Principles haven't been contradicted by shipped features +- [ ] No principles referencing removed capabilities + +### User Flow +- [ ] Dream scenario still reflects actual onboarding +- [ ] Steps are achievable with current features diff --git a/.agents/skills/doc-maintenance/references/section-map.md b/.agents/skills/doc-maintenance/references/section-map.md new file mode 100644 index 00000000000..4ec64f83875 --- /dev/null +++ b/.agents/skills/doc-maintenance/references/section-map.md @@ -0,0 +1,22 @@ +# Section Map + +Maps feature areas to specific document sections so the skill knows where to +look when a feature ships or changes. + +| Feature Area | README Section | SPEC Section | PRODUCT Section | +|-------------|---------------|-------------|----------------| +| Plugins / Extensions | Features table, Roadmap | Extensions, Agent Model | Core Concepts | +| Adapters (new runtimes) | "Works with" table, FAQ | Agent Model, Agent Configuration | Employees & Agents, Agent Execution | +| Governance / Approvals | Features table, "Why special" | Board Governance, Board Approval Gates | Principles | +| Budget / Cost Control | Features table, "Why special" | Budget Delegation | Company (revenue & expenses) | +| Task Management | Features table | Task Model | Task Management | +| Org Chart / Hierarchy | Features table | Agent Model (reporting) | Employees & Agents | +| Multi-Company | Features table, FAQ | Company Model | Company | +| Heartbeats | Features table, FAQ | Agent Execution | Agent Execution | +| CLI Commands | Development section | — | — | +| Onboarding / Quickstart | Quickstart, FAQ | — | User Flow | +| Skills / Skill Injection | "Why special" | — | — | +| Company Templates | "Why special", Roadmap (ClipMart) | — | — | +| Mobile / UI | Features table | — | — | +| Project Archiving | — | — | — | +| OpenClaw Integration | "Works with" table, FAQ | Agent Model | Agent Execution | diff --git a/.agents/skills/pr-report/SKILL.md b/.agents/skills/pr-report/SKILL.md new file mode 100644 index 00000000000..5064b67c090 --- /dev/null +++ b/.agents/skills/pr-report/SKILL.md @@ -0,0 +1,202 @@ +--- +name: pr-report +description: > + Review a pull request or contribution deeply, explain it tutorial-style for a + maintainer, and produce a polished report artifact such as HTML or Markdown. + Use when asked to analyze a PR, explain a contributor's design decisions, + compare it with similar systems, or prepare a merge recommendation. +--- + +# PR Report Skill + +Produce a maintainer-grade review of a PR, branch, or large contribution. + +Default posture: + +- understand the change before judging it +- explain the system as built, not just the diff +- separate architectural problems from product-scope objections +- make a concrete recommendation, not a vague impression + +## When to Use + +Use this skill when the user asks for things like: + +- "review this PR deeply" +- "explain this contribution to me" +- "make me a report or webpage for this PR" +- "compare this design to similar systems" +- "should I merge this?" + +## Outputs + +Common outputs: + +- standalone HTML report in `tmp/reports/...` +- Markdown report in `report/` or another requested folder +- short maintainer summary in chat + +If the user asks for a webpage, build a polished standalone HTML artifact with +clear sections and readable visual hierarchy. + +Resources bundled with this skill: + +- `references/style-guide.md` for visual direction and report presentation rules +- `assets/html-report-starter.html` for a reusable standalone HTML/CSS starter + +## Workflow + +### 1. Acquire and frame the target + +Work from local code when possible, not just the GitHub PR page. + +Gather: + +- target branch or worktree +- diff size and changed subsystems +- relevant repo docs, specs, and invariants +- contributor intent if it is documented in PR text or design docs + +Start by answering: what is this change *trying* to become? + +### 2. Build a mental model of the system + +Do not stop at file-by-file notes. Reconstruct the design: + +- what new runtime or contract exists +- which layers changed: db, shared types, server, UI, CLI, docs +- lifecycle: install, startup, execution, UI, failure, disablement +- trust boundary: what code runs where, under what authority + +For large contributions, include a tutorial-style section that teaches the +system from first principles. + +### 3. Review like a maintainer + +Findings come first. Order by severity. + +Prioritize: + +- behavioral regressions +- trust or security gaps +- misleading abstractions +- lifecycle and operational risks +- coupling that will be hard to unwind +- missing tests or unverifiable claims + +Always cite concrete file references when possible. + +### 4. Distinguish the objection type + +Be explicit about whether a concern is: + +- product direction +- architecture +- implementation quality +- rollout strategy +- documentation honesty + +Do not hide an architectural objection inside a scope objection. + +### 5. Compare to external precedents when needed + +If the contribution introduces a framework or platform concept, compare it to +similar open-source systems. + +When comparing: + +- prefer official docs or source +- focus on extension boundaries, context passing, trust model, and UI ownership +- extract lessons, not just similarities + +Good comparison questions: + +- Who owns lifecycle? +- Who owns UI composition? +- Is context explicit or ambient? +- Are plugins trusted code or sandboxed code? +- Are extension points named and typed? + +### 6. Make the recommendation actionable + +Do not stop at "merge" or "do not merge." + +Choose one: + +- merge as-is +- merge after specific redesign +- salvage specific pieces +- keep as design research + +If rejecting or narrowing, say what should be kept. + +Useful recommendation buckets: + +- keep the protocol/type model +- redesign the UI boundary +- narrow the initial surface area +- defer third-party execution +- ship a host-owned extension-point model first + +### 7. Build the artifact + +Suggested report structure: + +1. Executive summary +2. What the PR actually adds +3. Tutorial: how the system works +4. Strengths +5. Main findings +6. Comparisons +7. Recommendation + +For HTML reports: + +- use intentional typography and color +- make navigation easy for long reports +- favor strong section headings and small reference labels +- avoid generic dashboard styling + +Before building from scratch, read `references/style-guide.md`. +If a fast polished starter is helpful, begin from `assets/html-report-starter.html` +and replace the placeholder content with the actual report. + +### 8. Verify before handoff + +Check: + +- artifact path exists +- findings still match the actual code +- any requested forbidden strings are absent from generated output +- if tests were not run, say so explicitly + +## Review Heuristics + +### Plugin and platform work + +Watch closely for: + +- docs claiming sandboxing while runtime executes trusted host processes +- module-global state used to smuggle React context +- hidden dependence on render order +- plugins reaching into host internals instead of using explicit APIs +- "capabilities" that are really policy labels on top of fully trusted code + +### Good signs + +- typed contracts shared across layers +- explicit extension points +- host-owned lifecycle +- honest trust model +- narrow first rollout with room to grow + +## Final Response + +In chat, summarize: + +- where the report is +- your overall call +- the top one or two reasons +- whether verification or tests were skipped + +Keep the chat summary shorter than the report itself. diff --git a/.agents/skills/pr-report/assets/html-report-starter.html b/.agents/skills/pr-report/assets/html-report-starter.html new file mode 100644 index 00000000000..be6f0550c8c --- /dev/null +++ b/.agents/skills/pr-report/assets/html-report-starter.html @@ -0,0 +1,426 @@ + + + + + + PR Report Starter + + + + + + +
+ + +
+
+
Executive Summary
+

Use the hero for the clearest one-line judgment.

+

+ Replace this with the short explanation of what the contribution does, why it matters, + and what the core maintainer question is. +

+
+ Strength + Tradeoff + Risk +
+
+
+
Overall Call
+
Placeholder
+
+
+
Main Concern
+
Placeholder
+
+
+
Best Part
+
Placeholder
+
+
+
Weakest Part
+
Placeholder
+
+
+
+ Use this block for the thesis, a sharp takeaway, or a key cited point. +
+
+ +
+

Tutorial Section

+
+
+

Concept Card

+

Use cards for mental models, subsystems, or comparison slices.

+
path/to/file.ts:10
+
+
+

Second Card

+

Keep cards fairly dense. This template is about style, not fixed structure.

+
path/to/file.ts:20
+
+
+
+ +
+

Findings

+
+
High
+

Finding Title

+

Use findings for the sharpest judgment calls and risks.

+
path/to/file.ts:30
+
+
+ +
+

Recommendation

+
+
+

Path Forward

+

Use this area for merge guidance, salvage plan, or rollout advice.

+
+
+

What To Keep

+

Call out the parts worth preserving even if the whole proposal should not land.

+
+
+
+
+
+ + diff --git a/.agents/skills/pr-report/references/style-guide.md b/.agents/skills/pr-report/references/style-guide.md new file mode 100644 index 00000000000..35158d1adcf --- /dev/null +++ b/.agents/skills/pr-report/references/style-guide.md @@ -0,0 +1,149 @@ +# PR Report Style Guide + +Use this guide when the user wants a report artifact, especially a webpage. + +## Goal + +Make the report feel like an editorial review, not an internal admin dashboard. +The page should make a long technical argument easy to scan without looking +generic or overdesigned. + +## Visual Direction + +Preferred tone: + +- editorial +- warm +- serious +- high-contrast +- handcrafted, not corporate SaaS + +Avoid: + +- default app-shell layouts +- purple gradients on white +- generic card dashboards +- cramped pages with weak hierarchy +- novelty fonts that hurt readability + +## Typography + +Recommended pattern: + +- one expressive serif or display face for major headings +- one sturdy sans-serif for body copy and UI labels + +Good combinations: + +- Newsreader + IBM Plex Sans +- Source Serif 4 + Instrument Sans +- Fraunces + Public Sans +- Libre Baskerville + Work Sans + +Rules: + +- headings should feel deliberate and large +- body copy should stay comfortable for long reading +- reference labels and badges should use smaller dense sans text + +## Layout + +Recommended structure: + +- a sticky side or top navigation for long reports +- one strong hero summary at the top +- panel or paper-like sections for each major topic +- multi-column card grids for comparisons and strengths +- single-column body text for findings and recommendations + +Use generous spacing. Long-form technical reports need breathing room. + +## Color + +Prefer muted paper-like backgrounds with one warm accent and one cool counterweight. + +Suggested token categories: + +- `--bg` +- `--paper` +- `--ink` +- `--muted` +- `--line` +- `--accent` +- `--good` +- `--warn` +- `--bad` + +The accent should highlight navigation, badges, and important labels. Do not +let accent colors dominate body text. + +## Useful UI Elements + +Include small reusable styles for: + +- summary metrics +- badges +- quotes or callouts +- finding cards +- severity labels +- reference labels +- comparison cards +- responsive two-column sections + +## Motion + +Keep motion restrained. + +Good: + +- soft fade/slide-in on first load +- hover response on nav items or cards + +Bad: + +- constant animation +- floating blobs +- decorative motion with no reading benefit + +## Content Presentation + +Even when the user wants design polish, clarity stays primary. + +Good structure for long reports: + +1. executive summary +2. what changed +3. tutorial explanation +4. strengths +5. findings +6. comparisons +7. recommendation + +The exact headings can change. The important thing is to separate explanation +from judgment. + +## References + +Reference labels should be visually quiet but easy to spot. + +Good pattern: + +- small muted text +- monospace or compact sans +- keep them close to the paragraph they support + +## Starter Usage + +If you need a fast polished base, start from: + +- `assets/html-report-starter.html` + +Customize: + +- fonts +- color tokens +- hero copy +- section ordering +- card density + +Do not preserve the placeholder sections if they do not fit the actual report. diff --git a/.agents/skills/prcheckloop/SKILL.md b/.agents/skills/prcheckloop/SKILL.md new file mode 100644 index 00000000000..70d9e19af66 --- /dev/null +++ b/.agents/skills/prcheckloop/SKILL.md @@ -0,0 +1,209 @@ +--- +name: prcheckloop +description: > + Iteratively gets a GitHub pull request's checks green. Detects the PR for the + current branch or uses a provided PR number, waits for every check on the + latest head SHA to appear and finish, investigates failing checks, fixes + actionable code or test issues, pushes, and repeats. Escalates with a precise + blocker when failures are external, flaky, or not safely fixable. Use when a + PR still has unsuccessful checks after review fixes, including after greploop. +--- + +# PRCheckloop + +Get a GitHub PR to a fully green check state, or exit with a concrete blocker. + +## Scope + +- GitHub PRs only. If the repo is GitLab, stop and use `check-pr`. +- Focus on checks for the latest PR head SHA, not old commits. +- Focus on CI/status checks, not review comments or PR template cleanup. +- If the user also wants review-comment cleanup, pair this with `check-pr`. + +## Inputs + +- **PR number** (optional): If not provided, detect the PR for the current branch. +- **Max iterations**: default `5`. + +## Workflow + +### 1. Identify the PR + +If no PR number is provided, detect it from the current branch: + +```bash +gh pr view --json number,headRefName,headRefOid,url,isDraft +``` + +If needed, switch to the PR branch before making changes. + +Stop early if: + +- `gh` is not authenticated +- there is no PR for the branch +- the repo is not hosted on GitHub + +### 2. Track the latest head SHA + +Always work against the current PR head SHA: + +```bash +PR_JSON=$(gh pr view "$PR_NUMBER" --json number,headRefName,headRefOid,url) +HEAD_SHA=$(echo "$PR_JSON" | jq -r .headRefOid) +PR_URL=$(echo "$PR_JSON" | jq -r .url) +``` + +Ignore failing checks from older SHAs. After every push, refresh `HEAD_SHA` and +restart the inspection loop. + +### 3. Inventory checks for that SHA + +Fetch both GitHub check runs and legacy commit status contexts: + +```bash +gh api "repos/{owner}/{repo}/commits/$HEAD_SHA/check-runs?per_page=100" +gh api "repos/{owner}/{repo}/commits/$HEAD_SHA/status" +``` + +For a compact PR-level view, this GraphQL payload is useful: + +```bash +gh api graphql -f query=' +query($owner:String!, $repo:String!, $pr:Int!) { + repository(owner:$owner, name:$repo) { + pullRequest(number:$pr) { + headRefOid + url + statusCheckRollup { + contexts(first:100) { + nodes { + __typename + ... on CheckRun { name status conclusion detailsUrl workflowName } + ... on StatusContext { context state targetUrl description } + } + } + } + } + } +}' -F owner=OWNER -F repo=REPO -F pr="$PR_NUMBER" +``` + +### 4. Wait for checks to actually run + +After a new push, checks can take a moment to appear. Poll every 15-30 seconds +until one of these is true: + +- checks have appeared and every item is in a terminal state +- checks have appeared and at least one failed +- no checks appear after a reasonable wait, usually 2 minutes + +Treat these as terminal success states: + +- check runs: `SUCCESS`, `NEUTRAL`, `SKIPPED` +- status contexts: `SUCCESS` + +Treat these as pending: + +- check runs: `QUEUED`, `PENDING`, `WAITING`, `REQUESTED`, `IN_PROGRESS` +- status contexts: `PENDING` + +Treat these as failures: + +- check runs: `FAILURE`, `TIMED_OUT`, `CANCELLED`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `STALE` +- status contexts: `FAILURE`, `ERROR` + +If no checks appear for the latest SHA, inspect `.github/workflows/`, workflow +path filters, and branch protection expectations. If the missing check cannot be +caused or fixed from the repo, escalate. + +### 5. Investigate failing checks + +For GitHub Actions failures, inspect runs and failed logs for the current SHA: + +```bash +gh run list --commit "$HEAD_SHA" --json databaseId,workflowName,status,conclusion,url,headSha +gh run view --json databaseId,name,workflowName,status,conclusion,jobs,url,headSha +gh run view --log-failed +``` + +For each failing check, classify it: + +| Failure type | Action | +|---|---| +| Code/test regression | Reproduce locally, fix, and verify | +| Lint/type/build mismatch | Run the matching local command from the workflow and fix it | +| Flake or transient infra issue | Rerun once if evidence supports flakiness | +| External service/status app failure | Escalate with the details URL and owner guess | +| Missing secret/permission/branch protection issue | Escalate immediately | + +Only rerun a failed job once without code changes. Do not loop on reruns. + +### 6. Fix actionable failures + +If the failure is actionable from the checked-out code: + +1. Read the workflow or failing command to identify the real gate. +2. Reproduce locally where reasonable. +3. Make the smallest correct fix. +4. Run focused verification first, then broader verification if needed. +5. Commit in a logical commit. +6. Push before re-checking the PR. + +Do not stop at a local fix. The loop is only complete when the remote PR checks +for the new head SHA are green. + +### 7. Push and repeat + +After each fix: + +```bash +git push +sleep 5 +``` + +Then refresh the PR metadata, get the new `HEAD_SHA`, and restart from Step 3. + +Exit the loop only when: + +- all checks for the latest head SHA are green, or +- a blocker remains after reasonable repair effort, or +- the max iteration count is reached + +### 8. Escalate blockers precisely + +If you cannot get the PR green, report: + +- PR URL +- latest head SHA +- exact failing or missing check names +- details URLs +- what you already tried +- why it is blocked +- who should likely unblock it +- the next concrete action + +Good blocker examples: + +- external status app outage +- missing GitHub secret or permission +- required check name mismatch in branch protection +- persistent flake after one rerun +- failure needs credentials or infrastructure access you do not have + +## Output + +When the skill completes, report: + +- PR URL and branch +- final head SHA +- green/pending/failing check summary +- fixes made and verification run +- whether changes were pushed +- blocker summary if not fully green + +## Notes + +- This skill is intentionally narrower than `check-pr`: it is a repair loop for + PR checks. +- This skill complements `greploop`: Greptile can be perfect while CI is still + red. diff --git a/.agents/skills/release-changelog-discord-message/SKILL.md b/.agents/skills/release-changelog-discord-message/SKILL.md new file mode 100644 index 00000000000..df6c85925b6 --- /dev/null +++ b/.agents/skills/release-changelog-discord-message/SKILL.md @@ -0,0 +1,406 @@ +--- +name: release-changelog-discord-message +description: > + Write the Discord release announcement for a stable Paperclip release. Companion + to `release-changelog` — that skill produces the file at `releases/vYYYY.MDD.P.md`; + this one turns that file into a single copy-pasteable Discord post in dotta's + voice and attaches it as the `discord_announcement` document on the release + issue. +--- + +# Release Discord Announcement Skill + +Write the Discord release announcement for the **stable** Paperclip release. + +This is the companion to `.agents/skills/release-changelog/SKILL.md`. That skill +generates the file at `releases/vYYYY.MDD.P.md`. This skill turns that file into +a single copy-pasteable Discord block, in dotta's voice, and posts it as the +`discord_announcement` document on the release issue. + +## What dotta said + +> This is for discord — try to follow my format. If I have a section where I +> think about the future, pull from recent issues we're working on etc. + +The Discord announcement is **not** the changelog. The changelog is exhaustive; +the announcement is opinionated, in-voice, and built around the same handful of +shipped highlights plus a real "what's next" + "what's on my mind" pulled from +current Paperclip work — not invented. + +## When to use + +- After `release-changelog` has produced `releases/vYYYY.MDD.P.md` on the + release worktree/PR. +- When the release issue (the one assigned by the release routine) asks for a + Discord announcement, or has a `discord_announcement` document that needs to + be refreshed for a new date/version. +- Never run this in isolation. The version, date, contributor list, and + highlight set MUST match the matching changelog file — if the changelog has + been updated, refresh this too. + +## Output + +A single fenced markdown code block, ready to paste into Discord. Attached as +issue document key `discord_announcement` on the release issue, and pasted +verbatim into a comment on that issue so the human can copy it out. + +```bash +PUT /api/issues/{releaseIssueId}/documents/discord_announcement +{ + "title": "Discord announcement", + "format": "markdown", + "body": "", + "baseRevisionId": "" +} +``` + +If the document already exists, fetch it first and pass the current +`baseRevisionId`. Never overwrite silently — if the version has changed since +the document was last written, mention what changed in the issue comment. + +## Format (follow this template) + +Use Discord emoji shortcodes (`:paperclip:`, `:lock:`, `:brain:` …) — NOT the +Unicode emoji. Discord renders the shortcodes; the changelog file uses prose. + +``` +:paperclip: :paperclip: :paperclip: CLIPPERS!!! v{VERSION} IS OUT :paperclip: :paperclip: :paperclip: + +OFFICIAL TWITTER: https://x.com/papercliping - follow it, report any others + +## Highlights + +:emoji: **Feature Name** - one-sentence description in dotta's voice. +:emoji: **Feature Name** - … +:emoji: **Feature Name** - … + +... and a long tail of {flavor of the rest}. Read the [full release notes](). + +## WHATS NEXT (:motorway: Roadmap) + +* **Theme A** - one-line forward-looking blurb +* **Theme B** - … +* **Theme C** - … + +## What's on my mind + +* **Topic** - what's bugging dotta / what's queued / open questions +* **Topic** - … + +## PRESS (optional — only if there is real press) + +* **Outlet / Person** - what happened ([link]()) + +## WHAT I NEED FROM YOU (optional — only if there's a real ask) + +FOLLOW THE TWITTER: https://x.com/papercliping - that's the only official one +TELL ME if you're using Paperclip in your business - I want to meet you + +## Community + +Thank you to everyone who contributed to this release! + +``` +@username1, @username2, @username3 +``` + +## In Summary + +PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK + +Every single person will be managing a team of a dozen, or a hundred, or a +thousand agents and Paperclip will be the default tool to manage it all. + +ITS TIME TO CLIP :paperclip: :paperclip: :paperclip: + +FULL RELEASE NOTES + +https://github.com/paperclipai/paperclip/blob/master/releases/v{VERSION}.md + +||@everyone|| +``` + +Notes on the template: + +- The opening and closing `:paperclip: :paperclip: :paperclip:` bookends are + part of the brand — keep them. +- Sections may be UPPERCASE or Title Case — dotta has used both. Pick a style + and stay consistent within a single post. +- Use `||@everyone||` (Discord spoiler-wrapped) at the very end so it pings + exactly once when the spoiler is removed by the poster. + +## Language tips + +These are extracted from how dotta has written the last several announcements. +Mimic this register; do not invent a "professional" tone. + +- **First person, conversational.** "I want to meet companies using Paperclip", + "what's on my mind", "if that's you let me know". Not "Paperclip is excited + to announce". +- **ALL CAPS for excitement and asks**, especially in the opener, the section + headers, the "WHAT I NEED FROM YOU" section, and the closing tagline. Do not + ALL-CAPS feature descriptions. +- **One emoji shortcode per highlight bullet**, picked to evoke the feature + (`:lock:` for secrets, `:brain:` for planning, `:mag:` for search, + `:cloud:` for cloud / sandbox, `:jigsaw:` for plugins, `:rewind:` for + history/restore, `:thread:` for threads, etc.). +- **Highlight bullets are one sentence**, opinionated, told from the user's + perspective — "the cloud-secrets prereq is real now", not "added support + for…". +- **Tail line after highlights** wraps the rest in a single sentence and links + to the full release notes ("… and a long tail of {flavor}. Read the [full + release notes](url)."). +- **"WHATS NEXT" is forward-looking themes**, not a literal sprint list. 3–5 + bullets is the right size. Pull these from active goals, in-flight projects, + and recent issues the team is working on — do not invent themes. +- **"What's on my mind"** is dotta's personal/strategic thinking — docs gaps, + philosophical positioning ("we're the human control plane for ai labor"), + invitations ("if you've ever wanted to write about how you use Paperclip, + hit me up"). Pull real tensions from recent issues/comments; do not invent. +- **Press section** is optional. Only include it if there is real press in the + release window (a tweet, a podcast, a talk, a star milestone). No press → + drop the section entirely. +- **"WHAT I NEED FROM YOU"** is optional. Use it for a single concrete ask + (follow the twitter, intros, beta sign-ups). No real ask → drop it. +- **Community** is the same contributors list that's in the changelog file, + fenced in a triple-backtick block, comma-separated `@username, @username`. + Exclude bots and Paperclip founders, same rules as the changelog skill. +- **The "In Summary" mission line** evolves slowly. Use the most recent + variant unless dotta tells you otherwise. Recent variants: + - "PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK" + - "PAPERCLIP WILL BE THE DEFAULT AGENT-MANAGEMENT TOOL FOR EVERY COMPANY" + - "Paperclip will be _the_ control plane for AI agents in **every** company." +- **Closing tagline** is always `ITS TIME TO CLIP :paperclip: :paperclip: + :paperclip:`. Keep it. + +## Workflow + +1. Read the matching `releases/vYYYY.MDD.P.md` produced by `release-changelog`. + Use the version and contributor list from that file — never re-derive them. +2. Read the **release issue thread** (the one assigned to you that ran the + release routine) — comments + linked issues + recent issues in the company + are the source for `WHATS NEXT` and `What's on my mind`. Pull real themes, + not invented ones. +3. Re-read the three verbatim examples below — they're the canonical voice. +4. Draft the announcement using the template above. +5. PUT it as the `discord_announcement` document on the release issue (see + "Output" above). If updating, send the latest `baseRevisionId`. +6. Post a comment on the release issue that includes the announcement inside a + single fenced markdown code block, so dotta can copy-paste it into Discord + without opening the document. + +Do not publish to Discord. This skill only prepares the artifact. + +## Verbatim previous examples + +Three previous Discord announcements from dotta, included **verbatim** as the +ground-truth examples for voice, structure, and emoji usage. When in doubt, +match these. + +### Example 1 — v2026.403.0 + +``` +CLIPPERS! v2026.403.0 has dropped!! :paperclip: :paperclip: :paperclip: + +## Highlights + +:inbox_tray: **Inbox overhaul** - there is a new "mine" tab that has mail-client like keyboard shortcuts. It's my new default view for managing work +:thumbsup: **Feedback and evals** - you can now vote :thumbsup: / :thumbsdown: on your agent's responses. If you choose to share your traces with me, I'll use it to make Paperclip better. In either case you can export locally for your own org's learning +:page_with_curl: **Document revisions** - you can now restore old versions of your documents +:ping_pong: **Telemetry** - this version has anonymized telemetry that helps me better understand the basic uses of Paperclip (adapters and so on) - if you hate that, just it disable with `DO_NOT_TRACK=1` or `PAPERCLIP_TELEMETRY_DISABLED=1` environment variables +:construction_worker: **Execution Workspaces (experimental)** - Paperclip is not a "code review" tool, but I have been finding worktrees are important for certain projects. Enable it in experimental settings +:loop: **Routine variables** - sometimes you need to customize a routine and the new variables feature makes that easy + +PLUS **tons** of improvements aound adapters, bugfixes, qol + +## COMMUNITY + +HUGE THANKS to the contributors with commits in this release: + +``` +@aronprins, @bittoby, @edimuj, @HenkDz, @kevmok, @mvanhorn, @radiusred, @remdev, @statxc, @vanductai +``` + +## WHATS NEXT (ROADMAP) + +* **Multi-human users** -- you've been asking for it, we have a draft and will have this shortly +* **Sandbox execution** - the other half of cloud deployment: run your agents in a sandbox across any provider + +PLUS: just dealing with the excellent PRs we have sitting in our inbox. + +**What's also on my mind (coming soonish)** + +* MAXIMIZER MODE - for when you've got a dream and tokens to burn +* Artifacts, work products, and deployments +* CEO Chat +* Stronger agent defaults + +## PRESS + +I've been doing my part to spread the word about Paperclip + +* We talked to the incredible [Andrew Warner of Mixergy Fame](https://x.com/dotta/status/2039087507514507407) +* We gave a tutorial with the [inimitable Greg Isenberg](https://x.com/dotta/status/2037279902445994345) +* We met with the [Seed Club guys](https://x.com/dotta/status/2039020365926576377) +* We crossed [40k stars (46k now!)](https://x.com/dotta/status/2038638188227387613) +* ... and a couple others that will be released in a few days + +## SUCCESS STORIES + +* [Nevo made $76k in march](https://x.com/dotta/status/2039406772859920758) after using Paperclip to automate his marketing +* [Lewis Jackson](https://x.com/WhatSayLew/status/2039810227394978158) said 34 agents were already operating his trading firm through Paperclip and called it his "holy s***" AI moment. +* [Neal Kotak](https://x.com/nkotak1/status/2039582439459209638) said Paperclip already runs most of Roominary for him and praised how strong the product is. +* [Sam Woods](https://x.com/samwoods/status/2039039305960587755) said he knows several people who moved from OpenClaw to Paperclip, often with Hermes in the stack, and that they love it. +* [Josh Galt](https://x.com/JoshGalt/status/2039386307219095557) called Paperclip the coolest agent tooling he has used and said it is finally something that just works. + +## IN SUMMARY + +I know there are still some rough edges, but + +Paperclip will be *the* control plane for AI agents in **every** company. + +and I think we're moving at a pretty good clip :paperclip: :paperclip: :paperclip: + +FULL RELEASE NOTES HERE + +https://github.com/paperclipai/paperclip/releases/tag/v2026.403.0 + +||@everyone|| +``` + +### Example 2 — v2026.416.0 + +``` +:paperclip: :paperclip: :paperclip: CLIPPERS!!! v2026.416.0 IS OUT :paperclip: :paperclip: :paperclip: + +## Highlights + +This release has *tons* of quality of life improvements around speed, performance, and workflow. You should notice that comment threads feel faster and your agents stay on task longer + +:thread: Issue chat threads now are a conversation more than comments +:police_officer: Execution policies like **Reviewer** and **Approver** are now first-class in the harness (e.g. enforce that QA *must* review a task) +:no_smoking: Blocker dependencies - first-class "wake on blocker resolved" which means now you can have "task graphs" that depend on one another and it's enforced by Paperclip +:woman_feeding_baby: Parent-child tasks - better support for sub-tasks all around, which makes it much easier to organize your work + +And then a million fixes around ux, details, keyboard shortcuts, bug fixes, security fixes, etc. Really you should read the [full release notes here](https://github.com/paperclipai/paperclip/releases/tag/v2026.416.0) + +## COMMUNITY + +INCREDIBLE INCREDIBLE WORK BY folks with commits and reports in this release: + +``` +@AllenHyang, @antonio-mello-ai, @aronprins, @chrisschwer, @cleanunicorn, @DanielSousa, @davison, @ergonaworks, @HearthCore, @HenkDz, @KhairulA, @kimnamu, @Lempkey, @marysomething99-prog, @mvanhorn, @officialasishkumar, @plind-dm, @shoaib050326, @sparkeros, @wbelt, @offset, @sagilayani, @mattdonnelly10, @peaktwilight, @YuvalElbar6 +``` + +## WHATS NEXT (:motorway: Roadmap) + +* **Multi-human users** - in the last stages of testing, Paperclip is better with teams +* **Memory Infrastructure** - your agents will remember everything about yoru business +* **Sandbox execution** - run your agents anywhere + +## What's on my mind + +* I want to meet with companies who are using Paperclip in their business - if that's you let me know +* We need more Paperclip tutorials, defaults, and education - thanks to @aronprins for his work in this area already! +* We still need to get better at reviewing your PRs and we're improving our process every day +* "Zero-human company" language has to go - we're the human control plane for ai labor +* We're adding better support for *knowledge (wikis & files)*, *artifacts*, and *work product* in Paperclip soon. + +## PRESS + +* **AI Engineer Europe Tutorial** - I gave a tutorial for AIE. If someone is looking for a basics ABC of Paperclip [you can send them this](https://x.com/dotta/status/2044575580264316931) +* **AI Club Chicago** - JB gave a talk on Paperclip [at AI Tinkerers in Chicago](https://x.com/developwithJB/status/2044281068778316268) ! + +## IN SUMMARY + +PAPERCLIP WILL BE THE DEFAULT AGENT-MANAGEMENT TOOL FOR EVERY COMPANY + +If there's anything I can do to help you and your company use Paperclip, hit me up. Until then, enjoy the new release + +ITS TIME TO CLIP :paperclip: :paperclip: :paperclip: + +FULL RELEASE NOTES + +https://github.com/paperclipai/paperclip/releases/tag/v2026.416.0 + +||@everyone|| +``` + +### Example 3 — v2026.427.0 + +``` +:paperclip: :paperclip: :paperclip: CLIPPERS!!! v2026.427.0 IS OUT :paperclip: :paperclip: :paperclip: + +THIS IS THE OFFICIAL TWITTER FOLLOW IT: https://x.com/papercliping + +## Highlights + +:man_feeding_baby: **MULTI USER** - you can now invite multiple users to your instance +:factory_worker: **HARDER WORKING** - robosut liveness continuations and lifecycle recovery means your instance tries harder before involving you +:white_check_mark: **SUBISSUE CHECKLISTS** - subissues have better ordering which allows for long-run planning +:thread: **Rich Thread UX** - now your agents can ask you questions, ask for approvals, suggest tasks and you can approve or refine them right in your task threads +:cloud: **BETA: Sandbox Providers** - Cloud sandboxing is in beta - the API ships in this release and we'll be adding more providers +... and *tons* of other improvements and bugfixes. + +## Community + +Thank you to everyone who contributed to this release! + +``` +@akhater, @aronprins, @GodsBoy, @LeonSGP43, @neerazz, @NoronhaH, @rbarinov, @rvanduiven, @SgtPooki, @superbiche +``` + +## WHATS NEXT (:motorway: Roadmap) + +* **Longer-range planning and execution** - Paperclip will support longer and longer tasks and work until it's done +* **Secrets Service v2** - an important prereq for Paperclip cloud +* **Artifacts, memory, and knowledge** +* **Conference Room** aka CEO/Agent Chat + +## What's on my mind + +* **Documentation & Blog posts** - I've fallen behind on the docs but aron has done a good job here - we'll be setting up Clips to help maintain these +* **Paperclip Cloud** - will be a critical unlock for us, but even the shared team story needs developed more - *where should the work be done* and *where are the outputs stored* and *how do we surface them to users*? Each of these questions are a core Paperclip service that needs developed +* **Paperclip Bench** - In the vein of SWE-Bench I've started an internal benchmark for Paperclip - we have to be able to measure that our changes are improving the system and not regressing +* **Paperclip Connections Store** - connecting to Github, Slack, Google Docs, and the hundreds of other services we use every day should be easy, secure, and configurable per agent and team + +## Press + +I met with the [Wisemen about Paperclip](https://x.com/dotta/status/2045146539534827998) + +## WHAT I NEED FROM YOU + +FOLLOW THIS TWITTER ACCOUNT: https://x.com/papercliping - that's the only official one, report any others + +## In Summary + +PAPERCLIP IS THE AI ORCHESTRATOR FOR HUMANS TO ACCOMPLISH 100x MORE WORK + +Every single person will be managing a team of a dozen, or a hundred, or a thousand agents and Paperclip will be the default tool to manage it all. + +ITS TIME TO CLIP :paperclip: :paperclip: :paperclip: + +FULL RELEASE NOTES + +https://github.com/paperclipai/paperclip/blob/master/releases/v2026.427.0.md + +||@everyone|| +``` + +## Review checklist + +Before handing off: + +1. Version + date match the matching `releases/vYYYY.MDD.P.md` exactly. +2. Contributor list matches the changelog (same exclusions: bots, founders). +3. Highlights are a subset of the changelog Highlights — same shipped features, + not invented or pre-alpha work. +4. `WHATS NEXT` and `What's on my mind` are pulled from real recent issues / + active goals — not invented themes. +5. Section style (UPPERCASE vs Title Case) is internally consistent. +6. Closing tagline is `ITS TIME TO CLIP :paperclip: :paperclip: :paperclip:` + and `||@everyone||` is the very last line. +7. Document `discord_announcement` is updated on the release issue, and the + announcement is also posted in a comment inside a fenced code block. + +This skill never posts to Discord. It only prepares the announcement artifact. diff --git a/.agents/skills/release-changelog/SKILL.md b/.agents/skills/release-changelog/SKILL.md new file mode 100644 index 00000000000..04de31dc59f --- /dev/null +++ b/.agents/skills/release-changelog/SKILL.md @@ -0,0 +1,196 @@ +--- +name: release-changelog +description: > + Generate the stable Paperclip release changelog at releases/vYYYY.MDD.P.md by + reading commits, changesets, and merged PR context since the last stable tag. +--- + +# Release Changelog Skill + +Generate the user-facing changelog for the **stable** Paperclip release. + +## Versioning Model + +Paperclip uses **calendar versioning (calver)**: + +- Stable releases: `YYYY.MDD.P` (e.g. `2026.318.0`) +- Canary releases: `YYYY.MDD.P-canary.N` (e.g. `2026.318.1-canary.0`) +- Git tags: `vYYYY.MDD.P` for stable, `canary/vYYYY.MDD.P-canary.N` for canary + +There are no major/minor/patch bumps. The stable version is derived from the +intended release date (UTC) plus the next same-day stable patch slot. + +Output: + +- `releases/vYYYY.MDD.P.md` + +Important rules: + +- even if there are canary releases such as `2026.318.1-canary.0`, the changelog file stays `releases/v2026.318.1.md` +- do not derive versions from semver bump types +- do not create canary changelog files + +## Step 0 — Idempotency Check + +Before generating anything, check whether the file already exists: + +```bash +ls releases/vYYYY.MDD.P.md 2>/dev/null +``` + +If it exists: + +1. read it first +2. present it to the reviewer +3. ask whether to keep it, regenerate it, or update specific sections +4. never overwrite it silently + +## Step 1 — Determine the Stable Range + +Find the last stable tag: + +```bash +git tag --list 'v*' --sort=-version:refname | head -1 +git log v{last}..HEAD --oneline --no-merges +``` + +The stable version comes from one of: + +- an explicit maintainer request +- `./scripts/release.sh stable --date YYYY-MM-DD --print-version` +- the release plan already agreed in `doc/RELEASING.md` + +Do not derive the changelog version from a canary tag or prerelease suffix. +Do not derive major/minor/patch bumps from API intent — calver uses the date and same-day stable slot. + +## Step 2 — Gather the Raw Inputs + +Collect release data from: + +1. git commits since the last stable tag +2. `.changeset/*.md` files +3. merged PRs via `gh` when available + +Useful commands: + +```bash +git log v{last}..HEAD --oneline --no-merges +git log v{last}..HEAD --format="%H %s" --no-merges +ls .changeset/*.md | grep -v README.md +gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels +``` + +## Step 3 — Detect Breaking Changes + +Look for: + +- destructive migrations +- removed or changed API fields/endpoints +- renamed or removed config keys +- `BREAKING:` or `BREAKING CHANGE:` commit signals + +Key commands: + +```bash +git diff --name-only v{last}..HEAD -- packages/db/src/migrations/ +git diff v{last}..HEAD -- packages/db/src/schema/ +git diff v{last}..HEAD -- server/src/routes/ server/src/api/ +git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true +``` + +If breaking changes are detected, flag them prominently — they must appear in the +Breaking Changes section with an upgrade path. + +## Step 4 — Categorize for Users + +Use these stable changelog sections: + +- `Breaking Changes` +- `Highlights` +- `Improvements` +- `Fixes` +- `Upgrade Guide` when needed + +Exclude purely internal refactors, CI changes, and docs-only work unless they materially affect users. + +Guidelines: + +- group related commits into one user-facing entry +- write from the user perspective +- keep highlights short and concrete +- spell out upgrade actions for breaking changes + +### Inline PR and contributor attribution + +When a bullet item clearly maps to a merged pull request, add inline attribution at the +end of the entry in this format: + +``` +- **Feature name** — Description. ([#123](https://github.com/paperclipai/paperclip/pull/123), @contributor1, @contributor2) +``` + +Rules: + +- Only add a PR link when you can confidently trace the bullet to a specific merged PR. + Use merge commit messages (`Merge pull request #N from user/branch`) to map PRs. +- List the contributor(s) who authored the PR. Use GitHub usernames, not real names or emails. +- If multiple PRs contributed to a single bullet, list them all: `([#10](url), [#12](url), @user1, @user2)`. +- If you cannot determine the PR number or contributor with confidence, omit the attribution + parenthetical — do not guess. +- Core maintainer commits that don't have an external PR can omit the parenthetical. + +## Step 5 — Write the File + +Template: + +```markdown +# vYYYY.MDD.P + +> Released: YYYY-MM-DD + +## Breaking Changes + +## Highlights + +## Improvements + +## Fixes + +## Upgrade Guide + +## Contributors + +Thank you to everyone who contributed to this release! + +@username1, @username2, @username3 +``` + +Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist. + +The `Contributors` section should always be included. List every person who authored +commits in the release range, @-mentioning them by their **GitHub username** (not their +real name or email). To find GitHub usernames: + +1. Extract usernames from merge commit messages: `git log v{last}..HEAD --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username. +2. For noreply emails like `user@users.noreply.github.com`, the username is the part before `@`. +3. For contributors whose username is ambiguous, check `gh api users/{guess}` or the PR page. + +**Never expose contributor email addresses.** Use `@username` only. + +Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list. +Exclude Paperclip founders from the list (e.g. `cryppadotta`, `forgottendev`, `devinfoley`, `sockmonster`, `scotttong`) + +List contributors in alphabetical order by GitHub username (case-insensitive). + +If there are no contributors left after exclusions, then just skip this section and don't mention it. + +## Step 6 — Review Before Release + +Before handing it off: + +1. confirm the heading is the stable version only +2. confirm there is no `-canary` language in the title or filename +3. confirm any breaking changes have an upgrade path +4. present the draft for human sign-off + +This skill never publishes anything. It only prepares the stable changelog artifact. diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 00000000000..8f8e7ca25de --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,247 @@ +--- +name: release +description: > + Coordinate a full Paperclip release across engineering verification, npm, + GitHub, smoke testing, and announcement follow-up. Use when leadership asks + to ship a release, not merely to discuss versioning. +--- + +# Release Coordination Skill + +Run the full Paperclip maintainer release workflow, not just an npm publish. + +This skill coordinates: + +- stable changelog drafting via `release-changelog` +- canary verification and publish status from `master` +- Docker smoke testing via `scripts/docker-onboard-smoke.sh` +- manual stable promotion from a chosen source ref +- GitHub Release creation +- website / announcement follow-up tasks + +## Trigger + +Use this skill when leadership asks for: + +- "do a release" +- "ship the release" +- "promote this canary to stable" +- "cut the stable release" + +## Preconditions + +Before proceeding, verify all of the following: + +1. `.agents/skills/release-changelog/SKILL.md` exists and is usable. +2. The repo working tree is clean, including untracked files. +3. There is at least one canary or candidate commit since the last stable tag. +4. The candidate SHA has passed the verification gate or is about to. +5. If manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`. +6. npm publish rights are available through GitHub trusted publishing, or through local npm auth for emergency/manual use. +7. If running through Paperclip, you have issue context for status updates and follow-up task creation. + +If any precondition fails, stop and report the blocker. + +## Inputs + +Collect these inputs up front: + +- whether the target is a canary check or a stable promotion +- the candidate `source_ref` for stable +- whether the stable run is dry-run or live +- release issue / company context for website and announcement follow-up + +## Step 0 — Release Model + +Paperclip now uses a commit-driven release model: + +1. every push to `master` publishes a canary automatically +2. canaries use `YYYY.MDD.P-canary.N` +3. stable releases use `YYYY.MDD.P` +4. the middle slot is `MDD`, where `M` is the UTC month and `DD` is the zero-padded UTC day +5. the stable patch slot increments when more than one stable ships on the same UTC date +6. stable releases are manually promoted from a chosen tested commit or canary source commit +7. only stable releases get `releases/vYYYY.MDD.P.md`, git tag `vYYYY.MDD.P`, and a GitHub Release + +Critical consequences: + +- do not use release branches as the default path +- do not derive major/minor/patch bumps +- do not create canary changelog files +- do not create canary GitHub Releases + +## Step 1 — Choose the Candidate + +For canary validation: + +- inspect the latest successful canary run on `master` +- record the canary version and source SHA + +For stable promotion: + +1. choose the tested source ref +2. confirm it is the exact SHA you want to promote +3. resolve the target stable version with `./scripts/release.sh stable --date YYYY-MM-DD --print-version` + +Useful commands: + +```bash +git tag --list 'v*' --sort=-version:refname | head -1 +git log --oneline --no-merges +npm view paperclipai@canary version +``` + +## Step 2 — Draft the Stable Changelog + +Stable changelog files live at: + +- `releases/vYYYY.MDD.P.md` + +Invoke `release-changelog` and generate or update the stable notes only. + +Rules: + +- review the draft with a human before publish +- preserve manual edits if the file already exists +- keep the filename stable-only +- do not create a canary changelog file + +## Step 3 — Verify the Candidate SHA + +Run the standard gate: + +```bash +pnpm -r typecheck +pnpm test:run +pnpm build +``` + +If the GitHub release workflow will run the publish, it can rerun this gate. Still report local status if you checked it. + +For PRs that touch release logic, the repo also runs a canary release dry-run in CI. That is a release-specific guard, not a substitute for the standard gate. + +## Step 4 — Validate the Canary + +The normal canary path is automatic from `master` via: + +- `.github/workflows/release.yml` + +Confirm: + +1. verification passed +2. npm canary publish succeeded +3. git tag `canary/vYYYY.MDD.P-canary.N` exists + +Useful checks: + +```bash +npm view paperclipai@canary version +git tag --list 'canary/v*' --sort=-version:refname | head -5 +``` + +## Step 5 — Smoke Test the Canary + +Run: + +```bash +PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +``` + +Useful isolated variant: + +```bash +HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +``` + +Confirm: + +1. install succeeds +2. onboarding completes without crashes +3. the server boots +4. the UI loads +5. basic company creation and dashboard load work + +If smoke testing fails: + +- stop the stable release +- fix the issue on `master` +- wait for the next automatic canary +- rerun smoke testing + +## Step 6 — Preview or Publish Stable + +The normal stable path is manual `workflow_dispatch` on: + +- `.github/workflows/release.yml` + +Inputs: + +- `source_ref` +- `stable_date` +- `dry_run` + +Before live stable: + +1. resolve the target stable version with `./scripts/release.sh stable --date YYYY-MM-DD --print-version` +2. ensure `releases/vYYYY.MDD.P.md` exists on the source ref +3. run the stable workflow in dry-run mode first when practical +4. then run the real stable publish + +The stable workflow: + +- re-verifies the exact source ref +- computes the next stable patch slot for the chosen UTC date +- publishes `YYYY.MDD.P` under dist-tag `latest` +- creates git tag `vYYYY.MDD.P` +- creates or updates the GitHub Release from `releases/vYYYY.MDD.P.md` + +Local emergency/manual commands: + +```bash +./scripts/release.sh stable --dry-run +./scripts/release.sh stable +git push public-gh refs/tags/vYYYY.MDD.P +./scripts/create-github-release.sh YYYY.MDD.P +``` + +## Step 7 — Finish the Other Surfaces + +Create or verify follow-up work for: + +- website changelog publishing +- launch post / social announcement +- release summary in Paperclip issue context + +These should reference the stable release, not the canary. + +## Failure Handling + +If the canary is bad: + +- publish another canary, do not ship stable + +If stable npm publish succeeds but tag push or GitHub release creation fails: + +- fix the git/GitHub issue immediately from the same release result +- do not republish the same version + +If `latest` is bad after stable publish: + +```bash +./scripts/rollback-latest.sh +``` + +Then fix forward with a new stable release. + +## Output + +When the skill completes, provide: + +- candidate SHA and tested canary version, if relevant +- stable version, if promoted +- verification status +- npm status +- smoke-test status +- git tag / GitHub Release status +- website / announcement follow-up status +- rollback recommendation if anything is still partially complete diff --git a/.changeset/README.md b/.changeset/README.md deleted file mode 100644 index 654c6d4750d..00000000000 --- a/.changeset/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Changesets - -Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works -with multi-package repos, or single-package repos to help you version and publish your code. You can -find the full documentation for it [in our repository](https://github.com/changesets/changesets). - -We have a quick list of common questions to get you started engaging with this project in -[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md). diff --git a/.changeset/config.json b/.changeset/config.json deleted file mode 100644 index 5373961171e..00000000000 --- a/.changeset/config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [["@paperclipai/*", "paperclipai"]], - "linked": [], - "access": "public", - "baseBranch": "master", - "updateInternalDependencies": "patch", - "ignore": ["@paperclipai/ui"] -} diff --git a/.claude/skills/company-creator b/.claude/skills/company-creator new file mode 120000 index 00000000000..8e2823ffa0e --- /dev/null +++ b/.claude/skills/company-creator @@ -0,0 +1 @@ +../../.agents/skills/company-creator \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 00000000000..1ca81248b36 --- /dev/null +++ b/.env @@ -0,0 +1,12 @@ +# DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip +PORT=3100 +SERVE_UI=true +BETTER_AUTH_SECRET=paperclip-dev-secret + +# Paperclip API (for agent import) +PAPERCLIP_API_URL=http://localhost:3100 +PAPERCLIP_API_KEY=pc_test_b6be659b410cc4f1818f0a7e8eba1518 + +# Notifications +# TELEGRAM_BOT_TOKEN=xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# TELEGRAM_CHAT_ID=xxxxxxxxxx diff --git a/.env.example b/.env.example index b1cab5a5f21..84e20d96058 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip PORT=3100 SERVE_UI=false +BETTER_AUTH_SECRET=paperclip-dev-secret + +# Discord webhook for daily merge digest (scripts/discord-daily-digest.sh) +# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..0b8981e1516 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,17 @@ +# Replace @cryppadotta if a different maintainer or team should own release infrastructure. + +.github/** @cryppadotta @devinfoley +scripts/release*.sh @cryppadotta @devinfoley +scripts/release-*.mjs @cryppadotta @devinfoley +scripts/create-github-release.sh @cryppadotta @devinfoley +scripts/rollback-latest.sh @cryppadotta @devinfoley +doc/RELEASING.md @cryppadotta @devinfoley +doc/PUBLISHING.md @cryppadotta @devinfoley +doc/RELEASE-AUTOMATION-SETUP.md @cryppadotta @devinfoley + +# Package files — dependency changes require review +# package.json matches recursively at all depths (covers root + all workspaces) +package.json @cryppadotta @devinfoley +pnpm-lock.yaml @cryppadotta @devinfoley +pnpm-workspace.yaml @cryppadotta @devinfoley +.npmrc @cryppadotta @devinfoley diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..c12c05eb70f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,68 @@ +## Thinking Path + + + +> - Paperclip orchestrates AI agents for zero-human companies +> - [Which subsystem or capability is involved] +> - [What problem or gap exists] +> - [Why it needs to be addressed] +> - This pull request ... +> - The benefit is ... + +## What Changed + + + +- + +## Verification + + + +- + +## Risks + + + +- + +> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. + +## Model Used + + + +- + +## Checklist + +- [ ] I have included a thinking path that traces from project context to this change +- [ ] I have specified the model used (with version and capability details) +- [ ] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work +- [ ] I have run tests locally and they pass +- [ ] I have added or updated tests where applicable +- [ ] If this change affects the UI, I have included before/after screenshots +- [ ] I have updated relevant documentation to reflect my changes +- [ ] I have considered and documented any risks above +- [ ] I will address all Greptile and reviewer comments before requesting merge diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000000..08ff2462f0d --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,55 @@ +name: Docker + +on: + push: + branches: + - "master" + tags: + - "v*" + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 60 + concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000000..8d15462704a --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,44 @@ +name: E2E Tests + +on: + workflow_dispatch: + inputs: + skip_llm: + description: "Skip LLM-dependent assertions (default: true)" + type: boolean + default: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: npx playwright install --with-deps chromium + + - name: Run e2e tests + run: pnpm run test:e2e + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: | + tests/e2e/playwright-report/ + tests/e2e/test-results/ + retention-days: 14 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 00000000000..fa0817968d9 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,307 @@ +name: PR + +on: + pull_request: + branches: + - master + +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + policy: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Block manual lockfile edits + if: github.head_ref != 'chore/refresh-lockfile' + run: | + # Diff the PR branch against its merge base so recent base-branch commits + # do not masquerade as changes made by the PR itself. + changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")" + if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then + echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates." + exit 1 + fi + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Validate Dockerfile deps stage + run: node ./scripts/check-docker-deps-stage.mjs + + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + + - name: Verify release package bootstrap for changed manifests + run: | + mapfile -t changed_paths < <(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}") + PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA="${{ github.event.pull_request.base.sha }}" \ + node ./scripts/check-release-package-bootstrap.mjs "${changed_paths[@]}" + + - name: Validate dependency resolution when manifests change + run: | + changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")" + manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$' + if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then + pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile + fi + + typecheck_release_registry: + name: Typecheck + Release Registry + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck workspaces whose build scripts skip TypeScript + run: pnpm run typecheck:build-gaps + + - name: Verify release registry test coverage + run: pnpm run test:release-registry + + general_tests: + name: General tests (${{ matrix.group_label }}) + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - group: general-server + group_label: server + - group: general-workspaces-a + group_label: workspaces-a + - group: general-workspaces-b + group_label: workspaces-b + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run grouped general test suites + run: pnpm test:run:general -- --group '${{ matrix.group }}' + + verify: + # Preserve the legacy required-check name while the underlying work runs in parallel. + name: verify + if: ${{ always() }} + needs: [typecheck_release_registry, general_tests, build] + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Fail if any split verify lane failed + env: + TYPECHECK_RELEASE_REGISTRY_RESULT: ${{ needs.typecheck_release_registry.result }} + GENERAL_TESTS_RESULT: ${{ needs.general_tests.result }} + BUILD_RESULT: ${{ needs.build.result }} + run: | + test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success" + test "$GENERAL_TESTS_RESULT" = "success" + test "$BUILD_RESULT" = "success" + + build: + name: Build + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + verify_serialized_server: + name: Verify serialized server suites (${{ matrix.shard_label }}) + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - shard_index: 0 + shard_count: 4 + shard_label: 1/4 + - shard_index: 1 + shard_count: 4 + shard_label: 2/4 + - shard_index: 2 + shard_count: 4 + shard_label: 3/4 + - shard_index: 3 + shard_count: 4 + shard_label: 4/4 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run serialized server test shard + run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }} + + canary_dry_run: + name: Canary Dry Run + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # `release.sh` always executes its Step 2/7 workspace build, even when + # `--skip-verify` bypasses the initial verification gate. + - name: Release canary dry run via release.sh internal build + run: | + git checkout -B master HEAD + git checkout -- pnpm-lock.yaml + ./scripts/release.sh canary --skip-verify --dry-run + + e2e: + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright + run: npx playwright install --with-deps chromium + + - name: Generate Paperclip config + run: | + mkdir -p ~/.paperclip/instances/default + cat > ~/.paperclip/instances/default/config.json << 'CONF' + { + "$meta": { "version": 1, "updatedAt": "2026-01-01T00:00:00.000Z", "source": "onboard" }, + "database": { "mode": "embedded-postgres" }, + "logging": { "mode": "file" }, + "server": { "deploymentMode": "local_trusted", "host": "127.0.0.1", "port": 3100 }, + "auth": { "baseUrlMode": "auto" }, + "storage": { "provider": "local_disk" }, + "secrets": { "provider": "local_encrypted", "strictMode": false } + } + CONF + + - name: Run e2e tests + env: + PAPERCLIP_E2E_SKIP_LLM: "true" + run: pnpm run test:e2e + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: | + tests/e2e/playwright-report/ + tests/e2e/test-results/ + retention-days: 14 diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml new file mode 100644 index 00000000000..a52d6f002f8 --- /dev/null +++ b/.github/workflows/refresh-lockfile.yml @@ -0,0 +1,96 @@ +name: Refresh Lockfile + +on: + push: + branches: + - master + workflow_dispatch: + +concurrency: + group: refresh-lockfile-master + cancel-in-progress: false + +jobs: + refresh: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Refresh pnpm lockfile + run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile + + - name: Fail on unexpected file changes + run: | + changed="$(git status --porcelain)" + if [ -z "$changed" ]; then + echo "Lockfile is already up to date." + exit 0 + fi + if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then + echo "Unexpected files changed during lockfile refresh:" + echo "$changed" + exit 1 + fi + + - name: Create or update pull request + id: upsert-pr + env: + GH_TOKEN: ${{ github.token }} + REPO_OWNER: ${{ github.repository_owner }} + run: | + if git diff --quiet -- pnpm-lock.yaml; then + echo "Lockfile unchanged, nothing to do." + echo "pr_url=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + BRANCH="chore/refresh-lockfile" + git config user.name "lockfile-bot" + git config user.email "lockfile-bot@users.noreply.github.com" + + git checkout -B "$BRANCH" + git add pnpm-lock.yaml + git commit -m "chore(lockfile): refresh pnpm-lock.yaml" + git push --force origin "$BRANCH" + + # Only reuse an open PR from this repository owner, not a fork with the same branch name. + pr_url="$( + gh pr list --state open --head "$BRANCH" --json url,headRepositoryOwner \ + --jq ".[] | select(.headRepositoryOwner.login == \"$REPO_OWNER\") | .url" | + head -n 1 + )" + if [ -z "$pr_url" ]; then + pr_url="$(gh pr create \ + --head "$BRANCH" \ + --title "chore(lockfile): refresh pnpm-lock.yaml" \ + --body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml.")" + echo "Created new PR: $pr_url" + else + echo "PR already exists: $pr_url" + fi + echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT" + + - name: Enable auto-merge for lockfile PR + if: steps.upsert-pr.outputs.pr_url != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr merge --auto --squash --delete-branch "${{ steps.upsert-pr.outputs.pr_url }}" diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml new file mode 100644 index 00000000000..823a578cf1e --- /dev/null +++ b/.github/workflows/release-smoke.yml @@ -0,0 +1,118 @@ +name: Release Smoke + +on: + workflow_dispatch: + inputs: + paperclip_version: + description: Published Paperclip dist-tag to test + required: true + default: canary + type: choice + options: + - canary + - latest + host_port: + description: Host port for the Docker smoke container + required: false + default: "3232" + type: string + artifact_name: + description: Artifact name for uploaded diagnostics + required: false + default: release-smoke + type: string + workflow_call: + inputs: + paperclip_version: + required: true + type: string + host_port: + required: false + default: "3232" + type: string + artifact_name: + required: false + default: release-smoke + type: string + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Launch Docker smoke harness + run: | + metadata_file="$RUNNER_TEMP/release-smoke.env" + HOST_PORT="${{ inputs.host_port }}" \ + DATA_DIR="$RUNNER_TEMP/release-smoke-data" \ + PAPERCLIPAI_VERSION="${{ inputs.paperclip_version }}" \ + SMOKE_DETACH=true \ + SMOKE_METADATA_FILE="$metadata_file" \ + ./scripts/docker-onboard-smoke.sh + set -a + source "$metadata_file" + set +a + { + echo "SMOKE_BASE_URL=$SMOKE_BASE_URL" + echo "SMOKE_ADMIN_EMAIL=$SMOKE_ADMIN_EMAIL" + echo "SMOKE_ADMIN_PASSWORD=$SMOKE_ADMIN_PASSWORD" + echo "SMOKE_CONTAINER_NAME=$SMOKE_CONTAINER_NAME" + echo "SMOKE_DATA_DIR=$SMOKE_DATA_DIR" + echo "SMOKE_IMAGE_NAME=$SMOKE_IMAGE_NAME" + echo "SMOKE_PAPERCLIPAI_VERSION=$SMOKE_PAPERCLIPAI_VERSION" + echo "SMOKE_METADATA_FILE=$metadata_file" + } >> "$GITHUB_ENV" + + - name: Run release smoke Playwright suite + env: + PAPERCLIP_RELEASE_SMOKE_BASE_URL: ${{ env.SMOKE_BASE_URL }} + PAPERCLIP_RELEASE_SMOKE_EMAIL: ${{ env.SMOKE_ADMIN_EMAIL }} + PAPERCLIP_RELEASE_SMOKE_PASSWORD: ${{ env.SMOKE_ADMIN_PASSWORD }} + run: pnpm run test:release-smoke + + - name: Capture Docker logs + if: always() + run: | + if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then + docker logs "$SMOKE_CONTAINER_NAME" >"$RUNNER_TEMP/docker-onboard-smoke.log" 2>&1 || true + fi + + - name: Upload diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact_name }} + path: | + ${{ runner.temp }}/docker-onboard-smoke.log + ${{ env.SMOKE_METADATA_FILE }} + tests/release-smoke/playwright-report/ + tests/release-smoke/test-results/ + retention-days: 14 + + - name: Stop Docker smoke container + if: always() + run: | + if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then + docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000000..c16bad2323d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,273 @@ +name: Release + +on: + push: + branches: + - master + workflow_dispatch: + inputs: + source_ref: + description: Commit SHA, branch, or tag to publish as stable + required: true + type: string + default: master + stable_date: + description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable. + required: false + type: string + dry_run: + description: Preview the stable release without publishing + required: true + type: boolean + default: false + +concurrency: + group: release-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify_canary: + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Typecheck + run: pnpm -r typecheck + + - name: Run tests + run: pnpm test:run + + - name: Build + run: pnpm build + + publish_canary: + if: github.event_name == 'push' + needs: verify_canary + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: npm-canary + permissions: + contents: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Restore tracked install-time changes + run: git checkout -- pnpm-lock.yaml + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Publish canary + env: + GITHUB_ACTIONS: "true" + run: ./scripts/release.sh canary --skip-verify + + - name: Push canary tag + run: | + tag="$(git tag --points-at HEAD | grep '^canary/v' | head -1)" + if [ -z "$tag" ]; then + echo "Error: no canary tag points at HEAD after release." >&2 + exit 1 + fi + git push origin "refs/tags/${tag}" + + verify_stable: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.source_ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Typecheck + run: pnpm -r typecheck + + - name: Run tests + run: pnpm test:run + + - name: Build + run: pnpm build + + preview_stable: + if: github.event_name == 'workflow_dispatch' && inputs.dry_run + needs: verify_stable + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.source_ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Dry-run stable release + env: + GITHUB_ACTIONS: "true" + run: | + args=(stable --skip-verify --dry-run) + if [ -n "${{ inputs.stable_date }}" ]; then + args+=(--date "${{ inputs.stable_date }}") + fi + ./scripts/release.sh "${args[@]}" + + publish_stable: + if: github.event_name == 'workflow_dispatch' && !inputs.dry_run + needs: verify_stable + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: npm-stable + permissions: + contents: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.source_ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Restore tracked install-time changes + run: git checkout -- pnpm-lock.yaml + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Publish stable + env: + GITHUB_ACTIONS: "true" + run: | + args=(stable --skip-verify) + if [ -n "${{ inputs.stable_date }}" ]; then + args+=(--date "${{ inputs.stable_date }}") + fi + ./scripts/release.sh "${args[@]}" + + - name: Push stable tag + run: | + tag="$(git tag --points-at HEAD | grep '^v' | head -1)" + if [ -z "$tag" ]; then + echo "Error: no stable tag points at HEAD after release." >&2 + exit 1 + fi + git push origin "refs/tags/${tag}" + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + PUBLISH_REMOTE: origin + run: | + version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')" + if [ -z "$version" ]; then + echo "Error: no v* tag points at HEAD after stable release." >&2 + exit 1 + fi + ./scripts/create-github-release.sh "$version" diff --git a/.gitignore b/.gitignore index 9d9f5e35522..2d8d5454c87 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ +node_modules node_modules/ +**/node_modules +**/node_modules/ dist/ +ui/storybook-static/ .env *.tsbuildinfo drizzle/meta/ @@ -31,9 +35,26 @@ server/src/**/*.js.map server/src/**/*.d.ts server/src/**/*.d.ts.map tmp/ +feedback-export-* +diagnostics/ # Editor / tool temp files *.tmp .vscode/ .claude/settings.local.json -.paperclip-local/ \ No newline at end of file +.paperclip-local/ +/.idea/ +/.agents/ + +# Doc maintenance cursor +.doc-review-cursor + +# Playwright +tests/e2e/test-results/ +tests/e2e/playwright-report/ +tests/release-smoke/test-results/ +tests/release-smoke/playwright-report/ +.superset/ +.superpowers/ +.claude/worktrees/ +.herenow diff --git a/.mailmap b/.mailmap index 4a1dd6697a2..31780e745b2 100644 --- a/.mailmap +++ b/.mailmap @@ -1 +1,3 @@ -Dotta Forgotten +Dotta <34892728+cryppadotta@users.noreply.github.com> +Dotta +Dotta diff --git a/AGENTS.md b/AGENTS.md index e4b5b514546..3555bfcdaf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,9 @@ Before making changes, read in this order: - `ui/`: React + Vite board UI - `packages/db/`: Drizzle schema, migrations, DB clients - `packages/shared/`: shared types, constants, validators, API path constants +- `packages/adapters/`: agent adapter implementations (Claude, Codex, Cursor, etc.) +- `packages/adapter-utils/`: shared adapter utilities +- `packages/plugins/`: plugin system packages - `doc/`: operational and product docs ## 4. Dev Setup (Auto DB) @@ -78,6 +81,9 @@ If you change schema/API behavior, update all impacted layers: 4. Do not replace strategic docs wholesale unless asked. Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned. +5. Keep repo plan docs dated and centralized. +When you are creating a plan file in the repository itself, new plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. This does not replace Paperclip issue planning: if a Paperclip issue asks for a plan, update the issue `plan` document per the `paperclip` skill instead of creating a repo markdown file. + ## 6. Database Change Workflow When changing data model: @@ -102,7 +108,24 @@ Notes: ## 7. Verification Before Hand-off -Run this full check before claiming done: +Default local/agent test path: + +```sh +pnpm test +``` + +This is the cheap default and only runs the Vitest suite. Browser suites stay opt-in: + +```sh +pnpm test:e2e +pnpm test:release-smoke +``` + +Run the browser suites only when your change touches them or when you are explicitly verifying CI/release flows. + +For normal issue work, run the smallest relevant verification first. Do not default to repo-wide typecheck/build/test on every heartbeat when a narrower check is enough to prove the change. + +Run this full check before claiming repo work done in a PR-ready hand-off, or when the change scope is broad enough that targeted checks are not sufficient: ```sh pnpm -r typecheck @@ -132,7 +155,18 @@ When adding endpoints: - Use company selection context for company-scoped pages - Surface failures clearly; do not silently ignore API errors -## 10. Definition of Done +## 10. Pull Request Requirements + +When creating a pull request (via `gh pr create` or any other method), you **must** read and fill in every section of [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). Do not craft ad-hoc PR bodies — use the template as the structure for your PR description. Required sections: + +- **Thinking Path** — trace reasoning from project context to this change (see `CONTRIBUTING.md` for examples) +- **What Changed** — bullet list of concrete changes +- **Verification** — how a reviewer can confirm it works +- **Risks** — what could go wrong +- **Model Used** — the AI model that produced or assisted with the change (provider, exact model ID, context window, capabilities). Write "None — human-authored" if no AI was used. +- **Checklist** — all items checked + +## 11. Definition of Done A change is done when all are true: @@ -140,3 +174,45 @@ A change is done when all are true: 2. Typecheck, tests, and build pass 3. Contracts are synced across db/shared/server/ui 4. Docs updated when behavior or commands change +5. PR description follows the [PR template](.github/PULL_REQUEST_TEMPLATE.md) with all sections filled in (including Model Used) + +## 11. Fork-Specific: HenkDz/paperclip + +This is a fork of `paperclipai/paperclip` with QoL patches and an **external-only** Hermes adapter story on branch `feat/externalize-hermes-adapter` ([tree](https://github.com/HenkDz/paperclip/tree/feat/externalize-hermes-adapter)). + +### Branch Strategy + +- `feat/externalize-hermes-adapter` → core has **no** `hermes-paperclip-adapter` dependency and **no** built-in `hermes_local` registration. Install Hermes via the Adapter Plugin manager (`@henkey/hermes-paperclip-adapter` or a `file:` path). +- Older fork branches may still document built-in Hermes; treat this file as authoritative for the externalize branch. + +### Hermes (plugin only) + +- Register through **Board → Adapter manager** (same as Droid). Type remains `hermes_local` once the package is loaded. +- UI uses generic **config-schema** + **ui-parser.js** from the package — no Hermes imports in `server/` or `ui/` source. +- Optional: `file:` entry in `~/.paperclip/adapter-plugins.json` for local dev of the adapter repo. + +### Local Dev + +- Fork runs on port 3101+ (auto-detects if 3100 is taken by upstream instance) +- `npx vite build` hangs on NTFS — use `node node_modules/vite/bin/vite.js build` instead +- Server startup from NTFS takes 30-60s — don't assume failure immediately +- Kill ALL paperclip processes before starting: `pkill -f "paperclip"; pkill -f "tsx.*index.ts"` +- Vite cache survives `rm -rf dist` — delete both: `rm -rf ui/dist ui/node_modules/.vite` + +### Fork QoL Patches (not in upstream) + +These are local modifications in the fork's UI. If re-copying source, these must be re-applied: + +1. **stderr_group** — amber accordion for MCP init noise in `RunTranscriptView.tsx` +2. **tool_group** — accordion for consecutive non-terminal tools (write, read, search, browser) +3. **Dashboard excerpt** — `LatestRunCard` strips markdown, shows first 3 lines/280 chars + +### Plugin System + +PR #2218 (`feat/external-adapter-phase1`) adds external adapter support. See root `AGENTS.md` for full details. + +- Adapters can be loaded as external plugins via `~/.paperclip/adapter-plugins.json` +- The plugin-loader should have ZERO hardcoded adapter imports — pure dynamic loading +- `createServerAdapter()` must include ALL optional fields (especially `detectModel`) +- Built-in UI adapters can shadow external plugin parsers — remove built-in when fully externalizing +- Reference external adapters: Hermes (`@henkey/hermes-paperclip-adapter` or `file:`) and Droid (npm) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000000..a69aa86d144 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,109 @@ +# Contributing Guide + +Thanks for wanting to contribute! + +We really appreciate both small fixes and thoughtful larger changes. + +## Two Paths to Get Your Pull Request Accepted + +### Path 1: Small, Focused Changes (Fastest way to get merged) + +- Pick **one** clear thing to fix/improve +- Touch the **smallest possible number of files** +- Make sure the change is very targeted and easy to review +- All tests pass and CI is green +- Greptile score is 5/5 with all comments addressed +- Use the [PR template](.github/PULL_REQUEST_TEMPLATE.md) + +These almost always get merged quickly when they're clean. + +### Path 2: Bigger or Impactful Changes + +- **First** talk about it in Discord → #dev channel + → Describe what you're trying to solve + → Share rough ideas / approach +- Once there's rough agreement, build it +- In your PR include: + - Before / After screenshots (or short video if UI/behavior change) + - Clear description of what & why + - Proof it works (manual testing notes) + - All tests passing and CI green + - Greptile score 5/5 with all comments addressed + - [PR template](.github/PULL_REQUEST_TEMPLATE.md) fully filled out + +PRs that follow this path are **much** more likely to be accepted, even when they're large. + +## PR Requirements (all PRs) + +### Use the PR Template + +Every pull request **must** follow the PR template at [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). If you create a PR via the GitHub API or other tooling that bypasses the template, copy its contents into your PR description manually. The template includes required sections: Thinking Path, What Changed, Verification, Risks, Model Used, and a Checklist. + +### Model Used (Required) + +Every PR must include a **Model Used** section specifying which AI model produced or assisted with the change. Include the provider, exact model ID/version, context window size, and any relevant capability details (e.g., reasoning mode, tool use). If no AI was used, write "None — human-authored". This applies to all contributors — human and AI alike. + +### Tests Must Pass + +All tests must pass before a PR can be merged. Run them locally first and verify CI is green after pushing. + +### Greptile Review + +We use [Greptile](https://greptile.com) for automated code review. Your PR must achieve a **5/5 Greptile score** with **all Greptile comments addressed** before it can be merged. If Greptile leaves comments, fix or respond to each one and request a re-review. + +## Feature Contributions + +We actively manage the core Paperclip feature roadmap. + +Uncoordinated feature PRs against the core product may be closed, even when the implementation is thoughtful and high quality. That is about roadmap ownership, product coherence, and long-term maintenance commitment, not a judgment about the effort. + +If you want to contribute a feature: + +- Check [ROADMAP.md](ROADMAP.md) first +- Start the discussion in Discord -> `#dev` before writing code +- If the idea fits as an extension, prefer building it with the [plugin system](doc/plugins/PLUGIN_SPEC.md) +- If you want to show a possible direction, reference implementations are welcome as feedback, but they generally will not be merged directly into core + +Bugs, docs improvements, and small targeted improvements are still the easiest path to getting merged, and we really do appreciate them. + +## General Rules (both paths) + +- Write clear commit messages +- Keep PR title + description meaningful +- One PR = one logical change (unless it's a small related group) +- Run tests locally first +- Be kind in discussions 😄 + +## Writing a Good PR message + +Your PR description must follow the [PR template](.github/PULL_REQUEST_TEMPLATE.md). All sections are required. The "thinking path" at the top explains from the top of the project down to what you fixed. E.g.: + +### Thinking Path Example 1: + +> - Paperclip orchestrates ai-agents for zero-human companies +> - There are many types of adapters for each LLM model provider +> - But LLM's have a context limit and not all agents can automatically compact their context +> - So we need to have an adapter-specific configuration for which adapters can and cannot automatically compact their context +> - This pull request adds per-adapter configuration of compaction, either auto or paperclip managed +> - That way we can get optimal performance from any adapter/provider in Paperclip + +### Thinking Path Example 2: + +> - Paperclip orchestrates ai-agents for zero-human companies +> - But humans want to watch the agents and oversee their work +> - Human users also operate in teams and so they need their own logins, profiles, views etc. +> - So we have a multi-user system for humans +> - But humans want to be able to update their own profile picture and avatar +> - But the avatar upload form wasn't saving the avatar to the file storage system +> - So this PR fixes the avatar upload form to use the file storage service +> - The benefit is we don't have a one-off file storage for just one aspect of the system, which would cause confusion and extra configuration + +Then have the rest of your normal PR message after the Thinking Path. + +This should include details about what you did, why you did it, why it matters & the benefits, how we can verify it works, and any risks. + +Please include screenshots if possible if you have a visible change. (use something like the [agent-browser skill](https://github.com/vercel-labs/agent-browser/blob/main/skills/agent-browser/SKILL.md) or similar to take screenshots). Ideally, you include before and after screenshots. + +Questions? Just ask in #dev — we're happy to help. + +Happy hacking! diff --git a/Dockerfile b/Dockerfile index 2339d2ff880..03f26942b40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,16 @@ -FROM node:20-bookworm-slim AS base +# syntax=docker/dockerfile:1.20 +FROM node:lts-trixie-slim AS base +ARG USER_UID=1000 +ARG USER_GID=1000 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl git \ - && rm -rf /var/lib/apt/lists/* -RUN corepack enable + && apt-get install -y --no-install-recommends ca-certificates gosu curl gh git wget ripgrep python3 \ + && rm -rf /var/lib/apt/lists/* \ + && corepack enable + +# Modify the existing node user/group to have the specified UID/GID to match host user +RUN usermod -u $USER_UID --non-unique node \ + && groupmod -g $USER_GID --non-unique node \ + && usermod -g $USER_GID -d /paperclip node FROM base AS deps WORKDIR /app @@ -13,21 +21,50 @@ COPY ui/package.json ui/ COPY packages/shared/package.json packages/shared/ COPY packages/db/package.json packages/db/ COPY packages/adapter-utils/package.json packages/adapter-utils/ +COPY packages/mcp-server/package.json packages/mcp-server/ +COPY packages/adapters/acpx-local/package.json packages/adapters/acpx-local/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ +COPY packages/adapters/cursor-cloud/package.json packages/adapters/cursor-cloud/ +COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ +COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/ +COPY packages/adapters/grok-local/package.json packages/adapters/grok-local/ +COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ +COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ +COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ +COPY packages/plugins/sdk/package.json packages/plugins/sdk/ +COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/ +COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/ +COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/ +COPY packages/plugins/plugin-workspace-diff/package.json packages/plugins/plugin-workspace-diff/ +COPY patches/ patches/ +COPY scripts/link-plugin-dev-sdk.mjs scripts/ + RUN pnpm install --frozen-lockfile FROM base AS build WORKDIR /app COPY --from=deps /app /app COPY . . -RUN pnpm --filter @paperclip/ui build -RUN pnpm --filter @paperclip/server build +RUN pnpm --filter @paperclipai/ui build +RUN pnpm --filter @paperclipai/plugin-sdk build +RUN pnpm --filter @paperclipai/server build +RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1) FROM base AS production +ARG USER_UID=1000 +ARG USER_GID=1000 WORKDIR /app -COPY --from=build /app /app -RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest +COPY --chown=node:node --from=build /app /app +RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \ + && apt-get update \ + && apt-get install -y --no-install-recommends openssh-client jq \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /paperclip \ + && chown node:node /paperclip + +COPY scripts/docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh ENV NODE_ENV=production \ HOME=/paperclip \ @@ -36,11 +73,15 @@ ENV NODE_ENV=production \ SERVE_UI=true \ PAPERCLIP_HOME=/paperclip \ PAPERCLIP_INSTANCE_ID=default \ + USER_UID=${USER_UID} \ + USER_GID=${USER_GID} \ PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \ - PAPERCLIP_DEPLOYMENT_MODE=local_trusted \ - PAPERCLIP_DEPLOYMENT_EXPOSURE=private + PAPERCLIP_DEPLOYMENT_MODE=authenticated \ + PAPERCLIP_DEPLOYMENT_EXPOSURE=private \ + OPENCODE_ALLOW_ALL_MODELS=true VOLUME ["/paperclip"] EXPOSE 3100 +ENTRYPOINT ["docker-entrypoint.sh"] CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000000..a63594a5150 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Paperclip AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e38cb1bacf5..fe49f3d5cdf 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ Quickstart · Docs · GitHub · - Discord + Discord · + Twitter

@@ -156,6 +157,115 @@ Paperclip handles the hard orchestration details correctly.
+## 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 + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +**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. + + + +**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. + +
+ +**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. + + + +**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. + +
+ +**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. + + + +**Governance & Approvals** — Board approval workflows, execution policies with review/approval stages, decision tracking, budget hard-stops, agent pause/resume/terminate, and full audit logging. You're the board — nothing ships without your sign-off. + +
+ +**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. + + + +**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. + +
+ +**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. + + + +**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. + +
+ +**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. + + + +**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. + +
+ +
+ ## What Paperclip is not | | | @@ -177,6 +287,16 @@ Open source. Self-hosted. No Paperclip account required. 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 @@ -218,42 +338,79 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as ## Development ```bash -pnpm dev # Full dev (API + UI) +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:run # Run tests +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](doc/DEVELOPING.md) for the full development guide.
## Roadmap -- ⚪ Get OpenClaw onboarding easier -- ⚪ Get cloud agents working e.g. Cursor / e2b agents -- ⚪ ClipMart - buy and sell entire agent companies -- ⚪ Easy agent configurations / easier to understand -- ⚪ Better support for harness engineering -- ⚪ Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc) -- ⚪ Better docs +- ✅ 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 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](ROADMAP.md).
+## 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](CONTRIBUTING.md) for details. - -
## 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 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000000..d4036c3db94 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,97 @@ +# Roadmap + +This document expands the roadmap preview in `README.md`. + +Paperclip is still moving quickly. The list below is directional, not promised, and priorities may shift as we learn from users and from operating real AI companies with the product. + +We value community involvement and want to make sure contributor energy goes toward areas where it can land. + +We may accept contributions in the areas below, but if you want to work on roadmap-level core features, please coordinate with us first in Discord (`#dev`) before writing code. Bugs, docs, polish, and tightly scoped improvements are still the easiest contributions to merge. + +If you want to extend Paperclip today, the best path is often the [plugin system](doc/plugins/PLUGIN_SPEC.md). Community reference implementations are also useful feedback even when they are not merged directly into core. + +## Milestones + +### ✅ Plugin system + +Paperclip should keep a thin core and rich edges. Plugins are the path for optional capabilities like knowledge bases, custom tracing, queues, doc editors, and other product-specific surfaces that do not need to live in the control plane itself. + +### ✅ Get OpenClaw / claw-style agent employees + +Paperclip should be able to hire and manage real claw-style agent workers, not just a narrow built-in runtime. This is part of the larger "bring your own agent" story and keeps the control plane useful across different agent ecosystems. + +### ✅ companies.sh - import and export entire organizations + +Reusable companies matter. Import/export is the foundation for moving org structures, agent definitions, and reusable company setups between environments and eventually for broader company-template distribution. + +### ✅ Easy AGENTS.md configurations + +Agent setup should feel repo-native and legible. Simple `AGENTS.md`-style configuration lowers the barrier to getting an agent team running and makes it easier for contributors to understand how a company is wired together. + +### ✅ Skills Manager + +Agents need a practical way to discover, install, and use skills without every setup becoming bespoke. The skills layer is part of making Paperclip companies more reusable and easier to operate. + +### ✅ Scheduled Routines + +Recurring work should be native. Routine tasks like reports, reviews, and other periodic work need first-class scheduling so the company keeps operating even when no human is manually kicking work off. + +### ✅ Better Budgeting + +Budgets are a core control-plane feature, not an afterthought. Better budgeting means clearer spend visibility, safer hard stops, and better operator control over how autonomy turns into real cost. + +### ✅ Agent Reviews and Approvals + +Paperclip should support explicit review and approval stages as first-class workflow steps, not just ad hoc comments. That means reviewer routing, approval gates, change requests, and durable audit trails that fit the same task model as the rest of the control plane. + +### ✅ Multiple Human Users + +Paperclip needs a clearer path from solo operator to real human teams. That means shared board access, safer collaboration, and a better model for several humans supervising the same autonomous company. + +### ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents) + +We want agents to run in more remote and sandboxed environments while preserving the same Paperclip control-plane model. This makes the system safer, more flexible, and more useful outside a single trusted local machine. + +### ⚪ Artifacts & Work Products + +Paperclip should make outputs first-class. That means generated artifacts, previews, deployable outputs, and the handoff from "agent did work" to "here is the result" should become more visible and easier to operate. + +### ⚪ Memory / Knowledge + +We want a stronger memory and knowledge surface for companies, agents, and projects. That includes durable memory, better recall of prior decisions and context, and a clearer path for knowledge-style capabilities without turning Paperclip into a generic chat app. + +### ⚪ Enforced Outcomes + +Paperclip should get stricter about what counts as finished work. Tasks, approvals, and execution flows should resolve to clear outcomes like merged code, published artifacts, shipped docs, or explicit decisions instead of stopping at vague status updates. + +### ⚪ MAXIMIZER MODE + +This is the direction for higher-autonomy execution: more aggressive delegation, deeper follow-through, and stronger operating loops with clear budgets, visibility, and governance. The point is not hidden autonomy; the point is more output per human supervisor. + +### ⚪ Deep Planning + +Some work needs more than a task description before execution starts. Deeper planning means stronger issue documents, revisionable plans, and clearer review loops for strategy-heavy work before agents begin execution. + +### ⚪ Work Queues + +Paperclip should support queue-style work streams for repeatable inputs like support, triage, review, and backlog intake. That would make it easier to route work continuously without turning every system into a one-off workflow. + +### ⚪ Self-Organization + +As companies grow, agents should be able to propose useful structural changes such as role adjustments, delegation changes, and new recurring routines. The goal is adaptive organizations that still stay within governance and approval boundaries. + +### ⚪ Automatic Organizational Learning + +Paperclip should get better at turning completed work into reusable organizational knowledge. That includes capturing playbooks, recurring fixes, and decision patterns so future work starts from what the company has already learned. + +### ⚪ CEO Chat + +We want a lighter-weight way to talk to leadership agents, but those conversations should still resolve to real work objects like plans, issues, approvals, or decisions. This should improve interaction without changing the core task-and-comments model. + +### ⚪ Cloud deployments + +Local-first remains important, but Paperclip also needs a cleaner shared deployment story. Teams should be able to run the same product in hosted or semi-hosted environments without changing the mental model. + +### ⚪ Desktop App + +A desktop app can make Paperclip feel more accessible and persistent for day-to-day operators. The goal is easier access, better local ergonomics, and a smoother default experience for users who want the control plane always close at hand. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..ccc4b6a03d2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,8 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities through GitHub's Security Advisory feature: +[https://github.com/paperclipai/paperclip/security/advisories/new](https://github.com/paperclipai/paperclip/security/advisories/new) + +Do not open public issues for security vulnerabilities. diff --git a/adapter-plugin.md b/adapter-plugin.md new file mode 100644 index 00000000000..13994ba3b32 --- /dev/null +++ b/adapter-plugin.md @@ -0,0 +1,143 @@ +- Created branch: feat/external-adapter-phase1 + + I started phase 1 in the most merge-friendly way I could: small central changes, reusing existing registry patterns instead of inventing a whole new plugin system up front. + + What I changed + + 1. Server adapter registry is now mutable + Files: + - server/src/adapters/registry.ts + - server/src/adapters/index.ts + + Added: + - registerServerAdapter(adapter) + - unregisterServerAdapter(type) + - requireServerAdapter(type) + + Kept the existing built-in registry shape, but changed initialization so built-ins are registered into a mutable map on startup. + + Why this is merge-friendly: + - existing built-in adapter definitions stay where they already are + - existing lookup helpers still exist + - no big architectural rewrite yet + + 1. Runtime adapter validation moved to server routes + File: + - server/src/routes/agents.ts + + Added: + - assertKnownAdapterType(...) + + Used it in: + - /companies/:companyId/adapters/:type/models + - /companies/:companyId/adapters/:type/detect-model + - /companies/:companyId/adapters/:type/test-environment + - POST /companies/:companyId/agents + - POST /companies/:companyId/agent-hires + - PATCH /agents/:id when adapterType is touched + + Why: + - shared schemas can now allow external adapter strings + - server becomes the real source of truth for “is this adapter actually registered?” + + 1. Shared adapterType validation is now open-ended for inputs + Files: + - packages/shared/src/adapter-type.ts + - packages/shared/src/validators/agent.ts + - packages/shared/src/validators/access.ts + - packages/shared/src/index.ts + + Changed input validation from hardcoded z.enum(AGENT_ADAPTER_TYPES) to: + - agentAdapterTypeSchema + - optionalAgentAdapterTypeSchema + + These accept any non-empty string. + + Important: + - I did not remove AGENT_ADAPTER_TYPES from constants + - I did not change the built-in type unions yet + - this keeps the change minimal and limits blast radius + + So: + - input payloads can carry external adapter types + - actual acceptance still depends on server registry validation + + 1. UI adapter registry is now mutable too + Files: + - ui/src/adapters/registry.ts + - ui/src/adapters/index.ts + + Added: + - registerUIAdapter(adapter) + - unregisterUIAdapter(type) + - findUIAdapter(type) + + Built-ins are still registered from the same file, same pattern as server. + + 1. Began switching UI adapter lists toward the registry + Files: + - ui/src/adapters/metadata.ts + - ui/src/components/AgentConfigForm.tsx + - ui/src/pages/NewAgent.tsx + + Changed: + - AgentConfigForm adapter list now comes from registered UI adapters via metadata helper + - NewAgent supported adapter set now derives from listUIAdapters() + + Why: + - phase 1 should start reducing hardcoded UI lists + - but without rewriting every page at once + + 1. Tests added first, then implementation + New tests: + - server/src/__tests__/adapter-registry.test.ts + - server/src/__tests__/agent-adapter-validation-routes.test.ts + - ui/src/adapters/registry.test.ts + - packages/shared/src/adapter-types.test.ts + + Small test mock fix: + - server/src/__tests__/agent-skills-routes.test.ts + + Current status + + The branch is created and the phase-1 code is started. + + I also followed TDD in spirit: + - wrote failing tests first for missing register/unregister APIs + - then implemented the registry changes + + What is not finished yet + + I have not finished the green/stabilization pass yet. + + I still need to: + - run the targeted tests again after the implementation changes + - fix any TS / mocking fallout + - decide how far to take phase-1 UI wiring in this branch before stopping + + Also, I intentionally did not yet touch the bigger hardcoded surfaces like: + - NewAgentDialog + - OnboardingWizard + - InviteLanding + - duplicated adapterLabels maps + - plugin manifest / plugin loader adapter contributions + + That is deliberate. + For merge safety, I started with the smallest useful seam: + - dynamic server registry + - dynamic UI registry + - runtime validation based on registry + - first couple of UI consumers + + One note + There was already an unrelated dirty change in: + - server/scripts/dev-watch.ts + + I left that alone and did not make phase-1 changes there. + + Recommended next move + I should keep going on this same branch and do the stabilization pass: + 1. rerun server/ui/shared targeted tests + 2. fix failures + 3. run typechecks + 4. then extend the same registry-driven approach to the next safest UI surfaces diff --git a/bin/import-agents b/bin/import-agents new file mode 100755 index 00000000000..cf3b5f5e760 --- /dev/null +++ b/bin/import-agents @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Auto-detect tsx path +TSX="" +for path in \ + "./server/node_modules/.bin/tsx" \ + "./node_modules/.bin/tsx" \ + "$(which tsx 2>/dev/null || true)" +do + if [ -x "$path" ]; then + TSX="$path" + break + fi +done + +if [ -z "$TSX" ]; then + echo "❌ tsx not found. Install with: npm install -g tsx" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_URL="${1:-}" +shift || true + +if [ -z "$REPO_URL" ]; then + echo "Usage: import-agents [options]" + echo "" + echo "Options:" + echo " --company-id Company ID for agent creation" + echo " --github-token GitHub API token (optional)" + echo " --reports-to Agent ID to report to" + echo " --dry-run Preview without creating agents" + echo "" + echo "Example:" + echo " import-agents https://github.com/msitarzewski/agency-agents --dry-run" + exit 1 +fi + +echo "🚀 Paperclip Agent Importer" +echo "============================" +echo "" + +# Parse owner/repo from URL +if [[ "$REPO_URL" =~ github\.com/([^/]+)/([^/]+) ]]; then + OWNER="${BASH_REMATCH[1]}" + REPO="${BASH_REMATCH[2]}" + echo "📦 Repository: $OWNER/$REPO" +else + echo "❌ Invalid GitHub URL: $REPO_URL" >&2 + exit 1 +fi + +# Step 1: Read GitHub repo +echo "" +echo "📖 Step 1: Fetching agent definitions..." +"$TSX" "$SCRIPT_DIR/scripts/github-repo-reader.ts" "$OWNER/$REPO" "$@" > /tmp/import_step1.txt 2>&1 + +if ! grep -q "Found" /tmp/import_step1.txt; then + echo "❌ Failed to read repository" >&2 + cat /tmp/import_step1.txt >&2 + exit 1 +fi + +AGENT_COUNT=$(grep -oP "Found \K\d+" /tmp/import_step1.txt || echo "0") +echo "✅ Found $AGENT_COUNT agent(s)" + +if [ "$AGENT_COUNT" -eq 0 ]; then + echo "⚠️ No agents found. Exiting." + exit 0 +fi + +# Extract JSON +sed -n '/---JSON---/,$p' /tmp/import_step1.txt | tail -n +2 > /tmp/import_agents.json + +# Step 2: Convert +echo "" +echo "🔧 Step 2: Converting to Paperclip format..." +"$TSX" "$SCRIPT_DIR/scripts/agent-format-converter.ts" /tmp/import_agents.json > /tmp/import_step2.txt 2>&1 + +if ! grep -q "Success:" /tmp/import_step2.txt; then + echo "❌ Conversion failed" >&2 + cat /tmp/import_step2.txt >&2 + exit 1 +fi + +SUCCESS_COUNT=$(grep -oP "Success: \K\d+" /tmp/import_step2.txt || echo "0") +FAILED_COUNT=$(grep -oP "Failed: \K\d+" /tmp/import_step2.txt || echo "0") +echo "✅ Converted: $SUCCESS_COUNT success, $FAILED_COUNT failed" + +# Extract converted agents +sed -n '/---JSON---/,$p' /tmp/import_step2.txt | tail -n +2 > /tmp/import_converted.json + +# Step 3: Create agents (or dry-run) +echo "" +if echo "$@" | grep -q "\-\-dry-run"; then + echo "🔍 Step 3: DRY RUN — Preview only" + echo "" + echo "Agents that would be created:" + python3 -c " +import json +agents = json.load(open('/tmp/import_converted.json')) +for a in agents[:10]: + print(f' • {a[\"name\"]} ({a[\"role\"]}) — {a[\"title\"]}') +if len(agents) > 10: + print(f' ... and {len(agents)-10} more') +" 2>/dev/null || echo " (install python3 for pretty output)" + echo "" + echo "💡 Remove --dry-run to actually create agents" +else + echo "📝 Step 3: Creating agents via API..." + "$TSX" "$SCRIPT_DIR/scripts/import-agents.ts" "$REPO_URL" "$@" +fi + +echo "" +echo "🎉 Done!" diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index e72da8394e4..d261b8a85d6 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,5 +1,44 @@ # 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 diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000000..b23c32a69cf --- /dev/null +++ b/cli/README.md @@ -0,0 +1,306 @@ +

+ Paperclip — runs your business +

+ +

+ Quickstart · + Docs · + GitHub · + Discord · + Twitter +

+ +

+ MIT License + Stars + Discord +

+ +
+ +
+ +
+ +
+ +## What is Paperclip? + +# Open-source orchestration for zero-human companies + +**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 your agents' work and costs from one dashboard. + +It looks like a task manager — but under the hood it has 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. | + +
+ +> **COMING SOON: Clipmart** — Download and run entire companies with one click. Browse pre-built company templates — full org structures, agent configs, and skills — and import them into your Paperclip instance in seconds. + +
+ +
+ + + + + + + + + + +
Works
with
OpenClaw
OpenClaw
Claude
Claude Code
Codex
Codex
Cursor
Cursor
Bash
Bash
HTTP
HTTP
+ +If it can receive a heartbeat, it's hired. + +
+ +
+ +## 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** + +
+ +## Features + + + + + + + + + + + + + + + + + +
+

🔌 Bring Your Own Agent

+Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired. +
+

🎯 Goal Alignment

+Every task traces back to the company mission. Agents know what to do and why. +
+

💓 Heartbeats

+Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart. +
+

💰 Cost Control

+Monthly budgets per agent. When they hit the limit, they stop. No runaway costs. +
+

🏢 Multi-Company

+One deployment, many companies. Complete data isolation. One control plane for your portfolio. +
+

🎫 Ticket System

+Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log. +
+

🛡️ Governance

+You're the board. Approve hires, override strategy, pause or terminate any agent — at any time. +
+

📊 Org Chart

+Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description. +
+

📱 Mobile Ready

+Monitor and manage your autonomous businesses from anywhere. +
+ +
+ +## 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. | + +
+ +## 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. | + +
+ +## 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. | + +
+ +## 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+ + +
+ +## 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-entreprenuer 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. + +
+ +## 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. + +
+ +## 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 +- ⚪ Artifacts & Deployments +- ⚪ CEO Chat +- ⚪ MAXIMIZER MODE +- ✅ Multiple Human Users +- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents) +- ⚪ Cloud deployments +- ⚪ Desktop App + +
+ +## Community & Plugins + +Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip) + +## Contributing + +We welcome contributions. See the [contributing guide](https://github.com/paperclipai/paperclip/blob/master/CONTRIBUTING.md) for details. + +
+ +## 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 + +
+ +## License + +MIT © 2026 Paperclip + +## 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) + +
+ +--- + +

+ +

+ +

+ Open source under MIT. Built for people who want to run companies, not babysit agents. +

diff --git a/cli/esbuild.config.mjs b/cli/esbuild.config.mjs index c116047c79e..7976b7c9cdf 100644 --- a/cli/esbuild.config.mjs +++ b/cli/esbuild.config.mjs @@ -21,7 +21,7 @@ const workspacePaths = [ "packages/adapter-utils", "packages/adapters/claude-local", "packages/adapters/codex-local", - "packages/adapters/openclaw", + "packages/adapters/openclaw-gateway", ]; // Workspace packages that should NOT be bundled — they'll be published diff --git a/cli/package.json b/cli/package.json index 4126d93b886..b60dde4d974 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "paperclipai", - "version": "0.2.7", + "version": "0.3.1", "description": "Paperclip CLI — orchestrate AI agent teams to run a business", "type": "module", "bin": { @@ -16,10 +16,13 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/paperclipai/paperclip.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" ], @@ -34,18 +37,24 @@ }, "dependencies": { "@clack/prompts": "^0.10.0", + "@paperclipai/adapter-acpx-local": "workspace:*", "@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-openclaw": "workspace:*", + "@paperclipai/adapter-pi-local": "workspace:*", + "@paperclipai/adapter-openclaw-gateway": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/db": "workspace:*", "@paperclipai/server": "workspace:*", "@paperclipai/shared": "workspace:*", - "drizzle-orm": "0.38.4", + "drizzle-orm": "0.45.2", "dotenv": "^17.0.1", "commander": "^13.1.0", + "embedded-postgres": "^18.1.0-beta.16", "picocolors": "^1.1.1" }, "devDependencies": { diff --git a/cli/src/__tests__/agent-jwt-env.test.ts b/cli/src/__tests__/agent-jwt-env.test.ts index 40bb15544de..baf5db5128b 100644 --- a/cli/src/__tests__/agent-jwt-env.test.ts +++ b/cli/src/__tests__/agent-jwt-env.test.ts @@ -4,7 +4,9 @@ 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"; @@ -58,4 +60,20 @@ describe("agent jwt env helpers", () => { 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"); + }); }); diff --git a/cli/src/__tests__/allowed-hostname.test.ts b/cli/src/__tests__/allowed-hostname.test.ts index 92dfbf42218..8e17e56bfad 100644 --- a/cli/src/__tests__/allowed-hostname.test.ts +++ b/cli/src/__tests__/allowed-hostname.test.ts @@ -42,6 +42,10 @@ function writeBaseConfig(configPath: string) { }, auth: { baseUrlMode: "auto", + disableSignUp: false, + }, + telemetry: { + enabled: true, }, storage: { provider: "local_disk", diff --git a/cli/src/__tests__/auth-command-registration.test.ts b/cli/src/__tests__/auth-command-registration.test.ts new file mode 100644 index 00000000000..a93d8fa7c6c --- /dev/null +++ b/cli/src/__tests__/auth-command-registration.test.ts @@ -0,0 +1,16 @@ +import { Command } from "commander"; +import { describe, expect, it } 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); + }); +}); diff --git a/cli/src/__tests__/board-auth.test.ts b/cli/src/__tests__/board-auth.test.ts new file mode 100644 index 00000000000..f86f539e903 --- /dev/null +++ b/cli/src/__tests__/board-auth.test.ts @@ -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(); + }); +}); diff --git a/cli/src/__tests__/company-delete.test.ts b/cli/src/__tests__/company-delete.test.ts index 6858a3d114b..8865585e751 100644 --- a/cli/src/__tests__/company-delete.test.ts +++ b/cli/src/__tests__/company-delete.test.ts @@ -8,12 +8,21 @@ function makeCompany(overrides: Partial): Company { 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, createdAt: new Date(), updatedAt: new Date(), ...overrides, diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts new file mode 100644 index 00000000000..ecefbfba423 --- /dev/null +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -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; + +async function getAvailablePort(): Promise { + 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, +) { + 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) { + 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((resolve) => { + child.once("exit", () => resolve()); + setTimeout(() => { + if (child.exitCode === null) { + child.kill("SIGKILL"); + } + }, 5_000); + }); +} + +async function api(baseUrl: string, pathname: string, init?: RequestInit): Promise { + 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( + 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> | 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>( + apiBase, + `/api/companies/${importedNew.company.id}/agents`, + ); + const importedProjects = await api>( + apiBase, + `/api/companies/${importedNew.company.id}/projects`, + ); + const importedIssues = await api>( + 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>( + apiBase, + `/api/companies/${importedNew.company.id}/agents`, + ); + const twiceImportedProjects = await api>( + apiBase, + `/api/companies/${importedNew.company.id}/projects`, + ); + const twiceImportedIssues = await api>( + 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 = {}; + 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); +}); diff --git a/cli/src/__tests__/company-import-url.test.ts b/cli/src/__tests__/company-import-url.test.ts new file mode 100644 index 00000000000..1f1548bdffe --- /dev/null +++ b/cli/src/__tests__/company-import-url.test.ts @@ -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", + ); + }); +}); diff --git a/cli/src/__tests__/company-import-zip.test.ts b/cli/src/__tests__/company-import-zip.test.ts new file mode 100644 index 00000000000..e2983e9a3a9 --- /dev/null +++ b/cli/src/__tests__/company-import-zip.test.ts @@ -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", + }, + }); + }); +}); diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts new file mode 100644 index 00000000000..144b3147dcb --- /dev/null +++ b/cli/src/__tests__/company.test.ts @@ -0,0 +1,603 @@ +import { describe, expect, it } from "vitest"; +import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared"; +import { + buildCompanyDashboardUrl, + buildDefaultImportAdapterOverrides, + buildDefaultImportSelectionState, + buildImportSelectionCatalog, + buildSelectedFilesFromImportSelection, + renderCompanyImportPreview, + renderCompanyImportResult, + resolveCompanyImportApplyConfirmationMode, + resolveCompanyImportApiPath, +} from "../commands/client/company.js"; + +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, + 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, + 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, + 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, + 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, + 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, + 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", + }, + }); + }); +}); diff --git a/cli/src/__tests__/doctor.test.ts b/cli/src/__tests__/doctor.test.ts new file mode 100644 index 00000000000..2e1d7d85076 --- /dev/null +++ b/cli/src/__tests__/doctor.test.ts @@ -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(); + }); +}); diff --git a/cli/src/__tests__/env-lab.test.ts b/cli/src/__tests__/env-lab.test.ts new file mode 100644 index 00000000000..02d6d7daf18 --- /dev/null +++ b/cli/src/__tests__/env-lab.test.ts @@ -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(); + }); +}); diff --git a/cli/src/__tests__/feedback.test.ts b/cli/src/__tests__/feedback.test.ts new file mode 100644 index 00000000000..e46b307d127 --- /dev/null +++ b/cli/src/__tests__/feedback.test.ts @@ -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 { + 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"]); + 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]}`, + ]), + ); + }); +}); diff --git a/cli/src/__tests__/helpers/embedded-postgres.ts b/cli/src/__tests__/helpers/embedded-postgres.ts new file mode 100644 index 00000000000..4318162a9ac --- /dev/null +++ b/cli/src/__tests__/helpers/embedded-postgres.ts @@ -0,0 +1,6 @@ +export { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestSupport, +} from "@paperclipai/db"; diff --git a/cli/src/__tests__/helpers/zip.ts b/cli/src/__tests__/helpers/zip.ts new file mode 100644 index 00000000000..ef79b5beda6 --- /dev/null +++ b/cli/src/__tests__/helpers/zip.ts @@ -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, 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; +} diff --git a/cli/src/__tests__/home-paths.test.ts b/cli/src/__tests__/home-paths.test.ts index 1d9c654eeef..f06d2c2222e 100644 --- a/cli/src/__tests__/home-paths.test.ts +++ b/cli/src/__tests__/home-paths.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -16,13 +17,14 @@ describe("home path resolution", () => { }); it("defaults to ~/.paperclip and default instance", () => { - delete process.env.PAPERCLIP_HOME; + 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(path.resolve(os.homedir(), ".paperclip")); + expect(paths.homeDir).toBe(home); expect(paths.instanceId).toBe("default"); - expect(paths.configPath).toBe(path.resolve(os.homedir(), ".paperclip", "instances", "default", "config.json")); + expect(paths.configPath).toBe(path.resolve(home, "instances", "default", "config.json")); }); it("supports PAPERCLIP_HOME and explicit instance ids", () => { @@ -34,7 +36,7 @@ describe("home path resolution", () => { }); it("rejects invalid instance ids", () => { - expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid instance id/); + expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid PAPERCLIP_INSTANCE_ID/); }); it("expands ~ prefixes", () => { diff --git a/cli/src/__tests__/http.test.ts b/cli/src/__tests__/http.test.ts index 3681d798c1c..0bacec7d64d 100644 --- a/cli/src/__tests__/http.test.ts +++ b/cli/src/__tests__/http.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { ApiRequestError, PaperclipApiClient } from "../client/http.js"; +import { ApiConnectionError, ApiRequestError, PaperclipApiClient } from "../client/http.js"; describe("PaperclipApiClient", () => { afterEach(() => { @@ -58,4 +58,49 @@ describe("PaperclipApiClient", () => { details: { issueId: "1" }, } satisfies Partial); }); + + 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); + 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; + expect(retryHeaders.authorization).toBe("Bearer board-token-123"); + }); }); diff --git a/cli/src/__tests__/network-bind.test.ts b/cli/src/__tests__/network-bind.test.ts new file mode 100644 index 00000000000..d75452abafd --- /dev/null +++ b/cli/src/__tests__/network-bind.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared"; +import { buildPresetServerConfig } from "../config/server-bind.js"; + +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; + + const preset = buildPresetServerConfig("tailnet", { + port: 3100, + allowedHostnames: [], + serveUi: true, + }); + + expect(preset.server.host).toBe("127.0.0.1"); + }); +}); diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts new file mode 100644 index 00000000000..0c694f4be83 --- /dev/null +++ b/cli/src/__tests__/onboard.test.ts @@ -0,0 +1,196 @@ +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(); + +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; + + 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("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"); + }); +}); diff --git a/cli/src/__tests__/plugin-init.test.ts b/cli/src/__tests__/plugin-init.test.ts new file mode 100644 index 00000000000..8faeb4592f2 --- /dev/null +++ b/cli/src/__tests__/plugin-init.test.ts @@ -0,0 +1,164 @@ +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( + "../../../packages/plugins/create-paperclip-plugin/src/index.js", + ); + return { + ...actual, + scaffoldPluginProject: mocks.scaffoldPluginProject, + }; +}); + +import { + buildPluginInstallRequest, + buildPluginInitNextCommands, + buildPluginInitScaffoldOptions, + 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, + }); + }); +}); diff --git a/cli/src/__tests__/routines.test.ts b/cli/src/__tests__/routines.test.ts new file mode 100644 index 00000000000..e24a2b5bba3 --- /dev/null +++ b/cli/src/__tests__/routines.test.ts @@ -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; + let tempDb: Awaited> | 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"); + }); +}); diff --git a/cli/src/__tests__/secrets.test.ts b/cli/src/__tests__/secrets.test.ts new file mode 100644 index 00000000000..a1089ae0a05 --- /dev/null +++ b/cli/src/__tests__/secrets.test.ts @@ -0,0 +1,257 @@ +import { afterEach, beforeEach, describe, expect, it } 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, + toPlainEnvValue, +} from "../commands/client/secrets.js"; + +function agent(partial: Partial): 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 { + return { + id: "secret-1", + companyId: "company-1", + 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"); + }); +}); diff --git a/cli/src/__tests__/telemetry.test.ts b/cli/src/__tests__/telemetry.test.ts new file mode 100644 index 00000000000..9d42a609716 --- /dev/null +++ b/cli/src/__tests__/telemetry.test.ts @@ -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(); + }); +}); diff --git a/cli/src/__tests__/worktree-merge-history.test.ts b/cli/src/__tests__/worktree-merge-history.test.ts new file mode 100644 index 00000000000..fa910872d44 --- /dev/null +++ b/cli/src/__tests__/worktree-merge-history.test.ts @@ -0,0 +1,492 @@ +import { describe, expect, it } from "vitest"; +import { buildWorktreeMergePlan, parseWorktreeMergeScopes } from "../commands/worktree-merge-history-lib.js"; + +function makeIssue(overrides: Record = {}) { + 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 = {}) { + 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 = {}) { + 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 = {}) { + 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 = {}) { + 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 = {}) { + 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 = {}) { + 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, + }); + }); +}); diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts new file mode 100644 index 00000000000..49c445b1bd6 --- /dev/null +++ b/cli/src/__tests__/worktree.test.ts @@ -0,0 +1,1359 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + agents, + authUsers, + companies, + createDb, + issueComments, + issues, + projects, + routines, + routineTriggers, +} from "@paperclipai/db"; +import { + copyGitHooksToWorktreeGitDir, + copySeededSecretsKey, + pauseSeededScheduledRoutines, + quarantineSeededWorktreeExecutionState, + readSourceAttachmentBody, + rebindWorkspaceCwd, + resolveSourceConfigPath, + resolveWorktreeReseedSource, + resolveWorktreeReseedTargetPaths, + resolveGitWorktreeAddArgs, + resolveWorktreeMakeTargetPath, + worktreeRepairCommand, + worktreeInitCommand, + worktreeMakeCommand, + worktreeReseedCommand, +} from "../commands/worktree.js"; +import { + buildWorktreeConfig, + buildWorktreeEnvEntries, + formatShellExports, + generateWorktreeColor, + resolveWorktreeSeedPlan, + resolveWorktreeLocalPaths, + rewriteLocalUrlPort, + sanitizeWorktreeInstanceId, +} from "../commands/worktree-lib.js"; +import type { PaperclipConfig } from "../config/schema.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const ORIGINAL_CWD = process.cwd(); +const ORIGINAL_ENV = { ...process.env }; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip; +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres worktree CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +afterEach(() => { + process.chdir(ORIGINAL_CWD); + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +function buildSourceConfig(): PaperclipConfig { + return { + $meta: { + version: 1, + updatedAt: "2026-03-09T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: "/tmp/main/db", + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: "/tmp/main/backups", + }, + }, + logging: { + mode: "file", + logDir: "/tmp/main/logs", + }, + server: { + deploymentMode: "authenticated", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: ["localhost"], + serveUi: true, + }, + auth: { + baseUrlMode: "explicit", + publicBaseUrl: "http://127.0.0.1:3100", + disableSignUp: false, + }, + telemetry: { + enabled: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: "/tmp/main/storage", + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: "/tmp/main/secrets/master.key", + }, + }, + }; +} + +describe("worktree helpers", () => { + it("sanitizes instance ids", () => { + expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support"); + expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree"); + }); + + it("resolves worktree:make target paths under the user home directory", () => { + expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe( + path.resolve(os.homedir(), "paperclip-pr-432"), + ); + }); + + it("rejects worktree:make names that are not safe directory/branch names", () => { + expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow( + "Worktree name must contain only letters, numbers, dots, underscores, or dashes.", + ); + }); + + it("builds git worktree add args for new and existing branches", () => { + expect( + resolveGitWorktreeAddArgs({ + branchName: "feature-branch", + targetPath: "/tmp/feature-branch", + branchExists: false, + }), + ).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]); + + expect( + resolveGitWorktreeAddArgs({ + branchName: "feature-branch", + targetPath: "/tmp/feature-branch", + branchExists: true, + }), + ).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]); + }); + + it("builds git worktree add args with a start point", () => { + expect( + resolveGitWorktreeAddArgs({ + branchName: "my-worktree", + targetPath: "/tmp/my-worktree", + branchExists: false, + startPoint: "public-gh/master", + }), + ).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]); + }); + + it("uses start point even when a local branch with the same name exists", () => { + expect( + resolveGitWorktreeAddArgs({ + branchName: "my-worktree", + targetPath: "/tmp/my-worktree", + branchExists: true, + startPoint: "origin/main", + }), + ).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]); + }); + + it("rewrites auth URLs only when they already include a port", () => { + expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/"); + expect(rewriteLocalUrlPort("http://my-host.ts.net:3100", 3110)).toBe("http://my-host.ts.net:3110/"); + expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example"); + }); + + it("builds isolated config and env paths for a worktree", () => { + const paths = resolveWorktreeLocalPaths({ + cwd: "/tmp/paperclip-feature", + homeDir: "/tmp/paperclip-worktrees", + instanceId: "feature-worktree-support", + }); + const config = buildWorktreeConfig({ + sourceConfig: buildSourceConfig(), + paths, + serverPort: 3110, + databasePort: 54339, + now: new Date("2026-03-09T12:00:00.000Z"), + }); + + expect(config.database.embeddedPostgresDataDir).toBe( + path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"), + ); + expect(config.database.embeddedPostgresPort).toBe(54339); + expect(config.server.port).toBe(3110); + expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/"); + expect(config.storage.localDisk.baseDir).toBe( + path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"), + ); + + const env = buildWorktreeEnvEntries(paths, { + name: "feature-worktree-support", + color: "#3abf7a", + }); + expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees")); + expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support"); + expect(env.PAPERCLIP_IN_WORKTREE).toBe("true"); + expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support"); + expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a"); + expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); + }); + + it("falls back across storage roots before skipping a missing attachment object", async () => { + const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" }); + const expected = Buffer.from("image-bytes"); + await expect( + readSourceAttachmentBody( + [ + { + getObject: vi.fn().mockRejectedValue(missingErr), + }, + { + getObject: vi.fn().mockResolvedValue(expected), + }, + ], + "company-1", + "company-1/issues/issue-1/missing.png", + ), + ).resolves.toEqual(expected); + }); + + it("returns null when an attachment object is missing from every lookup storage", async () => { + const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" }); + await expect( + readSourceAttachmentBody( + [ + { + getObject: vi.fn().mockRejectedValue(missingErr), + }, + { + getObject: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { status: 404 })), + }, + ], + "company-1", + "company-1/issues/issue-1/missing.png", + ), + ).resolves.toBeNull(); + }); + + it("generates vivid worktree colors as hex", () => { + expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/); + }); + + it("uses minimal seed mode to keep app state but drop heavy runtime history", () => { + const minimal = resolveWorktreeSeedPlan("minimal"); + const full = resolveWorktreeSeedPlan("full"); + + expect(minimal.excludedTables).toContain("heartbeat_runs"); + expect(minimal.excludedTables).toContain("heartbeat_run_events"); + expect(minimal.excludedTables).toContain("workspace_runtime_services"); + expect(minimal.excludedTables).toContain("agent_task_sessions"); + expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]); + + expect(full.excludedTables).toEqual([]); + expect(full.nullifyColumns).toEqual({}); + }); + + itEmbeddedPostgres("quarantines copied live execution state in seeded worktree databases", async () => { + const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-quarantine-"); + const db = createDb(tempDb.connectionString); + const companyId = randomUUID(); + const agentId = randomUUID(); + const idleAgentId = randomUUID(); + const inProgressIssueId = randomUUID(); + const todoIssueId = randomUUID(); + const reviewIssueId = randomUUID(); + const userIssueId = randomUUID(); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "WTQ", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values([ + { + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { enabled: true, intervalSec: 60 }, + wakeOnDemand: true, + }, + permissions: {}, + }, + { + id: idleAgentId, + companyId, + name: "Reviewer", + role: "reviewer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { heartbeat: { enabled: false, intervalSec: 300 } }, + permissions: {}, + }, + ]); + await db.insert(issues).values([ + { + id: inProgressIssueId, + companyId, + title: "Copied in-flight issue", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + issueNumber: 1, + identifier: "WTQ-1", + executionAgentNameKey: "codexcoder", + executionLockedAt: new Date("2026-04-18T00:00:00.000Z"), + }, + { + id: todoIssueId, + companyId, + title: "Copied assigned todo issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + issueNumber: 2, + identifier: "WTQ-2", + }, + { + id: reviewIssueId, + companyId, + title: "Copied assigned review issue", + status: "in_review", + priority: "medium", + assigneeAgentId: idleAgentId, + issueNumber: 3, + identifier: "WTQ-3", + }, + { + id: userIssueId, + companyId, + title: "Copied user issue", + status: "todo", + priority: "medium", + assigneeUserId: "user-1", + issueNumber: 4, + identifier: "WTQ-4", + }, + ]); + + await expect(quarantineSeededWorktreeExecutionState(tempDb.connectionString)).resolves.toEqual({ + disabledTimerHeartbeats: 1, + resetRunningAgents: 1, + quarantinedInProgressIssues: 1, + unassignedTodoIssues: 1, + unassignedReviewIssues: 1, + }); + + const [quarantinedAgent] = await db.select().from(agents).where(eq(agents.id, agentId)); + expect(quarantinedAgent?.status).toBe("idle"); + expect(quarantinedAgent?.runtimeConfig).toMatchObject({ + heartbeat: { enabled: false, intervalSec: 60 }, + wakeOnDemand: true, + }); + + const [inProgressIssue] = await db.select().from(issues).where(eq(issues.id, inProgressIssueId)); + expect(inProgressIssue?.status).toBe("blocked"); + expect(inProgressIssue?.assigneeAgentId).toBeNull(); + expect(inProgressIssue?.executionAgentNameKey).toBeNull(); + expect(inProgressIssue?.executionLockedAt).toBeNull(); + + const [todoIssue] = await db.select().from(issues).where(eq(issues.id, todoIssueId)); + expect(todoIssue?.status).toBe("todo"); + expect(todoIssue?.assigneeAgentId).toBeNull(); + + const [reviewIssue] = await db.select().from(issues).where(eq(issues.id, reviewIssueId)); + expect(reviewIssue?.status).toBe("in_review"); + expect(reviewIssue?.assigneeAgentId).toBeNull(); + + const [userIssue] = await db.select().from(issues).where(eq(issues.id, userIssueId)); + expect(userIssue?.status).toBe("todo"); + expect(userIssue?.assigneeUserId).toBe("user-1"); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, inProgressIssueId)); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("Quarantined during worktree seed"); + } finally { + await db.$client?.end?.({ timeout: 5 }).catch(() => undefined); + await tempDb.cleanup(); + } + }, 20_000); + + it("copies the source local_encrypted secrets key into the seeded worktree instance", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); + const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + try { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key"); + const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); + fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true }); + fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8"); + + const sourceConfig = buildSourceConfig(); + sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath; + + copySeededSecretsKey({ + sourceConfigPath, + sourceConfig, + sourceEnvEntries: {}, + targetKeyFilePath: targetKeyPath, + }); + + expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key"); + } finally { + if (originalInlineMasterKey === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY = originalInlineMasterKey; + } + if (originalKeyFile === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = originalKeyFile; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("writes the source inline secrets master key into the seeded worktree instance", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); + try { + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); + + copySeededSecretsKey({ + sourceConfigPath, + sourceConfig: buildSourceConfig(), + sourceEnvEntries: { + PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key", + }, + targetKeyFilePath: targetKeyPath, + }); + + expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key"); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("persists the current agent jwt secret into the worktree env file", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-")); + const repoRoot = path.join(tempRoot, "repo"); + const originalCwd = process.cwd(); + const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret"; + process.chdir(repoRoot); + + await worktreeInitCommand({ + seed: false, + fromConfig: path.join(tempRoot, "missing", "config.json"), + home: path.join(tempRoot, ".paperclip-worktrees"), + }); + + const envPath = path.join(repoRoot, ".paperclip", ".env"); + const envContents = fs.readFileSync(envPath, "utf8"); + expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret"); + expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo"); + expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/); + } finally { + process.chdir(originalCwd); + if (originalJwtSecret === undefined) { + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + } else { + process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("preserves repo-managed worktree checkouts when --force re-runs from the source repo", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-force-preserve-")); + const repoRoot = path.join(tempRoot, "repo"); + const originalCwd = process.cwd(); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + const repoConfigDir = path.join(repoRoot, ".paperclip"); + fs.mkdirSync(repoConfigDir, { recursive: true }); + fs.writeFileSync(path.join(repoConfigDir, "config.json"), "stale", "utf8"); + fs.writeFileSync(path.join(repoConfigDir, ".env"), "STALE=1", "utf8"); + + // Simulate the repo-managed worktrees subfolder that holds every + // worktree checkout (the directory PAPA-358 reported as nuked). + const worktreesDir = path.join(repoConfigDir, "worktrees"); + const checkoutDir = path.join(worktreesDir, "PAP-100-feature"); + fs.mkdirSync(checkoutDir, { recursive: true }); + const sentinelPath = path.join(checkoutDir, "sentinel.txt"); + fs.writeFileSync(sentinelPath, "do-not-delete", "utf8"); + + process.chdir(repoRoot); + + await worktreeInitCommand({ + seed: false, + force: true, + fromConfig: path.join(tempRoot, "missing", "config.json"), + home: path.join(tempRoot, ".paperclip-worktrees"), + }); + + expect(fs.existsSync(sentinelPath)).toBe(true); + expect(fs.readFileSync(sentinelPath, "utf8")).toBe("do-not-delete"); + expect(fs.existsSync(path.join(repoConfigDir, "config.json"))).toBe(true); + expect(fs.readFileSync(path.join(repoConfigDir, "config.json"), "utf8")).not.toBe("stale"); + } finally { + process.chdir(originalCwd); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + itEmbeddedPostgres( + "seeds authenticated users into minimally cloned worktree instances", + async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-")); + const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed"); + const sourceHome = path.join(tempRoot, "source-home"); + const sourceConfigDir = path.join(sourceHome, "instances", "source"); + const sourceConfigPath = path.join(sourceConfigDir, "config.json"); + const sourceEnvPath = path.join(sourceConfigDir, ".env"); + const sourceKeyPath = path.join(sourceConfigDir, "secrets", "master.key"); + const worktreeHome = path.join(tempRoot, ".paperclip-worktrees"); + const originalCwd = process.cwd(); + const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-"); + + try { + const sourceDbClient = createDb(sourceDb.connectionString); + await sourceDbClient.insert(authUsers).values({ + id: "user-existing", + email: "existing@paperclip.ing", + name: "Existing User", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + + fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true }); + fs.mkdirSync(worktreeRoot, { recursive: true }); + + const sourceConfig = buildSourceConfig(); + sourceConfig.database = { + mode: "postgres", + embeddedPostgresDataDir: path.join(sourceConfigDir, "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(sourceConfigDir, "backups"), + }, + connectionString: sourceDb.connectionString, + }; + sourceConfig.logging.logDir = path.join(sourceConfigDir, "logs"); + sourceConfig.storage.localDisk.baseDir = path.join(sourceConfigDir, "storage"); + sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath; + + fs.writeFileSync(sourceConfigPath, JSON.stringify(sourceConfig, null, 2) + "\n", "utf8"); + fs.writeFileSync(sourceEnvPath, "", "utf8"); + fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8"); + + process.chdir(worktreeRoot); + await worktreeInitCommand({ + name: "PAP-999-auth-seed", + home: worktreeHome, + fromConfig: sourceConfigPath, + force: true, + }); + + const targetConfig = JSON.parse( + fs.readFileSync(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8"), + ) as PaperclipConfig; + const { default: EmbeddedPostgres } = await import("embedded-postgres"); + const targetPg = new EmbeddedPostgres({ + databaseDir: targetConfig.database.embeddedPostgresDataDir, + user: "paperclip", + password: "paperclip", + port: targetConfig.database.embeddedPostgresPort, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + await targetPg.start(); + try { + const targetDb = createDb( + `postgres://paperclip:paperclip@127.0.0.1:${targetConfig.database.embeddedPostgresPort}/paperclip`, + ); + const seededUsers = await targetDb.select().from(authUsers); + expect(seededUsers.some((row) => row.email === "existing@paperclip.ing")).toBe(true); + } finally { + await targetPg.stop(); + } + } finally { + process.chdir(originalCwd); + await sourceDb.cleanup(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }, + 30000, + ); + + it("avoids ports already claimed by sibling worktree instance configs", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-")); + const repoRoot = path.join(tempRoot, "repo"); + const homeDir = path.join(tempRoot, ".paperclip-worktrees"); + const siblingInstanceRoot = path.join(homeDir, "instances", "existing-worktree"); + const originalCwd = process.cwd(); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + fs.mkdirSync(siblingInstanceRoot, { recursive: true }); + fs.writeFileSync( + path.join(siblingInstanceRoot, "config.json"), + JSON.stringify( + { + ...buildSourceConfig(), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(siblingInstanceRoot, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(siblingInstanceRoot, "logs"), + }, + server: { + deploymentMode: "authenticated", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: ["localhost"], + serveUi: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(siblingInstanceRoot, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(siblingInstanceRoot, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + ); + + process.chdir(repoRoot); + await worktreeInitCommand({ + seed: false, + fromConfig: path.join(tempRoot, "missing", "config.json"), + home: homeDir, + }); + + const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8")); + expect(config.server.port).toBeGreaterThan(3101); + expect(config.database.embeddedPostgresPort).not.toBe(54330); + expect(config.database.embeddedPostgresPort).not.toBe(config.server.port); + expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330); + } finally { + process.chdir(originalCwd); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("defaults the seed source config to the current repo-local Paperclip config", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-")); + const repoRoot = path.join(tempRoot, "repo"); + const localConfigPath = path.join(repoRoot, ".paperclip", "config.json"); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(path.dirname(localConfigPath), { recursive: true }); + fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8"); + delete process.env.PAPERCLIP_CONFIG; + process.chdir(repoRoot); + + expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath)); + } finally { + process.chdir(originalCwd); + if (originalPaperclipConfig === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("preserves the source config path across worktree:make cwd changes", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-")); + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const targetRoot = path.join(tempRoot, "target"); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8"); + delete process.env.PAPERCLIP_CONFIG; + process.chdir(targetRoot); + + expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe( + path.resolve(sourceConfigPath), + ); + } finally { + process.chdir(originalCwd); + if (originalPaperclipConfig === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("requires an explicit reseed source", () => { + expect(() => resolveWorktreeReseedSource({})).toThrow( + "Pass --from or --from-config/--from-instance explicitly so the reseed source is unambiguous.", + ); + }); + + it("rejects mixed reseed source selectors", () => { + expect(() => resolveWorktreeReseedSource({ + from: "current", + fromInstance: "default", + })).toThrow( + "Use either --from or --from-config/--from-data-dir/--from-instance, not both.", + ); + }); + + it("derives worktree reseed target paths from the adjacent env file", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-")); + const worktreeRoot = path.join(tempRoot, "repo"); + const configPath = path.join(worktreeRoot, ".paperclip", "config.json"); + const envPath = path.join(worktreeRoot, ".paperclip", ".env"); + + try { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8"); + fs.writeFileSync( + envPath, + [ + "PAPERCLIP_HOME=/tmp/paperclip-worktrees", + "PAPERCLIP_INSTANCE_ID=pap-1132-chat", + ].join("\n"), + "utf8", + ); + expect( + resolveWorktreeReseedTargetPaths({ + configPath, + rootPath: worktreeRoot, + }), + ).toMatchObject({ + cwd: worktreeRoot, + homeDir: "/tmp/paperclip-worktrees", + instanceId: "pap-1132-chat", + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("rejects reseed targets without worktree env metadata", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-missing-")); + const worktreeRoot = path.join(tempRoot, "repo"); + const configPath = path.join(worktreeRoot, ".paperclip", "config.json"); + + try { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8"); + fs.writeFileSync(path.join(worktreeRoot, ".paperclip", ".env"), "", "utf8"); + + expect(() => + resolveWorktreeReseedTargetPaths({ + configPath, + rootPath: worktreeRoot, + })).toThrow("does not look like a worktree-local Paperclip instance"); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("reseed preserves the current worktree ports, instance id, and branding", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-")); + const repoRoot = path.join(tempRoot, "repo"); + const sourceRoot = path.join(tempRoot, "source"); + const homeDir = path.join(tempRoot, ".paperclip-worktrees"); + const currentInstanceId = "existing-worktree"; + const currentPaths = resolveWorktreeLocalPaths({ + cwd: repoRoot, + homeDir, + instanceId: currentInstanceId, + }); + const sourcePaths = resolveWorktreeLocalPaths({ + cwd: sourceRoot, + homeDir: path.join(tempRoot, ".paperclip-source"), + instanceId: "default", + }); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true }); + fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true }); + fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true }); + fs.mkdirSync(repoRoot, { recursive: true }); + fs.mkdirSync(sourceRoot, { recursive: true }); + + const currentConfig = buildWorktreeConfig({ + sourceConfig: buildSourceConfig(), + paths: currentPaths, + serverPort: 3114, + databasePort: 54341, + }); + const sourceConfig = buildWorktreeConfig({ + sourceConfig: buildSourceConfig(), + paths: sourcePaths, + serverPort: 3200, + databasePort: 54400, + }); + fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8"); + fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8"); + fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8"); + fs.writeFileSync( + currentPaths.envPath, + [ + `PAPERCLIP_HOME=${homeDir}`, + `PAPERCLIP_INSTANCE_ID=${currentInstanceId}`, + "PAPERCLIP_WORKTREE_NAME=existing-name", + "PAPERCLIP_WORKTREE_COLOR=\"#112233\"", + ].join("\n"), + "utf8", + ); + + delete process.env.PAPERCLIP_CONFIG; + process.chdir(repoRoot); + + await worktreeReseedCommand({ + fromConfig: sourcePaths.configPath, + yes: true, + }); + + const rewrittenConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8")); + const rewrittenEnv = fs.readFileSync(currentPaths.envPath, "utf8"); + + expect(rewrittenConfig.server.port).toBe(3114); + expect(rewrittenConfig.database.embeddedPostgresPort).toBe(54341); + expect(rewrittenConfig.database.embeddedPostgresDataDir).toBe(currentPaths.embeddedPostgresDataDir); + expect(rewrittenEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`); + expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_NAME=existing-name"); + expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_COLOR=\"#112233\""); + } finally { + process.chdir(originalCwd); + if (originalPaperclipConfig === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }, 30_000); + + it("restores the current worktree config and instance data if reseed fails", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-")); + const repoRoot = path.join(tempRoot, "repo"); + const sourceRoot = path.join(tempRoot, "source"); + const homeDir = path.join(tempRoot, ".paperclip-worktrees"); + const currentInstanceId = "rollback-worktree"; + const currentPaths = resolveWorktreeLocalPaths({ + cwd: repoRoot, + homeDir, + instanceId: currentInstanceId, + }); + const sourcePaths = resolveWorktreeLocalPaths({ + cwd: sourceRoot, + homeDir: path.join(tempRoot, ".paperclip-source"), + instanceId: "default", + }); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true }); + fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true }); + fs.mkdirSync(currentPaths.instanceRoot, { recursive: true }); + fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true }); + fs.mkdirSync(repoRoot, { recursive: true }); + fs.mkdirSync(sourceRoot, { recursive: true }); + + const currentConfig = buildWorktreeConfig({ + sourceConfig: buildSourceConfig(), + paths: currentPaths, + serverPort: 3114, + databasePort: 54341, + }); + const sourceConfig = { + ...buildSourceConfig(), + database: { + mode: "postgres", + connectionString: "", + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: sourcePaths.secretsKeyFilePath, + }, + }, + } as PaperclipConfig; + + fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8"); + fs.writeFileSync(currentPaths.envPath, `PAPERCLIP_HOME=${homeDir}\nPAPERCLIP_INSTANCE_ID=${currentInstanceId}\n`, "utf8"); + fs.writeFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "keep me", "utf8"); + fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8"); + fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8"); + + delete process.env.PAPERCLIP_CONFIG; + process.chdir(repoRoot); + + await expect(worktreeReseedCommand({ + fromConfig: sourcePaths.configPath, + yes: true, + })).rejects.toThrow("Source instance uses postgres mode but has no connection string"); + + const restoredConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8")); + const restoredEnv = fs.readFileSync(currentPaths.envPath, "utf8"); + const restoredMarker = fs.readFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "utf8"); + + expect(restoredConfig.server.port).toBe(3114); + expect(restoredConfig.database.embeddedPostgresPort).toBe(54341); + expect(restoredEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`); + expect(restoredMarker).toBe("keep me"); + } finally { + process.chdir(originalCwd); + if (originalPaperclipConfig === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("rebinds same-repo workspace paths onto the current worktree root", () => { + expect( + rebindWorkspaceCwd({ + sourceRepoRoot: "/Users/example/paperclip", + targetRepoRoot: "/Users/example/paperclip-pr-432", + workspaceCwd: "/Users/example/paperclip", + }), + ).toBe("/Users/example/paperclip-pr-432"); + + expect( + rebindWorkspaceCwd({ + sourceRepoRoot: "/Users/example/paperclip", + targetRepoRoot: "/Users/example/paperclip-pr-432", + workspaceCwd: "/Users/example/paperclip/packages/db", + }), + ).toBe("/Users/example/paperclip-pr-432/packages/db"); + }); + + it("does not rebind paths outside the source repo root", () => { + expect( + rebindWorkspaceCwd({ + sourceRepoRoot: "/Users/example/paperclip", + targetRepoRoot: "/Users/example/paperclip-pr-432", + workspaceCwd: "/Users/example/other-project", + }), + ).toBeNull(); + }); + + it("copies shared git hooks into a linked worktree git dir", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-")); + const repoRoot = path.join(tempRoot, "repo"); + const worktreePath = path.join(tempRoot, "repo-feature"); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + + const sourceHooksDir = path.join(repoRoot, ".git", "hooks"); + const sourceHookPath = path.join(sourceHooksDir, "pre-commit"); + const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt"); + fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 }); + fs.chmodSync(sourceHookPath, 0o755); + fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8"); + + execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" }); + + const copied = copyGitHooksToWorktreeGitDir(worktreePath); + const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], { + cwd: worktreePath, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir); + const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks")); + const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit"); + const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt"); + + expect(copied).toMatchObject({ + sourceHooksPath: resolvedSourceHooksDir, + targetHooksPath: resolvedTargetHooksDir, + copied: true, + }); + expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n"); + expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0); + expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n"); + } finally { + execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" }); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }, 15_000); + + it("creates and initializes a worktree from the top-level worktree:make command", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-")); + const repoRoot = path.join(tempRoot, "repo"); + const fakeHome = path.join(tempRoot, "home"); + const worktreePath = path.join(fakeHome, "paperclip-make-test"); + const originalCwd = process.cwd(); + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + fs.mkdirSync(fakeHome, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + + process.chdir(repoRoot); + + await worktreeMakeCommand("paperclip-make-test", { + seed: false, + home: path.join(tempRoot, ".paperclip-worktrees"), + }); + + expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true); + expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true); + expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true); + } finally { + process.chdir(originalCwd); + homedirSpy.mockRestore(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }, 20_000); + + it("no-ops on the primary checkout unless --branch is provided", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-primary-")); + const repoRoot = path.join(tempRoot, "repo"); + const originalCwd = process.cwd(); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + + process.chdir(repoRoot); + await worktreeRepairCommand({}); + + expect(fs.existsSync(path.join(repoRoot, ".paperclip", "config.json"))).toBe(false); + expect(fs.existsSync(path.join(repoRoot, ".paperclip", "worktrees"))).toBe(false); + } finally { + process.chdir(originalCwd); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("repairs the current linked worktree when Paperclip metadata is missing", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-current-")); + const repoRoot = path.join(tempRoot, "repo"); + const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", "repair-me"); + const sourceConfigPath = path.join(tempRoot, "source-config.json"); + const worktreeHome = path.join(tempRoot, ".paperclip-worktrees"); + const worktreePaths = resolveWorktreeLocalPaths({ + cwd: worktreePath, + homeDir: worktreeHome, + instanceId: sanitizeWorktreeInstanceId(path.basename(worktreePath)), + }); + const originalCwd = process.cwd(); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); + execFileSync("git", ["worktree", "add", "-b", "repair-me", worktreePath, "HEAD"], { + cwd: repoRoot, + stdio: "ignore", + }); + + fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8"); + fs.mkdirSync(worktreePaths.instanceRoot, { recursive: true }); + fs.writeFileSync(path.join(worktreePaths.instanceRoot, "marker.txt"), "stale", "utf8"); + + process.chdir(worktreePath); + await worktreeRepairCommand({ + fromConfig: sourceConfigPath, + home: worktreeHome, + noSeed: true, + }); + + expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true); + expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true); + expect(fs.existsSync(path.join(worktreePaths.instanceRoot, "marker.txt"))).toBe(false); + } finally { + process.chdir(originalCwd); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }, 20_000); + + it("creates and repairs a missing branch worktree when --branch is provided", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-branch-")); + const repoRoot = path.join(tempRoot, "repo"); + const sourceConfigPath = path.join(tempRoot, "source-config.json"); + const worktreeHome = path.join(tempRoot, ".paperclip-worktrees"); + const originalCwd = process.cwd(); + const expectedWorktreePath = path.join(repoRoot, ".paperclip", "worktrees", "feature-repair-me"); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" }); + fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8"); + execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); + fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8"); + + process.chdir(repoRoot); + await worktreeRepairCommand({ + branch: "feature/repair-me", + fromConfig: sourceConfigPath, + home: worktreeHome, + noSeed: true, + }); + + expect(fs.existsSync(path.join(expectedWorktreePath, ".git"))).toBe(true); + expect(fs.existsSync(path.join(expectedWorktreePath, ".paperclip", "config.json"))).toBe(true); + expect(fs.existsSync(path.join(expectedWorktreePath, ".paperclip", ".env"))).toBe(true); + } finally { + process.chdir(originalCwd); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }, 20_000); +}); + +describeEmbeddedPostgres("pauseSeededScheduledRoutines", () => { + it("pauses only routines with enabled schedule triggers", async () => { + const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-routines-"); + const db = createDb(tempDb.connectionString); + const companyId = randomUUID(); + const projectId = randomUUID(); + const agentId = randomUUID(); + const activeScheduledRoutineId = randomUUID(); + const activeApiRoutineId = randomUUID(); + const pausedScheduledRoutineId = randomUUID(); + const archivedScheduledRoutineId = randomUUID(); + const disabledScheduleRoutineId = randomUUID(); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Coder", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Project", + status: "in_progress", + }); + await db.insert(routines).values([ + { + id: activeScheduledRoutineId, + companyId, + projectId, + assigneeAgentId: agentId, + title: "Active scheduled", + status: "active", + }, + { + id: activeApiRoutineId, + companyId, + projectId, + assigneeAgentId: agentId, + title: "Active API", + status: "active", + }, + { + id: pausedScheduledRoutineId, + companyId, + projectId, + assigneeAgentId: agentId, + title: "Paused scheduled", + status: "paused", + }, + { + id: archivedScheduledRoutineId, + companyId, + projectId, + assigneeAgentId: agentId, + title: "Archived scheduled", + status: "archived", + }, + { + id: disabledScheduleRoutineId, + companyId, + projectId, + assigneeAgentId: agentId, + title: "Disabled schedule", + status: "active", + }, + ]); + await db.insert(routineTriggers).values([ + { + companyId, + routineId: activeScheduledRoutineId, + kind: "schedule", + enabled: true, + cronExpression: "0 9 * * *", + timezone: "UTC", + }, + { + companyId, + routineId: activeApiRoutineId, + kind: "api", + enabled: true, + }, + { + companyId, + routineId: pausedScheduledRoutineId, + kind: "schedule", + enabled: true, + cronExpression: "0 10 * * *", + timezone: "UTC", + }, + { + companyId, + routineId: archivedScheduledRoutineId, + kind: "schedule", + enabled: true, + cronExpression: "0 11 * * *", + timezone: "UTC", + }, + { + companyId, + routineId: disabledScheduleRoutineId, + kind: "schedule", + enabled: false, + cronExpression: "0 12 * * *", + timezone: "UTC", + }, + ]); + + const pausedCount = await pauseSeededScheduledRoutines(tempDb.connectionString); + expect(pausedCount).toBe(1); + + const rows = await db.select({ id: routines.id, status: routines.status }).from(routines); + const statusById = new Map(rows.map((row) => [row.id, row.status])); + expect(statusById.get(activeScheduledRoutineId)).toBe("paused"); + expect(statusById.get(activeApiRoutineId)).toBe("active"); + expect(statusById.get(pausedScheduledRoutineId)).toBe("paused"); + expect(statusById.get(archivedScheduledRoutineId)).toBe("archived"); + expect(statusById.get(disabledScheduleRoutineId)).toBe("active"); + } finally { + await db.$client?.end?.({ timeout: 5 }).catch(() => undefined); + await tempDb.cleanup(); + } + }, 20_000); +}); diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index ba8f93a8e26..31dfe0d0af7 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -1,9 +1,14 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils"; +import { printAcpxStreamEvent } from "@paperclipai/adapter-acpx-local/cli"; 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 { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; -import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/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"; @@ -12,28 +17,66 @@ const claudeLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printClaudeStreamEvent, }; +const acpxLocalCLIAdapter: CLIAdapterModule = { + type: "acpx_local", + formatStdoutEvent: printAcpxStreamEvent, +}; + const codexLocalCLIAdapter: CLIAdapterModule = { type: "codex_local", formatStdoutEvent: printCodexStreamEvent, }; -const opencodeLocalCLIAdapter: CLIAdapterModule = { +const openCodeLocalCLIAdapter: CLIAdapterModule = { type: "opencode_local", formatStdoutEvent: printOpenCodeStreamEvent, }; +const piLocalCLIAdapter: CLIAdapterModule = { + type: "pi_local", + formatStdoutEvent: printPiStreamEvent, +}; + const cursorLocalCLIAdapter: CLIAdapterModule = { type: "cursor", formatStdoutEvent: printCursorStreamEvent, }; -const openclawCLIAdapter: CLIAdapterModule = { - type: "openclaw", - formatStdoutEvent: printOpenClawStreamEvent, +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 openclawGatewayCLIAdapter: CLIAdapterModule = { + type: "openclaw_gateway", + formatStdoutEvent: printOpenClawGatewayStreamEvent, }; const adaptersByType = new Map( - [claudeLocalCLIAdapter, codexLocalCLIAdapter, opencodeLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]), + [ + acpxLocalCLIAdapter, + claudeLocalCLIAdapter, + codexLocalCLIAdapter, + openCodeLocalCLIAdapter, + piLocalCLIAdapter, + cursorLocalCLIAdapter, + cursorCloudCLIAdapter, + geminiLocalCLIAdapter, + grokLocalCLIAdapter, + openclawGatewayCLIAdapter, + processCLIAdapter, + httpCLIAdapter, + ].map((a) => [a.type, a]), ); export function getCLIAdapter(type: string): CLIAdapterModule { diff --git a/cli/src/checks/deployment-auth-check.ts b/cli/src/checks/deployment-auth-check.ts index 580e7e08f39..6434ede07cf 100644 --- a/cli/src/checks/deployment-auth-check.ts +++ b/cli/src/checks/deployment-auth-check.ts @@ -1,24 +1,21 @@ +import { inferBindModeFromHost } from "@paperclipai/shared"; import type { PaperclipConfig } from "../config/schema.js"; import type { CheckResult } from "./index.js"; -function isLoopbackHost(host: string) { - const normalized = host.trim().toLowerCase(); - return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; -} - 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 (!isLoopbackHost(config.server.host)) { + if (bind !== "loopback") { return { name: "Deployment/auth mode", status: "fail", - message: `local_trusted requires loopback host binding (found ${config.server.host})`, + message: `local_trusted requires loopback binding (found ${bind})`, canRepair: false, - repairHint: "Run `paperclipai configure --section server` and set host to 127.0.0.1", + repairHint: "Run `paperclipai configure --section server` and choose Local trusted / loopback reachability", }; } return { @@ -86,6 +83,6 @@ export function deploymentAuthCheck(config: PaperclipConfig): CheckResult { return { name: "Deployment/auth mode", status: "pass", - message: `Mode ${mode}/${exposure} with auth URL mode ${auth.baseUrlMode}`, + message: `Mode ${mode}/${exposure} with bind ${bind} and auth URL mode ${auth.baseUrlMode}`, }; } diff --git a/cli/src/checks/secrets-check.ts b/cli/src/checks/secrets-check.ts index 49c6a90b28e..73f9c0402c1 100644 --- a/cli/src/checks/secrets-check.ts +++ b/cli/src/checks/secrets-check.ts @@ -5,6 +5,9 @@ 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; @@ -47,13 +50,16 @@ function withStrictModeNote( 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`, + message: `${provider} is configured, but this build only supports local_encrypted and aws_secrets_manager`, canRepair: false, - repairHint: "Run `paperclipai configure --section secrets` and set provider to local_encrypted", + repairHint: "Run `paperclipai configure --section secrets` and choose local_encrypted or aws_secrets_manager", }; } @@ -135,12 +141,100 @@ export function secretsCheck(config: PaperclipConfig, configPath?: string): Chec }; } + 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: "pass", - message: `Local encrypted provider configured with key file ${keyFilePath}`, + 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; +} diff --git a/cli/src/client/board-auth.ts b/cli/src/client/board-auth.ts new file mode 100644 index 00000000000..7c1121ec8b2 --- /dev/null +++ b/cli/src/client/board-auth.ts @@ -0,0 +1,282 @@ +import { spawn } 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; +} + +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(/\/+$/, ""); +} + +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 | null; + const credentials = raw?.credentials && typeof raw.credentials === "object" ? raw.credentials : {}; + const normalized: Record = {}; + + for (const [key, value] of Object.entries(credentials)) { + if (typeof value !== "object" || value === null) continue; + const record = value as unknown as Record; + 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(url: string, init?: RequestInit): Promise { + 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; +} + +export function openUrl(url: string): boolean { + const platform = process.platform; + try { + if (platform === "darwin") { + const child = spawn("open", [url], { detached: true, stdio: "ignore" }); + child.unref(); + return true; + } + if (platform === "win32") { + const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }); + child.unref(); + return true; + } + const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" }); + child.unref(); + return true; + } catch { + return false; + } +} + +export async function loginBoardCli(params: { + apiBase: string; + requestedAccess: RequestedAccess; + requestedCompanyId?: string | null; + clientName?: string | null; + command?: string; + storePath?: string; + print?: boolean; +}): 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(createUrl, { + method: "POST", + body: JSON.stringify({ + command, + clientName: params.clientName?.trim() || "paperclipai cli", + requestedAccess: params.requestedAccess, + requestedCompanyId: params.requestedCompanyId?.trim() || null, + }), + }); + + const approvalUrl = 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 opened = openUrl(approvalUrl); + if (params.print !== false && opened) { + console.error(pc.dim("Opened the approval page in your browser.")); + } + + 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( + `${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 { + 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({}), + }); +} diff --git a/cli/src/client/command-label.ts b/cli/src/client/command-label.ts new file mode 100644 index 00000000000..21143b3b756 --- /dev/null +++ b/cli/src/client/command-label.ts @@ -0,0 +1,4 @@ +export function buildCliCommandLabel(): string { + const args = process.argv.slice(2); + return args.length > 0 ? `paperclipai ${args.join(" ")}` : "paperclipai"; +} diff --git a/cli/src/client/http.ts b/cli/src/client/http.ts index 863249b7d66..27de5eb104d 100644 --- a/cli/src/client/http.ts +++ b/cli/src/client/http.ts @@ -13,25 +13,54 @@ export class ApiRequestError extends Error { } } +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; } export class PaperclipApiClient { readonly apiBase: string; - readonly apiKey?: string; + apiKey?: string; readonly runId?: string; + readonly recoverAuth?: (input: RecoverAuthInput) => Promise; 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(path: string, opts?: RequestOptions): Promise { @@ -56,8 +85,18 @@ export class PaperclipApiClient { return this.request(path, { method: "DELETE" }, opts); } - private async request(path: string, init: RequestInit, opts?: RequestOptions): Promise { + setApiKey(apiKey: string | undefined) { + this.apiKey = apiKey?.trim() || undefined; + } + + private async request( + path: string, + init: RequestInit, + opts?: RequestOptions, + hasRetriedAuth = false, + ): Promise { const url = buildUrl(this.apiBase, path); + const method = String(init.method ?? "GET").toUpperCase(); const headers: Record = { accept: "application/json", @@ -76,17 +115,39 @@ export class PaperclipApiClient { headers["x-paperclip-run-id"] = this.runId; } - const response = await fetch(url, { - ...init, - headers, - }); + 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) { - throw await toApiError(response); + 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(path, init, opts, true); + } + } + throw apiError; } if (response.status === 204) { @@ -104,8 +165,10 @@ export class PaperclipApiClient { 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(/\/+$/, "")}${normalizedPath}`; + url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`; + if (query) url.search = query; return url.toString(); } @@ -134,6 +197,50 @@ async function toApiError(response: Response): Promise { 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 { if (!headers) return {}; if (Array.isArray(headers)) { diff --git a/cli/src/commands/allowed-hostname.ts b/cli/src/commands/allowed-hostname.ts index 942c464b36f..d47a3bba1b8 100644 --- a/cli/src/commands/allowed-hostname.ts +++ b/cli/src/commands/allowed-hostname.ts @@ -26,6 +26,9 @@ export async function addAllowedHostname(host: string, opts: { config?: string } 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")) { diff --git a/cli/src/commands/auth-bootstrap-ceo.ts b/cli/src/commands/auth-bootstrap-ceo.ts index 63490f2dc29..d07b9a70392 100644 --- a/cli/src/commands/auth-bootstrap-ceo.ts +++ b/cli/src/commands/auth-bootstrap-ceo.ts @@ -3,6 +3,8 @@ 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) { @@ -13,7 +15,8 @@ function createInviteToken() { return `pcp_bootstrap_${randomBytes(24).toString("hex")}`; } -function resolveDbUrl(configPath?: string) { +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) { @@ -28,13 +31,23 @@ function resolveDbUrl(configPath?: string) { 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 host = config?.server.host ?? "localhost"; + 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" ? "localhost" : host; + const publicHost = host === "0.0.0.0" || bind === "lan" ? "localhost" : host; return `http://${publicHost}:${port}`; } @@ -43,8 +56,10 @@ export async function bootstrapCeoInvite(opts: { 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.`); @@ -56,7 +71,7 @@ export async function bootstrapCeoInvite(opts: { return; } - const dbUrl = resolveDbUrl(configPath); + const dbUrl = resolveDbUrl(configPath, opts.dbUrl); if (!dbUrl) { p.log.error( "Could not resolve database connection for bootstrap.", @@ -65,6 +80,11 @@ export async function bootstrapCeoInvite(opts: { } const db = createDb(dbUrl); + const closableDb = db as typeof db & { + $client?: { + end?: (options?: { timeout?: number }) => Promise; + }; + }; try { const existingAdminCount = await db .select() @@ -112,5 +132,7 @@ export async function bootstrapCeoInvite(opts: { } 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); } } diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 2a1b4243d9d..2c294628369 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -1,5 +1,13 @@ import { Command } from "commander"; import type { Agent } 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, formatInlineRecord, @@ -13,6 +21,141 @@ interface AgentListOptions extends BaseClientOptions { companyId?: string; } +interface AgentLocalCliOptions extends BaseClientOptions { + companyId?: string; + keyName?: string; + installSkills?: 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 { + 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"); @@ -71,4 +214,102 @@ export function registerAgentCommands(program: Command): void { } }), ); + + addCommonClientOptions( + agent + .command("local-cli") + .description( + "Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports", + ) + .argument("", "Agent ID or shortname/url-key") + .requiredOption("-C, --company-id ", "Company ID") + .option("--key-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( + `/api/agents/${encodeURIComponent(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(`/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 }, + ); } diff --git a/cli/src/commands/client/auth.ts b/cli/src/commands/client/auth.ts new file mode 100644 index 00000000000..65f47610eb7 --- /dev/null +++ b/cli/src/commands/client/auth.ts @@ -0,0 +1,113 @@ +import type { Command } from "commander"; +import { + getStoredBoardCredential, + loginBoardCli, + removeStoredBoardCredential, + revokeStoredBoardCredential, +} from "../../client/board-auth.js"; +import { + addCommonClientOptions, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +interface AuthLoginOptions extends BaseClientOptions { + instanceAdmin?: boolean; +} + +interface AuthLogoutOptions extends BaseClientOptions {} +interface AuthWhoamiOptions extends BaseClientOptions {} + +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) + .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", + }); + 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("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); + } + }), + ); +} diff --git a/cli/src/commands/client/common.ts b/cli/src/commands/client/common.ts index 14de3ccf8fa..db5f7dbcd3a 100644 --- a/cli/src/commands/client/common.ts +++ b/cli/src/commands/client/common.ts @@ -1,5 +1,7 @@ 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"; @@ -53,10 +55,12 @@ export function resolveCommandContext( profile.apiBase || inferApiBaseFromConfig(options.config); - const apiKey = + const explicitApiKey = options.apiKey?.trim() || process.env.PAPERCLIP_API_KEY?.trim() || readKeyFromProfileEnv(profile); + const storedBoardCredential = explicitApiKey ? null : getStoredBoardCredential(apiBase); + const apiKey = explicitApiKey || storedBoardCredential?.token; const companyId = options.companyId?.trim() || @@ -69,7 +73,27 @@ export function resolveCommandContext( ); } - const api = new PaperclipApiClient({ apiBase, apiKey }); + const api = new PaperclipApiClient({ + apiBase, + apiKey, + 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, @@ -79,6 +103,16 @@ export function resolveCommandContext( }; } +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)); diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index b8ab3644f9e..6e4c668817e 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -1,15 +1,21 @@ import { Command } from "commander"; -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; import type { Company, + FeedbackTrace, + CompanyPortabilityFileEntry, CompanyPortabilityExportResult, CompanyPortabilityInclude, - CompanyPortabilityManifest, CompanyPortabilityPreviewResult, CompanyPortabilityImportResult, } from "@paperclipai/shared"; +import { getTelemetryClient, trackCompanyImported } from "../../telemetry.js"; import { ApiRequestError } from "../../client/http.js"; +import { openUrl } from "../../client/board-auth.js"; +import { binaryContentTypeByExtension, readZipArchive } from "./zip.js"; import { addCommonClientOptions, formatInlineRecord, @@ -18,6 +24,11 @@ import { resolveCommandContext, type BaseClientOptions, } from "./common.js"; +import { + buildFeedbackTraceQuery, + normalizeFeedbackTraceExportFormat, + serializeFeedbackTraces, +} from "./feedback.js"; interface CompanyCommandOptions extends BaseClientOptions {} type CompanyDeleteSelectorMode = "auto" | "id" | "prefix"; @@ -33,19 +44,107 @@ interface CompanyDeleteOptions extends BaseClientOptions { interface CompanyExportOptions extends BaseClientOptions { out?: string; include?: string; + skills?: string; + projects?: string; + issues?: string; + projectIssues?: string; + expandReferencedSkills?: boolean; } -interface CompanyImportOptions extends BaseClientOptions { +interface CompanyFeedbackOptions extends BaseClientOptions { + targetType?: string; + vote?: string; + status?: string; + projectId?: string; + issueId?: string; from?: string; + to?: string; + sharedOnly?: boolean; + includePayload?: boolean; + out?: string; + format?: string; +} + +interface CompanyImportOptions extends BaseClientOptions { include?: string; target?: CompanyImportTargetMode; companyId?: string; newCompanyName?: string; agents?: string; collision?: CompanyCollisionMode; + ref?: string; + paperclipUrl?: string; + yes?: boolean; dryRun?: boolean; } +const DEFAULT_EXPORT_INCLUDE: CompanyPortabilityInclude = { + company: true, + agents: true, + projects: false, + issues: false, + skills: false, +}; + +const DEFAULT_IMPORT_INCLUDE: CompanyPortabilityInclude = { + company: true, + agents: true, + projects: true, + issues: true, + skills: true, +}; + +const IMPORT_INCLUDE_OPTIONS: Array<{ + value: keyof CompanyPortabilityInclude; + label: string; + hint: string; +}> = [ + { value: "company", label: "Company", hint: "name, branding, and company settings" }, + { value: "projects", label: "Projects", hint: "projects and workspace metadata" }, + { value: "issues", label: "Tasks", hint: "tasks and recurring routines" }, + { value: "agents", label: "Agents", hint: "agent records and org structure" }, + { value: "skills", label: "Skills", hint: "company skill packages and references" }, +]; + +const IMPORT_PREVIEW_SAMPLE_LIMIT = 6; + +type ImportSelectableGroup = "projects" | "issues" | "agents" | "skills"; + +type ImportSelectionCatalog = { + company: { + includedByDefault: boolean; + files: string[]; + }; + projects: Array<{ key: string; label: string; hint?: string; files: string[] }>; + issues: Array<{ key: string; label: string; hint?: string; files: string[] }>; + agents: Array<{ key: string; label: string; hint?: string; files: string[] }>; + skills: Array<{ key: string; label: string; hint?: string; files: string[] }>; + extensionPath: string | null; +}; + +type ImportSelectionState = { + company: boolean; + projects: Set; + issues: Set; + agents: Set; + skills: Set; +}; + +function readPortableFileEntry(filePath: string, contents: Buffer): CompanyPortabilityFileEntry { + const contentType = binaryContentTypeByExtension[path.extname(filePath).toLowerCase()]; + if (!contentType) return contents.toString("utf8"); + return { + encoding: "base64", + data: contents.toString("base64"), + contentType, + }; +} + +function portableFileEntryToWriteValue(entry: CompanyPortabilityFileEntry): string | Uint8Array { + if (typeof entry === "string") return entry; + return Buffer.from(entry.data, "base64"); +} + 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); } @@ -54,15 +153,21 @@ function normalizeSelector(input: string): string { return input.trim(); } -function parseInclude(input: string | undefined): CompanyPortabilityInclude { - if (!input || !input.trim()) return { company: true, agents: true }; +function parseInclude( + input: string | undefined, + fallback: CompanyPortabilityInclude = DEFAULT_EXPORT_INCLUDE, +): CompanyPortabilityInclude { + if (!input || !input.trim()) return { ...fallback }; 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 (!include.company && !include.agents) { - throw new Error("Invalid --include value. Use one or both of: company,agents"); + if (!include.company && !include.agents && !include.projects && !include.issues && !include.skills) { + throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues,tasks,skills"); } return include; } @@ -76,50 +181,815 @@ function parseAgents(input: string | undefined): "all" | string[] { return Array.from(new Set(values)); } -function isHttpUrl(input: string): boolean { +function parseCsvValues(input: string | undefined): string[] { + if (!input || !input.trim()) return []; + return Array.from(new Set(input.split(",").map((part) => part.trim()).filter(Boolean))); +} + +function isInteractiveTerminal(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +function resolveImportInclude(input: string | undefined): CompanyPortabilityInclude { + return parseInclude(input, DEFAULT_IMPORT_INCLUDE); +} + +function normalizePortablePath(filePath: string): string { + return filePath.replace(/\\/g, "/"); +} + +function shouldIncludePortableFile(filePath: string): boolean { + const baseName = path.basename(filePath); + const isMarkdown = baseName.endsWith(".md"); + const isPaperclipYaml = baseName === ".paperclip.yaml" || baseName === ".paperclip.yml"; + const contentType = binaryContentTypeByExtension[path.extname(baseName).toLowerCase()]; + return isMarkdown || isPaperclipYaml || Boolean(contentType); +} + +function findPortableExtensionPath(files: Record): string | null { + if (files[".paperclip.yaml"] !== undefined) return ".paperclip.yaml"; + if (files[".paperclip.yml"] !== undefined) return ".paperclip.yml"; + return Object.keys(files).find((entry) => entry.endsWith("/.paperclip.yaml") || entry.endsWith("/.paperclip.yml")) ?? null; +} + +function collectFilesUnderDirectory( + files: Record, + directory: string, + opts?: { excludePrefixes?: string[] }, +): string[] { + const normalizedDirectory = normalizePortablePath(directory).replace(/\/+$/, ""); + if (!normalizedDirectory) return []; + const prefix = `${normalizedDirectory}/`; + const excluded = (opts?.excludePrefixes ?? []).map((entry) => normalizePortablePath(entry).replace(/\/+$/, "")).filter(Boolean); + return Object.keys(files) + .map(normalizePortablePath) + .filter((filePath) => filePath.startsWith(prefix)) + .filter((filePath) => !excluded.some((excludePrefix) => filePath.startsWith(`${excludePrefix}/`))) + .sort((left, right) => left.localeCompare(right)); +} + +function collectEntityFiles( + files: Record, + entryPath: string, + opts?: { excludePrefixes?: string[] }, +): string[] { + const normalizedPath = normalizePortablePath(entryPath); + const directory = normalizedPath.includes("/") ? normalizedPath.slice(0, normalizedPath.lastIndexOf("/")) : ""; + const selected = new Set([normalizedPath]); + if (directory) { + for (const filePath of collectFilesUnderDirectory(files, directory, opts)) { + selected.add(filePath); + } + } + return Array.from(selected).sort((left, right) => left.localeCompare(right)); +} + +export function buildImportSelectionCatalog(preview: CompanyPortabilityPreviewResult): ImportSelectionCatalog { + const selectedAgentSlugs = new Set(preview.selectedAgentSlugs); + const companyFiles = new Set(); + const companyPath = preview.manifest.company?.path ? normalizePortablePath(preview.manifest.company.path) : null; + if (companyPath) { + companyFiles.add(companyPath); + } + const readmePath = Object.keys(preview.files).find((entry) => normalizePortablePath(entry) === "README.md"); + if (readmePath) { + companyFiles.add(normalizePortablePath(readmePath)); + } + const logoPath = preview.manifest.company?.logoPath ? normalizePortablePath(preview.manifest.company.logoPath) : null; + if (logoPath && preview.files[logoPath] !== undefined) { + companyFiles.add(logoPath); + } + + return { + company: { + includedByDefault: preview.include.company && preview.manifest.company !== null, + files: Array.from(companyFiles).sort((left, right) => left.localeCompare(right)), + }, + projects: preview.manifest.projects.map((project) => { + const projectPath = normalizePortablePath(project.path); + const projectDir = projectPath.includes("/") ? projectPath.slice(0, projectPath.lastIndexOf("/")) : ""; + return { + key: project.slug, + label: project.name, + hint: project.slug, + files: collectEntityFiles(preview.files, projectPath, { + excludePrefixes: projectDir ? [`${projectDir}/issues`] : [], + }), + }; + }), + issues: preview.manifest.issues.map((issue) => ({ + key: issue.slug, + label: issue.title, + hint: issue.identifier ?? issue.slug, + files: collectEntityFiles(preview.files, normalizePortablePath(issue.path)), + })), + agents: preview.manifest.agents + .filter((agent) => selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug)) + .map((agent) => ({ + key: agent.slug, + label: agent.name, + hint: agent.slug, + files: collectEntityFiles(preview.files, normalizePortablePath(agent.path)), + })), + skills: preview.manifest.skills.map((skill) => ({ + key: skill.slug, + label: skill.name, + hint: skill.slug, + files: collectEntityFiles(preview.files, normalizePortablePath(skill.path)), + })), + extensionPath: findPortableExtensionPath(preview.files), + }; +} + +function toKeySet(items: Array<{ key: string }>): Set { + return new Set(items.map((item) => item.key)); +} + +export function buildDefaultImportSelectionState(catalog: ImportSelectionCatalog): ImportSelectionState { + return { + company: catalog.company.includedByDefault, + projects: toKeySet(catalog.projects), + issues: toKeySet(catalog.issues), + agents: toKeySet(catalog.agents), + skills: toKeySet(catalog.skills), + }; +} + +function countSelected(state: ImportSelectionState, group: ImportSelectableGroup): number { + return state[group].size; +} + +function countTotal(catalog: ImportSelectionCatalog, group: ImportSelectableGroup): number { + return catalog[group].length; +} + +function summarizeGroupSelection(catalog: ImportSelectionCatalog, state: ImportSelectionState, group: ImportSelectableGroup): string { + return `${countSelected(state, group)}/${countTotal(catalog, group)} selected`; +} + +function getGroupLabel(group: ImportSelectableGroup): string { + switch (group) { + case "projects": + return "Projects"; + case "issues": + return "Tasks"; + case "agents": + return "Agents"; + case "skills": + return "Skills"; + } +} + +export function buildSelectedFilesFromImportSelection( + catalog: ImportSelectionCatalog, + state: ImportSelectionState, +): string[] { + const selected = new Set(); + + if (state.company) { + for (const filePath of catalog.company.files) { + selected.add(normalizePortablePath(filePath)); + } + } + + for (const group of ["projects", "issues", "agents", "skills"] as const) { + const selectedKeys = state[group]; + for (const item of catalog[group]) { + if (!selectedKeys.has(item.key)) continue; + for (const filePath of item.files) { + selected.add(normalizePortablePath(filePath)); + } + } + } + + if (selected.size > 0 && catalog.extensionPath) { + selected.add(normalizePortablePath(catalog.extensionPath)); + } + + return Array.from(selected).sort((left, right) => left.localeCompare(right)); +} + +export function buildDefaultImportAdapterOverrides( + preview: Pick, +): Record | undefined { + const selectedAgentSlugs = new Set(preview.selectedAgentSlugs); + const overrides = Object.fromEntries( + preview.manifest.agents + .filter((agent) => selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug)) + .filter((agent) => agent.adapterType === "process") + .map((agent) => [ + agent.slug, + { + // TODO: replace this temporary claude_local fallback with adapter selection in the import TUI. + adapterType: "claude_local", + }, + ]), + ); + return Object.keys(overrides).length > 0 ? overrides : undefined; +} + +function buildDefaultImportAdapterMessages( + overrides: Record | undefined, +): string[] { + if (!overrides) return []; + const adapterTypes = Array.from(new Set(Object.values(overrides).map((override) => override.adapterType))) + .map((adapterType) => adapterType.replace(/_/g, "-")); + const agentCount = Object.keys(overrides).length; + return [ + `Using ${adapterTypes.join(", ")} adapter${adapterTypes.length === 1 ? "" : "s"} for ${agentCount} imported ${pluralize(agentCount, "agent")} without an explicit adapter.`, + ]; +} + +async function promptForImportSelection(preview: CompanyPortabilityPreviewResult): Promise { + const catalog = buildImportSelectionCatalog(preview); + const state = buildDefaultImportSelectionState(catalog); + + while (true) { + const choice = await p.select({ + message: "Select what Paperclip should import", + options: [ + { + value: "company", + label: state.company ? "Company: included" : "Company: skipped", + hint: catalog.company.files.length > 0 ? "toggle company metadata" : "no company metadata in package", + }, + { + value: "projects", + label: "Select Projects", + hint: summarizeGroupSelection(catalog, state, "projects"), + }, + { + value: "issues", + label: "Select Tasks", + hint: summarizeGroupSelection(catalog, state, "issues"), + }, + { + value: "agents", + label: "Select Agents", + hint: summarizeGroupSelection(catalog, state, "agents"), + }, + { + value: "skills", + label: "Select Skills", + hint: summarizeGroupSelection(catalog, state, "skills"), + }, + { + value: "confirm", + label: "Confirm", + hint: `${buildSelectedFilesFromImportSelection(catalog, state).length} files selected`, + }, + ], + initialValue: "confirm", + }); + + if (p.isCancel(choice)) { + p.cancel("Import cancelled."); + process.exit(0); + } + + if (choice === "confirm") { + const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state); + if (selectedFiles.length === 0) { + p.note("Select at least one import target before confirming.", "Nothing selected"); + continue; + } + return selectedFiles; + } + + if (choice === "company") { + if (catalog.company.files.length === 0) { + p.note("This package does not include company metadata to toggle.", "No company metadata"); + continue; + } + state.company = !state.company; + continue; + } + + const group = choice; + const groupItems = catalog[group]; + if (groupItems.length === 0) { + p.note(`This package does not include any ${getGroupLabel(group).toLowerCase()}.`, `No ${getGroupLabel(group)}`); + continue; + } + + const selection = await p.multiselect({ + message: `${getGroupLabel(group)} to import. Space toggles, enter returns to the main menu.`, + options: groupItems.map((item) => ({ + value: item.key, + label: item.label, + hint: item.hint, + })), + initialValues: Array.from(state[group]), + }); + + if (p.isCancel(selection)) { + p.cancel("Import cancelled."); + process.exit(0); + } + + state[group] = new Set(selection); + } +} + +function summarizeInclude(include: CompanyPortabilityInclude): string { + const labels = IMPORT_INCLUDE_OPTIONS + .filter((option) => include[option.value]) + .map((option) => option.label.toLowerCase()); + return labels.length > 0 ? labels.join(", ") : "nothing selected"; +} + +function formatSourceLabel(source: { type: "inline"; rootPath?: string | null } | { type: "github"; url: string }): string { + if (source.type === "github") { + return `GitHub: ${source.url}`; + } + return `Local package: ${source.rootPath?.trim() || "(current folder)"}`; +} + +function formatTargetLabel( + target: { mode: "existing_company"; companyId?: string | null } | { mode: "new_company"; newCompanyName?: string | null }, + preview?: CompanyPortabilityPreviewResult, +): string { + if (target.mode === "existing_company") { + const targetName = preview?.targetCompanyName?.trim(); + const targetId = preview?.targetCompanyId?.trim() || target.companyId?.trim() || "unknown-company"; + return targetName ? `${targetName} (${targetId})` : targetId; + } + return target.newCompanyName?.trim() || preview?.manifest.company?.name || "new company"; +} + +function pluralize(count: number, singular: string, plural = `${singular}s`): string { + return count === 1 ? singular : plural; +} + +function summarizePlanCounts( + plans: Array<{ action: "create" | "update" | "skip" }>, + noun: string, +): string { + if (plans.length === 0) return `0 ${pluralize(0, noun)} selected`; + const createCount = plans.filter((plan) => plan.action === "create").length; + const updateCount = plans.filter((plan) => plan.action === "update").length; + const skipCount = plans.filter((plan) => plan.action === "skip").length; + const parts: string[] = []; + if (createCount > 0) parts.push(`${createCount} create`); + if (updateCount > 0) parts.push(`${updateCount} update`); + if (skipCount > 0) parts.push(`${skipCount} skip`); + return `${plans.length} ${pluralize(plans.length, noun)} total (${parts.join(", ")})`; +} + +function summarizeImportAgentResults(agents: CompanyPortabilityImportResult["agents"]): string { + if (agents.length === 0) return "0 agents changed"; + const created = agents.filter((agent) => agent.action === "created").length; + const updated = agents.filter((agent) => agent.action === "updated").length; + const skipped = agents.filter((agent) => agent.action === "skipped").length; + const parts: string[] = []; + if (created > 0) parts.push(`${created} created`); + if (updated > 0) parts.push(`${updated} updated`); + if (skipped > 0) parts.push(`${skipped} skipped`); + return `${agents.length} ${pluralize(agents.length, "agent")} total (${parts.join(", ")})`; +} + +function summarizeImportProjectResults(projects: CompanyPortabilityImportResult["projects"]): string { + if (projects.length === 0) return "0 projects changed"; + const created = projects.filter((project) => project.action === "created").length; + const updated = projects.filter((project) => project.action === "updated").length; + const skipped = projects.filter((project) => project.action === "skipped").length; + const parts: string[] = []; + if (created > 0) parts.push(`${created} created`); + if (updated > 0) parts.push(`${updated} updated`); + if (skipped > 0) parts.push(`${skipped} skipped`); + return `${projects.length} ${pluralize(projects.length, "project")} total (${parts.join(", ")})`; +} + +function actionChip(action: string): string { + switch (action) { + case "create": + case "created": + return pc.green(action); + case "update": + case "updated": + return pc.yellow(action); + case "skip": + case "skipped": + case "none": + case "unchanged": + return pc.dim(action); + default: + return action; + } +} + +function appendPreviewExamples( + lines: string[], + title: string, + entries: Array<{ action: string; label: string; reason?: string | null }>, +): void { + if (entries.length === 0) return; + lines.push(""); + lines.push(pc.bold(title)); + const shown = entries.slice(0, IMPORT_PREVIEW_SAMPLE_LIMIT); + for (const entry of shown) { + const reason = entry.reason?.trim() ? pc.dim(` (${entry.reason.trim()})`) : ""; + lines.push(`- ${actionChip(entry.action)} ${entry.label}${reason}`); + } + if (entries.length > shown.length) { + lines.push(pc.dim(`- +${entries.length - shown.length} more`)); + } +} + +function appendMessageBlock(lines: string[], title: string, messages: string[]): void { + if (messages.length === 0) return; + lines.push(""); + lines.push(pc.bold(title)); + for (const message of messages) { + lines.push(`- ${message}`); + } +} + +export function renderCompanyImportPreview( + preview: CompanyPortabilityPreviewResult, + meta: { + sourceLabel: string; + targetLabel: string; + infoMessages?: string[]; + }, +): string { + const lines: string[] = [ + `${pc.bold("Source")} ${meta.sourceLabel}`, + `${pc.bold("Target")} ${meta.targetLabel}`, + `${pc.bold("Include")} ${summarizeInclude(preview.include)}`, + `${pc.bold("Mode")} ${preview.collisionStrategy} collisions`, + "", + pc.bold("Package"), + `- company: ${preview.manifest.company?.name ?? preview.manifest.source?.companyName ?? "not included"}`, + `- agents: ${preview.manifest.agents.length}`, + `- projects: ${preview.manifest.projects.length}`, + `- tasks: ${preview.manifest.issues.length}`, + `- skills: ${preview.manifest.skills.length}`, + ]; + + if (preview.envInputs.length > 0) { + const requiredCount = preview.envInputs.filter((item) => item.requirement === "required").length; + lines.push(`- env inputs: ${preview.envInputs.length} (${requiredCount} required)`); + } + + lines.push(""); + lines.push(pc.bold("Plan")); + lines.push(`- company: ${actionChip(preview.plan.companyAction === "none" ? "unchanged" : preview.plan.companyAction)}`); + lines.push(`- agents: ${summarizePlanCounts(preview.plan.agentPlans, "agent")}`); + lines.push(`- projects: ${summarizePlanCounts(preview.plan.projectPlans, "project")}`); + lines.push(`- tasks: ${summarizePlanCounts(preview.plan.issuePlans, "task")}`); + if (preview.include.skills) { + lines.push(`- skills: ${preview.manifest.skills.length} ${pluralize(preview.manifest.skills.length, "skill")} packaged`); + } + + appendPreviewExamples( + lines, + "Agent examples", + preview.plan.agentPlans.map((plan) => ({ + action: plan.action, + label: `${plan.slug} -> ${plan.plannedName}`, + reason: plan.reason, + })), + ); + appendPreviewExamples( + lines, + "Project examples", + preview.plan.projectPlans.map((plan) => ({ + action: plan.action, + label: `${plan.slug} -> ${plan.plannedName}`, + reason: plan.reason, + })), + ); + appendPreviewExamples( + lines, + "Task examples", + preview.plan.issuePlans.map((plan) => ({ + action: plan.action, + label: `${plan.slug} -> ${plan.plannedTitle}`, + reason: plan.reason, + })), + ); + + appendMessageBlock(lines, pc.cyan("Info"), meta.infoMessages ?? []); + appendMessageBlock(lines, pc.yellow("Warnings"), preview.warnings); + appendMessageBlock(lines, pc.red("Errors"), preview.errors); + + return lines.join("\n"); +} + +export function renderCompanyImportResult( + result: CompanyPortabilityImportResult, + meta: { targetLabel: string; companyUrl?: string; infoMessages?: string[] }, +): string { + const lines: string[] = [ + `${pc.bold("Target")} ${meta.targetLabel}`, + `${pc.bold("Company")} ${result.company.name} (${actionChip(result.company.action)})`, + `${pc.bold("Agents")} ${summarizeImportAgentResults(result.agents)}`, + `${pc.bold("Projects")} ${summarizeImportProjectResults(result.projects)}`, + ]; + + if (meta.companyUrl) { + lines.splice(1, 0, `${pc.bold("URL")} ${meta.companyUrl}`); + } + + appendPreviewExamples( + lines, + "Agent results", + result.agents.map((agent) => ({ + action: agent.action, + label: `${agent.slug} -> ${agent.name}`, + reason: agent.reason, + })), + ); + appendPreviewExamples( + lines, + "Project results", + result.projects.map((project) => ({ + action: project.action, + label: `${project.slug} -> ${project.name}`, + reason: project.reason, + })), + ); + + if (result.envInputs.length > 0) { + lines.push(""); + lines.push(pc.bold("Env inputs")); + lines.push( + `- ${result.envInputs.length} ${pluralize(result.envInputs.length, "input")} may need values after import`, + ); + } + + appendMessageBlock(lines, pc.cyan("Info"), meta.infoMessages ?? []); + appendMessageBlock(lines, pc.yellow("Warnings"), result.warnings); + + return lines.join("\n"); +} + +function printCompanyImportView(title: string, body: string, opts?: { interactive?: boolean }): void { + if (opts?.interactive) { + p.note(body, title); + return; + } + console.log(pc.bold(title)); + console.log(body); +} + +export function resolveCompanyImportApiPath(input: { + dryRun: boolean; + targetMode: "new_company" | "existing_company"; + companyId?: string | null; +}): string { + if (input.targetMode === "existing_company") { + const companyId = input.companyId?.trim(); + if (!companyId) { + throw new Error("Existing-company imports require a companyId to resolve the API route."); + } + return input.dryRun + ? `/api/companies/${companyId}/imports/preview` + : `/api/companies/${companyId}/imports/apply`; + } + + return input.dryRun ? "/api/companies/import/preview" : "/api/companies/import"; +} + +export function buildCompanyDashboardUrl(apiBase: string, issuePrefix: string): string { + const url = new URL(apiBase); + const normalizedPrefix = issuePrefix.trim().replace(/^\/+|\/+$/g, ""); + url.pathname = `${url.pathname.replace(/\/+$/, "")}/${normalizedPrefix}/dashboard`; + url.search = ""; + url.hash = ""; + return url.toString(); +} + +export function resolveCompanyImportApplyConfirmationMode(input: { + yes?: boolean; + interactive: boolean; + json: boolean; +}): "skip" | "prompt" { + if (input.yes) { + return "skip"; + } + if (input.json) { + throw new Error( + "Applying a company import with --json requires --yes. Use --dry-run first to inspect the preview.", + ); + } + if (!input.interactive) { + throw new Error( + "Applying a company import from a non-interactive terminal requires --yes. Use --dry-run first to inspect the preview.", + ); + } + return "prompt"; +} + +export function isHttpUrl(input: string): boolean { return /^https?:\/\//i.test(input.trim()); } -function isGithubUrl(input: string): boolean { - return /^https?:\/\/github\.com\//i.test(input.trim()); +export function looksLikeRepoUrl(input: string): boolean { + try { + const url = new URL(input.trim()); + if (url.protocol !== "https:") return false; + const segments = url.pathname.split("/").filter(Boolean); + return segments.length >= 2; + } catch { + return false; + } +} + +function isGithubSegment(input: string): boolean { + return /^[A-Za-z0-9._-]+$/.test(input); +} + +export function isGithubShorthand(input: string): boolean { + const trimmed = input.trim(); + if (!trimmed || isHttpUrl(trimmed)) return false; + if ( + trimmed.startsWith(".") || + trimmed.startsWith("/") || + trimmed.startsWith("~") || + trimmed.includes("\\") || + /^[A-Za-z]:/.test(trimmed) + ) { + return false; + } + + const segments = trimmed.split("/").filter(Boolean); + return segments.length >= 2 && segments.every(isGithubSegment); +} + +function normalizeGithubImportPath(input: string | null | undefined): string | null { + if (!input) return null; + const trimmed = input.trim().replace(/^\/+|\/+$/g, ""); + return trimmed || null; +} + +function buildGithubImportUrl(input: { + hostname?: string; + owner: string; + repo: string; + ref?: string | null; + path?: string | null; + companyPath?: string | null; +}): string { + const host = input.hostname || "github.com"; + const url = new URL(`https://${host}/${input.owner}/${input.repo.replace(/\.git$/i, "")}`); + const ref = input.ref?.trim(); + if (ref) { + url.searchParams.set("ref", ref); + } + const companyPath = normalizeGithubImportPath(input.companyPath); + if (companyPath) { + url.searchParams.set("companyPath", companyPath); + return url.toString(); + } + const sourcePath = normalizeGithubImportPath(input.path); + if (sourcePath) { + url.searchParams.set("path", sourcePath); + } + return url.toString(); +} + +export function normalizeGithubImportSource(input: string, refOverride?: string): string { + const trimmed = input.trim(); + const ref = refOverride?.trim(); + + if (isGithubShorthand(trimmed)) { + const [owner, repo, ...repoPath] = trimmed.split("/").filter(Boolean); + return buildGithubImportUrl({ + owner: owner!, + repo: repo!, + ref: ref || "main", + path: repoPath.join("/"), + }); + } + + if (!looksLikeRepoUrl(trimmed)) { + throw new Error("GitHub source must be a GitHub or GitHub Enterprise URL, or owner/repo[/path] shorthand."); + } + if (!ref) { + return trimmed; + } + + const url = new URL(trimmed); + const hostname = url.hostname; + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length < 2) { + throw new Error("Invalid GitHub URL."); + } + + const owner = parts[0]!; + const repo = parts[1]!; + const existingPath = normalizeGithubImportPath(url.searchParams.get("path")); + const existingCompanyPath = normalizeGithubImportPath(url.searchParams.get("companyPath")); + if (existingCompanyPath) { + return buildGithubImportUrl({ hostname, owner, repo, ref, companyPath: existingCompanyPath }); + } + if (existingPath) { + return buildGithubImportUrl({ hostname, owner, repo, ref, path: existingPath }); + } + if (parts[2] === "tree") { + return buildGithubImportUrl({ hostname, owner, repo, ref, path: parts.slice(4).join("/") }); + } + if (parts[2] === "blob") { + return buildGithubImportUrl({ hostname, owner, repo, ref, companyPath: parts.slice(4).join("/") }); + } + return buildGithubImportUrl({ hostname, owner, repo, ref }); +} + +async function pathExists(inputPath: string): Promise { + try { + await stat(path.resolve(inputPath)); + return true; + } catch { + return false; + } } -async function resolveInlineSourceFromPath(inputPath: string): Promise<{ - manifest: CompanyPortabilityManifest; - files: Record; +async function collectPackageFiles( + root: string, + current: string, + files: Record, +): Promise { + const entries = await readdir(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".git")) continue; + const absolutePath = path.join(current, entry.name); + if (entry.isDirectory()) { + await collectPackageFiles(root, absolutePath, files); + continue; + } + if (!entry.isFile()) continue; + const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/"); + if (!shouldIncludePortableFile(relativePath)) continue; + files[relativePath] = readPortableFileEntry(relativePath, await readFile(absolutePath)); + } +} + +export async function resolveInlineSourceFromPath(inputPath: string): Promise<{ + rootPath: string; + files: Record; }> { const resolved = path.resolve(inputPath); const resolvedStat = await stat(resolved); - const manifestPath = resolvedStat.isDirectory() - ? path.join(resolved, "paperclip.manifest.json") - : resolved; - const manifestBaseDir = path.dirname(manifestPath); - const manifestRaw = await readFile(manifestPath, "utf8"); - const manifest = JSON.parse(manifestRaw) as CompanyPortabilityManifest; - const files: Record = {}; - - if (manifest.company?.path) { - const companyPath = manifest.company.path.replace(/\\/g, "/"); - files[companyPath] = await readFile(path.join(manifestBaseDir, companyPath), "utf8"); - } - for (const agent of manifest.agents ?? []) { - const agentPath = agent.path.replace(/\\/g, "/"); - files[agentPath] = await readFile(path.join(manifestBaseDir, agentPath), "utf8"); + if (resolvedStat.isFile() && path.extname(resolved).toLowerCase() === ".zip") { + const archive = await readZipArchive(await readFile(resolved)); + const filteredFiles = Object.fromEntries( + Object.entries(archive.files).filter(([relativePath]) => shouldIncludePortableFile(relativePath)), + ); + return { + rootPath: archive.rootPath ?? path.basename(resolved, ".zip"), + files: filteredFiles, + }; } - return { manifest, files }; + const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved); + const files: Record = {}; + await collectPackageFiles(rootDir, rootDir, files); + return { + rootPath: path.basename(rootDir), + files, + }; } async function writeExportToFolder(outDir: string, exported: CompanyPortabilityExportResult): Promise { const root = path.resolve(outDir); await mkdir(root, { recursive: true }); - const manifestPath = path.join(root, "paperclip.manifest.json"); - await writeFile(manifestPath, JSON.stringify(exported.manifest, null, 2), "utf8"); for (const [relativePath, content] of Object.entries(exported.files)) { const normalized = relativePath.replace(/\\/g, "/"); const filePath = path.join(root, normalized); await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, content, "utf8"); + const writeValue = portableFileEntryToWriteValue(content); + if (typeof writeValue === "string") { + await writeFile(filePath, writeValue, "utf8"); + } else { + await writeFile(filePath, writeValue); + } + } +} + +async function confirmOverwriteExportDirectory(outDir: string): Promise { + const root = path.resolve(outDir); + const stats = await stat(root).catch(() => null); + if (!stats) return; + if (!stats.isDirectory()) { + throw new Error(`Export output path ${root} exists and is not a directory.`); + } + + const entries = await readdir(root); + if (entries.length === 0) return; + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error(`Export output directory ${root} already contains files. Re-run interactively or choose an empty directory.`); + } + + const confirmed = await p.confirm({ + message: `Overwrite existing files in ${root}?`, + initialValue: false, + }); + + if (p.isCancel(confirmed) || !confirmed) { + throw new Error("Export cancelled."); } } @@ -254,30 +1124,130 @@ export function registerCompanyCommands(program: Command): void { }), ); + addCommonClientOptions( + company + .command("feedback:list") + .description("List feedback traces for a company") + .requiredOption("-C, --company-id ", "Company ID") + .option("--target-type ", "Filter by target type") + .option("--vote ", "Filter by vote value") + .option("--status ", "Filter by trace status") + .option("--project-id ", "Filter by project ID") + .option("--issue-id ", "Filter by issue ID") + .option("--from ", "Only include traces created at or after this timestamp") + .option("--to ", "Only include traces created at or before this timestamp") + .option("--shared-only", "Only include traces eligible for sharing/export") + .option("--include-payload", "Include stored payload snapshots in the response") + .action(async (opts: CompanyFeedbackOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const traces = (await ctx.api.get( + `/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts)}`, + )) ?? []; + if (ctx.json) { + printOutput(traces, { json: true }); + return; + } + printOutput( + traces.map((trace) => ({ + id: trace.id, + issue: trace.issueIdentifier ?? trace.issueId, + vote: trace.vote, + status: trace.status, + targetType: trace.targetType, + target: trace.targetSummary.label, + })), + { json: false }, + ); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + + addCommonClientOptions( + company + .command("feedback:export") + .description("Export feedback traces for a company") + .requiredOption("-C, --company-id ", "Company ID") + .option("--target-type ", "Filter by target type") + .option("--vote ", "Filter by vote value") + .option("--status ", "Filter by trace status") + .option("--project-id ", "Filter by project ID") + .option("--issue-id ", "Filter by issue ID") + .option("--from ", "Only include traces created at or after this timestamp") + .option("--to ", "Only include traces created at or before this timestamp") + .option("--shared-only", "Only include traces eligible for sharing/export") + .option("--include-payload", "Include stored payload snapshots in the export") + .option("--out ", "Write export to a file path instead of stdout") + .option("--format ", "Export format: json or ndjson", "ndjson") + .action(async (opts: CompanyFeedbackOptions) => { + try { + const ctx = resolveCommandContext(opts, { requireCompany: true }); + const traces = (await ctx.api.get( + `/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`, + )) ?? []; + const serialized = serializeFeedbackTraces(traces, opts.format); + if (opts.out?.trim()) { + await writeFile(opts.out, serialized, "utf8"); + if (ctx.json) { + printOutput( + { out: opts.out, count: traces.length, format: normalizeFeedbackTraceExportFormat(opts.format) }, + { json: true }, + ); + return; + } + console.log(`Wrote ${traces.length} feedback trace(s) to ${opts.out}`); + return; + } + process.stdout.write(`${serialized}${serialized.endsWith("\n") ? "" : "\n"}`); + } catch (err) { + handleCommandError(err); + } + }), + { includeCompany: false }, + ); + addCommonClientOptions( company .command("export") - .description("Export a company into portable manifest + markdown files") + .description("Export a company into a portable markdown package") .argument("", "Company ID") .requiredOption("--out ", "Output directory") - .option("--include ", "Comma-separated include set: company,agents", "company,agents") + .option("--include ", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents") + .option("--skills ", "Comma-separated skill slugs/keys to export") + .option("--projects ", "Comma-separated project shortnames/ids to export") + .option("--issues ", "Comma-separated issue identifiers/ids to export") + .option("--project-issues ", "Comma-separated project shortnames/ids whose issues should be exported") + .option("--expand-referenced-skills", "Vendor skill contents instead of exporting upstream references", false) .action(async (companyId: string, opts: CompanyExportOptions) => { try { const ctx = resolveCommandContext(opts); const include = parseInclude(opts.include); const exported = await ctx.api.post( `/api/companies/${companyId}/export`, - { include }, + { + include, + skills: parseCsvValues(opts.skills), + projects: parseCsvValues(opts.projects), + issues: parseCsvValues(opts.issues), + projectIssues: parseCsvValues(opts.projectIssues), + expandReferencedSkills: Boolean(opts.expandReferencedSkills), + }, ); if (!exported) { throw new Error("Export request returned no data"); } + await confirmOverwriteExportDirectory(opts.out!); await writeExportToFolder(opts.out!, exported); printOutput( { ok: true, out: path.resolve(opts.out!), - filesWritten: Object.keys(exported.files).length + 1, + rootPath: exported.rootPath, + filesWritten: Object.keys(exported.files).length, + paperclipExtensionPath: exported.paperclipExtensionPath, warningCount: exported.warnings.length, }, { json: ctx.json }, @@ -296,24 +1266,31 @@ export function registerCompanyCommands(program: Command): void { addCommonClientOptions( company .command("import") - .description("Import a portable company package from local path, URL, or GitHub") - .requiredOption("--from ", "Source path or URL") - .option("--include ", "Comma-separated include set: company,agents", "company,agents") + .description("Import a portable markdown company package from local path, URL, or GitHub") + .argument("", "Source path or URL") + .option("--include ", "Comma-separated include set: company,agents,projects,issues,tasks,skills") .option("--target ", "Target mode: new | existing") .option("-C, --company-id ", "Existing target company ID") .option("--new-company-name ", "Name override for --target new") .option("--agents ", "Comma-separated agent slugs to import, or all", "all") .option("--collision ", "Collision strategy: rename | skip | replace", "rename") + .option("--ref ", "Git ref to use for GitHub imports (branch, tag, or commit)") + .option("--paperclip-url ", "Alias for --api-base on this command") + .option("--yes", "Accept default selection and skip the pre-import confirmation prompt", false) .option("--dry-run", "Run preview only without applying", false) - .action(async (opts: CompanyImportOptions) => { + .action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => { try { + if (!opts.apiBase?.trim() && opts.paperclipUrl?.trim()) { + opts.apiBase = opts.paperclipUrl.trim(); + } const ctx = resolveCommandContext(opts); - const from = (opts.from ?? "").trim(); + const interactiveView = isInteractiveTerminal() && !ctx.json; + const from = fromPathOrUrl.trim(); if (!from) { - throw new Error("--from is required"); + throw new Error("Source path or URL is required."); } - const include = parseInclude(opts.include); + const include = resolveImportInclude(opts.include); const agents = parseAgents(opts.agents); const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode; if (!["rename", "skip", "replace"].includes(collision)) { @@ -343,42 +1320,171 @@ export function registerCompanyCommands(program: Command): void { } let sourcePayload: - | { type: "inline"; manifest: CompanyPortabilityManifest; files: Record } - | { type: "url"; url: string } + | { type: "inline"; rootPath?: string | null; files: Record } | { type: "github"; url: string }; - if (isHttpUrl(from)) { - sourcePayload = isGithubUrl(from) - ? { type: "github", url: from } - : { type: "url", url: from }; + const treatAsLocalPath = !isHttpUrl(from) && await pathExists(from); + const isGithubSource = looksLikeRepoUrl(from) || (isGithubShorthand(from) && !treatAsLocalPath); + + if (isHttpUrl(from) || isGithubSource) { + if (!looksLikeRepoUrl(from) && !isGithubShorthand(from)) { + throw new Error( + "Only GitHub URLs and local paths are supported for import. " + + "Generic HTTP URLs are not supported. Use a GitHub or GitHub Enterprise URL (https://github.com/... or https://ghe.example.com/...) or a local directory path.", + ); + } + sourcePayload = { type: "github", url: normalizeGithubImportSource(from, opts.ref) }; } else { + if (opts.ref?.trim()) { + throw new Error("--ref is only supported for GitHub import sources."); + } const inline = await resolveInlineSourceFromPath(from); sourcePayload = { type: "inline", - manifest: inline.manifest, + rootPath: inline.rootPath, files: inline.files, }; } - const payload = { + const sourceLabel = formatSourceLabel(sourcePayload); + const targetLabel = formatTargetLabel(targetPayload); + const previewApiPath = resolveCompanyImportApiPath({ + dryRun: true, + targetMode: targetPayload.mode, + companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null, + }); + + let selectedFiles: string[] | undefined; + if (interactiveView && !opts.yes && !opts.include?.trim()) { + const initialPreview = await ctx.api.post(previewApiPath, { + source: sourcePayload, + include, + target: targetPayload, + agents, + collisionStrategy: collision, + }); + if (!initialPreview) { + throw new Error("Import preview returned no data."); + } + selectedFiles = await promptForImportSelection(initialPreview); + } + + const previewPayload = { source: sourcePayload, include, target: targetPayload, agents, collisionStrategy: collision, + selectedFiles, }; + const preview = await ctx.api.post(previewApiPath, previewPayload); + if (!preview) { + throw new Error("Import preview returned no data."); + } + const adapterOverrides = buildDefaultImportAdapterOverrides(preview); + const adapterMessages = buildDefaultImportAdapterMessages(adapterOverrides); if (opts.dryRun) { - const preview = await ctx.api.post( - "/api/companies/import/preview", - payload, - ); - printOutput(preview, { json: ctx.json }); + if (ctx.json) { + printOutput(preview, { json: true }); + } else { + printCompanyImportView( + "Import Preview", + renderCompanyImportPreview(preview, { + sourceLabel, + targetLabel: formatTargetLabel(targetPayload, preview), + infoMessages: adapterMessages, + }), + { interactive: interactiveView }, + ); + } return; } - const imported = await ctx.api.post("/api/companies/import", payload); - printOutput(imported, { json: ctx.json }); + if (!ctx.json) { + printCompanyImportView( + "Import Preview", + renderCompanyImportPreview(preview, { + sourceLabel, + targetLabel: formatTargetLabel(targetPayload, preview), + infoMessages: adapterMessages, + }), + { interactive: interactiveView }, + ); + } + + const confirmationMode = resolveCompanyImportApplyConfirmationMode({ + yes: opts.yes, + interactive: interactiveView, + json: ctx.json, + }); + if (confirmationMode === "prompt") { + const confirmed = await p.confirm({ + message: "Apply this import? (y/N)", + initialValue: false, + }); + if (p.isCancel(confirmed) || !confirmed) { + p.log.warn("Import cancelled."); + return; + } + } + + const importApiPath = resolveCompanyImportApiPath({ + dryRun: false, + targetMode: targetPayload.mode, + companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null, + }); + const imported = await ctx.api.post(importApiPath, { + ...previewPayload, + adapterOverrides, + }); + if (!imported) { + throw new Error("Import request returned no data."); + } + const tc = getTelemetryClient(); + if (tc) { + const isPrivate = sourcePayload.type !== "github"; + const sourceRef = sourcePayload.type === "github" ? sourcePayload.url : from; + trackCompanyImported(tc, { sourceType: sourcePayload.type, sourceRef, isPrivate }); + } + let companyUrl: string | undefined; + if (!ctx.json) { + try { + const importedCompany = await ctx.api.get(`/api/companies/${imported.company.id}`); + const issuePrefix = importedCompany?.issuePrefix?.trim(); + if (issuePrefix) { + companyUrl = buildCompanyDashboardUrl(ctx.api.apiBase, issuePrefix); + } + } catch { + companyUrl = undefined; + } + } + if (ctx.json) { + printOutput(imported, { json: true }); + } else { + printCompanyImportView( + "Import Result", + renderCompanyImportResult(imported, { + targetLabel, + companyUrl, + infoMessages: adapterMessages, + }), + { interactive: interactiveView }, + ); + if (interactiveView && companyUrl) { + const openImportedCompany = await p.confirm({ + message: "Open the imported company in your browser?", + initialValue: true, + }); + if (!p.isCancel(openImportedCompany) && openImportedCompany) { + if (openUrl(companyUrl)) { + p.log.info(`Opened ${companyUrl}`); + } else { + p.log.warn(`Could not open your browser automatically. Open this URL manually:\n${companyUrl}`); + } + } + } + } } catch (err) { handleCommandError(err); } diff --git a/cli/src/commands/client/feedback.ts b/cli/src/commands/client/feedback.ts new file mode 100644 index 00000000000..3ac247d9dc8 --- /dev/null +++ b/cli/src/commands/client/feedback.ts @@ -0,0 +1,645 @@ +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, + 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; +} + +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 ", "Company ID (overrides context default)") + .option("--target-type ", "Filter by target type") + .option("--vote ", "Filter by vote value") + .option("--status ", "Filter by trace status") + .option("--project-id ", "Filter by project ID") + .option("--issue-id ", "Filter by issue ID") + .option("--from ", "Only include traces created at or after this timestamp") + .option("--to ", "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 ", "Company ID (overrides context default)") + .option("--target-type ", "Filter by target type") + .option("--vote ", "Filter by vote value") + .option("--status ", "Filter by trace status") + .option("--project-id ", "Filter by project ID") + .option("--issue-id ", "Filter by issue ID") + .option("--from ", "Only include traces created at or after this timestamp") + .option("--to ", "Only include traces created at or before this timestamp") + .option("--shared-only", "Only include traces eligible for sharing/export") + .option("--out ", "Output directory (default: ./feedback-export-)") + .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 }, + ); +} + +export async function resolveFeedbackCompanyId( + ctx: ResolvedClientContext, + explicitCompanyId?: string, +): Promise { + const direct = explicitCompanyId?.trim() || ctx.companyId?.trim(); + if (direct) return direct; + const companies = (await ctx.api.get("/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 { + return ( + (await ctx.api.get( + `/api/companies/${companyId}/feedback-traces${buildFeedbackTraceQuery(opts, true)}`, + )) ?? [] + ); +} + +export async function fetchFeedbackTraceBundle( + ctx: ResolvedClientContext, + traceId: string, +): Promise { + const bundle = await ctx.api.get(`/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 = {}; + 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; +}): Promise { + 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(); + + 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 | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +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 { + 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> { + const files: Record = {}; + 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, 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; +} diff --git a/cli/src/commands/client/issue.ts b/cli/src/commands/client/issue.ts index 8db617d96d0..afef1923b9c 100644 --- a/cli/src/commands/client/issue.ts +++ b/cli/src/commands/client/issue.ts @@ -1,8 +1,10 @@ import { Command } from "commander"; +import { writeFile } from "node:fs/promises"; import { addIssueCommentSchema, checkoutIssueSchema, createIssueSchema, + type FeedbackTrace, updateIssueSchema, type Issue, type IssueComment, @@ -15,6 +17,11 @@ import { resolveCommandContext, type BaseClientOptions, } from "./common.js"; +import { + buildFeedbackTraceQuery, + normalizeFeedbackTraceExportFormat, + serializeFeedbackTraces, +} from "./feedback.js"; interface IssueBaseOptions extends BaseClientOptions { status?: string; @@ -54,6 +61,7 @@ interface IssueUpdateOptions extends BaseClientOptions { interface IssueCommentOptions extends BaseClientOptions { body: string; reopen?: boolean; + resume?: boolean; } interface IssueCheckoutOptions extends BaseClientOptions { @@ -61,6 +69,18 @@ interface IssueCheckoutOptions extends BaseClientOptions { expectedStatuses?: string; } +interface IssueFeedbackOptions extends BaseClientOptions { + targetType?: string; + vote?: string; + status?: string; + from?: string; + to?: string; + sharedOnly?: boolean; + includePayload?: boolean; + out?: string; + format?: string; +} + export function registerIssueCommands(program: Command): void { const issue = program.command("issue").description("Issue operations"); @@ -222,12 +242,14 @@ export function registerIssueCommands(program: Command): void { .argument("", "Issue ID") .requiredOption("--body ", "Comment body") .option("--reopen", "Reopen if issue is done/cancelled") + .option("--resume", "Request explicit follow-up and wake the assignee when resumable") .action(async (issueId: string, opts: IssueCommentOptions) => { try { const ctx = resolveCommandContext(opts); const payload = addIssueCommentSchema.parse({ body: opts.body, reopen: opts.reopen, + resume: opts.resume, }); const comment = await ctx.api.post(`/api/issues/${issueId}/comments`, payload); printOutput(comment, { json: ctx.json }); @@ -237,6 +259,85 @@ export function registerIssueCommands(program: Command): void { }), ); + addCommonClientOptions( + issue + .command("feedback:list") + .description("List feedback traces for an issue") + .argument("", "Issue ID") + .option("--target-type ", "Filter by target type") + .option("--vote ", "Filter by vote value") + .option("--status ", "Filter by trace status") + .option("--from ", "Only include traces created at or after this timestamp") + .option("--to ", "Only include traces created at or before this timestamp") + .option("--shared-only", "Only include traces eligible for sharing/export") + .option("--include-payload", "Include stored payload snapshots in the response") + .action(async (issueId: string, opts: IssueFeedbackOptions) => { + try { + const ctx = resolveCommandContext(opts); + const traces = (await ctx.api.get( + `/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts)}`, + )) ?? []; + if (ctx.json) { + printOutput(traces, { json: true }); + return; + } + printOutput( + traces.map((trace) => ({ + id: trace.id, + issue: trace.issueIdentifier ?? trace.issueId, + vote: trace.vote, + status: trace.status, + targetType: trace.targetType, + target: trace.targetSummary.label, + })), + { json: false }, + ); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + issue + .command("feedback:export") + .description("Export feedback traces for an issue") + .argument("", "Issue ID") + .option("--target-type ", "Filter by target type") + .option("--vote ", "Filter by vote value") + .option("--status ", "Filter by trace status") + .option("--from ", "Only include traces created at or after this timestamp") + .option("--to ", "Only include traces created at or before this timestamp") + .option("--shared-only", "Only include traces eligible for sharing/export") + .option("--include-payload", "Include stored payload snapshots in the export") + .option("--out ", "Write export to a file path instead of stdout") + .option("--format ", "Export format: json or ndjson", "ndjson") + .action(async (issueId: string, opts: IssueFeedbackOptions) => { + try { + const ctx = resolveCommandContext(opts); + const traces = (await ctx.api.get( + `/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`, + )) ?? []; + const serialized = serializeFeedbackTraces(traces, opts.format); + if (opts.out?.trim()) { + await writeFile(opts.out, serialized, "utf8"); + if (ctx.json) { + printOutput( + { out: opts.out, count: traces.length, format: normalizeFeedbackTraceExportFormat(opts.format) }, + { json: true }, + ); + return; + } + console.log(`Wrote ${traces.length} feedback trace(s) to ${opts.out}`); + return; + } + process.stdout.write(`${serialized}${serialized.endsWith("\n") ? "" : "\n"}`); + } catch (err) { + handleCommandError(err); + } + }), + ); + addCommonClientOptions( issue .command("checkout") diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts new file mode 100644 index 00000000000..933671e356b --- /dev/null +++ b/cli/src/commands/client/plugin.ts @@ -0,0 +1,534 @@ +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; +} + + +// --------------------------------------------------------------------------- +// Option types +// --------------------------------------------------------------------------- + +interface PluginListOptions extends BaseClientOptions { + status?: string; +} + +interface PluginInstallOptions extends BaseClientOptions { + local?: boolean; + version?: string; +} + +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 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 = {}, + 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"); +} + +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 + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("init ") + .description("Scaffold a local Paperclip plugin project") + .option("--output ", "Directory to create the plugin folder in") + .addOption( + new Option("--template