Spaces:
Paused
Paused
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .claude/skills/design-guide/SKILL.md +351 -0
- .claude/skills/design-guide/references/component-index.md +323 -0
- .dockerignore +10 -5
- .env.example +3 -0
- .gitattributes +2 -0
- .github/CODEOWNERS +10 -0
- .github/workflows/e2e.yml +44 -0
- .github/workflows/pr-policy.yml +49 -0
- .github/workflows/pr-verify.yml +48 -0
- .github/workflows/refresh-lockfile.yml +93 -0
- .github/workflows/release.yml +260 -0
- .gitignore +45 -22
- .mailmap +1 -0
- .npmrc +1 -0
- AGENTS.md +145 -0
- Agent.md +168 -0
- CONTRIBUTING.md +74 -0
- Dockerfile +51 -50
- Dockerfile.onboard-smoke +40 -0
- LICENSE +2 -2
- README.md +281 -239
- cli/CHANGELOG.md +138 -0
- cli/esbuild.config.mjs +65 -0
- cli/package.json +62 -0
- cli/src/__tests__/agent-jwt-env.test.ts +79 -0
- cli/src/__tests__/allowed-hostname.test.ts +77 -0
- cli/src/__tests__/common.test.ts +98 -0
- cli/src/__tests__/company-delete.test.ts +91 -0
- cli/src/__tests__/context.test.ts +70 -0
- cli/src/__tests__/data-dir.test.ts +79 -0
- cli/src/__tests__/doctor.test.ts +99 -0
- cli/src/__tests__/home-paths.test.ts +44 -0
- cli/src/__tests__/http.test.ts +61 -0
- cli/src/__tests__/worktree.test.ts +472 -0
- cli/src/adapters/http/format-event.ts +4 -0
- cli/src/adapters/http/index.ts +7 -0
- cli/src/adapters/index.ts +2 -0
- cli/src/adapters/process/format-event.ts +4 -0
- cli/src/adapters/process/index.ts +7 -0
- cli/src/adapters/registry.ts +63 -0
- cli/src/checks/agent-jwt-secret-check.ts +40 -0
- cli/src/checks/config-check.ts +33 -0
- cli/src/checks/database-check.ts +59 -0
- cli/src/checks/deployment-auth-check.ts +91 -0
- cli/src/checks/index.ts +18 -0
- cli/src/checks/llm-check.ts +82 -0
- cli/src/checks/log-check.ts +30 -0
- cli/src/checks/path-resolver.ts +1 -0
- cli/src/checks/port-check.ts +24 -0
- cli/src/checks/secrets-check.ts +146 -0
.claude/skills/design-guide/SKILL.md
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: design-guide
|
| 3 |
+
description: >
|
| 4 |
+
Paperclip UI design system guide for building consistent, reusable frontend
|
| 5 |
+
components. Use when creating new UI components, modifying existing ones,
|
| 6 |
+
adding pages or features to the frontend, styling UI elements, or when you
|
| 7 |
+
need to understand the design language and conventions. Covers: component
|
| 8 |
+
creation, design tokens, typography, status/priority systems, composition
|
| 9 |
+
patterns, and the /design-guide showcase page. Always use this skill
|
| 10 |
+
alongside the frontend-design skill (for visual quality) and the
|
| 11 |
+
web-design-guidelines skill (for web best practices).
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# Paperclip Design Guide
|
| 15 |
+
|
| 16 |
+
Paperclip's UI is a professional-grade control plane — dense, keyboard-driven, dark-themed by default. Every pixel earns its place.
|
| 17 |
+
|
| 18 |
+
**Always use with:** `frontend-design` (visual polish) and `web-design-guidelines` (web best practices).
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## 1. Design Principles
|
| 23 |
+
|
| 24 |
+
- **Dense but scannable.** Maximum information without clicks to reveal. Whitespace separates, not pads.
|
| 25 |
+
- **Keyboard-first.** Global shortcuts (Cmd+K, C, [, ]). Power users rarely touch the mouse.
|
| 26 |
+
- **Contextual, not modal.** Inline editing over dialog boxes. Dropdowns over page navigations.
|
| 27 |
+
- **Dark theme default.** Neutral grays (OKLCH), not pure black. Accent colors for status/priority only. Text is the primary visual element.
|
| 28 |
+
- **Component-driven.** Prefer reusable components that capture style conventions. Build at the right abstraction — not too granular, not too monolithic.
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## 2. Tech Stack
|
| 33 |
+
|
| 34 |
+
- **React 19** + **TypeScript** + **Vite**
|
| 35 |
+
- **Tailwind CSS v4** with CSS variables (OKLCH color space)
|
| 36 |
+
- **shadcn/ui** (new-york style, neutral base, CSS variables enabled)
|
| 37 |
+
- **Radix UI** primitives (accessibility, focus management)
|
| 38 |
+
- **Lucide React** icons (16px nav, 14px inline)
|
| 39 |
+
- **class-variance-authority** (CVA) for component variants
|
| 40 |
+
- **clsx + tailwind-merge** via `cn()` utility
|
| 41 |
+
|
| 42 |
+
Config: `ui/components.json` (aliases: `@/components`, `@/components/ui`, `@/lib`, `@/hooks`)
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
## 3. Design Tokens
|
| 47 |
+
|
| 48 |
+
All tokens defined as CSS variables in `ui/src/index.css`. Both light and dark themes use OKLCH.
|
| 49 |
+
|
| 50 |
+
### Colors
|
| 51 |
+
|
| 52 |
+
Use semantic token names, never raw color values:
|
| 53 |
+
|
| 54 |
+
| Token | Usage |
|
| 55 |
+
|-------|-------|
|
| 56 |
+
| `--background` / `--foreground` | Page background and primary text |
|
| 57 |
+
| `--card` / `--card-foreground` | Card surfaces |
|
| 58 |
+
| `--primary` / `--primary-foreground` | Primary actions, emphasis |
|
| 59 |
+
| `--secondary` / `--secondary-foreground` | Secondary surfaces |
|
| 60 |
+
| `--muted` / `--muted-foreground` | Subdued text, labels |
|
| 61 |
+
| `--accent` / `--accent-foreground` | Hover states, active nav items |
|
| 62 |
+
| `--destructive` | Destructive actions |
|
| 63 |
+
| `--border` | All borders |
|
| 64 |
+
| `--ring` | Focus rings |
|
| 65 |
+
| `--sidebar-*` | Sidebar-specific variants |
|
| 66 |
+
| `--chart-1` through `--chart-5` | Data visualization |
|
| 67 |
+
|
| 68 |
+
### Radius
|
| 69 |
+
|
| 70 |
+
Single `--radius` variable (0.625rem) with derived sizes:
|
| 71 |
+
|
| 72 |
+
- `rounded-sm` — small inputs, pills
|
| 73 |
+
- `rounded-md` — buttons, inputs, small components
|
| 74 |
+
- `rounded-lg` — cards, dialogs
|
| 75 |
+
- `rounded-xl` — card containers, large components
|
| 76 |
+
- `rounded-full` — badges, avatars, status dots
|
| 77 |
+
|
| 78 |
+
### Shadows
|
| 79 |
+
|
| 80 |
+
Minimal shadows: `shadow-xs` (outline buttons), `shadow-sm` (cards). No heavy shadows.
|
| 81 |
+
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
## 4. Typography Scale
|
| 85 |
+
|
| 86 |
+
Use these exact patterns — do not invent new ones:
|
| 87 |
+
|
| 88 |
+
| Pattern | Classes | Usage |
|
| 89 |
+
|---------|---------|-------|
|
| 90 |
+
| Page title | `text-xl font-bold` | Top of pages |
|
| 91 |
+
| Section title | `text-lg font-semibold` | Major sections |
|
| 92 |
+
| Section heading | `text-sm font-semibold text-muted-foreground uppercase tracking-wide` | Section headers in design guide, sidebar |
|
| 93 |
+
| Card title | `text-sm font-medium` or `text-sm font-semibold` | Card headers, list item titles |
|
| 94 |
+
| Body | `text-sm` | Default body text |
|
| 95 |
+
| Muted | `text-sm text-muted-foreground` | Descriptions, secondary text |
|
| 96 |
+
| Tiny label | `text-xs text-muted-foreground` | Metadata, timestamps, property labels |
|
| 97 |
+
| Mono identifier | `text-xs font-mono text-muted-foreground` | Issue keys (PAP-001), CSS vars |
|
| 98 |
+
| Large stat | `text-2xl font-bold` | Dashboard metric values |
|
| 99 |
+
| Code/log | `font-mono text-xs` | Log output, code snippets |
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## 5. Status & Priority Systems
|
| 104 |
+
|
| 105 |
+
### Status Colors (consistent across all entities)
|
| 106 |
+
|
| 107 |
+
Defined in `StatusBadge.tsx` and `StatusIcon.tsx`:
|
| 108 |
+
|
| 109 |
+
| Status | Color | Entity types |
|
| 110 |
+
|--------|-------|-------------|
|
| 111 |
+
| active, achieved, completed, succeeded, approved, done | Green shades | Agents, goals, issues, approvals |
|
| 112 |
+
| running | Cyan | Agents |
|
| 113 |
+
| paused | Orange | Agents |
|
| 114 |
+
| idle, pending | Yellow | Agents, approvals |
|
| 115 |
+
| failed, error, rejected, blocked | Red shades | Runs, agents, approvals, issues |
|
| 116 |
+
| archived, planned, backlog, cancelled | Neutral gray | Various |
|
| 117 |
+
| todo | Blue | Issues |
|
| 118 |
+
| in_progress | Indigo | Issues |
|
| 119 |
+
| in_review | Violet | Issues |
|
| 120 |
+
|
| 121 |
+
### Priority Icons
|
| 122 |
+
|
| 123 |
+
Defined in `PriorityIcon.tsx`: critical (red/AlertTriangle), high (orange/ArrowUp), medium (yellow/Minus), low (blue/ArrowDown).
|
| 124 |
+
|
| 125 |
+
### Agent Status Dots
|
| 126 |
+
|
| 127 |
+
Inline colored dots: running (cyan, animate-pulse), active (green), paused (yellow), error (red), offline (neutral).
|
| 128 |
+
|
| 129 |
+
---
|
| 130 |
+
|
| 131 |
+
## 6. Component Hierarchy
|
| 132 |
+
|
| 133 |
+
Three tiers:
|
| 134 |
+
|
| 135 |
+
1. **shadcn/ui primitives** (`ui/src/components/ui/`) — Button, Card, Input, Badge, Dialog, Tabs, etc. Do not modify these directly; extend via composition.
|
| 136 |
+
2. **Custom composites** (`ui/src/components/`) — StatusBadge, EntityRow, MetricCard, etc. These capture Paperclip-specific design language.
|
| 137 |
+
3. **Page components** (`ui/src/pages/`) — Compose primitives and composites into full views.
|
| 138 |
+
|
| 139 |
+
**See [references/component-index.md](references/component-index.md) for the complete component inventory with usage guidance.**
|
| 140 |
+
|
| 141 |
+
### When to Create a New Component
|
| 142 |
+
|
| 143 |
+
Create a reusable component when:
|
| 144 |
+
- The same visual pattern appears in 2+ places
|
| 145 |
+
- The pattern has interactive behavior (status changing, inline editing)
|
| 146 |
+
- The pattern encodes domain logic (status colors, priority icons)
|
| 147 |
+
|
| 148 |
+
Do NOT create a component for:
|
| 149 |
+
- One-off layouts specific to a single page
|
| 150 |
+
- Simple className combinations (use Tailwind directly)
|
| 151 |
+
- Thin wrappers that add no semantic value
|
| 152 |
+
|
| 153 |
+
---
|
| 154 |
+
|
| 155 |
+
## 7. Composition Patterns
|
| 156 |
+
|
| 157 |
+
These patterns describe how components work together. They may not be their own component, but they must be used consistently across the app.
|
| 158 |
+
|
| 159 |
+
### Entity Row with Status + Priority
|
| 160 |
+
|
| 161 |
+
The standard list item for issues and similar entities:
|
| 162 |
+
|
| 163 |
+
```tsx
|
| 164 |
+
<EntityRow
|
| 165 |
+
leading={<><StatusIcon status="in_progress" /><PriorityIcon priority="high" /></>}
|
| 166 |
+
identifier="PAP-001"
|
| 167 |
+
title="Implement authentication flow"
|
| 168 |
+
subtitle="Assigned to Agent Alpha"
|
| 169 |
+
trailing={<StatusBadge status="in_progress" />}
|
| 170 |
+
onClick={() => {}}
|
| 171 |
+
/>
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
Leading slot always: StatusIcon first, then PriorityIcon. Trailing slot: StatusBadge or timestamp.
|
| 175 |
+
|
| 176 |
+
### Grouped List
|
| 177 |
+
|
| 178 |
+
Issues grouped by status header + entity rows:
|
| 179 |
+
|
| 180 |
+
```tsx
|
| 181 |
+
<div className="flex items-center gap-2 px-4 py-2 bg-muted/50 rounded-t-md">
|
| 182 |
+
<StatusIcon status="in_progress" />
|
| 183 |
+
<span className="text-sm font-medium">In Progress</span>
|
| 184 |
+
<span className="text-xs text-muted-foreground ml-1">2</span>
|
| 185 |
+
</div>
|
| 186 |
+
<div className="border border-border rounded-b-md">
|
| 187 |
+
<EntityRow ... />
|
| 188 |
+
<EntityRow ... />
|
| 189 |
+
</div>
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
### Property Row
|
| 193 |
+
|
| 194 |
+
Key-value pairs in properties panels:
|
| 195 |
+
|
| 196 |
+
```tsx
|
| 197 |
+
<div className="flex items-center justify-between py-1.5">
|
| 198 |
+
<span className="text-xs text-muted-foreground">Status</span>
|
| 199 |
+
<StatusBadge status="active" />
|
| 200 |
+
</div>
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
Label is always `text-xs text-muted-foreground`, value on the right. Wrap in a container with `space-y-1`.
|
| 204 |
+
|
| 205 |
+
### Metric Card Grid
|
| 206 |
+
|
| 207 |
+
Dashboard metrics in a responsive grid:
|
| 208 |
+
|
| 209 |
+
```tsx
|
| 210 |
+
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-4">
|
| 211 |
+
<MetricCard icon={Bot} value={12} label="Active Agents" description="+3 this week" />
|
| 212 |
+
...
|
| 213 |
+
</div>
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
### Progress Bar (Budget)
|
| 217 |
+
|
| 218 |
+
Color by threshold: green (<60%), yellow (60-85%), red (>85%):
|
| 219 |
+
|
| 220 |
+
```tsx
|
| 221 |
+
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
| 222 |
+
<div className="h-full rounded-full bg-green-400" style={{ width: `${pct}%` }} />
|
| 223 |
+
</div>
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
### Comment Thread
|
| 227 |
+
|
| 228 |
+
Author header (name + timestamp) then body, in bordered cards with `space-y-3`. Add comment textarea + button below.
|
| 229 |
+
|
| 230 |
+
### Cost Table
|
| 231 |
+
|
| 232 |
+
Standard `<table>` with `text-xs`, header row with `bg-accent/20`, `font-mono` for numeric values.
|
| 233 |
+
|
| 234 |
+
### Log Viewer
|
| 235 |
+
|
| 236 |
+
`bg-neutral-950 rounded-lg p-3 font-mono text-xs` container. Color lines by level: default (foreground), WARN (yellow-400), ERROR (red-400), SYS (blue-300). Include live indicator dot when streaming.
|
| 237 |
+
|
| 238 |
+
---
|
| 239 |
+
|
| 240 |
+
## 8. Interactive Patterns
|
| 241 |
+
|
| 242 |
+
### Hover States
|
| 243 |
+
|
| 244 |
+
- Entity rows: `hover:bg-accent/50`
|
| 245 |
+
- Nav items: `hover:bg-accent/50 hover:text-accent-foreground`
|
| 246 |
+
- Active nav: `bg-accent text-accent-foreground`
|
| 247 |
+
|
| 248 |
+
### Focus
|
| 249 |
+
|
| 250 |
+
`focus-visible:ring-ring focus-visible:ring-[3px]` — standard Tailwind focus-visible ring.
|
| 251 |
+
|
| 252 |
+
### Disabled
|
| 253 |
+
|
| 254 |
+
`disabled:opacity-50 disabled:pointer-events-none`
|
| 255 |
+
|
| 256 |
+
### Inline Editing
|
| 257 |
+
|
| 258 |
+
Use `InlineEditor` component — click text to edit, Enter saves, Escape cancels.
|
| 259 |
+
|
| 260 |
+
### Popover Selectors
|
| 261 |
+
|
| 262 |
+
StatusIcon and PriorityIcon use Radix Popover for inline selection. Follow this pattern for any clickable property that opens a picker.
|
| 263 |
+
|
| 264 |
+
---
|
| 265 |
+
|
| 266 |
+
## 9. Layout System
|
| 267 |
+
|
| 268 |
+
Three-zone layout defined in `Layout.tsx`:
|
| 269 |
+
|
| 270 |
+
```
|
| 271 |
+
┌──────────┬──────────────────────────────┬──────────────────────┐
|
| 272 |
+
│ Sidebar │ Breadcrumb bar │ │
|
| 273 |
+
│ (w-60) ├──────────────────────────────┤ Properties panel │
|
| 274 |
+
│ │ Main content (flex-1) │ (w-80, optional) │
|
| 275 |
+
└──────────┴──────────────────────────────┴──────────────────────┘
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
+
- Sidebar: `w-60`, collapsible, contains CompanySwitcher + SidebarSections
|
| 279 |
+
- Properties panel: `w-80`, shown on detail views, hidden on lists
|
| 280 |
+
- Main content: scrollable, `flex-1`
|
| 281 |
+
|
| 282 |
+
---
|
| 283 |
+
|
| 284 |
+
## 10. The /design-guide Page
|
| 285 |
+
|
| 286 |
+
**Location:** `ui/src/pages/DesignGuide.tsx`
|
| 287 |
+
**Route:** `/design-guide`
|
| 288 |
+
|
| 289 |
+
This is the living showcase of every component and pattern in the app. It is the source of truth for how things look.
|
| 290 |
+
|
| 291 |
+
### Rules
|
| 292 |
+
|
| 293 |
+
1. **When you add a new reusable component, you MUST add it to the design guide page.** Show all variants, sizes, and states.
|
| 294 |
+
2. **When you modify an existing component's API, update its design guide section.**
|
| 295 |
+
3. **When you add a new composition pattern, add a section demonstrating it.**
|
| 296 |
+
4. Follow the existing structure: `<Section title="...">` wrapper with `<SubSection>` for grouping.
|
| 297 |
+
5. Keep sections ordered logically: foundational (colors, typography) first, then primitives, then composites, then patterns.
|
| 298 |
+
|
| 299 |
+
### Adding a New Section
|
| 300 |
+
|
| 301 |
+
```tsx
|
| 302 |
+
<Section title="My New Component">
|
| 303 |
+
<SubSection title="Variants">
|
| 304 |
+
{/* Show all variants */}
|
| 305 |
+
</SubSection>
|
| 306 |
+
<SubSection title="Sizes">
|
| 307 |
+
{/* Show all sizes */}
|
| 308 |
+
</SubSection>
|
| 309 |
+
<SubSection title="States">
|
| 310 |
+
{/* Show interactive/disabled states */}
|
| 311 |
+
</SubSection>
|
| 312 |
+
</Section>
|
| 313 |
+
```
|
| 314 |
+
|
| 315 |
+
---
|
| 316 |
+
|
| 317 |
+
## 11. Component Index
|
| 318 |
+
|
| 319 |
+
**See [references/component-index.md](references/component-index.md) for the full component inventory.**
|
| 320 |
+
|
| 321 |
+
When you create a new reusable component:
|
| 322 |
+
1. Add it to the component index reference file
|
| 323 |
+
2. Add it to the /design-guide page
|
| 324 |
+
3. Follow existing naming and file conventions
|
| 325 |
+
|
| 326 |
+
---
|
| 327 |
+
|
| 328 |
+
## 12. File Conventions
|
| 329 |
+
|
| 330 |
+
- **shadcn primitives:** `ui/src/components/ui/{component}.tsx` — lowercase, kebab-case
|
| 331 |
+
- **Custom components:** `ui/src/components/{ComponentName}.tsx` — PascalCase
|
| 332 |
+
- **Pages:** `ui/src/pages/{PageName}.tsx` — PascalCase
|
| 333 |
+
- **Utilities:** `ui/src/lib/{name}.ts`
|
| 334 |
+
- **Hooks:** `ui/src/hooks/{useName}.ts`
|
| 335 |
+
- **API modules:** `ui/src/api/{entity}.ts`
|
| 336 |
+
- **Context providers:** `ui/src/context/{Name}Context.tsx`
|
| 337 |
+
|
| 338 |
+
All components use `cn()` from `@/lib/utils` for className merging. All components use CVA for variant definitions when they have multiple visual variants.
|
| 339 |
+
|
| 340 |
+
---
|
| 341 |
+
|
| 342 |
+
## 13. Common Mistakes to Avoid
|
| 343 |
+
|
| 344 |
+
- Using raw hex/rgb colors instead of CSS variable tokens
|
| 345 |
+
- Creating ad-hoc typography styles instead of using the established scale
|
| 346 |
+
- Hardcoding status colors instead of using StatusBadge/StatusIcon
|
| 347 |
+
- Building one-off styled elements when a reusable component exists
|
| 348 |
+
- Adding components without updating the design guide page
|
| 349 |
+
- Using `shadow-md` or heavier — keep shadows minimal (xs, sm only)
|
| 350 |
+
- Using `rounded-2xl` or larger — max is `rounded-xl` (except `rounded-full` for pills)
|
| 351 |
+
- Forgetting dark mode — always use semantic tokens, never hardcode light/dark values
|
.claude/skills/design-guide/references/component-index.md
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Paperclip Component Index
|
| 2 |
+
|
| 3 |
+
Complete inventory of all UI components. Update this file when adding new reusable components.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Table of Contents
|
| 8 |
+
|
| 9 |
+
1. [shadcn/ui Primitives](#shadcnui-primitives)
|
| 10 |
+
2. [Custom Components](#custom-components)
|
| 11 |
+
3. [Layout Components](#layout-components)
|
| 12 |
+
4. [Dialog & Form Components](#dialog--form-components)
|
| 13 |
+
5. [Property Panel Components](#property-panel-components)
|
| 14 |
+
6. [Agent Configuration](#agent-configuration)
|
| 15 |
+
7. [Utilities & Hooks](#utilities--hooks)
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## shadcn/ui Primitives
|
| 20 |
+
|
| 21 |
+
Location: `ui/src/components/ui/`
|
| 22 |
+
|
| 23 |
+
These are shadcn/ui base components. Do not modify directly — extend via composition.
|
| 24 |
+
|
| 25 |
+
| Component | File | Key Props | Notes |
|
| 26 |
+
|-----------|------|-----------|-------|
|
| 27 |
+
| Button | `button.tsx` | `variant` (default, secondary, outline, ghost, destructive, link), `size` (xs, sm, default, lg, icon, icon-xs, icon-sm, icon-lg) | Primary interactive element. Uses CVA. |
|
| 28 |
+
| Card | `card.tsx` | CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter | Compound component. `py-6` default padding. |
|
| 29 |
+
| Input | `input.tsx` | `disabled` | Standard text input. |
|
| 30 |
+
| Badge | `badge.tsx` | `variant` (default, secondary, outline, destructive, ghost) | Generic label/tag. For status, use StatusBadge instead. |
|
| 31 |
+
| Label | `label.tsx` | — | Form label, wraps Radix Label. |
|
| 32 |
+
| Select | `select.tsx` | Trigger, Content, Item, etc. | Radix-based dropdown select. |
|
| 33 |
+
| Separator | `separator.tsx` | `orientation` (horizontal, vertical) | Divider line. |
|
| 34 |
+
| Checkbox | `checkbox.tsx` | `checked`, `onCheckedChange` | Radix checkbox with indicator. |
|
| 35 |
+
| Textarea | `textarea.tsx` | Standard textarea props | Multi-line input. |
|
| 36 |
+
| Avatar | `avatar.tsx` | `size` (sm, default, lg). Includes AvatarGroup, AvatarGroupCount | Image or fallback initials. |
|
| 37 |
+
| Breadcrumb | `breadcrumb.tsx` | BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage | Navigation breadcrumbs. |
|
| 38 |
+
| Command | `command.tsx` | CommandInput, CommandList, CommandGroup, CommandItem | Command palette / search. Based on cmdk. |
|
| 39 |
+
| Dialog | `dialog.tsx` | DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter | Modal overlay. |
|
| 40 |
+
| DropdownMenu | `dropdown-menu.tsx` | Trigger, Content, Item, Separator, etc. | Context/action menus. |
|
| 41 |
+
| Popover | `popover.tsx` | PopoverTrigger, PopoverContent | Floating content panel. |
|
| 42 |
+
| Tabs | `tabs.tsx` | `variant` (pill, line). TabsList, TabsTrigger, TabsContent | Tabbed navigation. Pill = default, line = underline style. |
|
| 43 |
+
| Tooltip | `tooltip.tsx` | TooltipTrigger, TooltipContent | Hover tooltips. App is wrapped in TooltipProvider. |
|
| 44 |
+
| ScrollArea | `scroll-area.tsx` | — | Custom scrollable container. |
|
| 45 |
+
| Collapsible | `collapsible.tsx` | CollapsibleTrigger, CollapsibleContent | Expand/collapse sections. |
|
| 46 |
+
| Skeleton | `skeleton.tsx` | className for sizing | Loading placeholder with shimmer. |
|
| 47 |
+
| Sheet | `sheet.tsx` | SheetTrigger, SheetContent, SheetHeader, etc. | Side panel overlay. |
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## Custom Components
|
| 52 |
+
|
| 53 |
+
Location: `ui/src/components/`
|
| 54 |
+
|
| 55 |
+
### StatusBadge
|
| 56 |
+
|
| 57 |
+
**File:** `StatusBadge.tsx`
|
| 58 |
+
**Props:** `status: string`
|
| 59 |
+
**Usage:** Colored pill showing entity status. Supports 20+ statuses with mapped colors.
|
| 60 |
+
|
| 61 |
+
```tsx
|
| 62 |
+
<StatusBadge status="in_progress" />
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
Use for displaying status in properties panels, entity rows, and list views. Never hardcode status colors — always use this component.
|
| 66 |
+
|
| 67 |
+
### StatusIcon
|
| 68 |
+
|
| 69 |
+
**File:** `StatusIcon.tsx`
|
| 70 |
+
**Props:** `status: string`, `onChange?: (status: string) => void`
|
| 71 |
+
**Usage:** Circle icon representing issue status. When `onChange` provided, opens a popover picker.
|
| 72 |
+
|
| 73 |
+
```tsx
|
| 74 |
+
<StatusIcon status="todo" onChange={setStatus} />
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
Supports: backlog, todo, in_progress, in_review, done, cancelled, blocked. Use in entity row leading slots and grouped list headers.
|
| 78 |
+
|
| 79 |
+
### PriorityIcon
|
| 80 |
+
|
| 81 |
+
**File:** `PriorityIcon.tsx`
|
| 82 |
+
**Props:** `priority: string`, `onChange?: (priority: string) => void`
|
| 83 |
+
**Usage:** Priority indicator icon. Interactive when `onChange` provided.
|
| 84 |
+
|
| 85 |
+
```tsx
|
| 86 |
+
<PriorityIcon priority="high" onChange={setPriority} />
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
Supports: critical, high, medium, low. Use alongside StatusIcon in entity row leading slots.
|
| 90 |
+
|
| 91 |
+
### EntityRow
|
| 92 |
+
|
| 93 |
+
**File:** `EntityRow.tsx`
|
| 94 |
+
**Props:** `leading`, `identifier`, `title`, `subtitle?`, `trailing?`, `onClick?`, `selected?`
|
| 95 |
+
**Usage:** Standard list row for issues, agents, projects. Supports hover highlight and selected state.
|
| 96 |
+
|
| 97 |
+
```tsx
|
| 98 |
+
<EntityRow
|
| 99 |
+
leading={<><StatusIcon status="todo" /><PriorityIcon priority="medium" /></>}
|
| 100 |
+
identifier="PAP-003"
|
| 101 |
+
title="Write API documentation"
|
| 102 |
+
trailing={<StatusBadge status="todo" />}
|
| 103 |
+
onClick={() => navigate(`/issues/${id}`)}
|
| 104 |
+
/>
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
Wrap multiple EntityRows in a `border border-border rounded-md` container.
|
| 108 |
+
|
| 109 |
+
### MetricCard
|
| 110 |
+
|
| 111 |
+
**File:** `MetricCard.tsx`
|
| 112 |
+
**Props:** `icon: LucideIcon`, `value: string | number`, `label: string`, `description?: string`
|
| 113 |
+
**Usage:** Dashboard stat card with icon, large value, label, and optional description.
|
| 114 |
+
|
| 115 |
+
```tsx
|
| 116 |
+
<MetricCard icon={Bot} value={12} label="Active Agents" description="+3 this week" />
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
Always use in a responsive grid: `grid md:grid-cols-2 xl:grid-cols-4 gap-4`.
|
| 120 |
+
|
| 121 |
+
### EmptyState
|
| 122 |
+
|
| 123 |
+
**File:** `EmptyState.tsx`
|
| 124 |
+
**Props:** `icon: LucideIcon`, `message: string`, `action?: string`, `onAction?: () => void`
|
| 125 |
+
**Usage:** Empty list placeholder with icon, message, and optional CTA button.
|
| 126 |
+
|
| 127 |
+
```tsx
|
| 128 |
+
<EmptyState icon={Inbox} message="No items yet." action="Create Item" onAction={handleCreate} />
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
### FilterBar
|
| 132 |
+
|
| 133 |
+
**File:** `FilterBar.tsx`
|
| 134 |
+
**Props:** `filters: FilterValue[]`, `onRemove: (key) => void`, `onClear: () => void`
|
| 135 |
+
**Type:** `FilterValue = { key: string; label: string; value: string }`
|
| 136 |
+
**Usage:** Filter chip display with remove buttons and clear all.
|
| 137 |
+
|
| 138 |
+
```tsx
|
| 139 |
+
<FilterBar filters={filters} onRemove={handleRemove} onClear={() => setFilters([])} />
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
### Identity
|
| 143 |
+
|
| 144 |
+
**File:** `Identity.tsx`
|
| 145 |
+
**Props:** `name: string`, `avatarUrl?: string`, `initials?: string`, `size?: "sm" | "default" | "lg"`
|
| 146 |
+
**Usage:** Avatar + name display for users and agents. Derives initials from name automatically. Three sizes matching Avatar sizes.
|
| 147 |
+
|
| 148 |
+
```tsx
|
| 149 |
+
<Identity name="Agent Alpha" size="sm" />
|
| 150 |
+
<Identity name="CEO Agent" />
|
| 151 |
+
<Identity name="Backend Service" size="lg" avatarUrl="/img/bot.png" />
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
Use in property rows, comment headers, assignee displays, and anywhere a user/agent reference is shown.
|
| 155 |
+
|
| 156 |
+
### InlineEditor
|
| 157 |
+
|
| 158 |
+
**File:** `InlineEditor.tsx`
|
| 159 |
+
**Props:** `value: string`, `onSave: (val: string) => void`, `as?: string`, `className?: string`
|
| 160 |
+
**Usage:** Click-to-edit text. Renders as display text, clicking enters edit mode. Enter saves, Escape cancels.
|
| 161 |
+
|
| 162 |
+
```tsx
|
| 163 |
+
<InlineEditor value={title} onSave={updateTitle} as="h2" className="text-xl font-bold" />
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
### PageSkeleton
|
| 167 |
+
|
| 168 |
+
**File:** `PageSkeleton.tsx`
|
| 169 |
+
**Props:** `variant: "list" | "detail"`
|
| 170 |
+
**Usage:** Full-page loading skeleton matching list or detail layout.
|
| 171 |
+
|
| 172 |
+
```tsx
|
| 173 |
+
<PageSkeleton variant="list" />
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
### CommentThread
|
| 177 |
+
|
| 178 |
+
**File:** `CommentThread.tsx`
|
| 179 |
+
**Usage:** Comment list with add-comment form. Used on issue and entity detail views.
|
| 180 |
+
|
| 181 |
+
### GoalTree
|
| 182 |
+
|
| 183 |
+
**File:** `GoalTree.tsx`
|
| 184 |
+
**Usage:** Hierarchical goal tree with expand/collapse. Used on the goals page.
|
| 185 |
+
|
| 186 |
+
### CompanySwitcher
|
| 187 |
+
|
| 188 |
+
**File:** `CompanySwitcher.tsx`
|
| 189 |
+
**Usage:** Company selector dropdown in sidebar header.
|
| 190 |
+
|
| 191 |
+
---
|
| 192 |
+
|
| 193 |
+
## Layout Components
|
| 194 |
+
|
| 195 |
+
### Layout
|
| 196 |
+
|
| 197 |
+
**File:** `Layout.tsx`
|
| 198 |
+
**Usage:** Main app shell. Three-zone layout: Sidebar + Main content + Properties panel. Wraps all routes.
|
| 199 |
+
|
| 200 |
+
### Sidebar
|
| 201 |
+
|
| 202 |
+
**File:** `Sidebar.tsx`
|
| 203 |
+
**Usage:** Left navigation sidebar (`w-60`). Contains CompanySwitcher, search button, new issue button, and SidebarSections.
|
| 204 |
+
|
| 205 |
+
### SidebarSection
|
| 206 |
+
|
| 207 |
+
**File:** `SidebarSection.tsx`
|
| 208 |
+
**Usage:** Collapsible sidebar group with header label and chevron toggle.
|
| 209 |
+
|
| 210 |
+
### SidebarNavItem
|
| 211 |
+
|
| 212 |
+
**File:** `SidebarNavItem.tsx`
|
| 213 |
+
**Props:** Icon, label, optional badge count
|
| 214 |
+
**Usage:** Individual nav item within a SidebarSection.
|
| 215 |
+
|
| 216 |
+
### BreadcrumbBar
|
| 217 |
+
|
| 218 |
+
**File:** `BreadcrumbBar.tsx`
|
| 219 |
+
**Usage:** Top breadcrumb navigation spanning main content + properties panel.
|
| 220 |
+
|
| 221 |
+
### PropertiesPanel
|
| 222 |
+
|
| 223 |
+
**File:** `PropertiesPanel.tsx`
|
| 224 |
+
**Usage:** Right-side properties panel (`w-80`). Closeable. Shown on detail views.
|
| 225 |
+
|
| 226 |
+
### CommandPalette
|
| 227 |
+
|
| 228 |
+
**File:** `CommandPalette.tsx`
|
| 229 |
+
**Usage:** Cmd+K global search modal. Searches issues, projects, agents.
|
| 230 |
+
|
| 231 |
+
---
|
| 232 |
+
|
| 233 |
+
## Dialog & Form Components
|
| 234 |
+
|
| 235 |
+
### NewIssueDialog
|
| 236 |
+
|
| 237 |
+
**File:** `NewIssueDialog.tsx`
|
| 238 |
+
**Usage:** Create new issue with project/assignee/priority selection. Supports draft saving.
|
| 239 |
+
|
| 240 |
+
### NewProjectDialog
|
| 241 |
+
|
| 242 |
+
**File:** `NewProjectDialog.tsx`
|
| 243 |
+
**Usage:** Create new project dialog.
|
| 244 |
+
|
| 245 |
+
### NewAgentDialog
|
| 246 |
+
|
| 247 |
+
**File:** `NewAgentDialog.tsx`
|
| 248 |
+
**Usage:** Create new agent dialog.
|
| 249 |
+
|
| 250 |
+
### OnboardingWizard
|
| 251 |
+
|
| 252 |
+
**File:** `OnboardingWizard.tsx`
|
| 253 |
+
**Usage:** Multi-step onboarding flow for new users/companies.
|
| 254 |
+
|
| 255 |
+
---
|
| 256 |
+
|
| 257 |
+
## Property Panel Components
|
| 258 |
+
|
| 259 |
+
These render inside the PropertiesPanel for different entity types:
|
| 260 |
+
|
| 261 |
+
| Component | File | Entity |
|
| 262 |
+
|-----------|------|--------|
|
| 263 |
+
| IssueProperties | `IssueProperties.tsx` | Issues |
|
| 264 |
+
| AgentProperties | `AgentProperties.tsx` | Agents |
|
| 265 |
+
| ProjectProperties | `ProjectProperties.tsx` | Projects |
|
| 266 |
+
| GoalProperties | `GoalProperties.tsx` | Goals |
|
| 267 |
+
|
| 268 |
+
All follow the property row pattern: `text-xs text-muted-foreground` label on left, value on right, `py-1.5` spacing.
|
| 269 |
+
|
| 270 |
+
---
|
| 271 |
+
|
| 272 |
+
## Agent Configuration
|
| 273 |
+
|
| 274 |
+
### agent-config-primitives
|
| 275 |
+
|
| 276 |
+
**File:** `agent-config-primitives.tsx`
|
| 277 |
+
**Exports:** Field, ToggleField, ToggleWithNumber, CollapsibleSection, AutoExpandTextarea, DraftInput
|
| 278 |
+
**Usage:** Reusable form field primitives for agent configuration forms.
|
| 279 |
+
|
| 280 |
+
### AgentConfigForm
|
| 281 |
+
|
| 282 |
+
**File:** `AgentConfigForm.tsx`
|
| 283 |
+
**Usage:** Full agent creation/editing form with adapter type selection.
|
| 284 |
+
|
| 285 |
+
---
|
| 286 |
+
|
| 287 |
+
## Utilities & Hooks
|
| 288 |
+
|
| 289 |
+
### cn() — Class Name Merger
|
| 290 |
+
|
| 291 |
+
**File:** `ui/src/lib/utils.ts`
|
| 292 |
+
**Usage:** Merges class names with clsx + tailwind-merge. Use in every component.
|
| 293 |
+
|
| 294 |
+
```tsx
|
| 295 |
+
import { cn } from "@/lib/utils";
|
| 296 |
+
<div className={cn("base-classes", conditional && "extra", className)} />
|
| 297 |
+
```
|
| 298 |
+
|
| 299 |
+
### Formatting Utilities
|
| 300 |
+
|
| 301 |
+
**File:** `ui/src/lib/utils.ts`
|
| 302 |
+
|
| 303 |
+
| Function | Usage |
|
| 304 |
+
|----------|-------|
|
| 305 |
+
| `formatCents(cents)` | Money display: `$12.34` |
|
| 306 |
+
| `formatDate(date)` | Date display: `Jan 15, 2025` |
|
| 307 |
+
| `relativeTime(date)` | Relative time: `2m ago`, `Jan 15` |
|
| 308 |
+
| `formatTokens(count)` | Token counts: `1.2M`, `500k` |
|
| 309 |
+
|
| 310 |
+
### useKeyboardShortcuts
|
| 311 |
+
|
| 312 |
+
**File:** `ui/src/hooks/useKeyboardShortcuts.ts`
|
| 313 |
+
**Usage:** Global keyboard shortcut handler. Registers Cmd+K, C, [, ], Cmd+Enter.
|
| 314 |
+
|
| 315 |
+
### Query Keys
|
| 316 |
+
|
| 317 |
+
**File:** `ui/src/lib/queryKeys.ts`
|
| 318 |
+
**Usage:** Structured React Query key factories for cache management.
|
| 319 |
+
|
| 320 |
+
### groupBy
|
| 321 |
+
|
| 322 |
+
**File:** `ui/src/lib/groupBy.ts`
|
| 323 |
+
**Usage:** Generic array grouping utility.
|
.dockerignore
CHANGED
|
@@ -1,5 +1,10 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.github
|
| 3 |
+
.paperclip
|
| 4 |
+
.pnpm-store
|
| 5 |
+
node_modules
|
| 6 |
+
**/node_modules
|
| 7 |
+
coverage
|
| 8 |
+
data
|
| 9 |
+
tmp
|
| 10 |
+
*.log
|
.env.example
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
DATABASE_URL=postgres://paperclip:paperclip@localhost:5432/paperclip
|
| 2 |
+
PORT=3100
|
| 3 |
+
SERVE_UI=false
|
.gitattributes
CHANGED
|
@@ -37,3 +37,5 @@ test/pong/install_dependencies.sh filter=lfs diff=lfs merge=lfs -text
|
|
| 37 |
test/smoke_test.sh filter=lfs diff=lfs merge=lfs -text
|
| 38 |
test/test_custom_models.sh filter=lfs diff=lfs merge=lfs -text
|
| 39 |
test/test_utils.sh filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 37 |
test/smoke_test.sh filter=lfs diff=lfs merge=lfs -text
|
| 38 |
test/test_custom_models.sh filter=lfs diff=lfs merge=lfs -text
|
| 39 |
test/test_utils.sh filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
doc/assets/footer.jpg filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
doc/assets/header.png filter=lfs diff=lfs merge=lfs -text
|
.github/CODEOWNERS
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Replace @cryppadotta if a different maintainer or team should own release infrastructure.
|
| 2 |
+
|
| 3 |
+
.github/** @cryppadotta @devinfoley
|
| 4 |
+
scripts/release*.sh @cryppadotta @devinfoley
|
| 5 |
+
scripts/release-*.mjs @cryppadotta @devinfoley
|
| 6 |
+
scripts/create-github-release.sh @cryppadotta @devinfoley
|
| 7 |
+
scripts/rollback-latest.sh @cryppadotta @devinfoley
|
| 8 |
+
doc/RELEASING.md @cryppadotta @devinfoley
|
| 9 |
+
doc/PUBLISHING.md @cryppadotta @devinfoley
|
| 10 |
+
doc/RELEASE-AUTOMATION-SETUP.md @cryppadotta @devinfoley
|
.github/workflows/e2e.yml
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: E2E Tests
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
workflow_dispatch:
|
| 5 |
+
inputs:
|
| 6 |
+
skip_llm:
|
| 7 |
+
description: "Skip LLM-dependent assertions (default: true)"
|
| 8 |
+
type: boolean
|
| 9 |
+
default: true
|
| 10 |
+
|
| 11 |
+
jobs:
|
| 12 |
+
e2e:
|
| 13 |
+
runs-on: ubuntu-latest
|
| 14 |
+
timeout-minutes: 30
|
| 15 |
+
env:
|
| 16 |
+
PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }}
|
| 17 |
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
| 18 |
+
steps:
|
| 19 |
+
- uses: actions/checkout@v4
|
| 20 |
+
|
| 21 |
+
- uses: pnpm/action-setup@v4
|
| 22 |
+
with:
|
| 23 |
+
version: 9
|
| 24 |
+
|
| 25 |
+
- uses: actions/setup-node@v4
|
| 26 |
+
with:
|
| 27 |
+
node-version: 20
|
| 28 |
+
cache: pnpm
|
| 29 |
+
|
| 30 |
+
- run: pnpm install --frozen-lockfile
|
| 31 |
+
- run: pnpm build
|
| 32 |
+
- run: npx playwright install --with-deps chromium
|
| 33 |
+
|
| 34 |
+
- name: Run e2e tests
|
| 35 |
+
run: pnpm run test:e2e
|
| 36 |
+
|
| 37 |
+
- uses: actions/upload-artifact@v4
|
| 38 |
+
if: always()
|
| 39 |
+
with:
|
| 40 |
+
name: playwright-report
|
| 41 |
+
path: |
|
| 42 |
+
tests/e2e/playwright-report/
|
| 43 |
+
tests/e2e/test-results/
|
| 44 |
+
retention-days: 14
|
.github/workflows/pr-policy.yml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: PR Policy
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
branches:
|
| 6 |
+
- master
|
| 7 |
+
|
| 8 |
+
concurrency:
|
| 9 |
+
group: pr-policy-${{ github.event.pull_request.number }}
|
| 10 |
+
cancel-in-progress: true
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
policy:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
timeout-minutes: 10
|
| 16 |
+
|
| 17 |
+
steps:
|
| 18 |
+
- name: Checkout repository
|
| 19 |
+
uses: actions/checkout@v4
|
| 20 |
+
with:
|
| 21 |
+
fetch-depth: 0
|
| 22 |
+
|
| 23 |
+
- name: Setup pnpm
|
| 24 |
+
uses: pnpm/action-setup@v4
|
| 25 |
+
with:
|
| 26 |
+
version: 9.15.4
|
| 27 |
+
run_install: false
|
| 28 |
+
|
| 29 |
+
- name: Setup Node.js
|
| 30 |
+
uses: actions/setup-node@v4
|
| 31 |
+
with:
|
| 32 |
+
node-version: 20
|
| 33 |
+
|
| 34 |
+
- name: Block manual lockfile edits
|
| 35 |
+
if: github.head_ref != 'chore/refresh-lockfile'
|
| 36 |
+
run: |
|
| 37 |
+
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
| 38 |
+
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
|
| 39 |
+
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
|
| 40 |
+
exit 1
|
| 41 |
+
fi
|
| 42 |
+
|
| 43 |
+
- name: Validate dependency resolution when manifests change
|
| 44 |
+
run: |
|
| 45 |
+
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
| 46 |
+
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
|
| 47 |
+
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
|
| 48 |
+
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
| 49 |
+
fi
|
.github/workflows/pr-verify.yml
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: PR Verify
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
branches:
|
| 6 |
+
- master
|
| 7 |
+
|
| 8 |
+
concurrency:
|
| 9 |
+
group: pr-verify-${{ github.event.pull_request.number }}
|
| 10 |
+
cancel-in-progress: true
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
verify:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
timeout-minutes: 20
|
| 16 |
+
|
| 17 |
+
steps:
|
| 18 |
+
- name: Checkout repository
|
| 19 |
+
uses: actions/checkout@v4
|
| 20 |
+
|
| 21 |
+
- name: Setup pnpm
|
| 22 |
+
uses: pnpm/action-setup@v4
|
| 23 |
+
with:
|
| 24 |
+
version: 9.15.4
|
| 25 |
+
|
| 26 |
+
- name: Setup Node.js
|
| 27 |
+
uses: actions/setup-node@v4
|
| 28 |
+
with:
|
| 29 |
+
node-version: 24
|
| 30 |
+
cache: pnpm
|
| 31 |
+
|
| 32 |
+
- name: Install dependencies
|
| 33 |
+
run: pnpm install --no-frozen-lockfile
|
| 34 |
+
|
| 35 |
+
- name: Typecheck
|
| 36 |
+
run: pnpm -r typecheck
|
| 37 |
+
|
| 38 |
+
- name: Run tests
|
| 39 |
+
run: pnpm test:run
|
| 40 |
+
|
| 41 |
+
- name: Build
|
| 42 |
+
run: pnpm build
|
| 43 |
+
|
| 44 |
+
- name: Release canary dry run
|
| 45 |
+
run: |
|
| 46 |
+
git checkout -B master HEAD
|
| 47 |
+
git checkout -- pnpm-lock.yaml
|
| 48 |
+
./scripts/release.sh canary --skip-verify --dry-run
|
.github/workflows/refresh-lockfile.yml
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Refresh Lockfile
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- master
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
|
| 9 |
+
concurrency:
|
| 10 |
+
group: refresh-lockfile-master
|
| 11 |
+
cancel-in-progress: false
|
| 12 |
+
|
| 13 |
+
jobs:
|
| 14 |
+
refresh:
|
| 15 |
+
runs-on: ubuntu-latest
|
| 16 |
+
timeout-minutes: 10
|
| 17 |
+
permissions:
|
| 18 |
+
contents: write
|
| 19 |
+
pull-requests: write
|
| 20 |
+
|
| 21 |
+
steps:
|
| 22 |
+
- name: Checkout repository
|
| 23 |
+
uses: actions/checkout@v4
|
| 24 |
+
|
| 25 |
+
- name: Setup pnpm
|
| 26 |
+
uses: pnpm/action-setup@v4
|
| 27 |
+
with:
|
| 28 |
+
version: 9.15.4
|
| 29 |
+
run_install: false
|
| 30 |
+
|
| 31 |
+
- name: Setup Node.js
|
| 32 |
+
uses: actions/setup-node@v4
|
| 33 |
+
with:
|
| 34 |
+
node-version: 20
|
| 35 |
+
cache: pnpm
|
| 36 |
+
|
| 37 |
+
- name: Refresh pnpm lockfile
|
| 38 |
+
run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
| 39 |
+
|
| 40 |
+
- name: Fail on unexpected file changes
|
| 41 |
+
run: |
|
| 42 |
+
changed="$(git status --porcelain)"
|
| 43 |
+
if [ -z "$changed" ]; then
|
| 44 |
+
echo "Lockfile is already up to date."
|
| 45 |
+
exit 0
|
| 46 |
+
fi
|
| 47 |
+
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
|
| 48 |
+
echo "Unexpected files changed during lockfile refresh:"
|
| 49 |
+
echo "$changed"
|
| 50 |
+
exit 1
|
| 51 |
+
fi
|
| 52 |
+
|
| 53 |
+
- name: Create or update pull request
|
| 54 |
+
env:
|
| 55 |
+
GH_TOKEN: ${{ github.token }}
|
| 56 |
+
run: |
|
| 57 |
+
if git diff --quiet -- pnpm-lock.yaml; then
|
| 58 |
+
echo "Lockfile unchanged, nothing to do."
|
| 59 |
+
exit 0
|
| 60 |
+
fi
|
| 61 |
+
|
| 62 |
+
BRANCH="chore/refresh-lockfile"
|
| 63 |
+
git config user.name "lockfile-bot"
|
| 64 |
+
git config user.email "lockfile-bot@users.noreply.github.com"
|
| 65 |
+
|
| 66 |
+
git checkout -B "$BRANCH"
|
| 67 |
+
git add pnpm-lock.yaml
|
| 68 |
+
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
|
| 69 |
+
git push --force origin "$BRANCH"
|
| 70 |
+
|
| 71 |
+
# Create PR if one doesn't already exist
|
| 72 |
+
existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')
|
| 73 |
+
if [ -z "$existing" ]; then
|
| 74 |
+
gh pr create \
|
| 75 |
+
--head "$BRANCH" \
|
| 76 |
+
--title "chore(lockfile): refresh pnpm-lock.yaml" \
|
| 77 |
+
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml."
|
| 78 |
+
echo "Created new PR."
|
| 79 |
+
else
|
| 80 |
+
echo "PR #$existing already exists, branch updated via force push."
|
| 81 |
+
fi
|
| 82 |
+
|
| 83 |
+
- name: Enable auto-merge for lockfile PR
|
| 84 |
+
env:
|
| 85 |
+
GH_TOKEN: ${{ github.token }}
|
| 86 |
+
run: |
|
| 87 |
+
pr_url="$(gh pr list --head chore/refresh-lockfile --json url --jq '.[0].url')"
|
| 88 |
+
if [ -z "$pr_url" ]; then
|
| 89 |
+
echo "Error: lockfile PR was not found." >&2
|
| 90 |
+
exit 1
|
| 91 |
+
fi
|
| 92 |
+
|
| 93 |
+
gh pr merge --auto --squash --delete-branch "$pr_url"
|
.github/workflows/release.yml
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Release
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- master
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
inputs:
|
| 9 |
+
source_ref:
|
| 10 |
+
description: Commit SHA, branch, or tag to publish as stable
|
| 11 |
+
required: true
|
| 12 |
+
type: string
|
| 13 |
+
default: master
|
| 14 |
+
stable_date:
|
| 15 |
+
description: Stable release date in UTC (YYYY-MM-DD). Defaults to today.
|
| 16 |
+
required: false
|
| 17 |
+
type: string
|
| 18 |
+
dry_run:
|
| 19 |
+
description: Preview the stable release without publishing
|
| 20 |
+
required: true
|
| 21 |
+
type: boolean
|
| 22 |
+
default: false
|
| 23 |
+
|
| 24 |
+
concurrency:
|
| 25 |
+
group: release-${{ github.event_name }}-${{ github.ref }}
|
| 26 |
+
cancel-in-progress: false
|
| 27 |
+
|
| 28 |
+
jobs:
|
| 29 |
+
verify_canary:
|
| 30 |
+
if: github.event_name == 'push'
|
| 31 |
+
runs-on: ubuntu-latest
|
| 32 |
+
timeout-minutes: 30
|
| 33 |
+
permissions:
|
| 34 |
+
contents: read
|
| 35 |
+
|
| 36 |
+
steps:
|
| 37 |
+
- name: Checkout repository
|
| 38 |
+
uses: actions/checkout@v4
|
| 39 |
+
with:
|
| 40 |
+
fetch-depth: 0
|
| 41 |
+
|
| 42 |
+
- name: Setup pnpm
|
| 43 |
+
uses: pnpm/action-setup@v4
|
| 44 |
+
with:
|
| 45 |
+
version: 9.15.4
|
| 46 |
+
|
| 47 |
+
- name: Setup Node.js
|
| 48 |
+
uses: actions/setup-node@v4
|
| 49 |
+
with:
|
| 50 |
+
node-version: 24
|
| 51 |
+
cache: pnpm
|
| 52 |
+
|
| 53 |
+
- name: Install dependencies
|
| 54 |
+
run: pnpm install --no-frozen-lockfile
|
| 55 |
+
|
| 56 |
+
- name: Typecheck
|
| 57 |
+
run: pnpm -r typecheck
|
| 58 |
+
|
| 59 |
+
- name: Run tests
|
| 60 |
+
run: pnpm test:run
|
| 61 |
+
|
| 62 |
+
- name: Build
|
| 63 |
+
run: pnpm build
|
| 64 |
+
|
| 65 |
+
publish_canary:
|
| 66 |
+
if: github.event_name == 'push'
|
| 67 |
+
needs: verify_canary
|
| 68 |
+
runs-on: ubuntu-latest
|
| 69 |
+
timeout-minutes: 45
|
| 70 |
+
environment: npm-canary
|
| 71 |
+
permissions:
|
| 72 |
+
contents: write
|
| 73 |
+
id-token: write
|
| 74 |
+
|
| 75 |
+
steps:
|
| 76 |
+
- name: Checkout repository
|
| 77 |
+
uses: actions/checkout@v4
|
| 78 |
+
with:
|
| 79 |
+
fetch-depth: 0
|
| 80 |
+
|
| 81 |
+
- name: Setup pnpm
|
| 82 |
+
uses: pnpm/action-setup@v4
|
| 83 |
+
with:
|
| 84 |
+
version: 9.15.4
|
| 85 |
+
|
| 86 |
+
- name: Setup Node.js
|
| 87 |
+
uses: actions/setup-node@v4
|
| 88 |
+
with:
|
| 89 |
+
node-version: 24
|
| 90 |
+
cache: pnpm
|
| 91 |
+
|
| 92 |
+
- name: Install dependencies
|
| 93 |
+
run: pnpm install --no-frozen-lockfile
|
| 94 |
+
|
| 95 |
+
- name: Restore tracked install-time changes
|
| 96 |
+
run: git checkout -- pnpm-lock.yaml
|
| 97 |
+
|
| 98 |
+
- name: Configure git author
|
| 99 |
+
run: |
|
| 100 |
+
git config user.name "github-actions[bot]"
|
| 101 |
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
| 102 |
+
|
| 103 |
+
- name: Publish canary
|
| 104 |
+
env:
|
| 105 |
+
GITHUB_ACTIONS: "true"
|
| 106 |
+
run: ./scripts/release.sh canary --skip-verify
|
| 107 |
+
|
| 108 |
+
- name: Push canary tag
|
| 109 |
+
run: |
|
| 110 |
+
tag="$(git tag --points-at HEAD | grep '^canary/v' | head -1)"
|
| 111 |
+
if [ -z "$tag" ]; then
|
| 112 |
+
echo "Error: no canary tag points at HEAD after release." >&2
|
| 113 |
+
exit 1
|
| 114 |
+
fi
|
| 115 |
+
git push origin "refs/tags/${tag}"
|
| 116 |
+
|
| 117 |
+
verify_stable:
|
| 118 |
+
if: github.event_name == 'workflow_dispatch'
|
| 119 |
+
runs-on: ubuntu-latest
|
| 120 |
+
timeout-minutes: 30
|
| 121 |
+
permissions:
|
| 122 |
+
contents: read
|
| 123 |
+
|
| 124 |
+
steps:
|
| 125 |
+
- name: Checkout repository
|
| 126 |
+
uses: actions/checkout@v4
|
| 127 |
+
with:
|
| 128 |
+
fetch-depth: 0
|
| 129 |
+
ref: ${{ inputs.source_ref }}
|
| 130 |
+
|
| 131 |
+
- name: Setup pnpm
|
| 132 |
+
uses: pnpm/action-setup@v4
|
| 133 |
+
with:
|
| 134 |
+
version: 9.15.4
|
| 135 |
+
|
| 136 |
+
- name: Setup Node.js
|
| 137 |
+
uses: actions/setup-node@v4
|
| 138 |
+
with:
|
| 139 |
+
node-version: 24
|
| 140 |
+
cache: pnpm
|
| 141 |
+
|
| 142 |
+
- name: Install dependencies
|
| 143 |
+
run: pnpm install --no-frozen-lockfile
|
| 144 |
+
|
| 145 |
+
- name: Typecheck
|
| 146 |
+
run: pnpm -r typecheck
|
| 147 |
+
|
| 148 |
+
- name: Run tests
|
| 149 |
+
run: pnpm test:run
|
| 150 |
+
|
| 151 |
+
- name: Build
|
| 152 |
+
run: pnpm build
|
| 153 |
+
|
| 154 |
+
preview_stable:
|
| 155 |
+
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
|
| 156 |
+
needs: verify_stable
|
| 157 |
+
runs-on: ubuntu-latest
|
| 158 |
+
timeout-minutes: 45
|
| 159 |
+
permissions:
|
| 160 |
+
contents: read
|
| 161 |
+
|
| 162 |
+
steps:
|
| 163 |
+
- name: Checkout repository
|
| 164 |
+
uses: actions/checkout@v4
|
| 165 |
+
with:
|
| 166 |
+
fetch-depth: 0
|
| 167 |
+
ref: ${{ inputs.source_ref }}
|
| 168 |
+
|
| 169 |
+
- name: Setup pnpm
|
| 170 |
+
uses: pnpm/action-setup@v4
|
| 171 |
+
with:
|
| 172 |
+
version: 9.15.4
|
| 173 |
+
|
| 174 |
+
- name: Setup Node.js
|
| 175 |
+
uses: actions/setup-node@v4
|
| 176 |
+
with:
|
| 177 |
+
node-version: 24
|
| 178 |
+
cache: pnpm
|
| 179 |
+
|
| 180 |
+
- name: Install dependencies
|
| 181 |
+
run: pnpm install --no-frozen-lockfile
|
| 182 |
+
|
| 183 |
+
- name: Dry-run stable release
|
| 184 |
+
env:
|
| 185 |
+
GITHUB_ACTIONS: "true"
|
| 186 |
+
run: |
|
| 187 |
+
args=(stable --skip-verify --dry-run)
|
| 188 |
+
if [ -n "${{ inputs.stable_date }}" ]; then
|
| 189 |
+
args+=(--date "${{ inputs.stable_date }}")
|
| 190 |
+
fi
|
| 191 |
+
./scripts/release.sh "${args[@]}"
|
| 192 |
+
|
| 193 |
+
publish_stable:
|
| 194 |
+
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run
|
| 195 |
+
needs: verify_stable
|
| 196 |
+
runs-on: ubuntu-latest
|
| 197 |
+
timeout-minutes: 45
|
| 198 |
+
environment: npm-stable
|
| 199 |
+
permissions:
|
| 200 |
+
contents: write
|
| 201 |
+
id-token: write
|
| 202 |
+
|
| 203 |
+
steps:
|
| 204 |
+
- name: Checkout repository
|
| 205 |
+
uses: actions/checkout@v4
|
| 206 |
+
with:
|
| 207 |
+
fetch-depth: 0
|
| 208 |
+
ref: ${{ inputs.source_ref }}
|
| 209 |
+
|
| 210 |
+
- name: Setup pnpm
|
| 211 |
+
uses: pnpm/action-setup@v4
|
| 212 |
+
with:
|
| 213 |
+
version: 9.15.4
|
| 214 |
+
|
| 215 |
+
- name: Setup Node.js
|
| 216 |
+
uses: actions/setup-node@v4
|
| 217 |
+
with:
|
| 218 |
+
node-version: 24
|
| 219 |
+
cache: pnpm
|
| 220 |
+
|
| 221 |
+
- name: Install dependencies
|
| 222 |
+
run: pnpm install --no-frozen-lockfile
|
| 223 |
+
|
| 224 |
+
- name: Restore tracked install-time changes
|
| 225 |
+
run: git checkout -- pnpm-lock.yaml
|
| 226 |
+
|
| 227 |
+
- name: Configure git author
|
| 228 |
+
run: |
|
| 229 |
+
git config user.name "github-actions[bot]"
|
| 230 |
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
| 231 |
+
|
| 232 |
+
- name: Publish stable
|
| 233 |
+
env:
|
| 234 |
+
GITHUB_ACTIONS: "true"
|
| 235 |
+
run: |
|
| 236 |
+
args=(stable --skip-verify)
|
| 237 |
+
if [ -n "${{ inputs.stable_date }}" ]; then
|
| 238 |
+
args+=(--date "${{ inputs.stable_date }}")
|
| 239 |
+
fi
|
| 240 |
+
./scripts/release.sh "${args[@]}"
|
| 241 |
+
|
| 242 |
+
- name: Push stable tag
|
| 243 |
+
run: |
|
| 244 |
+
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
|
| 245 |
+
if [ -z "$tag" ]; then
|
| 246 |
+
echo "Error: no stable tag points at HEAD after release." >&2
|
| 247 |
+
exit 1
|
| 248 |
+
fi
|
| 249 |
+
git push origin "refs/tags/${tag}"
|
| 250 |
+
|
| 251 |
+
- name: Create GitHub Release
|
| 252 |
+
env:
|
| 253 |
+
GH_TOKEN: ${{ github.token }}
|
| 254 |
+
run: |
|
| 255 |
+
version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')"
|
| 256 |
+
if [ -z "$version" ]; then
|
| 257 |
+
echo "Error: no v* tag points at HEAD after stable release." >&2
|
| 258 |
+
exit 1
|
| 259 |
+
fi
|
| 260 |
+
./scripts/create-github-release.sh "$version"
|
.gitignore
CHANGED
|
@@ -1,27 +1,50 @@
|
|
| 1 |
-
.plandex/
|
| 2 |
-
.plandex-dev/
|
| 3 |
-
.plandex-v2/
|
| 4 |
-
.plandex-dev-v2/
|
| 5 |
-
.envkey
|
| 6 |
-
.env
|
| 7 |
-
.env.*
|
| 8 |
-
plandex
|
| 9 |
-
plandex-dev
|
| 10 |
-
plandex-server
|
| 11 |
-
*.exe
|
| 12 |
node_modules/
|
| 13 |
-
/tools/
|
| 14 |
-
/static/
|
| 15 |
-
/infra/
|
| 16 |
-
/payments-dashboard/
|
| 17 |
-
.DS_Store
|
| 18 |
-
.goreleaser.yml
|
| 19 |
dist/
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
*.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
|
|
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
node_modules/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
dist/
|
| 3 |
+
.env
|
| 4 |
+
*.tsbuildinfo
|
| 5 |
+
drizzle/meta/
|
| 6 |
+
.vite/
|
| 7 |
+
coverage/
|
| 8 |
+
.DS_Store
|
| 9 |
+
data/
|
| 10 |
+
.paperclip/
|
| 11 |
+
.pnpm-store/
|
| 12 |
+
tmp-*
|
| 13 |
+
cli/tmp/
|
| 14 |
+
|
| 15 |
+
# Scratch/seed scripts (but not scripts/ dir)
|
| 16 |
+
check-*.mjs
|
| 17 |
+
!scripts/check-*.mjs
|
| 18 |
+
new-agent*.json
|
| 19 |
+
newcompany.json
|
| 20 |
+
seed-*.mjs
|
| 21 |
+
server/check-*.mjs
|
| 22 |
+
server/seed-*.mjs
|
| 23 |
+
packages/db/seed-*.mjs
|
| 24 |
+
|
| 25 |
+
# npm publish build artifacts
|
| 26 |
+
cli/package.dev.json
|
| 27 |
+
|
| 28 |
+
# Build artifacts in src directories
|
| 29 |
+
server/src/**/*.js
|
| 30 |
+
server/src/**/*.js.map
|
| 31 |
+
server/src/**/*.d.ts
|
| 32 |
+
server/src/**/*.d.ts.map
|
| 33 |
+
tmp/
|
| 34 |
|
| 35 |
+
# Editor / tool temp files
|
| 36 |
+
*.tmp
|
| 37 |
+
.vscode/
|
| 38 |
+
.claude/settings.local.json
|
| 39 |
+
.paperclip-local/
|
| 40 |
+
/.idea/
|
| 41 |
+
/.agents/
|
| 42 |
|
| 43 |
+
# Doc maintenance cursor
|
| 44 |
+
.doc-review-cursor
|
| 45 |
|
| 46 |
+
# Playwright
|
| 47 |
+
tests/e2e/test-results/
|
| 48 |
+
tests/e2e/playwright-report/
|
| 49 |
+
.superset/
|
| 50 |
+
.claude/worktrees/
|
.mailmap
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Dotta <bippadotta@protonmail.com> Forgotten <forgottenrunes@protonmail.com>
|
.npmrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
auto-install-peers=true
|
AGENTS.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AGENTS.md
|
| 2 |
+
|
| 3 |
+
Guidance for human and AI contributors working in this repository.
|
| 4 |
+
|
| 5 |
+
## 1. Purpose
|
| 6 |
+
|
| 7 |
+
Paperclip is a control plane for AI-agent companies.
|
| 8 |
+
The current implementation target is V1 and is defined in `doc/SPEC-implementation.md`.
|
| 9 |
+
|
| 10 |
+
## 2. Read This First
|
| 11 |
+
|
| 12 |
+
Before making changes, read in this order:
|
| 13 |
+
|
| 14 |
+
1. `doc/GOAL.md`
|
| 15 |
+
2. `doc/PRODUCT.md`
|
| 16 |
+
3. `doc/SPEC-implementation.md`
|
| 17 |
+
4. `doc/DEVELOPING.md`
|
| 18 |
+
5. `doc/DATABASE.md`
|
| 19 |
+
|
| 20 |
+
`doc/SPEC.md` is long-horizon product context.
|
| 21 |
+
`doc/SPEC-implementation.md` is the concrete V1 build contract.
|
| 22 |
+
|
| 23 |
+
## 3. Repo Map
|
| 24 |
+
|
| 25 |
+
- `server/`: Express REST API and orchestration services
|
| 26 |
+
- `ui/`: React + Vite board UI
|
| 27 |
+
- `packages/db/`: Drizzle schema, migrations, DB clients
|
| 28 |
+
- `packages/shared/`: shared types, constants, validators, API path constants
|
| 29 |
+
- `doc/`: operational and product docs
|
| 30 |
+
|
| 31 |
+
## 4. Dev Setup (Auto DB)
|
| 32 |
+
|
| 33 |
+
Use embedded PGlite in dev by leaving `DATABASE_URL` unset.
|
| 34 |
+
|
| 35 |
+
```sh
|
| 36 |
+
pnpm install
|
| 37 |
+
pnpm dev
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
This starts:
|
| 41 |
+
|
| 42 |
+
- API: `http://localhost:3100`
|
| 43 |
+
- UI: `http://localhost:3100` (served by API server in dev middleware mode)
|
| 44 |
+
|
| 45 |
+
Quick checks:
|
| 46 |
+
|
| 47 |
+
```sh
|
| 48 |
+
curl http://localhost:3100/api/health
|
| 49 |
+
curl http://localhost:3100/api/companies
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
Reset local dev DB:
|
| 53 |
+
|
| 54 |
+
```sh
|
| 55 |
+
rm -rf data/pglite
|
| 56 |
+
pnpm dev
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## 5. Core Engineering Rules
|
| 60 |
+
|
| 61 |
+
1. Keep changes company-scoped.
|
| 62 |
+
Every domain entity should be scoped to a company and company boundaries must be enforced in routes/services.
|
| 63 |
+
|
| 64 |
+
2. Keep contracts synchronized.
|
| 65 |
+
If you change schema/API behavior, update all impacted layers:
|
| 66 |
+
- `packages/db` schema and exports
|
| 67 |
+
- `packages/shared` types/constants/validators
|
| 68 |
+
- `server` routes/services
|
| 69 |
+
- `ui` API clients and pages
|
| 70 |
+
|
| 71 |
+
3. Preserve control-plane invariants.
|
| 72 |
+
- Single-assignee task model
|
| 73 |
+
- Atomic issue checkout semantics
|
| 74 |
+
- Approval gates for governed actions
|
| 75 |
+
- Budget hard-stop auto-pause behavior
|
| 76 |
+
- Activity logging for mutating actions
|
| 77 |
+
|
| 78 |
+
4. Do not replace strategic docs wholesale unless asked.
|
| 79 |
+
Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned.
|
| 80 |
+
|
| 81 |
+
5. Keep plan docs dated and centralized.
|
| 82 |
+
New plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames.
|
| 83 |
+
|
| 84 |
+
## 6. Database Change Workflow
|
| 85 |
+
|
| 86 |
+
When changing data model:
|
| 87 |
+
|
| 88 |
+
1. Edit `packages/db/src/schema/*.ts`
|
| 89 |
+
2. Ensure new tables are exported from `packages/db/src/schema/index.ts`
|
| 90 |
+
3. Generate migration:
|
| 91 |
+
|
| 92 |
+
```sh
|
| 93 |
+
pnpm db:generate
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
4. Validate compile:
|
| 97 |
+
|
| 98 |
+
```sh
|
| 99 |
+
pnpm -r typecheck
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Notes:
|
| 103 |
+
- `packages/db/drizzle.config.ts` reads compiled schema from `dist/schema/*.js`
|
| 104 |
+
- `pnpm db:generate` compiles `packages/db` first
|
| 105 |
+
|
| 106 |
+
## 7. Verification Before Hand-off
|
| 107 |
+
|
| 108 |
+
Run this full check before claiming done:
|
| 109 |
+
|
| 110 |
+
```sh
|
| 111 |
+
pnpm -r typecheck
|
| 112 |
+
pnpm test:run
|
| 113 |
+
pnpm build
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
If anything cannot be run, explicitly report what was not run and why.
|
| 117 |
+
|
| 118 |
+
## 8. API and Auth Expectations
|
| 119 |
+
|
| 120 |
+
- Base path: `/api`
|
| 121 |
+
- Board access is treated as full-control operator context
|
| 122 |
+
- Agent access uses bearer API keys (`agent_api_keys`), hashed at rest
|
| 123 |
+
- Agent keys must not access other companies
|
| 124 |
+
|
| 125 |
+
When adding endpoints:
|
| 126 |
+
|
| 127 |
+
- apply company access checks
|
| 128 |
+
- enforce actor permissions (board vs agent)
|
| 129 |
+
- write activity log entries for mutations
|
| 130 |
+
- return consistent HTTP errors (`400/401/403/404/409/422/500`)
|
| 131 |
+
|
| 132 |
+
## 9. UI Expectations
|
| 133 |
+
|
| 134 |
+
- Keep routes and nav aligned with available API surface
|
| 135 |
+
- Use company selection context for company-scoped pages
|
| 136 |
+
- Surface failures clearly; do not silently ignore API errors
|
| 137 |
+
|
| 138 |
+
## 10. Definition of Done
|
| 139 |
+
|
| 140 |
+
A change is done when all are true:
|
| 141 |
+
|
| 142 |
+
1. Behavior matches `doc/SPEC-implementation.md`
|
| 143 |
+
2. Typecheck, tests, and build pass
|
| 144 |
+
3. Contracts are synced across db/shared/server/ui
|
| 145 |
+
4. Docs updated when behavior or commands change
|
Agent.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Agent.md
|
| 2 |
+
|
| 3 |
+
1. Deployment Configuration
|
| 4 |
+
Target Space
|
| 5 |
+
Profile: AUXteam
|
| 6 |
+
Space: Plandex
|
| 7 |
+
Full Identifier: AUXteam/Plandex
|
| 8 |
+
Frontend Port: 7860 (mandatory for all Hugging Face Spaces)
|
| 9 |
+
|
| 10 |
+
Deployment Method
|
| 11 |
+
Choose the correct SDK based on the app type based on the codebase language:
|
| 12 |
+
Docker SDK — for all other applications (recommended default for flexibility)
|
| 13 |
+
|
| 14 |
+
HF Token
|
| 15 |
+
The environment variable `$HF_TOKEN` will always be provided at execution time.
|
| 16 |
+
Never hardcode the token. Always read it from the environment.
|
| 17 |
+
All monitoring and log‑streaming commands rely on `$HF_TOKEN`.
|
| 18 |
+
|
| 19 |
+
Required Files
|
| 20 |
+
Dockerfile
|
| 21 |
+
README.md with Hugging Face YAML frontmatter:
|
| 22 |
+
---
|
| 23 |
+
title: Paperclip
|
| 24 |
+
sdk: docker
|
| 25 |
+
app_port: 7860
|
| 26 |
+
---
|
| 27 |
+
.hfignore to exclude unnecessary files
|
| 28 |
+
This Agent.md file (must be committed before deployment)
|
| 29 |
+
|
| 30 |
+
2. API Exposure and Documentation
|
| 31 |
+
|
| 32 |
+
Mandatory Endpoints
|
| 33 |
+
Every deployment must expose:
|
| 34 |
+
|
| 35 |
+
/health
|
| 36 |
+
Returns HTTP 200 when the app is ready.
|
| 37 |
+
Required for Hugging Face to transition the Space from starting → running.
|
| 38 |
+
|
| 39 |
+
/api-docs
|
| 40 |
+
Documents all available API endpoints.
|
| 41 |
+
Must be reachable at:
|
| 42 |
+
https://AUXteam-Plandex.hf.space/api-docs
|
| 43 |
+
|
| 44 |
+
Functional Endpoints
|
| 45 |
+
|
| 46 |
+
### /api/health
|
| 47 |
+
- Method: GET
|
| 48 |
+
- Purpose: Returns API health status
|
| 49 |
+
- Request: null
|
| 50 |
+
- Response:
|
| 51 |
+
{
|
| 52 |
+
"status": "ok",
|
| 53 |
+
"version": "1.0.0",
|
| 54 |
+
...
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
### /api/companies
|
| 58 |
+
- Method: GET
|
| 59 |
+
- Purpose: Lists all companies
|
| 60 |
+
- Request: null
|
| 61 |
+
- Response:
|
| 62 |
+
[
|
| 63 |
+
{
|
| 64 |
+
"id": "123",
|
| 65 |
+
"name": "My Company"
|
| 66 |
+
}
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
### /api/companies
|
| 70 |
+
- Method: POST
|
| 71 |
+
- Purpose: Creates a new company
|
| 72 |
+
- Request:
|
| 73 |
+
{
|
| 74 |
+
"name": "My Company",
|
| 75 |
+
"goal": "Build a product"
|
| 76 |
+
}
|
| 77 |
+
- Response:
|
| 78 |
+
{
|
| 79 |
+
"id": "123",
|
| 80 |
+
"name": "My Company",
|
| 81 |
+
...
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
3. Deployment Workflow
|
| 85 |
+
|
| 86 |
+
Standard Deployment Command
|
| 87 |
+
After any code change, run:
|
| 88 |
+
`hf upload AUXteam/Plandex --repo-type=space`
|
| 89 |
+
This command must be executed after updating and committing Agent.md.
|
| 90 |
+
|
| 91 |
+
Deployment Steps
|
| 92 |
+
Ensure all code changes are committed.
|
| 93 |
+
Ensure Agent.md is updated and committed.
|
| 94 |
+
Run the upload command.
|
| 95 |
+
Wait for the Space to build.
|
| 96 |
+
Monitor logs (see next section).
|
| 97 |
+
When the Space is running, execute all test cases.
|
| 98 |
+
|
| 99 |
+
Continuous Deployment Rule
|
| 100 |
+
After every relevant edit (logic, dependencies, API changes):
|
| 101 |
+
Update Agent.md
|
| 102 |
+
Redeploy using the upload command
|
| 103 |
+
Re-run all test cases
|
| 104 |
+
Confirm /health and /api-docs are functional
|
| 105 |
+
This applies even for long-running projects.
|
| 106 |
+
|
| 107 |
+
4. Monitoring and Logs
|
| 108 |
+
|
| 109 |
+
Build Logs (SSE)
|
| 110 |
+
curl -N \
|
| 111 |
+
-H "Authorization: Bearer $HF_TOKEN" \
|
| 112 |
+
"https://huggingface.co/api/spaces/AUXteam/Plandex/logs/build"
|
| 113 |
+
|
| 114 |
+
Run Logs (SSE)
|
| 115 |
+
curl -N \
|
| 116 |
+
-H "Authorization: Bearer $HF_TOKEN" \
|
| 117 |
+
"https://huggingface.co/api/spaces/AUXteam/Plandex/logs/run"
|
| 118 |
+
|
| 119 |
+
Notes
|
| 120 |
+
If the Space stays in starting for too long, /health is usually failing.
|
| 121 |
+
If the Space times out after ~30 minutes, check logs immediately.
|
| 122 |
+
Fix issues, commit changes, redeploy.
|
| 123 |
+
|
| 124 |
+
5. Test Run Cases (Mandatory After Every Deployment)
|
| 125 |
+
|
| 126 |
+
1. Health Check
|
| 127 |
+
GET https://AUXteam-Plandex.hf.space/health
|
| 128 |
+
Expected: HTTP 200, body: {"status": "ok"} or similar
|
| 129 |
+
|
| 130 |
+
2. API Docs Check
|
| 131 |
+
GET https://AUXteam-Plandex.hf.space/api-docs
|
| 132 |
+
Expected: HTTP 200, valid documentation UI or JSON spec
|
| 133 |
+
|
| 134 |
+
3. Functional Endpoint Tests
|
| 135 |
+
|
| 136 |
+
POST https://AUXteam-Plandex.hf.space/api/companies
|
| 137 |
+
Payload:
|
| 138 |
+
{
|
| 139 |
+
"name": "Test Company",
|
| 140 |
+
"goal": "Testing"
|
| 141 |
+
}
|
| 142 |
+
Expected:
|
| 143 |
+
- HTTP 200
|
| 144 |
+
- JSON with key "id"
|
| 145 |
+
- No error fields
|
| 146 |
+
|
| 147 |
+
GET https://AUXteam-Plandex.hf.space/api/companies
|
| 148 |
+
Expected:
|
| 149 |
+
- HTTP 200
|
| 150 |
+
- JSON array of companies
|
| 151 |
+
- No error fields
|
| 152 |
+
|
| 153 |
+
4. End-to-End Behaviour
|
| 154 |
+
Confirm the UI loads (if applicable)
|
| 155 |
+
Confirm API endpoints respond within reasonable time
|
| 156 |
+
Confirm no errors appear in run logs
|
| 157 |
+
|
| 158 |
+
6. Maintenance Rules
|
| 159 |
+
Agent.md must always reflect the current deployment configuration, API surface, and test cases.
|
| 160 |
+
Any change to:
|
| 161 |
+
API routes
|
| 162 |
+
Dockerfile
|
| 163 |
+
Dependencies
|
| 164 |
+
App logic
|
| 165 |
+
Deployment method
|
| 166 |
+
requires updating this file.
|
| 167 |
+
This file must be committed before every deployment.
|
| 168 |
+
This file is the operational contract for autonomous agents interacting with the project.
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing Guide
|
| 2 |
+
|
| 3 |
+
Thanks for wanting to contribute!
|
| 4 |
+
|
| 5 |
+
We really appreciate both small fixes and thoughtful larger changes.
|
| 6 |
+
|
| 7 |
+
## Two Paths to Get Your Pull Request Accepted
|
| 8 |
+
|
| 9 |
+
### Path 1: Small, Focused Changes (Fastest way to get merged)
|
| 10 |
+
|
| 11 |
+
- Pick **one** clear thing to fix/improve
|
| 12 |
+
- Touch the **smallest possible number of files**
|
| 13 |
+
- Make sure the change is very targeted and easy to review
|
| 14 |
+
- All automated checks pass (including Greptile comments)
|
| 15 |
+
- No new lint/test failures
|
| 16 |
+
|
| 17 |
+
These almost always get merged quickly when they're clean.
|
| 18 |
+
|
| 19 |
+
### Path 2: Bigger or Impactful Changes
|
| 20 |
+
|
| 21 |
+
- **First** talk about it in Discord → #dev channel
|
| 22 |
+
→ Describe what you're trying to solve
|
| 23 |
+
→ Share rough ideas / approach
|
| 24 |
+
- Once there's rough agreement, build it
|
| 25 |
+
- In your PR include:
|
| 26 |
+
- Before / After screenshots (or short video if UI/behavior change)
|
| 27 |
+
- Clear description of what & why
|
| 28 |
+
- Proof it works (manual testing notes)
|
| 29 |
+
- All tests passing
|
| 30 |
+
- All Greptile + other PR comments addressed
|
| 31 |
+
|
| 32 |
+
PRs that follow this path are **much** more likely to be accepted, even when they're large.
|
| 33 |
+
|
| 34 |
+
## General Rules (both paths)
|
| 35 |
+
|
| 36 |
+
- Write clear commit messages
|
| 37 |
+
- Keep PR title + description meaningful
|
| 38 |
+
- One PR = one logical change (unless it's a small related group)
|
| 39 |
+
- Run tests locally first
|
| 40 |
+
- Be kind in discussions 😄
|
| 41 |
+
|
| 42 |
+
## Writing a Good PR message
|
| 43 |
+
|
| 44 |
+
Please include a "thinking path" at the top of your PR message that explains from the top of the project down to what you fixed. E.g.:
|
| 45 |
+
|
| 46 |
+
### Thinking Path Example 1:
|
| 47 |
+
|
| 48 |
+
> - Paperclip orchestrates ai-agents for zero-human companies
|
| 49 |
+
> - There are many types of adapters for each LLM model provider
|
| 50 |
+
> - But LLM's have a context limit and not all agents can automatically compact their context
|
| 51 |
+
> - So we need to have an adapter-specific configuration for which adapters can and cannot automatically compact their context
|
| 52 |
+
> - This pull request adds per-adapter configuration of compaction, either auto or paperclip managed
|
| 53 |
+
> - That way we can get optimal performance from any adapter/provider in Paperclip
|
| 54 |
+
|
| 55 |
+
### Thinking Path Example 2:
|
| 56 |
+
|
| 57 |
+
> - Paperclip orchestrates ai-agents for zero-human companies
|
| 58 |
+
> - But humans want to watch the agents and oversee their work
|
| 59 |
+
> - Human users also operate in teams and so they need their own logins, profiles, views etc.
|
| 60 |
+
> - So we have a multi-user system for humans
|
| 61 |
+
> - But humans want to be able to update their own profile picture and avatar
|
| 62 |
+
> - But the avatar upload form wasn't saving the avatar to the file storage system
|
| 63 |
+
> - So this PR fixes the avatar upload form to use the file storage service
|
| 64 |
+
> - 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
|
| 65 |
+
|
| 66 |
+
Then have the rest of your normal PR message after the Thinking Path.
|
| 67 |
+
|
| 68 |
+
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.
|
| 69 |
+
|
| 70 |
+
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.
|
| 71 |
+
|
| 72 |
+
Questions? Just ask in #dev — we're happy to help.
|
| 73 |
+
|
| 74 |
+
Happy hacking!
|
Dockerfile
CHANGED
|
@@ -1,55 +1,56 @@
|
|
| 1 |
-
FROM
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
# Install Python and create a virtual environment for litellm passthrough
|
| 8 |
-
RUN python3 -m venv /opt/venv
|
| 9 |
-
|
| 10 |
-
# Activate the virtual environment for all following RUN commands
|
| 11 |
-
ENV PATH="/opt/venv/bin:$PATH"
|
| 12 |
-
|
| 13 |
-
# Now install litellm passthrough dependencies in the virtual environment
|
| 14 |
-
RUN pip install --no-cache-dir "litellm==1.72.6" "fastapi==0.115.12" "uvicorn==0.34.1" "google-cloud-aiplatform==1.96.0" "boto3==1.38.40" "botocore==1.38.40"
|
| 15 |
|
|
|
|
| 16 |
WORKDIR /app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
RUN echo '#!/bin/bash\n\
|
| 38 |
-
service postgresql start\n\
|
| 39 |
-
su - postgres -c "psql -c \"CREATE USER plandex WITH PASSWORD '\''plandex'\'';\""\n\
|
| 40 |
-
su - postgres -c "psql -c \"CREATE DATABASE plandex OWNER plandex;\""\n\
|
| 41 |
-
export DATABASE_URL="postgres://plandex:plandex@localhost:5432/plandex?sslmode=disable"\n\
|
| 42 |
-
export GOENV=development\n\
|
| 43 |
-
export LOCAL_MODE=1\n\
|
| 44 |
-
export PLANDEX_BASE_DIR=/plandex-server\n\
|
| 45 |
-
export LITELLM_PROXY_DIR=/app/app/server\n\
|
| 46 |
-
mkdir -p /plandex-server\n\
|
| 47 |
-
cd /app/app/server\n\
|
| 48 |
-
./plandex-server' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
|
| 49 |
-
|
| 50 |
-
# Set the port and expose it
|
| 51 |
-
ENV PORT=7860
|
| 52 |
EXPOSE 7860
|
| 53 |
|
| 54 |
-
|
| 55 |
-
CMD ["/
|
|
|
|
| 1 |
+
FROM node:lts-trixie-slim AS base
|
| 2 |
+
RUN apt-get update \
|
| 3 |
+
&& apt-get install -y --no-install-recommends ca-certificates curl git \
|
| 4 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 5 |
+
RUN corepack enable
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
FROM base AS deps
|
| 8 |
WORKDIR /app
|
| 9 |
+
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
|
| 10 |
+
COPY cli/package.json cli/
|
| 11 |
+
COPY server/package.json server/
|
| 12 |
+
COPY ui/package.json ui/
|
| 13 |
+
COPY packages/shared/package.json packages/shared/
|
| 14 |
+
COPY packages/db/package.json packages/db/
|
| 15 |
+
COPY packages/adapter-utils/package.json packages/adapter-utils/
|
| 16 |
+
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
|
| 17 |
+
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
| 18 |
+
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
|
| 19 |
+
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
|
| 20 |
+
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
| 21 |
+
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
| 22 |
+
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
| 23 |
+
|
| 24 |
+
RUN pnpm install --frozen-lockfile
|
| 25 |
+
|
| 26 |
+
FROM base AS build
|
| 27 |
+
WORKDIR /app
|
| 28 |
+
COPY --from=deps /app /app
|
| 29 |
+
COPY . .
|
| 30 |
+
RUN pnpm --filter @paperclipai/ui build
|
| 31 |
+
RUN pnpm --filter @paperclipai/server build
|
| 32 |
+
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
|
| 33 |
|
| 34 |
+
FROM base AS production
|
| 35 |
+
WORKDIR /app
|
| 36 |
+
COPY --chown=node:node --from=build /app /app
|
| 37 |
+
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
|
| 38 |
+
&& mkdir -p /paperclip \
|
| 39 |
+
&& chown node:node /paperclip
|
| 40 |
+
|
| 41 |
+
ENV NODE_ENV=production \
|
| 42 |
+
HOME=/paperclip \
|
| 43 |
+
HOST=0.0.0.0 \
|
| 44 |
+
PORT=7860 \
|
| 45 |
+
SERVE_UI=true \
|
| 46 |
+
PAPERCLIP_HOME=/paperclip \
|
| 47 |
+
PAPERCLIP_INSTANCE_ID=default \
|
| 48 |
+
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
|
| 49 |
+
PAPERCLIP_DEPLOYMENT_MODE=authenticated \
|
| 50 |
+
PAPERCLIP_DEPLOYMENT_EXPOSURE=private
|
| 51 |
+
|
| 52 |
+
VOLUME ["/paperclip"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
EXPOSE 7860
|
| 54 |
|
| 55 |
+
USER node
|
| 56 |
+
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]
|
Dockerfile.onboard-smoke
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM ubuntu:24.04
|
| 2 |
+
|
| 3 |
+
ARG NODE_MAJOR=20
|
| 4 |
+
ARG PAPERCLIPAI_VERSION=latest
|
| 5 |
+
ARG HOST_UID=10001
|
| 6 |
+
|
| 7 |
+
ENV DEBIAN_FRONTEND=noninteractive \
|
| 8 |
+
PAPERCLIP_HOME=/paperclip \
|
| 9 |
+
PAPERCLIP_OPEN_ON_LISTEN=false \
|
| 10 |
+
HOST=0.0.0.0 \
|
| 11 |
+
PORT=3100 \
|
| 12 |
+
HOME=/home/paperclip \
|
| 13 |
+
LANG=en_US.UTF-8 \
|
| 14 |
+
LC_ALL=en_US.UTF-8 \
|
| 15 |
+
NPM_CONFIG_UPDATE_NOTIFIER=false \
|
| 16 |
+
NODE_MAJOR=${NODE_MAJOR} \
|
| 17 |
+
PAPERCLIPAI_VERSION=${PAPERCLIPAI_VERSION}
|
| 18 |
+
|
| 19 |
+
RUN apt-get update \
|
| 20 |
+
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg locales \
|
| 21 |
+
&& mkdir -p /etc/apt/keyrings \
|
| 22 |
+
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
|
| 23 |
+
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
| 24 |
+
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
|
| 25 |
+
> /etc/apt/sources.list.d/nodesource.list \
|
| 26 |
+
&& apt-get update \
|
| 27 |
+
&& apt-get install -y --no-install-recommends nodejs \
|
| 28 |
+
&& locale-gen en_US.UTF-8 \
|
| 29 |
+
&& groupadd --gid 10001 paperclip \
|
| 30 |
+
&& useradd --create-home --shell /bin/bash --uid "${HOST_UID}" --gid 10001 paperclip \
|
| 31 |
+
&& mkdir -p /paperclip /home/paperclip/workspace \
|
| 32 |
+
&& chown -R paperclip:paperclip /paperclip /home/paperclip \
|
| 33 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 34 |
+
|
| 35 |
+
VOLUME ["/paperclip"]
|
| 36 |
+
WORKDIR /home/paperclip/workspace
|
| 37 |
+
EXPOSE 3100
|
| 38 |
+
USER paperclip
|
| 39 |
+
|
| 40 |
+
CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\""]
|
LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
MIT License
|
| 2 |
|
| 3 |
-
Copyright (c) 2025
|
| 4 |
|
| 5 |
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
| 18 |
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
-
SOFTWARE.
|
|
|
|
| 1 |
MIT License
|
| 2 |
|
| 3 |
+
Copyright (c) 2025 Paperclip AI
|
| 4 |
|
| 5 |
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
of this software and associated documentation files (the "Software"), to deal
|
|
|
|
| 18 |
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,243 +1,285 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji: 🚀
|
| 4 |
-
colorFrom: blue
|
| 5 |
-
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
---
|
| 9 |
|
| 10 |
-
<
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
</a>
|
| 17 |
-
|
| 18 |
-
</
|
| 19 |
-
<
|
| 20 |
-
|
| 21 |
-
<
|
| 22 |
-
|
| 23 |
-
<
|
| 24 |
-
<
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
<
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
<
|
| 62 |
-
|
| 63 |
-
</
|
| 64 |
-
|
| 65 |
-
<
|
| 66 |
-
|
| 67 |
-
<
|
| 68 |
-
<
|
| 69 |
-
</
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
<br/>
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
<br/>
|
| 151 |
-
|
| 152 |
-
##
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
**
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
```
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
#
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Paperclip
|
|
|
|
|
|
|
|
|
|
| 3 |
sdk: docker
|
| 4 |
+
app_port: 7860
|
| 5 |
---
|
| 6 |
|
| 7 |
+
<p align="center">
|
| 8 |
+
<img src="doc/assets/header.png" alt="Paperclip — runs your business" width="720" />
|
| 9 |
+
</p>
|
| 10 |
+
|
| 11 |
+
<p align="center">
|
| 12 |
+
<a href="#quickstart"><strong>Quickstart</strong></a> ·
|
| 13 |
+
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> ·
|
| 14 |
+
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> ·
|
| 15 |
+
<a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a>
|
| 16 |
+
</p>
|
| 17 |
+
|
| 18 |
+
<p align="center">
|
| 19 |
+
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
|
| 20 |
+
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
|
| 21 |
+
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/discord/000000000?label=discord" alt="Discord" /></a>
|
| 22 |
+
</p>
|
| 23 |
+
|
| 24 |
+
<br/>
|
| 25 |
+
|
| 26 |
+
<div align="center">
|
| 27 |
+
<video src="https://github.com/user-attachments/assets/773bdfb2-6d1e-4e30-8c5f-3487d5b70c8f" width="600" controls></video>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<br/>
|
| 31 |
+
|
| 32 |
+
## What is Paperclip?
|
| 33 |
+
|
| 34 |
+
# Open-source orchestration for zero-human companies
|
| 35 |
+
|
| 36 |
+
**If OpenClaw is an _employee_, Paperclip is the _company_**
|
| 37 |
+
|
| 38 |
+
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.
|
| 39 |
+
|
| 40 |
+
It looks like a task manager — but under the hood it has org charts, budgets, governance, goal alignment, and agent coordination.
|
| 41 |
+
|
| 42 |
+
**Manage business goals, not pull requests.**
|
| 43 |
+
|
| 44 |
+
| | Step | Example |
|
| 45 |
+
| ------ | --------------- | ------------------------------------------------------------------ |
|
| 46 |
+
| **01** | Define the goal | _"Build the #1 AI note-taking app to $1M MRR."_ |
|
| 47 |
+
| **02** | Hire the team | CEO, CTO, engineers, designers, marketers — any bot, any provider. |
|
| 48 |
+
| **03** | Approve and run | Review strategy. Set budgets. Hit go. Monitor from the dashboard. |
|
| 49 |
+
|
| 50 |
+
<br/>
|
| 51 |
+
|
| 52 |
+
> **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.
|
| 53 |
+
|
| 54 |
+
<br/>
|
| 55 |
+
|
| 56 |
+
<div align="center">
|
| 57 |
+
<table>
|
| 58 |
+
<tr>
|
| 59 |
+
<td align="center"><strong>Works<br/>with</strong></td>
|
| 60 |
+
<td align="center"><img src="doc/assets/logos/openclaw.svg" width="32" alt="OpenClaw" /><br/><sub>OpenClaw</sub></td>
|
| 61 |
+
<td align="center"><img src="doc/assets/logos/claude.svg" width="32" alt="Claude" /><br/><sub>Claude Code</sub></td>
|
| 62 |
+
<td align="center"><img src="doc/assets/logos/codex.svg" width="32" alt="Codex" /><br/><sub>Codex</sub></td>
|
| 63 |
+
<td align="center"><img src="doc/assets/logos/cursor.svg" width="32" alt="Cursor" /><br/><sub>Cursor</sub></td>
|
| 64 |
+
<td align="center"><img src="doc/assets/logos/bash.svg" width="32" alt="Bash" /><br/><sub>Bash</sub></td>
|
| 65 |
+
<td align="center"><img src="doc/assets/logos/http.svg" width="32" alt="HTTP" /><br/><sub>HTTP</sub></td>
|
| 66 |
+
</tr>
|
| 67 |
+
</table>
|
| 68 |
+
|
| 69 |
+
<em>If it can receive a heartbeat, it's hired.</em>
|
| 70 |
+
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<br/>
|
| 74 |
+
|
| 75 |
+
## Paperclip is right for you if
|
| 76 |
+
|
| 77 |
+
- ✅ You want to build **autonomous AI companies**
|
| 78 |
+
- ✅ You **coordinate many different agents** (OpenClaw, Codex, Claude, Cursor) toward a common goal
|
| 79 |
+
- ✅ You have **20 simultaneous Claude Code terminals** open and lose track of what everyone is doing
|
| 80 |
+
- ✅ You want agents running **autonomously 24/7**, but still want to audit work and chime in when needed
|
| 81 |
+
- ✅ You want to **monitor costs** and enforce budgets
|
| 82 |
+
- ✅ You want a process for managing agents that **feels like using a task manager**
|
| 83 |
+
- ✅ You want to manage your autonomous businesses **from your phone**
|
| 84 |
+
|
| 85 |
+
<br/>
|
| 86 |
+
|
| 87 |
+
## Features
|
| 88 |
+
|
| 89 |
+
<table>
|
| 90 |
+
<tr>
|
| 91 |
+
<td align="center" width="33%">
|
| 92 |
+
<h3>🔌 Bring Your Own Agent</h3>
|
| 93 |
+
Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired.
|
| 94 |
+
</td>
|
| 95 |
+
<td align="center" width="33%">
|
| 96 |
+
<h3>🎯 Goal Alignment</h3>
|
| 97 |
+
Every task traces back to the company mission. Agents know <em>what</em> to do and <em>why</em>.
|
| 98 |
+
</td>
|
| 99 |
+
<td align="center" width="33%">
|
| 100 |
+
<h3>💓 Heartbeats</h3>
|
| 101 |
+
Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart.
|
| 102 |
+
</td>
|
| 103 |
+
</tr>
|
| 104 |
+
<tr>
|
| 105 |
+
<td align="center">
|
| 106 |
+
<h3>💰 Cost Control</h3>
|
| 107 |
+
Monthly budgets per agent. When they hit the limit, they stop. No runaway costs.
|
| 108 |
+
</td>
|
| 109 |
+
<td align="center">
|
| 110 |
+
<h3>🏢 Multi-Company</h3>
|
| 111 |
+
One deployment, many companies. Complete data isolation. One control plane for your portfolio.
|
| 112 |
+
</td>
|
| 113 |
+
<td align="center">
|
| 114 |
+
<h3>🎫 Ticket System</h3>
|
| 115 |
+
Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log.
|
| 116 |
+
</td>
|
| 117 |
+
</tr>
|
| 118 |
+
<tr>
|
| 119 |
+
<td align="center">
|
| 120 |
+
<h3>🛡️ Governance</h3>
|
| 121 |
+
You're the board. Approve hires, override strategy, pause or terminate any agent — at any time.
|
| 122 |
+
</td>
|
| 123 |
+
<td align="center">
|
| 124 |
+
<h3>📊 Org Chart</h3>
|
| 125 |
+
Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description.
|
| 126 |
+
</td>
|
| 127 |
+
<td align="center">
|
| 128 |
+
<h3>📱 Mobile Ready</h3>
|
| 129 |
+
Monitor and manage your autonomous businesses from anywhere.
|
| 130 |
+
</td>
|
| 131 |
+
</tr>
|
| 132 |
+
</table>
|
| 133 |
+
|
| 134 |
+
<br/>
|
| 135 |
+
|
| 136 |
+
## Problems Paperclip solves
|
| 137 |
+
|
| 138 |
+
| Without Paperclip | With Paperclip |
|
| 139 |
+
| ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
| 140 |
+
| ❌ 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. |
|
| 141 |
+
| ❌ 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. |
|
| 142 |
+
| ❌ 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. |
|
| 143 |
+
| ❌ 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. |
|
| 144 |
+
| ❌ 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. |
|
| 145 |
+
| ❌ 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. |
|
| 146 |
+
|
| 147 |
+
<br/>
|
| 148 |
+
|
| 149 |
+
## Why Paperclip is special
|
| 150 |
+
|
| 151 |
+
Paperclip handles the hard orchestration details correctly.
|
| 152 |
+
|
| 153 |
+
| | |
|
| 154 |
+
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
| 155 |
+
| **Atomic execution.** | Task checkout and budget enforcement are atomic, so no double-work and no runaway spend. |
|
| 156 |
+
| **Persistent agent state.** | Agents resume the same task context across heartbeats instead of restarting from scratch. |
|
| 157 |
+
| **Runtime skill injection.** | Agents can learn Paperclip workflows and project context at runtime, without retraining. |
|
| 158 |
+
| **Governance with rollback.** | Approval gates are enforced, config changes are revisioned, and bad changes can be rolled back safely. |
|
| 159 |
+
| **Goal-aware execution.** | Tasks carry full goal ancestry so agents consistently see the "why," not just a title. |
|
| 160 |
+
| **Portable company templates.** | Export/import orgs, agents, and skills with secret scrubbing and collision handling. |
|
| 161 |
+
| **True multi-company isolation.** | Every entity is company-scoped, so one deployment can run many companies with separate data and audit trails. |
|
| 162 |
+
|
| 163 |
+
<br/>
|
| 164 |
+
|
| 165 |
+
## What Paperclip is not
|
| 166 |
+
|
| 167 |
+
| | |
|
| 168 |
+
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
| 169 |
+
| **Not a chatbot.** | Agents have jobs, not chat windows. |
|
| 170 |
+
| **Not an agent framework.** | We don't tell you how to build agents. We tell you how to run a company made of them. |
|
| 171 |
+
| **Not a workflow builder.** | No drag-and-drop pipelines. Paperclip models companies — with org charts, goals, budgets, and governance. |
|
| 172 |
+
| **Not a prompt manager.** | Agents bring their own prompts, models, and runtimes. Paperclip manages the organization they work in. |
|
| 173 |
+
| **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. |
|
| 174 |
+
| **Not a code review tool.** | Paperclip orchestrates work, not pull requests. Bring your own review process. |
|
| 175 |
+
|
| 176 |
+
<br/>
|
| 177 |
+
|
| 178 |
+
## Quickstart
|
| 179 |
+
|
| 180 |
+
Open source. Self-hosted. No Paperclip account required.
|
| 181 |
+
|
| 182 |
+
```bash
|
| 183 |
+
npx paperclipai onboard --yes
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
Or manually:
|
| 187 |
+
|
| 188 |
+
```bash
|
| 189 |
+
git clone https://github.com/paperclipai/paperclip.git
|
| 190 |
+
cd paperclip
|
| 191 |
+
pnpm install
|
| 192 |
+
pnpm dev
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required.
|
| 196 |
+
|
| 197 |
+
> **Requirements:** Node.js 20+, pnpm 9.15+
|
| 198 |
+
|
| 199 |
+
<br/>
|
| 200 |
+
|
| 201 |
+
## FAQ
|
| 202 |
+
|
| 203 |
+
**What does a typical setup look like?**
|
| 204 |
+
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.
|
| 205 |
+
|
| 206 |
+
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.
|
| 207 |
+
|
| 208 |
+
**Can I run multiple companies?**
|
| 209 |
+
Yes. A single deployment can run an unlimited number of companies with complete data isolation.
|
| 210 |
+
|
| 211 |
+
**How is Paperclip different from agents like OpenClaw or Claude Code?**
|
| 212 |
+
Paperclip _uses_ those agents. It orchestrates them into a company — with org charts, budgets, goals, governance, and accountability.
|
| 213 |
+
|
| 214 |
+
**Why should I use Paperclip instead of just pointing my OpenClaw to Asana or Trello?**
|
| 215 |
+
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.
|
| 216 |
+
|
| 217 |
+
(Bring-your-own-ticket-system is on the Roadmap)
|
| 218 |
+
|
| 219 |
+
**Do agents run continuously?**
|
| 220 |
+
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.
|
| 221 |
+
|
| 222 |
+
<br/>
|
| 223 |
+
|
| 224 |
+
## Development
|
| 225 |
+
|
| 226 |
+
```bash
|
| 227 |
+
pnpm dev # Full dev (API + UI, watch mode)
|
| 228 |
+
pnpm dev:once # Full dev without file watching
|
| 229 |
+
pnpm dev:server # Server only
|
| 230 |
+
pnpm build # Build all
|
| 231 |
+
pnpm typecheck # Type checking
|
| 232 |
+
pnpm test:run # Run tests
|
| 233 |
+
pnpm db:generate # Generate DB migration
|
| 234 |
+
pnpm db:migrate # Apply migrations
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
| 238 |
+
|
| 239 |
+
<br/>
|
| 240 |
+
|
| 241 |
+
## Roadmap
|
| 242 |
+
|
| 243 |
+
- ⚪ Get OpenClaw onboarding easier
|
| 244 |
+
- ⚪ Get cloud agents working e.g. Cursor / e2b agents
|
| 245 |
+
- ⚪ ClipMart - buy and sell entire agent companies
|
| 246 |
+
- ⚪ Easy agent configurations / easier to understand
|
| 247 |
+
- ⚪ Better support for harness engineering
|
| 248 |
+
- 🟢 Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
|
| 249 |
+
- ⚪ Better docs
|
| 250 |
+
|
| 251 |
+
<br/>
|
| 252 |
+
|
| 253 |
+
## Contributing
|
| 254 |
+
|
| 255 |
+
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
|
| 256 |
+
|
| 257 |
+
<br/>
|
| 258 |
+
|
| 259 |
+
## Community
|
| 260 |
+
|
| 261 |
+
- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community
|
| 262 |
+
- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests
|
| 263 |
+
- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC
|
| 264 |
+
|
| 265 |
+
<br/>
|
| 266 |
+
|
| 267 |
+
## License
|
| 268 |
+
|
| 269 |
+
MIT © 2026 Paperclip
|
| 270 |
+
|
| 271 |
+
## Star History
|
| 272 |
+
|
| 273 |
+
[](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left)
|
| 274 |
+
|
| 275 |
+
<br/>
|
| 276 |
+
|
| 277 |
+
---
|
| 278 |
+
|
| 279 |
+
<p align="center">
|
| 280 |
+
<img src="doc/assets/footer.jpg" alt="" width="720" />
|
| 281 |
+
</p>
|
| 282 |
+
|
| 283 |
+
<p align="center">
|
| 284 |
+
<sub>Open source under MIT. Built for people who want to run companies, not babysit agents.</sub>
|
| 285 |
+
</p>
|
cli/CHANGELOG.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# paperclipai
|
| 2 |
+
|
| 3 |
+
## 0.3.1
|
| 4 |
+
|
| 5 |
+
### Patch Changes
|
| 6 |
+
|
| 7 |
+
- Stable release preparation for 0.3.1
|
| 8 |
+
- Updated dependencies
|
| 9 |
+
- @paperclipai/adapter-utils@0.3.1
|
| 10 |
+
- @paperclipai/adapter-claude-local@0.3.1
|
| 11 |
+
- @paperclipai/adapter-codex-local@0.3.1
|
| 12 |
+
- @paperclipai/adapter-cursor-local@0.3.1
|
| 13 |
+
- @paperclipai/adapter-gemini-local@0.3.1
|
| 14 |
+
- @paperclipai/adapter-openclaw-gateway@0.3.1
|
| 15 |
+
- @paperclipai/adapter-opencode-local@0.3.1
|
| 16 |
+
- @paperclipai/adapter-pi-local@0.3.1
|
| 17 |
+
- @paperclipai/db@0.3.1
|
| 18 |
+
- @paperclipai/shared@0.3.1
|
| 19 |
+
- @paperclipai/server@0.3.1
|
| 20 |
+
|
| 21 |
+
## 0.3.0
|
| 22 |
+
|
| 23 |
+
### Minor Changes
|
| 24 |
+
|
| 25 |
+
- Stable release preparation for 0.3.0
|
| 26 |
+
|
| 27 |
+
### Patch Changes
|
| 28 |
+
|
| 29 |
+
- Updated dependencies [6077ae6]
|
| 30 |
+
- Updated dependencies
|
| 31 |
+
- @paperclipai/shared@0.3.0
|
| 32 |
+
- @paperclipai/adapter-utils@0.3.0
|
| 33 |
+
- @paperclipai/adapter-claude-local@0.3.0
|
| 34 |
+
- @paperclipai/adapter-codex-local@0.3.0
|
| 35 |
+
- @paperclipai/adapter-cursor-local@0.3.0
|
| 36 |
+
- @paperclipai/adapter-openclaw-gateway@0.3.0
|
| 37 |
+
- @paperclipai/adapter-opencode-local@0.3.0
|
| 38 |
+
- @paperclipai/adapter-pi-local@0.3.0
|
| 39 |
+
- @paperclipai/db@0.3.0
|
| 40 |
+
- @paperclipai/server@0.3.0
|
| 41 |
+
|
| 42 |
+
## 0.2.7
|
| 43 |
+
|
| 44 |
+
### Patch Changes
|
| 45 |
+
|
| 46 |
+
- Version bump (patch)
|
| 47 |
+
- Updated dependencies
|
| 48 |
+
- @paperclipai/shared@0.2.7
|
| 49 |
+
- @paperclipai/adapter-utils@0.2.7
|
| 50 |
+
- @paperclipai/db@0.2.7
|
| 51 |
+
- @paperclipai/adapter-claude-local@0.2.7
|
| 52 |
+
- @paperclipai/adapter-codex-local@0.2.7
|
| 53 |
+
- @paperclipai/adapter-openclaw@0.2.7
|
| 54 |
+
- @paperclipai/server@0.2.7
|
| 55 |
+
|
| 56 |
+
## 0.2.6
|
| 57 |
+
|
| 58 |
+
### Patch Changes
|
| 59 |
+
|
| 60 |
+
- Version bump (patch)
|
| 61 |
+
- Updated dependencies
|
| 62 |
+
- @paperclipai/shared@0.2.6
|
| 63 |
+
- @paperclipai/adapter-utils@0.2.6
|
| 64 |
+
- @paperclipai/db@0.2.6
|
| 65 |
+
- @paperclipai/adapter-claude-local@0.2.6
|
| 66 |
+
- @paperclipai/adapter-codex-local@0.2.6
|
| 67 |
+
- @paperclipai/adapter-openclaw@0.2.6
|
| 68 |
+
- @paperclipai/server@0.2.6
|
| 69 |
+
|
| 70 |
+
## 0.2.5
|
| 71 |
+
|
| 72 |
+
### Patch Changes
|
| 73 |
+
|
| 74 |
+
- Version bump (patch)
|
| 75 |
+
- Updated dependencies
|
| 76 |
+
- @paperclipai/shared@0.2.5
|
| 77 |
+
- @paperclipai/adapter-utils@0.2.5
|
| 78 |
+
- @paperclipai/db@0.2.5
|
| 79 |
+
- @paperclipai/adapter-claude-local@0.2.5
|
| 80 |
+
- @paperclipai/adapter-codex-local@0.2.5
|
| 81 |
+
- @paperclipai/adapter-openclaw@0.2.5
|
| 82 |
+
- @paperclipai/server@0.2.5
|
| 83 |
+
|
| 84 |
+
## 0.2.4
|
| 85 |
+
|
| 86 |
+
### Patch Changes
|
| 87 |
+
|
| 88 |
+
- Version bump (patch)
|
| 89 |
+
- Updated dependencies
|
| 90 |
+
- @paperclipai/shared@0.2.4
|
| 91 |
+
- @paperclipai/adapter-utils@0.2.4
|
| 92 |
+
- @paperclipai/db@0.2.4
|
| 93 |
+
- @paperclipai/adapter-claude-local@0.2.4
|
| 94 |
+
- @paperclipai/adapter-codex-local@0.2.4
|
| 95 |
+
- @paperclipai/adapter-openclaw@0.2.4
|
| 96 |
+
- @paperclipai/server@0.2.4
|
| 97 |
+
|
| 98 |
+
## 0.2.3
|
| 99 |
+
|
| 100 |
+
### Patch Changes
|
| 101 |
+
|
| 102 |
+
- Version bump (patch)
|
| 103 |
+
- Updated dependencies
|
| 104 |
+
- @paperclipai/shared@0.2.3
|
| 105 |
+
- @paperclipai/adapter-utils@0.2.3
|
| 106 |
+
- @paperclipai/db@0.2.3
|
| 107 |
+
- @paperclipai/adapter-claude-local@0.2.3
|
| 108 |
+
- @paperclipai/adapter-codex-local@0.2.3
|
| 109 |
+
- @paperclipai/adapter-openclaw@0.2.3
|
| 110 |
+
- @paperclipai/server@0.2.3
|
| 111 |
+
|
| 112 |
+
## 0.2.2
|
| 113 |
+
|
| 114 |
+
### Patch Changes
|
| 115 |
+
|
| 116 |
+
- Version bump (patch)
|
| 117 |
+
- Updated dependencies
|
| 118 |
+
- @paperclipai/shared@0.2.2
|
| 119 |
+
- @paperclipai/adapter-utils@0.2.2
|
| 120 |
+
- @paperclipai/db@0.2.2
|
| 121 |
+
- @paperclipai/adapter-claude-local@0.2.2
|
| 122 |
+
- @paperclipai/adapter-codex-local@0.2.2
|
| 123 |
+
- @paperclipai/adapter-openclaw@0.2.2
|
| 124 |
+
- @paperclipai/server@0.2.2
|
| 125 |
+
|
| 126 |
+
## 0.2.1
|
| 127 |
+
|
| 128 |
+
### Patch Changes
|
| 129 |
+
|
| 130 |
+
- Version bump (patch)
|
| 131 |
+
- Updated dependencies
|
| 132 |
+
- @paperclipai/shared@0.2.1
|
| 133 |
+
- @paperclipai/adapter-utils@0.2.1
|
| 134 |
+
- @paperclipai/db@0.2.1
|
| 135 |
+
- @paperclipai/adapter-claude-local@0.2.1
|
| 136 |
+
- @paperclipai/adapter-codex-local@0.2.1
|
| 137 |
+
- @paperclipai/adapter-openclaw@0.2.1
|
| 138 |
+
- @paperclipai/server@0.2.1
|
cli/esbuild.config.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* esbuild configuration for building the paperclipai CLI for npm.
|
| 3 |
+
*
|
| 4 |
+
* Bundles all workspace packages (@paperclipai/*) into a single file.
|
| 5 |
+
* External npm packages remain as regular dependencies.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { readFileSync } from "node:fs";
|
| 9 |
+
import { resolve, dirname } from "node:path";
|
| 10 |
+
import { fileURLToPath } from "node:url";
|
| 11 |
+
|
| 12 |
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
| 13 |
+
const repoRoot = resolve(__dirname, "..");
|
| 14 |
+
|
| 15 |
+
// Workspace packages whose code should be bundled into the CLI.
|
| 16 |
+
// Note: "server" is excluded — it's published separately and resolved at runtime.
|
| 17 |
+
const workspacePaths = [
|
| 18 |
+
"cli",
|
| 19 |
+
"packages/db",
|
| 20 |
+
"packages/shared",
|
| 21 |
+
"packages/adapter-utils",
|
| 22 |
+
"packages/adapters/claude-local",
|
| 23 |
+
"packages/adapters/codex-local",
|
| 24 |
+
"packages/adapters/openclaw-gateway",
|
| 25 |
+
];
|
| 26 |
+
|
| 27 |
+
// Workspace packages that should NOT be bundled — they'll be published
|
| 28 |
+
// to npm and resolved at runtime (e.g. @paperclipai/server uses dynamic import).
|
| 29 |
+
const externalWorkspacePackages = new Set([
|
| 30 |
+
"@paperclipai/server",
|
| 31 |
+
]);
|
| 32 |
+
|
| 33 |
+
// Collect all external (non-workspace) npm package names
|
| 34 |
+
const externals = new Set();
|
| 35 |
+
for (const p of workspacePaths) {
|
| 36 |
+
const pkg = JSON.parse(readFileSync(resolve(repoRoot, p, "package.json"), "utf8"));
|
| 37 |
+
for (const name of Object.keys(pkg.dependencies || {})) {
|
| 38 |
+
if (externalWorkspacePackages.has(name)) {
|
| 39 |
+
externals.add(name);
|
| 40 |
+
} else if (!name.startsWith("@paperclipai/")) {
|
| 41 |
+
externals.add(name);
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
for (const name of Object.keys(pkg.optionalDependencies || {})) {
|
| 45 |
+
externals.add(name);
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
// Also add all published workspace packages as external
|
| 49 |
+
for (const name of externalWorkspacePackages) {
|
| 50 |
+
externals.add(name);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/** @type {import('esbuild').BuildOptions} */
|
| 54 |
+
export default {
|
| 55 |
+
entryPoints: ["src/index.ts"],
|
| 56 |
+
bundle: true,
|
| 57 |
+
platform: "node",
|
| 58 |
+
target: "node20",
|
| 59 |
+
format: "esm",
|
| 60 |
+
outfile: "dist/index.js",
|
| 61 |
+
banner: { js: "#!/usr/bin/env node" },
|
| 62 |
+
external: [...externals].sort(),
|
| 63 |
+
treeShaking: true,
|
| 64 |
+
sourcemap: true,
|
| 65 |
+
};
|
cli/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "paperclipai",
|
| 3 |
+
"version": "0.3.1",
|
| 4 |
+
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"bin": {
|
| 7 |
+
"paperclipai": "./dist/index.js"
|
| 8 |
+
},
|
| 9 |
+
"keywords": [
|
| 10 |
+
"paperclip",
|
| 11 |
+
"ai",
|
| 12 |
+
"agents",
|
| 13 |
+
"orchestration",
|
| 14 |
+
"cli"
|
| 15 |
+
],
|
| 16 |
+
"license": "MIT",
|
| 17 |
+
"repository": {
|
| 18 |
+
"type": "git",
|
| 19 |
+
"url": "https://github.com/paperclipai/paperclip",
|
| 20 |
+
"directory": "cli"
|
| 21 |
+
},
|
| 22 |
+
"homepage": "https://github.com/paperclipai/paperclip",
|
| 23 |
+
"bugs": {
|
| 24 |
+
"url": "https://github.com/paperclipai/paperclip/issues"
|
| 25 |
+
},
|
| 26 |
+
"files": [
|
| 27 |
+
"dist"
|
| 28 |
+
],
|
| 29 |
+
"publishConfig": {
|
| 30 |
+
"access": "public"
|
| 31 |
+
},
|
| 32 |
+
"scripts": {
|
| 33 |
+
"dev": "tsx src/index.ts",
|
| 34 |
+
"build": "node --input-type=module -e \"import esbuild from 'esbuild'; import config from './esbuild.config.mjs'; await esbuild.build(config);\" && chmod +x dist/index.js",
|
| 35 |
+
"clean": "rm -rf dist",
|
| 36 |
+
"typecheck": "tsc --noEmit"
|
| 37 |
+
},
|
| 38 |
+
"dependencies": {
|
| 39 |
+
"@clack/prompts": "^0.10.0",
|
| 40 |
+
"@paperclipai/adapter-claude-local": "workspace:*",
|
| 41 |
+
"@paperclipai/adapter-codex-local": "workspace:*",
|
| 42 |
+
"@paperclipai/adapter-cursor-local": "workspace:*",
|
| 43 |
+
"@paperclipai/adapter-gemini-local": "workspace:*",
|
| 44 |
+
"@paperclipai/adapter-opencode-local": "workspace:*",
|
| 45 |
+
"@paperclipai/adapter-pi-local": "workspace:*",
|
| 46 |
+
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
| 47 |
+
"@paperclipai/adapter-utils": "workspace:*",
|
| 48 |
+
"@paperclipai/db": "workspace:*",
|
| 49 |
+
"@paperclipai/server": "workspace:*",
|
| 50 |
+
"@paperclipai/shared": "workspace:*",
|
| 51 |
+
"drizzle-orm": "0.38.4",
|
| 52 |
+
"dotenv": "^17.0.1",
|
| 53 |
+
"commander": "^13.1.0",
|
| 54 |
+
"embedded-postgres": "^18.1.0-beta.16",
|
| 55 |
+
"picocolors": "^1.1.1"
|
| 56 |
+
},
|
| 57 |
+
"devDependencies": {
|
| 58 |
+
"@types/node": "^22.12.0",
|
| 59 |
+
"tsx": "^4.19.2",
|
| 60 |
+
"typescript": "^5.7.3"
|
| 61 |
+
}
|
| 62 |
+
}
|
cli/src/__tests__/agent-jwt-env.test.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from "node:fs";
|
| 2 |
+
import os from "node:os";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
| 5 |
+
import {
|
| 6 |
+
ensureAgentJwtSecret,
|
| 7 |
+
mergePaperclipEnvEntries,
|
| 8 |
+
readAgentJwtSecretFromEnv,
|
| 9 |
+
readPaperclipEnvEntries,
|
| 10 |
+
resolveAgentJwtEnvFile,
|
| 11 |
+
} from "../config/env.js";
|
| 12 |
+
import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js";
|
| 13 |
+
|
| 14 |
+
const ORIGINAL_ENV = { ...process.env };
|
| 15 |
+
|
| 16 |
+
function tempConfigPath(): string {
|
| 17 |
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-jwt-env-"));
|
| 18 |
+
const configDir = path.join(dir, "custom");
|
| 19 |
+
fs.mkdirSync(configDir, { recursive: true });
|
| 20 |
+
return path.join(configDir, "config.json");
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
describe("agent jwt env helpers", () => {
|
| 24 |
+
beforeEach(() => {
|
| 25 |
+
process.env = { ...ORIGINAL_ENV };
|
| 26 |
+
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
afterEach(() => {
|
| 30 |
+
process.env = { ...ORIGINAL_ENV };
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
it("writes .env next to explicit config path", () => {
|
| 34 |
+
const configPath = tempConfigPath();
|
| 35 |
+
const result = ensureAgentJwtSecret(configPath);
|
| 36 |
+
|
| 37 |
+
expect(result.created).toBe(true);
|
| 38 |
+
|
| 39 |
+
const envPath = resolveAgentJwtEnvFile(configPath);
|
| 40 |
+
expect(fs.existsSync(envPath)).toBe(true);
|
| 41 |
+
const contents = fs.readFileSync(envPath, "utf-8");
|
| 42 |
+
expect(contents).toContain("PAPERCLIP_AGENT_JWT_SECRET=");
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it("loads secret from .env next to explicit config path", () => {
|
| 46 |
+
const configPath = tempConfigPath();
|
| 47 |
+
const envPath = resolveAgentJwtEnvFile(configPath);
|
| 48 |
+
fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=test-secret\n", { mode: 0o600 });
|
| 49 |
+
|
| 50 |
+
const loaded = readAgentJwtSecretFromEnv(configPath);
|
| 51 |
+
expect(loaded).toBe("test-secret");
|
| 52 |
+
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBe("test-secret");
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
it("doctor check passes when secret exists in adjacent .env", () => {
|
| 56 |
+
const configPath = tempConfigPath();
|
| 57 |
+
const envPath = resolveAgentJwtEnvFile(configPath);
|
| 58 |
+
fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=check-secret\n", { mode: 0o600 });
|
| 59 |
+
|
| 60 |
+
const result = agentJwtSecretCheck(configPath);
|
| 61 |
+
expect(result.status).toBe("pass");
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
it("quotes hash-prefixed env values so dotenv round-trips them", () => {
|
| 65 |
+
const configPath = tempConfigPath();
|
| 66 |
+
const envPath = resolveAgentJwtEnvFile(configPath);
|
| 67 |
+
|
| 68 |
+
mergePaperclipEnvEntries(
|
| 69 |
+
{
|
| 70 |
+
PAPERCLIP_WORKTREE_COLOR: "#439edb",
|
| 71 |
+
},
|
| 72 |
+
envPath,
|
| 73 |
+
);
|
| 74 |
+
|
| 75 |
+
const contents = fs.readFileSync(envPath, "utf-8");
|
| 76 |
+
expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"');
|
| 77 |
+
expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb");
|
| 78 |
+
});
|
| 79 |
+
});
|
cli/src/__tests__/allowed-hostname.test.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from "node:fs";
|
| 2 |
+
import os from "node:os";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import { describe, expect, it } from "vitest";
|
| 5 |
+
import type { PaperclipConfig } from "../config/schema.js";
|
| 6 |
+
import { addAllowedHostname } from "../commands/allowed-hostname.js";
|
| 7 |
+
|
| 8 |
+
function createTempConfigPath() {
|
| 9 |
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-allowed-hostname-"));
|
| 10 |
+
return path.join(dir, "config.json");
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function writeBaseConfig(configPath: string) {
|
| 14 |
+
const base: PaperclipConfig = {
|
| 15 |
+
$meta: {
|
| 16 |
+
version: 1,
|
| 17 |
+
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
| 18 |
+
source: "configure",
|
| 19 |
+
},
|
| 20 |
+
database: {
|
| 21 |
+
mode: "embedded-postgres",
|
| 22 |
+
embeddedPostgresDataDir: "/tmp/paperclip-db",
|
| 23 |
+
embeddedPostgresPort: 54329,
|
| 24 |
+
backup: {
|
| 25 |
+
enabled: true,
|
| 26 |
+
intervalMinutes: 60,
|
| 27 |
+
retentionDays: 30,
|
| 28 |
+
dir: "/tmp/paperclip-backups",
|
| 29 |
+
},
|
| 30 |
+
},
|
| 31 |
+
logging: {
|
| 32 |
+
mode: "file",
|
| 33 |
+
logDir: "/tmp/paperclip-logs",
|
| 34 |
+
},
|
| 35 |
+
server: {
|
| 36 |
+
deploymentMode: "authenticated",
|
| 37 |
+
exposure: "private",
|
| 38 |
+
host: "0.0.0.0",
|
| 39 |
+
port: 3100,
|
| 40 |
+
allowedHostnames: [],
|
| 41 |
+
serveUi: true,
|
| 42 |
+
},
|
| 43 |
+
auth: {
|
| 44 |
+
baseUrlMode: "auto",
|
| 45 |
+
disableSignUp: false,
|
| 46 |
+
},
|
| 47 |
+
storage: {
|
| 48 |
+
provider: "local_disk",
|
| 49 |
+
localDisk: { baseDir: "/tmp/paperclip-storage" },
|
| 50 |
+
s3: {
|
| 51 |
+
bucket: "paperclip",
|
| 52 |
+
region: "us-east-1",
|
| 53 |
+
prefix: "",
|
| 54 |
+
forcePathStyle: false,
|
| 55 |
+
},
|
| 56 |
+
},
|
| 57 |
+
secrets: {
|
| 58 |
+
provider: "local_encrypted",
|
| 59 |
+
strictMode: false,
|
| 60 |
+
localEncrypted: { keyFilePath: "/tmp/paperclip-secrets/master.key" },
|
| 61 |
+
},
|
| 62 |
+
};
|
| 63 |
+
fs.writeFileSync(configPath, JSON.stringify(base, null, 2));
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
describe("allowed-hostname command", () => {
|
| 67 |
+
it("adds and normalizes hostnames", async () => {
|
| 68 |
+
const configPath = createTempConfigPath();
|
| 69 |
+
writeBaseConfig(configPath);
|
| 70 |
+
|
| 71 |
+
await addAllowedHostname("https://Dotta-MacBook-Pro:3100", { config: configPath });
|
| 72 |
+
await addAllowedHostname("dotta-macbook-pro", { config: configPath });
|
| 73 |
+
|
| 74 |
+
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8")) as PaperclipConfig;
|
| 75 |
+
expect(raw.server.allowedHostnames).toEqual(["dotta-macbook-pro"]);
|
| 76 |
+
});
|
| 77 |
+
});
|
cli/src/__tests__/common.test.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from "node:fs";
|
| 2 |
+
import os from "node:os";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
| 5 |
+
import { writeContext } from "../client/context.js";
|
| 6 |
+
import { resolveCommandContext } from "../commands/client/common.js";
|
| 7 |
+
|
| 8 |
+
const ORIGINAL_ENV = { ...process.env };
|
| 9 |
+
|
| 10 |
+
function createTempPath(name: string): string {
|
| 11 |
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-common-"));
|
| 12 |
+
return path.join(dir, name);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
describe("resolveCommandContext", () => {
|
| 16 |
+
beforeEach(() => {
|
| 17 |
+
process.env = { ...ORIGINAL_ENV };
|
| 18 |
+
delete process.env.PAPERCLIP_API_URL;
|
| 19 |
+
delete process.env.PAPERCLIP_API_KEY;
|
| 20 |
+
delete process.env.PAPERCLIP_COMPANY_ID;
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
afterEach(() => {
|
| 24 |
+
process.env = { ...ORIGINAL_ENV };
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
it("uses profile defaults when options/env are not provided", () => {
|
| 28 |
+
const contextPath = createTempPath("context.json");
|
| 29 |
+
|
| 30 |
+
writeContext(
|
| 31 |
+
{
|
| 32 |
+
version: 1,
|
| 33 |
+
currentProfile: "ops",
|
| 34 |
+
profiles: {
|
| 35 |
+
ops: {
|
| 36 |
+
apiBase: "http://127.0.0.1:9999",
|
| 37 |
+
companyId: "company-profile",
|
| 38 |
+
apiKeyEnvVarName: "AGENT_KEY",
|
| 39 |
+
},
|
| 40 |
+
},
|
| 41 |
+
},
|
| 42 |
+
contextPath,
|
| 43 |
+
);
|
| 44 |
+
process.env.AGENT_KEY = "key-from-env";
|
| 45 |
+
|
| 46 |
+
const resolved = resolveCommandContext({ context: contextPath }, { requireCompany: true });
|
| 47 |
+
expect(resolved.api.apiBase).toBe("http://127.0.0.1:9999");
|
| 48 |
+
expect(resolved.companyId).toBe("company-profile");
|
| 49 |
+
expect(resolved.api.apiKey).toBe("key-from-env");
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
it("prefers explicit options over profile values", () => {
|
| 53 |
+
const contextPath = createTempPath("context.json");
|
| 54 |
+
writeContext(
|
| 55 |
+
{
|
| 56 |
+
version: 1,
|
| 57 |
+
currentProfile: "default",
|
| 58 |
+
profiles: {
|
| 59 |
+
default: {
|
| 60 |
+
apiBase: "http://profile:3100",
|
| 61 |
+
companyId: "company-profile",
|
| 62 |
+
},
|
| 63 |
+
},
|
| 64 |
+
},
|
| 65 |
+
contextPath,
|
| 66 |
+
);
|
| 67 |
+
|
| 68 |
+
const resolved = resolveCommandContext(
|
| 69 |
+
{
|
| 70 |
+
context: contextPath,
|
| 71 |
+
apiBase: "http://override:3200",
|
| 72 |
+
apiKey: "direct-token",
|
| 73 |
+
companyId: "company-override",
|
| 74 |
+
},
|
| 75 |
+
{ requireCompany: true },
|
| 76 |
+
);
|
| 77 |
+
|
| 78 |
+
expect(resolved.api.apiBase).toBe("http://override:3200");
|
| 79 |
+
expect(resolved.companyId).toBe("company-override");
|
| 80 |
+
expect(resolved.api.apiKey).toBe("direct-token");
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
it("throws when company is required but unresolved", () => {
|
| 84 |
+
const contextPath = createTempPath("context.json");
|
| 85 |
+
writeContext(
|
| 86 |
+
{
|
| 87 |
+
version: 1,
|
| 88 |
+
currentProfile: "default",
|
| 89 |
+
profiles: { default: {} },
|
| 90 |
+
},
|
| 91 |
+
contextPath,
|
| 92 |
+
);
|
| 93 |
+
|
| 94 |
+
expect(() =>
|
| 95 |
+
resolveCommandContext({ context: contextPath, apiBase: "http://localhost:3100" }, { requireCompany: true }),
|
| 96 |
+
).toThrow(/Company ID is required/);
|
| 97 |
+
});
|
| 98 |
+
});
|
cli/src/__tests__/company-delete.test.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import type { Company } from "@paperclipai/shared";
|
| 3 |
+
import { assertDeleteConfirmation, resolveCompanyForDeletion } from "../commands/client/company.js";
|
| 4 |
+
|
| 5 |
+
function makeCompany(overrides: Partial<Company>): Company {
|
| 6 |
+
return {
|
| 7 |
+
id: "11111111-1111-1111-1111-111111111111",
|
| 8 |
+
name: "Alpha",
|
| 9 |
+
description: null,
|
| 10 |
+
status: "active",
|
| 11 |
+
pauseReason: null,
|
| 12 |
+
pausedAt: null,
|
| 13 |
+
issuePrefix: "ALP",
|
| 14 |
+
issueCounter: 1,
|
| 15 |
+
budgetMonthlyCents: 0,
|
| 16 |
+
spentMonthlyCents: 0,
|
| 17 |
+
requireBoardApprovalForNewAgents: false,
|
| 18 |
+
brandColor: null,
|
| 19 |
+
logoAssetId: null,
|
| 20 |
+
logoUrl: null,
|
| 21 |
+
createdAt: new Date(),
|
| 22 |
+
updatedAt: new Date(),
|
| 23 |
+
...overrides,
|
| 24 |
+
};
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
describe("resolveCompanyForDeletion", () => {
|
| 28 |
+
const companies: Company[] = [
|
| 29 |
+
makeCompany({
|
| 30 |
+
id: "11111111-1111-1111-1111-111111111111",
|
| 31 |
+
name: "Alpha",
|
| 32 |
+
issuePrefix: "ALP",
|
| 33 |
+
}),
|
| 34 |
+
makeCompany({
|
| 35 |
+
id: "22222222-2222-2222-2222-222222222222",
|
| 36 |
+
name: "Paperclip",
|
| 37 |
+
issuePrefix: "PAP",
|
| 38 |
+
}),
|
| 39 |
+
];
|
| 40 |
+
|
| 41 |
+
it("resolves by ID in auto mode", () => {
|
| 42 |
+
const result = resolveCompanyForDeletion(companies, "22222222-2222-2222-2222-222222222222", "auto");
|
| 43 |
+
expect(result.issuePrefix).toBe("PAP");
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
it("resolves by prefix in auto mode", () => {
|
| 47 |
+
const result = resolveCompanyForDeletion(companies, "pap", "auto");
|
| 48 |
+
expect(result.id).toBe("22222222-2222-2222-2222-222222222222");
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
it("throws when selector is not found", () => {
|
| 52 |
+
expect(() => resolveCompanyForDeletion(companies, "MISSING", "auto")).toThrow(/No company found/);
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
it("respects explicit id mode", () => {
|
| 56 |
+
expect(() => resolveCompanyForDeletion(companies, "PAP", "id")).toThrow(/No company found by ID/);
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
it("respects explicit prefix mode", () => {
|
| 60 |
+
expect(() => resolveCompanyForDeletion(companies, "22222222-2222-2222-2222-222222222222", "prefix"))
|
| 61 |
+
.toThrow(/No company found by shortname/);
|
| 62 |
+
});
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
describe("assertDeleteConfirmation", () => {
|
| 66 |
+
const company = makeCompany({
|
| 67 |
+
id: "22222222-2222-2222-2222-222222222222",
|
| 68 |
+
issuePrefix: "PAP",
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
it("requires --yes", () => {
|
| 72 |
+
expect(() => assertDeleteConfirmation(company, { confirm: "PAP" })).toThrow(/requires --yes/);
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
it("accepts matching prefix confirmation", () => {
|
| 76 |
+
expect(() => assertDeleteConfirmation(company, { yes: true, confirm: "pap" })).not.toThrow();
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
it("accepts matching id confirmation", () => {
|
| 80 |
+
expect(() =>
|
| 81 |
+
assertDeleteConfirmation(company, {
|
| 82 |
+
yes: true,
|
| 83 |
+
confirm: "22222222-2222-2222-2222-222222222222",
|
| 84 |
+
})).not.toThrow();
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
it("rejects mismatched confirmation", () => {
|
| 88 |
+
expect(() => assertDeleteConfirmation(company, { yes: true, confirm: "nope" }))
|
| 89 |
+
.toThrow(/does not match target company/);
|
| 90 |
+
});
|
| 91 |
+
});
|
cli/src/__tests__/context.test.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from "node:fs";
|
| 2 |
+
import os from "node:os";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import { describe, expect, it } from "vitest";
|
| 5 |
+
import {
|
| 6 |
+
defaultClientContext,
|
| 7 |
+
readContext,
|
| 8 |
+
setCurrentProfile,
|
| 9 |
+
upsertProfile,
|
| 10 |
+
writeContext,
|
| 11 |
+
} from "../client/context.js";
|
| 12 |
+
|
| 13 |
+
function createTempContextPath(): string {
|
| 14 |
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-context-"));
|
| 15 |
+
return path.join(dir, "context.json");
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
describe("client context store", () => {
|
| 19 |
+
it("returns default context when file does not exist", () => {
|
| 20 |
+
const contextPath = createTempContextPath();
|
| 21 |
+
const context = readContext(contextPath);
|
| 22 |
+
expect(context).toEqual(defaultClientContext());
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
it("upserts profile values and switches current profile", () => {
|
| 26 |
+
const contextPath = createTempContextPath();
|
| 27 |
+
|
| 28 |
+
upsertProfile(
|
| 29 |
+
"work",
|
| 30 |
+
{
|
| 31 |
+
apiBase: "http://localhost:3100",
|
| 32 |
+
companyId: "company-123",
|
| 33 |
+
apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN",
|
| 34 |
+
},
|
| 35 |
+
contextPath,
|
| 36 |
+
);
|
| 37 |
+
|
| 38 |
+
setCurrentProfile("work", contextPath);
|
| 39 |
+
const context = readContext(contextPath);
|
| 40 |
+
|
| 41 |
+
expect(context.currentProfile).toBe("work");
|
| 42 |
+
expect(context.profiles.work).toEqual({
|
| 43 |
+
apiBase: "http://localhost:3100",
|
| 44 |
+
companyId: "company-123",
|
| 45 |
+
apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN",
|
| 46 |
+
});
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
it("normalizes invalid file content to safe defaults", () => {
|
| 50 |
+
const contextPath = createTempContextPath();
|
| 51 |
+
writeContext(
|
| 52 |
+
{
|
| 53 |
+
version: 1,
|
| 54 |
+
currentProfile: "x",
|
| 55 |
+
profiles: {
|
| 56 |
+
x: {
|
| 57 |
+
apiBase: " ",
|
| 58 |
+
companyId: " ",
|
| 59 |
+
apiKeyEnvVarName: " ",
|
| 60 |
+
},
|
| 61 |
+
},
|
| 62 |
+
},
|
| 63 |
+
contextPath,
|
| 64 |
+
);
|
| 65 |
+
|
| 66 |
+
const context = readContext(contextPath);
|
| 67 |
+
expect(context.currentProfile).toBe("x");
|
| 68 |
+
expect(context.profiles.x).toEqual({});
|
| 69 |
+
});
|
| 70 |
+
});
|
cli/src/__tests__/data-dir.test.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os from "node:os";
|
| 2 |
+
import path from "node:path";
|
| 3 |
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
| 4 |
+
import { applyDataDirOverride } from "../config/data-dir.js";
|
| 5 |
+
|
| 6 |
+
const ORIGINAL_ENV = { ...process.env };
|
| 7 |
+
|
| 8 |
+
describe("applyDataDirOverride", () => {
|
| 9 |
+
beforeEach(() => {
|
| 10 |
+
process.env = { ...ORIGINAL_ENV };
|
| 11 |
+
delete process.env.PAPERCLIP_HOME;
|
| 12 |
+
delete process.env.PAPERCLIP_CONFIG;
|
| 13 |
+
delete process.env.PAPERCLIP_CONTEXT;
|
| 14 |
+
delete process.env.PAPERCLIP_INSTANCE_ID;
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
afterEach(() => {
|
| 18 |
+
process.env = { ...ORIGINAL_ENV };
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
it("sets PAPERCLIP_HOME and isolated default config/context paths", () => {
|
| 22 |
+
const home = applyDataDirOverride({
|
| 23 |
+
dataDir: "~/paperclip-data",
|
| 24 |
+
config: undefined,
|
| 25 |
+
context: undefined,
|
| 26 |
+
}, { hasConfigOption: true, hasContextOption: true });
|
| 27 |
+
|
| 28 |
+
const expectedHome = path.resolve(os.homedir(), "paperclip-data");
|
| 29 |
+
expect(home).toBe(expectedHome);
|
| 30 |
+
expect(process.env.PAPERCLIP_HOME).toBe(expectedHome);
|
| 31 |
+
expect(process.env.PAPERCLIP_CONFIG).toBe(
|
| 32 |
+
path.resolve(expectedHome, "instances", "default", "config.json"),
|
| 33 |
+
);
|
| 34 |
+
expect(process.env.PAPERCLIP_CONTEXT).toBe(path.resolve(expectedHome, "context.json"));
|
| 35 |
+
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("default");
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
it("uses the provided instance id when deriving default config path", () => {
|
| 39 |
+
const home = applyDataDirOverride({
|
| 40 |
+
dataDir: "/tmp/paperclip-alt",
|
| 41 |
+
instance: "dev_1",
|
| 42 |
+
config: undefined,
|
| 43 |
+
context: undefined,
|
| 44 |
+
}, { hasConfigOption: true, hasContextOption: true });
|
| 45 |
+
|
| 46 |
+
expect(home).toBe(path.resolve("/tmp/paperclip-alt"));
|
| 47 |
+
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("dev_1");
|
| 48 |
+
expect(process.env.PAPERCLIP_CONFIG).toBe(
|
| 49 |
+
path.resolve("/tmp/paperclip-alt", "instances", "dev_1", "config.json"),
|
| 50 |
+
);
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it("does not override explicit config/context settings", () => {
|
| 54 |
+
process.env.PAPERCLIP_CONFIG = "/env/config.json";
|
| 55 |
+
process.env.PAPERCLIP_CONTEXT = "/env/context.json";
|
| 56 |
+
|
| 57 |
+
applyDataDirOverride({
|
| 58 |
+
dataDir: "/tmp/paperclip-alt",
|
| 59 |
+
config: "/flag/config.json",
|
| 60 |
+
context: "/flag/context.json",
|
| 61 |
+
}, { hasConfigOption: true, hasContextOption: true });
|
| 62 |
+
|
| 63 |
+
expect(process.env.PAPERCLIP_CONFIG).toBe("/env/config.json");
|
| 64 |
+
expect(process.env.PAPERCLIP_CONTEXT).toBe("/env/context.json");
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
it("only applies defaults for options supported by the command", () => {
|
| 68 |
+
applyDataDirOverride(
|
| 69 |
+
{
|
| 70 |
+
dataDir: "/tmp/paperclip-alt",
|
| 71 |
+
},
|
| 72 |
+
{ hasConfigOption: false, hasContextOption: false },
|
| 73 |
+
);
|
| 74 |
+
|
| 75 |
+
expect(process.env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-alt"));
|
| 76 |
+
expect(process.env.PAPERCLIP_CONFIG).toBeUndefined();
|
| 77 |
+
expect(process.env.PAPERCLIP_CONTEXT).toBeUndefined();
|
| 78 |
+
});
|
| 79 |
+
});
|
cli/src/__tests__/doctor.test.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from "node:fs";
|
| 2 |
+
import os from "node:os";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
| 5 |
+
import { doctor } from "../commands/doctor.js";
|
| 6 |
+
import { writeConfig } from "../config/store.js";
|
| 7 |
+
import type { PaperclipConfig } from "../config/schema.js";
|
| 8 |
+
|
| 9 |
+
const ORIGINAL_ENV = { ...process.env };
|
| 10 |
+
|
| 11 |
+
function createTempConfig(): string {
|
| 12 |
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-"));
|
| 13 |
+
const configPath = path.join(root, ".paperclip", "config.json");
|
| 14 |
+
const runtimeRoot = path.join(root, "runtime");
|
| 15 |
+
|
| 16 |
+
const config: PaperclipConfig = {
|
| 17 |
+
$meta: {
|
| 18 |
+
version: 1,
|
| 19 |
+
updatedAt: "2026-03-10T00:00:00.000Z",
|
| 20 |
+
source: "configure",
|
| 21 |
+
},
|
| 22 |
+
database: {
|
| 23 |
+
mode: "embedded-postgres",
|
| 24 |
+
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
|
| 25 |
+
embeddedPostgresPort: 55432,
|
| 26 |
+
backup: {
|
| 27 |
+
enabled: true,
|
| 28 |
+
intervalMinutes: 60,
|
| 29 |
+
retentionDays: 30,
|
| 30 |
+
dir: path.join(runtimeRoot, "backups"),
|
| 31 |
+
},
|
| 32 |
+
},
|
| 33 |
+
logging: {
|
| 34 |
+
mode: "file",
|
| 35 |
+
logDir: path.join(runtimeRoot, "logs"),
|
| 36 |
+
},
|
| 37 |
+
server: {
|
| 38 |
+
deploymentMode: "local_trusted",
|
| 39 |
+
exposure: "private",
|
| 40 |
+
host: "127.0.0.1",
|
| 41 |
+
port: 3199,
|
| 42 |
+
allowedHostnames: [],
|
| 43 |
+
serveUi: true,
|
| 44 |
+
},
|
| 45 |
+
auth: {
|
| 46 |
+
baseUrlMode: "auto",
|
| 47 |
+
disableSignUp: false,
|
| 48 |
+
},
|
| 49 |
+
storage: {
|
| 50 |
+
provider: "local_disk",
|
| 51 |
+
localDisk: {
|
| 52 |
+
baseDir: path.join(runtimeRoot, "storage"),
|
| 53 |
+
},
|
| 54 |
+
s3: {
|
| 55 |
+
bucket: "paperclip",
|
| 56 |
+
region: "us-east-1",
|
| 57 |
+
prefix: "",
|
| 58 |
+
forcePathStyle: false,
|
| 59 |
+
},
|
| 60 |
+
},
|
| 61 |
+
secrets: {
|
| 62 |
+
provider: "local_encrypted",
|
| 63 |
+
strictMode: false,
|
| 64 |
+
localEncrypted: {
|
| 65 |
+
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
|
| 66 |
+
},
|
| 67 |
+
},
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
writeConfig(config, configPath);
|
| 71 |
+
return configPath;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
describe("doctor", () => {
|
| 75 |
+
beforeEach(() => {
|
| 76 |
+
process.env = { ...ORIGINAL_ENV };
|
| 77 |
+
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
| 78 |
+
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
| 79 |
+
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
afterEach(() => {
|
| 83 |
+
process.env = { ...ORIGINAL_ENV };
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
it("re-runs repairable checks so repaired failures do not remain blocking", async () => {
|
| 87 |
+
const configPath = createTempConfig();
|
| 88 |
+
|
| 89 |
+
const summary = await doctor({
|
| 90 |
+
config: configPath,
|
| 91 |
+
repair: true,
|
| 92 |
+
yes: true,
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
expect(summary.failed).toBe(0);
|
| 96 |
+
expect(summary.warned).toBe(0);
|
| 97 |
+
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy();
|
| 98 |
+
});
|
| 99 |
+
});
|
cli/src/__tests__/home-paths.test.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os from "node:os";
|
| 2 |
+
import path from "node:path";
|
| 3 |
+
import { afterEach, describe, expect, it } from "vitest";
|
| 4 |
+
import {
|
| 5 |
+
describeLocalInstancePaths,
|
| 6 |
+
expandHomePrefix,
|
| 7 |
+
resolvePaperclipHomeDir,
|
| 8 |
+
resolvePaperclipInstanceId,
|
| 9 |
+
} from "../config/home.js";
|
| 10 |
+
|
| 11 |
+
const ORIGINAL_ENV = { ...process.env };
|
| 12 |
+
|
| 13 |
+
describe("home path resolution", () => {
|
| 14 |
+
afterEach(() => {
|
| 15 |
+
process.env = { ...ORIGINAL_ENV };
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
it("defaults to ~/.paperclip and default instance", () => {
|
| 19 |
+
delete process.env.PAPERCLIP_HOME;
|
| 20 |
+
delete process.env.PAPERCLIP_INSTANCE_ID;
|
| 21 |
+
|
| 22 |
+
const paths = describeLocalInstancePaths();
|
| 23 |
+
expect(paths.homeDir).toBe(path.resolve(os.homedir(), ".paperclip"));
|
| 24 |
+
expect(paths.instanceId).toBe("default");
|
| 25 |
+
expect(paths.configPath).toBe(path.resolve(os.homedir(), ".paperclip", "instances", "default", "config.json"));
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
it("supports PAPERCLIP_HOME and explicit instance ids", () => {
|
| 29 |
+
process.env.PAPERCLIP_HOME = "~/paperclip-home";
|
| 30 |
+
|
| 31 |
+
const home = resolvePaperclipHomeDir();
|
| 32 |
+
expect(home).toBe(path.resolve(os.homedir(), "paperclip-home"));
|
| 33 |
+
expect(resolvePaperclipInstanceId("dev_1")).toBe("dev_1");
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
it("rejects invalid instance ids", () => {
|
| 37 |
+
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid instance id/);
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
it("expands ~ prefixes", () => {
|
| 41 |
+
expect(expandHomePrefix("~")).toBe(os.homedir());
|
| 42 |
+
expect(expandHomePrefix("~/x/y")).toBe(path.resolve(os.homedir(), "x/y"));
|
| 43 |
+
});
|
| 44 |
+
});
|
cli/src/__tests__/http.test.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { ApiRequestError, PaperclipApiClient } from "../client/http.js";
|
| 3 |
+
|
| 4 |
+
describe("PaperclipApiClient", () => {
|
| 5 |
+
afterEach(() => {
|
| 6 |
+
vi.restoreAllMocks();
|
| 7 |
+
});
|
| 8 |
+
|
| 9 |
+
it("adds authorization and run-id headers", async () => {
|
| 10 |
+
const fetchMock = vi.fn().mockResolvedValue(
|
| 11 |
+
new Response(JSON.stringify({ ok: true }), { status: 200 }),
|
| 12 |
+
);
|
| 13 |
+
vi.stubGlobal("fetch", fetchMock);
|
| 14 |
+
|
| 15 |
+
const client = new PaperclipApiClient({
|
| 16 |
+
apiBase: "http://localhost:3100",
|
| 17 |
+
apiKey: "token-123",
|
| 18 |
+
runId: "run-abc",
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
await client.post("/api/test", { hello: "world" });
|
| 22 |
+
|
| 23 |
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
| 24 |
+
const call = fetchMock.mock.calls[0] as [string, RequestInit];
|
| 25 |
+
expect(call[0]).toContain("/api/test");
|
| 26 |
+
|
| 27 |
+
const headers = call[1].headers as Record<string, string>;
|
| 28 |
+
expect(headers.authorization).toBe("Bearer token-123");
|
| 29 |
+
expect(headers["x-paperclip-run-id"]).toBe("run-abc");
|
| 30 |
+
expect(headers["content-type"]).toBe("application/json");
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
it("returns null on ignoreNotFound", async () => {
|
| 34 |
+
const fetchMock = vi.fn().mockResolvedValue(
|
| 35 |
+
new Response(JSON.stringify({ error: "Not found" }), { status: 404 }),
|
| 36 |
+
);
|
| 37 |
+
vi.stubGlobal("fetch", fetchMock);
|
| 38 |
+
|
| 39 |
+
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
|
| 40 |
+
const result = await client.get("/api/missing", { ignoreNotFound: true });
|
| 41 |
+
expect(result).toBeNull();
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
it("throws ApiRequestError with details", async () => {
|
| 45 |
+
const fetchMock = vi.fn().mockResolvedValue(
|
| 46 |
+
new Response(
|
| 47 |
+
JSON.stringify({ error: "Issue checkout conflict", details: { issueId: "1" } }),
|
| 48 |
+
{ status: 409 },
|
| 49 |
+
),
|
| 50 |
+
);
|
| 51 |
+
vi.stubGlobal("fetch", fetchMock);
|
| 52 |
+
|
| 53 |
+
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
|
| 54 |
+
|
| 55 |
+
await expect(client.post("/api/issues/1/checkout", {})).rejects.toMatchObject({
|
| 56 |
+
status: 409,
|
| 57 |
+
message: "Issue checkout conflict",
|
| 58 |
+
details: { issueId: "1" },
|
| 59 |
+
} satisfies Partial<ApiRequestError>);
|
| 60 |
+
});
|
| 61 |
+
});
|
cli/src/__tests__/worktree.test.ts
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from "node:fs";
|
| 2 |
+
import os from "node:os";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import { execFileSync } from "node:child_process";
|
| 5 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 6 |
+
import {
|
| 7 |
+
copyGitHooksToWorktreeGitDir,
|
| 8 |
+
copySeededSecretsKey,
|
| 9 |
+
rebindWorkspaceCwd,
|
| 10 |
+
resolveSourceConfigPath,
|
| 11 |
+
resolveGitWorktreeAddArgs,
|
| 12 |
+
resolveWorktreeMakeTargetPath,
|
| 13 |
+
worktreeInitCommand,
|
| 14 |
+
worktreeMakeCommand,
|
| 15 |
+
} from "../commands/worktree.js";
|
| 16 |
+
import {
|
| 17 |
+
buildWorktreeConfig,
|
| 18 |
+
buildWorktreeEnvEntries,
|
| 19 |
+
formatShellExports,
|
| 20 |
+
generateWorktreeColor,
|
| 21 |
+
resolveWorktreeSeedPlan,
|
| 22 |
+
resolveWorktreeLocalPaths,
|
| 23 |
+
rewriteLocalUrlPort,
|
| 24 |
+
sanitizeWorktreeInstanceId,
|
| 25 |
+
} from "../commands/worktree-lib.js";
|
| 26 |
+
import type { PaperclipConfig } from "../config/schema.js";
|
| 27 |
+
|
| 28 |
+
const ORIGINAL_CWD = process.cwd();
|
| 29 |
+
const ORIGINAL_ENV = { ...process.env };
|
| 30 |
+
|
| 31 |
+
afterEach(() => {
|
| 32 |
+
process.chdir(ORIGINAL_CWD);
|
| 33 |
+
for (const key of Object.keys(process.env)) {
|
| 34 |
+
if (!(key in ORIGINAL_ENV)) delete process.env[key];
|
| 35 |
+
}
|
| 36 |
+
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
| 37 |
+
if (value === undefined) delete process.env[key];
|
| 38 |
+
else process.env[key] = value;
|
| 39 |
+
}
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
function buildSourceConfig(): PaperclipConfig {
|
| 43 |
+
return {
|
| 44 |
+
$meta: {
|
| 45 |
+
version: 1,
|
| 46 |
+
updatedAt: "2026-03-09T00:00:00.000Z",
|
| 47 |
+
source: "configure",
|
| 48 |
+
},
|
| 49 |
+
database: {
|
| 50 |
+
mode: "embedded-postgres",
|
| 51 |
+
embeddedPostgresDataDir: "/tmp/main/db",
|
| 52 |
+
embeddedPostgresPort: 54329,
|
| 53 |
+
backup: {
|
| 54 |
+
enabled: true,
|
| 55 |
+
intervalMinutes: 60,
|
| 56 |
+
retentionDays: 30,
|
| 57 |
+
dir: "/tmp/main/backups",
|
| 58 |
+
},
|
| 59 |
+
},
|
| 60 |
+
logging: {
|
| 61 |
+
mode: "file",
|
| 62 |
+
logDir: "/tmp/main/logs",
|
| 63 |
+
},
|
| 64 |
+
server: {
|
| 65 |
+
deploymentMode: "authenticated",
|
| 66 |
+
exposure: "private",
|
| 67 |
+
host: "127.0.0.1",
|
| 68 |
+
port: 3100,
|
| 69 |
+
allowedHostnames: ["localhost"],
|
| 70 |
+
serveUi: true,
|
| 71 |
+
},
|
| 72 |
+
auth: {
|
| 73 |
+
baseUrlMode: "explicit",
|
| 74 |
+
publicBaseUrl: "http://127.0.0.1:3100",
|
| 75 |
+
disableSignUp: false,
|
| 76 |
+
},
|
| 77 |
+
storage: {
|
| 78 |
+
provider: "local_disk",
|
| 79 |
+
localDisk: {
|
| 80 |
+
baseDir: "/tmp/main/storage",
|
| 81 |
+
},
|
| 82 |
+
s3: {
|
| 83 |
+
bucket: "paperclip",
|
| 84 |
+
region: "us-east-1",
|
| 85 |
+
prefix: "",
|
| 86 |
+
forcePathStyle: false,
|
| 87 |
+
},
|
| 88 |
+
},
|
| 89 |
+
secrets: {
|
| 90 |
+
provider: "local_encrypted",
|
| 91 |
+
strictMode: false,
|
| 92 |
+
localEncrypted: {
|
| 93 |
+
keyFilePath: "/tmp/main/secrets/master.key",
|
| 94 |
+
},
|
| 95 |
+
},
|
| 96 |
+
};
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
describe("worktree helpers", () => {
|
| 100 |
+
it("sanitizes instance ids", () => {
|
| 101 |
+
expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support");
|
| 102 |
+
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
it("resolves worktree:make target paths under the user home directory", () => {
|
| 106 |
+
expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe(
|
| 107 |
+
path.resolve(os.homedir(), "paperclip-pr-432"),
|
| 108 |
+
);
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
it("rejects worktree:make names that are not safe directory/branch names", () => {
|
| 112 |
+
expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow(
|
| 113 |
+
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
|
| 114 |
+
);
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
it("builds git worktree add args for new and existing branches", () => {
|
| 118 |
+
expect(
|
| 119 |
+
resolveGitWorktreeAddArgs({
|
| 120 |
+
branchName: "feature-branch",
|
| 121 |
+
targetPath: "/tmp/feature-branch",
|
| 122 |
+
branchExists: false,
|
| 123 |
+
}),
|
| 124 |
+
).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]);
|
| 125 |
+
|
| 126 |
+
expect(
|
| 127 |
+
resolveGitWorktreeAddArgs({
|
| 128 |
+
branchName: "feature-branch",
|
| 129 |
+
targetPath: "/tmp/feature-branch",
|
| 130 |
+
branchExists: true,
|
| 131 |
+
}),
|
| 132 |
+
).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]);
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
it("builds git worktree add args with a start point", () => {
|
| 136 |
+
expect(
|
| 137 |
+
resolveGitWorktreeAddArgs({
|
| 138 |
+
branchName: "my-worktree",
|
| 139 |
+
targetPath: "/tmp/my-worktree",
|
| 140 |
+
branchExists: false,
|
| 141 |
+
startPoint: "public-gh/master",
|
| 142 |
+
}),
|
| 143 |
+
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]);
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
it("uses start point even when a local branch with the same name exists", () => {
|
| 147 |
+
expect(
|
| 148 |
+
resolveGitWorktreeAddArgs({
|
| 149 |
+
branchName: "my-worktree",
|
| 150 |
+
targetPath: "/tmp/my-worktree",
|
| 151 |
+
branchExists: true,
|
| 152 |
+
startPoint: "origin/main",
|
| 153 |
+
}),
|
| 154 |
+
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
it("rewrites loopback auth URLs to the new port only", () => {
|
| 158 |
+
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
|
| 159 |
+
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
it("builds isolated config and env paths for a worktree", () => {
|
| 163 |
+
const paths = resolveWorktreeLocalPaths({
|
| 164 |
+
cwd: "/tmp/paperclip-feature",
|
| 165 |
+
homeDir: "/tmp/paperclip-worktrees",
|
| 166 |
+
instanceId: "feature-worktree-support",
|
| 167 |
+
});
|
| 168 |
+
const config = buildWorktreeConfig({
|
| 169 |
+
sourceConfig: buildSourceConfig(),
|
| 170 |
+
paths,
|
| 171 |
+
serverPort: 3110,
|
| 172 |
+
databasePort: 54339,
|
| 173 |
+
now: new Date("2026-03-09T12:00:00.000Z"),
|
| 174 |
+
});
|
| 175 |
+
|
| 176 |
+
expect(config.database.embeddedPostgresDataDir).toBe(
|
| 177 |
+
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"),
|
| 178 |
+
);
|
| 179 |
+
expect(config.database.embeddedPostgresPort).toBe(54339);
|
| 180 |
+
expect(config.server.port).toBe(3110);
|
| 181 |
+
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/");
|
| 182 |
+
expect(config.storage.localDisk.baseDir).toBe(
|
| 183 |
+
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
|
| 184 |
+
);
|
| 185 |
+
|
| 186 |
+
const env = buildWorktreeEnvEntries(paths, {
|
| 187 |
+
name: "feature-worktree-support",
|
| 188 |
+
color: "#3abf7a",
|
| 189 |
+
});
|
| 190 |
+
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
|
| 191 |
+
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
|
| 192 |
+
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
|
| 193 |
+
expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support");
|
| 194 |
+
expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a");
|
| 195 |
+
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
it("generates vivid worktree colors as hex", () => {
|
| 199 |
+
expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/);
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
|
| 203 |
+
const minimal = resolveWorktreeSeedPlan("minimal");
|
| 204 |
+
const full = resolveWorktreeSeedPlan("full");
|
| 205 |
+
|
| 206 |
+
expect(minimal.excludedTables).toContain("heartbeat_runs");
|
| 207 |
+
expect(minimal.excludedTables).toContain("heartbeat_run_events");
|
| 208 |
+
expect(minimal.excludedTables).toContain("workspace_runtime_services");
|
| 209 |
+
expect(minimal.excludedTables).toContain("agent_task_sessions");
|
| 210 |
+
expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]);
|
| 211 |
+
|
| 212 |
+
expect(full.excludedTables).toEqual([]);
|
| 213 |
+
expect(full.nullifyColumns).toEqual({});
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
|
| 217 |
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
|
| 218 |
+
const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
| 219 |
+
const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
| 220 |
+
try {
|
| 221 |
+
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
| 222 |
+
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
| 223 |
+
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
| 224 |
+
const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key");
|
| 225 |
+
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
|
| 226 |
+
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
|
| 227 |
+
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
|
| 228 |
+
|
| 229 |
+
const sourceConfig = buildSourceConfig();
|
| 230 |
+
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
|
| 231 |
+
|
| 232 |
+
copySeededSecretsKey({
|
| 233 |
+
sourceConfigPath,
|
| 234 |
+
sourceConfig,
|
| 235 |
+
sourceEnvEntries: {},
|
| 236 |
+
targetKeyFilePath: targetKeyPath,
|
| 237 |
+
});
|
| 238 |
+
|
| 239 |
+
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key");
|
| 240 |
+
} finally {
|
| 241 |
+
if (originalInlineMasterKey === undefined) {
|
| 242 |
+
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
| 243 |
+
} else {
|
| 244 |
+
process.env.PAPERCLIP_SECRETS_MASTER_KEY = originalInlineMasterKey;
|
| 245 |
+
}
|
| 246 |
+
if (originalKeyFile === undefined) {
|
| 247 |
+
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
| 248 |
+
} else {
|
| 249 |
+
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = originalKeyFile;
|
| 250 |
+
}
|
| 251 |
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
| 252 |
+
}
|
| 253 |
+
});
|
| 254 |
+
|
| 255 |
+
it("writes the source inline secrets master key into the seeded worktree instance", () => {
|
| 256 |
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
|
| 257 |
+
try {
|
| 258 |
+
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
| 259 |
+
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
|
| 260 |
+
|
| 261 |
+
copySeededSecretsKey({
|
| 262 |
+
sourceConfigPath,
|
| 263 |
+
sourceConfig: buildSourceConfig(),
|
| 264 |
+
sourceEnvEntries: {
|
| 265 |
+
PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key",
|
| 266 |
+
},
|
| 267 |
+
targetKeyFilePath: targetKeyPath,
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key");
|
| 271 |
+
} finally {
|
| 272 |
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
| 273 |
+
}
|
| 274 |
+
});
|
| 275 |
+
|
| 276 |
+
it("persists the current agent jwt secret into the worktree env file", async () => {
|
| 277 |
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-"));
|
| 278 |
+
const repoRoot = path.join(tempRoot, "repo");
|
| 279 |
+
const originalCwd = process.cwd();
|
| 280 |
+
const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
| 281 |
+
|
| 282 |
+
try {
|
| 283 |
+
fs.mkdirSync(repoRoot, { recursive: true });
|
| 284 |
+
process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret";
|
| 285 |
+
process.chdir(repoRoot);
|
| 286 |
+
|
| 287 |
+
await worktreeInitCommand({
|
| 288 |
+
seed: false,
|
| 289 |
+
fromConfig: path.join(tempRoot, "missing", "config.json"),
|
| 290 |
+
home: path.join(tempRoot, ".paperclip-worktrees"),
|
| 291 |
+
});
|
| 292 |
+
|
| 293 |
+
const envPath = path.join(repoRoot, ".paperclip", ".env");
|
| 294 |
+
const envContents = fs.readFileSync(envPath, "utf8");
|
| 295 |
+
expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
|
| 296 |
+
expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo");
|
| 297 |
+
expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/);
|
| 298 |
+
} finally {
|
| 299 |
+
process.chdir(originalCwd);
|
| 300 |
+
if (originalJwtSecret === undefined) {
|
| 301 |
+
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
| 302 |
+
} else {
|
| 303 |
+
process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret;
|
| 304 |
+
}
|
| 305 |
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
| 306 |
+
}
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
it("defaults the seed source config to the current repo-local Paperclip config", () => {
|
| 310 |
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
|
| 311 |
+
const repoRoot = path.join(tempRoot, "repo");
|
| 312 |
+
const localConfigPath = path.join(repoRoot, ".paperclip", "config.json");
|
| 313 |
+
const originalCwd = process.cwd();
|
| 314 |
+
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
| 315 |
+
|
| 316 |
+
try {
|
| 317 |
+
fs.mkdirSync(path.dirname(localConfigPath), { recursive: true });
|
| 318 |
+
fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
|
| 319 |
+
delete process.env.PAPERCLIP_CONFIG;
|
| 320 |
+
process.chdir(repoRoot);
|
| 321 |
+
|
| 322 |
+
expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath));
|
| 323 |
+
} finally {
|
| 324 |
+
process.chdir(originalCwd);
|
| 325 |
+
if (originalPaperclipConfig === undefined) {
|
| 326 |
+
delete process.env.PAPERCLIP_CONFIG;
|
| 327 |
+
} else {
|
| 328 |
+
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
| 329 |
+
}
|
| 330 |
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
| 331 |
+
}
|
| 332 |
+
});
|
| 333 |
+
|
| 334 |
+
it("preserves the source config path across worktree:make cwd changes", () => {
|
| 335 |
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-"));
|
| 336 |
+
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
| 337 |
+
const targetRoot = path.join(tempRoot, "target");
|
| 338 |
+
const originalCwd = process.cwd();
|
| 339 |
+
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
| 340 |
+
|
| 341 |
+
try {
|
| 342 |
+
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
| 343 |
+
fs.mkdirSync(targetRoot, { recursive: true });
|
| 344 |
+
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
|
| 345 |
+
delete process.env.PAPERCLIP_CONFIG;
|
| 346 |
+
process.chdir(targetRoot);
|
| 347 |
+
|
| 348 |
+
expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe(
|
| 349 |
+
path.resolve(sourceConfigPath),
|
| 350 |
+
);
|
| 351 |
+
} finally {
|
| 352 |
+
process.chdir(originalCwd);
|
| 353 |
+
if (originalPaperclipConfig === undefined) {
|
| 354 |
+
delete process.env.PAPERCLIP_CONFIG;
|
| 355 |
+
} else {
|
| 356 |
+
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
| 357 |
+
}
|
| 358 |
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
| 359 |
+
}
|
| 360 |
+
});
|
| 361 |
+
|
| 362 |
+
it("rebinds same-repo workspace paths onto the current worktree root", () => {
|
| 363 |
+
expect(
|
| 364 |
+
rebindWorkspaceCwd({
|
| 365 |
+
sourceRepoRoot: "/Users/example/paperclip",
|
| 366 |
+
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
| 367 |
+
workspaceCwd: "/Users/example/paperclip",
|
| 368 |
+
}),
|
| 369 |
+
).toBe("/Users/example/paperclip-pr-432");
|
| 370 |
+
|
| 371 |
+
expect(
|
| 372 |
+
rebindWorkspaceCwd({
|
| 373 |
+
sourceRepoRoot: "/Users/example/paperclip",
|
| 374 |
+
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
| 375 |
+
workspaceCwd: "/Users/example/paperclip/packages/db",
|
| 376 |
+
}),
|
| 377 |
+
).toBe("/Users/example/paperclip-pr-432/packages/db");
|
| 378 |
+
});
|
| 379 |
+
|
| 380 |
+
it("does not rebind paths outside the source repo root", () => {
|
| 381 |
+
expect(
|
| 382 |
+
rebindWorkspaceCwd({
|
| 383 |
+
sourceRepoRoot: "/Users/example/paperclip",
|
| 384 |
+
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
| 385 |
+
workspaceCwd: "/Users/example/other-project",
|
| 386 |
+
}),
|
| 387 |
+
).toBeNull();
|
| 388 |
+
});
|
| 389 |
+
|
| 390 |
+
it("copies shared git hooks into a linked worktree git dir", () => {
|
| 391 |
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-"));
|
| 392 |
+
const repoRoot = path.join(tempRoot, "repo");
|
| 393 |
+
const worktreePath = path.join(tempRoot, "repo-feature");
|
| 394 |
+
|
| 395 |
+
try {
|
| 396 |
+
fs.mkdirSync(repoRoot, { recursive: true });
|
| 397 |
+
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
| 398 |
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
| 399 |
+
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
| 400 |
+
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
| 401 |
+
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
| 402 |
+
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
| 403 |
+
|
| 404 |
+
const sourceHooksDir = path.join(repoRoot, ".git", "hooks");
|
| 405 |
+
const sourceHookPath = path.join(sourceHooksDir, "pre-commit");
|
| 406 |
+
const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt");
|
| 407 |
+
fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 });
|
| 408 |
+
fs.chmodSync(sourceHookPath, 0o755);
|
| 409 |
+
fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8");
|
| 410 |
+
|
| 411 |
+
execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" });
|
| 412 |
+
|
| 413 |
+
const copied = copyGitHooksToWorktreeGitDir(worktreePath);
|
| 414 |
+
const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
|
| 415 |
+
cwd: worktreePath,
|
| 416 |
+
encoding: "utf8",
|
| 417 |
+
stdio: ["ignore", "pipe", "ignore"],
|
| 418 |
+
}).trim();
|
| 419 |
+
const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir);
|
| 420 |
+
const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks"));
|
| 421 |
+
const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit");
|
| 422 |
+
const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt");
|
| 423 |
+
|
| 424 |
+
expect(copied).toMatchObject({
|
| 425 |
+
sourceHooksPath: resolvedSourceHooksDir,
|
| 426 |
+
targetHooksPath: resolvedTargetHooksDir,
|
| 427 |
+
copied: true,
|
| 428 |
+
});
|
| 429 |
+
expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n");
|
| 430 |
+
expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0);
|
| 431 |
+
expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n");
|
| 432 |
+
} finally {
|
| 433 |
+
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" });
|
| 434 |
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
| 435 |
+
}
|
| 436 |
+
});
|
| 437 |
+
|
| 438 |
+
it("creates and initializes a worktree from the top-level worktree:make command", async () => {
|
| 439 |
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-"));
|
| 440 |
+
const repoRoot = path.join(tempRoot, "repo");
|
| 441 |
+
const fakeHome = path.join(tempRoot, "home");
|
| 442 |
+
const worktreePath = path.join(fakeHome, "paperclip-make-test");
|
| 443 |
+
const originalCwd = process.cwd();
|
| 444 |
+
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
| 445 |
+
|
| 446 |
+
try {
|
| 447 |
+
fs.mkdirSync(repoRoot, { recursive: true });
|
| 448 |
+
fs.mkdirSync(fakeHome, { recursive: true });
|
| 449 |
+
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
| 450 |
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
| 451 |
+
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
| 452 |
+
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
| 453 |
+
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
| 454 |
+
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
| 455 |
+
|
| 456 |
+
process.chdir(repoRoot);
|
| 457 |
+
|
| 458 |
+
await worktreeMakeCommand("paperclip-make-test", {
|
| 459 |
+
seed: false,
|
| 460 |
+
home: path.join(tempRoot, ".paperclip-worktrees"),
|
| 461 |
+
});
|
| 462 |
+
|
| 463 |
+
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
|
| 464 |
+
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
|
| 465 |
+
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
|
| 466 |
+
} finally {
|
| 467 |
+
process.chdir(originalCwd);
|
| 468 |
+
homedirSpy.mockRestore();
|
| 469 |
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
| 470 |
+
}
|
| 471 |
+
}, 20_000);
|
| 472 |
+
});
|
cli/src/adapters/http/format-event.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function printHttpStdoutEvent(raw: string, _debug: boolean): void {
|
| 2 |
+
const line = raw.trim();
|
| 3 |
+
if (line) console.log(line);
|
| 4 |
+
}
|
cli/src/adapters/http/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
| 2 |
+
import { printHttpStdoutEvent } from "./format-event.js";
|
| 3 |
+
|
| 4 |
+
export const httpCLIAdapter: CLIAdapterModule = {
|
| 5 |
+
type: "http",
|
| 6 |
+
formatStdoutEvent: printHttpStdoutEvent,
|
| 7 |
+
};
|
cli/src/adapters/index.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { getCLIAdapter } from "./registry.js";
|
| 2 |
+
export type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
cli/src/adapters/process/format-event.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function printProcessStdoutEvent(raw: string, _debug: boolean): void {
|
| 2 |
+
const line = raw.trim();
|
| 3 |
+
if (line) console.log(line);
|
| 4 |
+
}
|
cli/src/adapters/process/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
| 2 |
+
import { printProcessStdoutEvent } from "./format-event.js";
|
| 3 |
+
|
| 4 |
+
export const processCLIAdapter: CLIAdapterModule = {
|
| 5 |
+
type: "process",
|
| 6 |
+
formatStdoutEvent: printProcessStdoutEvent,
|
| 7 |
+
};
|
cli/src/adapters/registry.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
| 2 |
+
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
| 3 |
+
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
| 4 |
+
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
| 5 |
+
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
| 6 |
+
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
| 7 |
+
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
| 8 |
+
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
| 9 |
+
import { processCLIAdapter } from "./process/index.js";
|
| 10 |
+
import { httpCLIAdapter } from "./http/index.js";
|
| 11 |
+
|
| 12 |
+
const claudeLocalCLIAdapter: CLIAdapterModule = {
|
| 13 |
+
type: "claude_local",
|
| 14 |
+
formatStdoutEvent: printClaudeStreamEvent,
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
const codexLocalCLIAdapter: CLIAdapterModule = {
|
| 18 |
+
type: "codex_local",
|
| 19 |
+
formatStdoutEvent: printCodexStreamEvent,
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const openCodeLocalCLIAdapter: CLIAdapterModule = {
|
| 23 |
+
type: "opencode_local",
|
| 24 |
+
formatStdoutEvent: printOpenCodeStreamEvent,
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
const piLocalCLIAdapter: CLIAdapterModule = {
|
| 28 |
+
type: "pi_local",
|
| 29 |
+
formatStdoutEvent: printPiStreamEvent,
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const cursorLocalCLIAdapter: CLIAdapterModule = {
|
| 33 |
+
type: "cursor",
|
| 34 |
+
formatStdoutEvent: printCursorStreamEvent,
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
const geminiLocalCLIAdapter: CLIAdapterModule = {
|
| 38 |
+
type: "gemini_local",
|
| 39 |
+
formatStdoutEvent: printGeminiStreamEvent,
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
| 43 |
+
type: "openclaw_gateway",
|
| 44 |
+
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const adaptersByType = new Map<string, CLIAdapterModule>(
|
| 48 |
+
[
|
| 49 |
+
claudeLocalCLIAdapter,
|
| 50 |
+
codexLocalCLIAdapter,
|
| 51 |
+
openCodeLocalCLIAdapter,
|
| 52 |
+
piLocalCLIAdapter,
|
| 53 |
+
cursorLocalCLIAdapter,
|
| 54 |
+
geminiLocalCLIAdapter,
|
| 55 |
+
openclawGatewayCLIAdapter,
|
| 56 |
+
processCLIAdapter,
|
| 57 |
+
httpCLIAdapter,
|
| 58 |
+
].map((a) => [a.type, a]),
|
| 59 |
+
);
|
| 60 |
+
|
| 61 |
+
export function getCLIAdapter(type: string): CLIAdapterModule {
|
| 62 |
+
return adaptersByType.get(type) ?? processCLIAdapter;
|
| 63 |
+
}
|
cli/src/checks/agent-jwt-secret-check.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
ensureAgentJwtSecret,
|
| 3 |
+
readAgentJwtSecretFromEnv,
|
| 4 |
+
readAgentJwtSecretFromEnvFile,
|
| 5 |
+
resolveAgentJwtEnvFile,
|
| 6 |
+
} from "../config/env.js";
|
| 7 |
+
import type { CheckResult } from "./index.js";
|
| 8 |
+
|
| 9 |
+
export function agentJwtSecretCheck(configPath?: string): CheckResult {
|
| 10 |
+
if (readAgentJwtSecretFromEnv(configPath)) {
|
| 11 |
+
return {
|
| 12 |
+
name: "Agent JWT secret",
|
| 13 |
+
status: "pass",
|
| 14 |
+
message: "PAPERCLIP_AGENT_JWT_SECRET is set in environment",
|
| 15 |
+
};
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const envPath = resolveAgentJwtEnvFile(configPath);
|
| 19 |
+
const fileSecret = readAgentJwtSecretFromEnvFile(envPath);
|
| 20 |
+
|
| 21 |
+
if (fileSecret) {
|
| 22 |
+
return {
|
| 23 |
+
name: "Agent JWT secret",
|
| 24 |
+
status: "warn",
|
| 25 |
+
message: `PAPERCLIP_AGENT_JWT_SECRET is present in ${envPath} but not loaded into environment`,
|
| 26 |
+
repairHint: `Set the value from ${envPath} in your shell before starting the Paperclip server`,
|
| 27 |
+
};
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
return {
|
| 31 |
+
name: "Agent JWT secret",
|
| 32 |
+
status: "fail",
|
| 33 |
+
message: `PAPERCLIP_AGENT_JWT_SECRET missing from environment and ${envPath}`,
|
| 34 |
+
canRepair: true,
|
| 35 |
+
repair: () => {
|
| 36 |
+
ensureAgentJwtSecret(configPath);
|
| 37 |
+
},
|
| 38 |
+
repairHint: `Run with --repair to create ${envPath} containing PAPERCLIP_AGENT_JWT_SECRET`,
|
| 39 |
+
};
|
| 40 |
+
}
|
cli/src/checks/config-check.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { readConfig, configExists, resolveConfigPath } from "../config/store.js";
|
| 2 |
+
import type { CheckResult } from "./index.js";
|
| 3 |
+
|
| 4 |
+
export function configCheck(configPath?: string): CheckResult {
|
| 5 |
+
const filePath = resolveConfigPath(configPath);
|
| 6 |
+
|
| 7 |
+
if (!configExists(configPath)) {
|
| 8 |
+
return {
|
| 9 |
+
name: "Config file",
|
| 10 |
+
status: "fail",
|
| 11 |
+
message: `Config file not found at ${filePath}`,
|
| 12 |
+
canRepair: false,
|
| 13 |
+
repairHint: "Run `paperclipai onboard` to create one",
|
| 14 |
+
};
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
try {
|
| 18 |
+
readConfig(configPath);
|
| 19 |
+
return {
|
| 20 |
+
name: "Config file",
|
| 21 |
+
status: "pass",
|
| 22 |
+
message: `Valid config at ${filePath}`,
|
| 23 |
+
};
|
| 24 |
+
} catch (err) {
|
| 25 |
+
return {
|
| 26 |
+
name: "Config file",
|
| 27 |
+
status: "fail",
|
| 28 |
+
message: `Invalid config: ${err instanceof Error ? err.message : String(err)}`,
|
| 29 |
+
canRepair: false,
|
| 30 |
+
repairHint: "Run `paperclipai configure --section database` (or `paperclipai onboard` to recreate)",
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
}
|
cli/src/checks/database-check.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from "node:fs";
|
| 2 |
+
import type { PaperclipConfig } from "../config/schema.js";
|
| 3 |
+
import type { CheckResult } from "./index.js";
|
| 4 |
+
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
| 5 |
+
|
| 6 |
+
export async function databaseCheck(config: PaperclipConfig, configPath?: string): Promise<CheckResult> {
|
| 7 |
+
if (config.database.mode === "postgres") {
|
| 8 |
+
if (!config.database.connectionString) {
|
| 9 |
+
return {
|
| 10 |
+
name: "Database",
|
| 11 |
+
status: "fail",
|
| 12 |
+
message: "PostgreSQL mode selected but no connection string configured",
|
| 13 |
+
canRepair: false,
|
| 14 |
+
repairHint: "Run `paperclipai configure --section database`",
|
| 15 |
+
};
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
try {
|
| 19 |
+
const { createDb } = await import("@paperclipai/db");
|
| 20 |
+
const db = createDb(config.database.connectionString);
|
| 21 |
+
await db.execute("SELECT 1");
|
| 22 |
+
return {
|
| 23 |
+
name: "Database",
|
| 24 |
+
status: "pass",
|
| 25 |
+
message: "PostgreSQL connection successful",
|
| 26 |
+
};
|
| 27 |
+
} catch (err) {
|
| 28 |
+
return {
|
| 29 |
+
name: "Database",
|
| 30 |
+
status: "fail",
|
| 31 |
+
message: `Cannot connect to PostgreSQL: ${err instanceof Error ? err.message : String(err)}`,
|
| 32 |
+
canRepair: false,
|
| 33 |
+
repairHint: "Check your connection string and ensure PostgreSQL is running",
|
| 34 |
+
};
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
if (config.database.mode === "embedded-postgres") {
|
| 39 |
+
const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath);
|
| 40 |
+
const reportedPath = dataDir;
|
| 41 |
+
if (!fs.existsSync(dataDir)) {
|
| 42 |
+
fs.mkdirSync(reportedPath, { recursive: true });
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
return {
|
| 46 |
+
name: "Database",
|
| 47 |
+
status: "pass",
|
| 48 |
+
message: `Embedded PostgreSQL configured at ${dataDir} (port ${config.database.embeddedPostgresPort})`,
|
| 49 |
+
};
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return {
|
| 53 |
+
name: "Database",
|
| 54 |
+
status: "fail",
|
| 55 |
+
message: `Unknown database mode: ${String(config.database.mode)}`,
|
| 56 |
+
canRepair: false,
|
| 57 |
+
repairHint: "Run `paperclipai configure --section database`",
|
| 58 |
+
};
|
| 59 |
+
}
|
cli/src/checks/deployment-auth-check.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { PaperclipConfig } from "../config/schema.js";
|
| 2 |
+
import type { CheckResult } from "./index.js";
|
| 3 |
+
|
| 4 |
+
function isLoopbackHost(host: string) {
|
| 5 |
+
const normalized = host.trim().toLowerCase();
|
| 6 |
+
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
|
| 10 |
+
const mode = config.server.deploymentMode;
|
| 11 |
+
const exposure = config.server.exposure;
|
| 12 |
+
const auth = config.auth;
|
| 13 |
+
|
| 14 |
+
if (mode === "local_trusted") {
|
| 15 |
+
if (!isLoopbackHost(config.server.host)) {
|
| 16 |
+
return {
|
| 17 |
+
name: "Deployment/auth mode",
|
| 18 |
+
status: "fail",
|
| 19 |
+
message: `local_trusted requires loopback host binding (found ${config.server.host})`,
|
| 20 |
+
canRepair: false,
|
| 21 |
+
repairHint: "Run `paperclipai configure --section server` and set host to 127.0.0.1",
|
| 22 |
+
};
|
| 23 |
+
}
|
| 24 |
+
return {
|
| 25 |
+
name: "Deployment/auth mode",
|
| 26 |
+
status: "pass",
|
| 27 |
+
message: "local_trusted mode is configured for loopback-only access",
|
| 28 |
+
};
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const secret =
|
| 32 |
+
process.env.BETTER_AUTH_SECRET?.trim() ??
|
| 33 |
+
process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim();
|
| 34 |
+
if (!secret) {
|
| 35 |
+
return {
|
| 36 |
+
name: "Deployment/auth mode",
|
| 37 |
+
status: "fail",
|
| 38 |
+
message: "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET)",
|
| 39 |
+
canRepair: false,
|
| 40 |
+
repairHint: "Set BETTER_AUTH_SECRET before starting Paperclip",
|
| 41 |
+
};
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
if (auth.baseUrlMode === "explicit" && !auth.publicBaseUrl) {
|
| 45 |
+
return {
|
| 46 |
+
name: "Deployment/auth mode",
|
| 47 |
+
status: "fail",
|
| 48 |
+
message: "auth.baseUrlMode=explicit requires auth.publicBaseUrl",
|
| 49 |
+
canRepair: false,
|
| 50 |
+
repairHint: "Run `paperclipai configure --section server` and provide a base URL",
|
| 51 |
+
};
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
if (exposure === "public") {
|
| 55 |
+
if (auth.baseUrlMode !== "explicit" || !auth.publicBaseUrl) {
|
| 56 |
+
return {
|
| 57 |
+
name: "Deployment/auth mode",
|
| 58 |
+
status: "fail",
|
| 59 |
+
message: "authenticated/public requires explicit auth.publicBaseUrl",
|
| 60 |
+
canRepair: false,
|
| 61 |
+
repairHint: "Run `paperclipai configure --section server` and select public exposure",
|
| 62 |
+
};
|
| 63 |
+
}
|
| 64 |
+
try {
|
| 65 |
+
const url = new URL(auth.publicBaseUrl);
|
| 66 |
+
if (url.protocol !== "https:") {
|
| 67 |
+
return {
|
| 68 |
+
name: "Deployment/auth mode",
|
| 69 |
+
status: "warn",
|
| 70 |
+
message: "Public exposure should use an https:// auth.publicBaseUrl",
|
| 71 |
+
canRepair: false,
|
| 72 |
+
repairHint: "Use HTTPS in production for secure session cookies",
|
| 73 |
+
};
|
| 74 |
+
}
|
| 75 |
+
} catch {
|
| 76 |
+
return {
|
| 77 |
+
name: "Deployment/auth mode",
|
| 78 |
+
status: "fail",
|
| 79 |
+
message: "auth.publicBaseUrl is not a valid URL",
|
| 80 |
+
canRepair: false,
|
| 81 |
+
repairHint: "Run `paperclipai configure --section server` and provide a valid URL",
|
| 82 |
+
};
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
return {
|
| 87 |
+
name: "Deployment/auth mode",
|
| 88 |
+
status: "pass",
|
| 89 |
+
message: `Mode ${mode}/${exposure} with auth URL mode ${auth.baseUrlMode}`,
|
| 90 |
+
};
|
| 91 |
+
}
|
cli/src/checks/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface CheckResult {
|
| 2 |
+
name: string;
|
| 3 |
+
status: "pass" | "warn" | "fail";
|
| 4 |
+
message: string;
|
| 5 |
+
canRepair?: boolean;
|
| 6 |
+
repair?: () => void | Promise<void>;
|
| 7 |
+
repairHint?: string;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export { agentJwtSecretCheck } from "./agent-jwt-secret-check.js";
|
| 11 |
+
export { configCheck } from "./config-check.js";
|
| 12 |
+
export { deploymentAuthCheck } from "./deployment-auth-check.js";
|
| 13 |
+
export { databaseCheck } from "./database-check.js";
|
| 14 |
+
export { llmCheck } from "./llm-check.js";
|
| 15 |
+
export { logCheck } from "./log-check.js";
|
| 16 |
+
export { portCheck } from "./port-check.js";
|
| 17 |
+
export { secretsCheck } from "./secrets-check.js";
|
| 18 |
+
export { storageCheck } from "./storage-check.js";
|
cli/src/checks/llm-check.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { PaperclipConfig } from "../config/schema.js";
|
| 2 |
+
import type { CheckResult } from "./index.js";
|
| 3 |
+
|
| 4 |
+
export async function llmCheck(config: PaperclipConfig): Promise<CheckResult> {
|
| 5 |
+
if (!config.llm) {
|
| 6 |
+
return {
|
| 7 |
+
name: "LLM provider",
|
| 8 |
+
status: "pass",
|
| 9 |
+
message: "No LLM provider configured (optional)",
|
| 10 |
+
};
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
if (!config.llm.apiKey) {
|
| 14 |
+
return {
|
| 15 |
+
name: "LLM provider",
|
| 16 |
+
status: "pass",
|
| 17 |
+
message: `${config.llm.provider} configured but no API key set (optional)`,
|
| 18 |
+
};
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
try {
|
| 22 |
+
if (config.llm.provider === "claude") {
|
| 23 |
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
| 24 |
+
method: "POST",
|
| 25 |
+
headers: {
|
| 26 |
+
"x-api-key": config.llm.apiKey,
|
| 27 |
+
"anthropic-version": "2023-06-01",
|
| 28 |
+
"content-type": "application/json",
|
| 29 |
+
},
|
| 30 |
+
body: JSON.stringify({
|
| 31 |
+
model: "claude-sonnet-4-5-20250929",
|
| 32 |
+
max_tokens: 1,
|
| 33 |
+
messages: [{ role: "user", content: "hi" }],
|
| 34 |
+
}),
|
| 35 |
+
});
|
| 36 |
+
if (res.ok || res.status === 400) {
|
| 37 |
+
return { name: "LLM provider", status: "pass", message: "Claude API key is valid" };
|
| 38 |
+
}
|
| 39 |
+
if (res.status === 401) {
|
| 40 |
+
return {
|
| 41 |
+
name: "LLM provider",
|
| 42 |
+
status: "fail",
|
| 43 |
+
message: "Claude API key is invalid (401)",
|
| 44 |
+
canRepair: false,
|
| 45 |
+
repairHint: "Run `paperclipai configure --section llm`",
|
| 46 |
+
};
|
| 47 |
+
}
|
| 48 |
+
return {
|
| 49 |
+
name: "LLM provider",
|
| 50 |
+
status: "warn",
|
| 51 |
+
message: `Claude API returned status ${res.status}`,
|
| 52 |
+
};
|
| 53 |
+
} else {
|
| 54 |
+
const res = await fetch("https://api.openai.com/v1/models", {
|
| 55 |
+
headers: { Authorization: `Bearer ${config.llm.apiKey}` },
|
| 56 |
+
});
|
| 57 |
+
if (res.ok) {
|
| 58 |
+
return { name: "LLM provider", status: "pass", message: "OpenAI API key is valid" };
|
| 59 |
+
}
|
| 60 |
+
if (res.status === 401) {
|
| 61 |
+
return {
|
| 62 |
+
name: "LLM provider",
|
| 63 |
+
status: "fail",
|
| 64 |
+
message: "OpenAI API key is invalid (401)",
|
| 65 |
+
canRepair: false,
|
| 66 |
+
repairHint: "Run `paperclipai configure --section llm`",
|
| 67 |
+
};
|
| 68 |
+
}
|
| 69 |
+
return {
|
| 70 |
+
name: "LLM provider",
|
| 71 |
+
status: "warn",
|
| 72 |
+
message: `OpenAI API returned status ${res.status}`,
|
| 73 |
+
};
|
| 74 |
+
}
|
| 75 |
+
} catch {
|
| 76 |
+
return {
|
| 77 |
+
name: "LLM provider",
|
| 78 |
+
status: "warn",
|
| 79 |
+
message: "Could not reach API to validate key",
|
| 80 |
+
};
|
| 81 |
+
}
|
| 82 |
+
}
|
cli/src/checks/log-check.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from "node:fs";
|
| 2 |
+
import type { PaperclipConfig } from "../config/schema.js";
|
| 3 |
+
import type { CheckResult } from "./index.js";
|
| 4 |
+
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
| 5 |
+
|
| 6 |
+
export function logCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
| 7 |
+
const logDir = resolveRuntimeLikePath(config.logging.logDir, configPath);
|
| 8 |
+
const reportedDir = logDir;
|
| 9 |
+
|
| 10 |
+
if (!fs.existsSync(logDir)) {
|
| 11 |
+
fs.mkdirSync(reportedDir, { recursive: true });
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
try {
|
| 15 |
+
fs.accessSync(reportedDir, fs.constants.W_OK);
|
| 16 |
+
return {
|
| 17 |
+
name: "Log directory",
|
| 18 |
+
status: "pass",
|
| 19 |
+
message: `Log directory is writable: ${reportedDir}`,
|
| 20 |
+
};
|
| 21 |
+
} catch {
|
| 22 |
+
return {
|
| 23 |
+
name: "Log directory",
|
| 24 |
+
status: "fail",
|
| 25 |
+
message: `Log directory is not writable: ${logDir}`,
|
| 26 |
+
canRepair: false,
|
| 27 |
+
repairHint: "Check file permissions on the log directory",
|
| 28 |
+
};
|
| 29 |
+
}
|
| 30 |
+
}
|
cli/src/checks/path-resolver.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
export { resolveRuntimeLikePath } from "../utils/path-resolver.js";
|
cli/src/checks/port-check.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { PaperclipConfig } from "../config/schema.js";
|
| 2 |
+
import { checkPort } from "../utils/net.js";
|
| 3 |
+
import type { CheckResult } from "./index.js";
|
| 4 |
+
|
| 5 |
+
export async function portCheck(config: PaperclipConfig): Promise<CheckResult> {
|
| 6 |
+
const port = config.server.port;
|
| 7 |
+
const result = await checkPort(port);
|
| 8 |
+
|
| 9 |
+
if (result.available) {
|
| 10 |
+
return {
|
| 11 |
+
name: "Server port",
|
| 12 |
+
status: "pass",
|
| 13 |
+
message: `Port ${port} is available`,
|
| 14 |
+
};
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
return {
|
| 18 |
+
name: "Server port",
|
| 19 |
+
status: "warn",
|
| 20 |
+
message: result.error ?? `Port ${port} is not available`,
|
| 21 |
+
canRepair: false,
|
| 22 |
+
repairHint: `Check what's using port ${port} with: lsof -i :${port}`,
|
| 23 |
+
};
|
| 24 |
+
}
|
cli/src/checks/secrets-check.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { randomBytes } from "node:crypto";
|
| 2 |
+
import fs from "node:fs";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import type { PaperclipConfig } from "../config/schema.js";
|
| 5 |
+
import type { CheckResult } from "./index.js";
|
| 6 |
+
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
| 7 |
+
|
| 8 |
+
function decodeMasterKey(raw: string): Buffer | null {
|
| 9 |
+
const trimmed = raw.trim();
|
| 10 |
+
if (!trimmed) return null;
|
| 11 |
+
|
| 12 |
+
if (/^[A-Fa-f0-9]{64}$/.test(trimmed)) {
|
| 13 |
+
return Buffer.from(trimmed, "hex");
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
try {
|
| 17 |
+
const decoded = Buffer.from(trimmed, "base64");
|
| 18 |
+
if (decoded.length === 32) return decoded;
|
| 19 |
+
} catch {
|
| 20 |
+
// ignored
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
if (Buffer.byteLength(trimmed, "utf8") === 32) {
|
| 24 |
+
return Buffer.from(trimmed, "utf8");
|
| 25 |
+
}
|
| 26 |
+
return null;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function withStrictModeNote(
|
| 30 |
+
base: Pick<CheckResult, "name" | "status" | "message" | "canRepair" | "repair" | "repairHint">,
|
| 31 |
+
config: PaperclipConfig,
|
| 32 |
+
): CheckResult {
|
| 33 |
+
const strictModeDisabledInDeployedSetup =
|
| 34 |
+
config.database.mode === "postgres" && config.secrets.strictMode === false;
|
| 35 |
+
if (!strictModeDisabledInDeployedSetup) return base;
|
| 36 |
+
|
| 37 |
+
if (base.status === "fail") return base;
|
| 38 |
+
return {
|
| 39 |
+
...base,
|
| 40 |
+
status: "warn",
|
| 41 |
+
message: `${base.message}; strict secret mode is disabled for postgres deployment`,
|
| 42 |
+
repairHint: base.repairHint
|
| 43 |
+
? `${base.repairHint}. Consider enabling secrets.strictMode`
|
| 44 |
+
: "Consider enabling secrets.strictMode",
|
| 45 |
+
};
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export function secretsCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
| 49 |
+
const provider = config.secrets.provider;
|
| 50 |
+
if (provider !== "local_encrypted") {
|
| 51 |
+
return {
|
| 52 |
+
name: "Secrets adapter",
|
| 53 |
+
status: "fail",
|
| 54 |
+
message: `${provider} is configured, but this build only supports local_encrypted`,
|
| 55 |
+
canRepair: false,
|
| 56 |
+
repairHint: "Run `paperclipai configure --section secrets` and set provider to local_encrypted",
|
| 57 |
+
};
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const envMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
| 61 |
+
if (envMasterKey && envMasterKey.trim().length > 0) {
|
| 62 |
+
if (!decodeMasterKey(envMasterKey)) {
|
| 63 |
+
return {
|
| 64 |
+
name: "Secrets adapter",
|
| 65 |
+
status: "fail",
|
| 66 |
+
message:
|
| 67 |
+
"PAPERCLIP_SECRETS_MASTER_KEY is invalid (expected 32-byte base64, 64-char hex, or raw 32-char string)",
|
| 68 |
+
canRepair: false,
|
| 69 |
+
repairHint: "Set PAPERCLIP_SECRETS_MASTER_KEY to a valid key or unset it to use a key file",
|
| 70 |
+
};
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
return withStrictModeNote(
|
| 74 |
+
{
|
| 75 |
+
name: "Secrets adapter",
|
| 76 |
+
status: "pass",
|
| 77 |
+
message: "Local encrypted provider configured via PAPERCLIP_SECRETS_MASTER_KEY",
|
| 78 |
+
},
|
| 79 |
+
config,
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const keyFileOverride = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
| 84 |
+
const configuredPath =
|
| 85 |
+
keyFileOverride && keyFileOverride.trim().length > 0
|
| 86 |
+
? keyFileOverride.trim()
|
| 87 |
+
: config.secrets.localEncrypted.keyFilePath;
|
| 88 |
+
const keyFilePath = resolveRuntimeLikePath(configuredPath, configPath);
|
| 89 |
+
|
| 90 |
+
if (!fs.existsSync(keyFilePath)) {
|
| 91 |
+
return withStrictModeNote(
|
| 92 |
+
{
|
| 93 |
+
name: "Secrets adapter",
|
| 94 |
+
status: "warn",
|
| 95 |
+
message: `Secrets key file does not exist yet: ${keyFilePath}`,
|
| 96 |
+
canRepair: true,
|
| 97 |
+
repair: () => {
|
| 98 |
+
fs.mkdirSync(path.dirname(keyFilePath), { recursive: true });
|
| 99 |
+
fs.writeFileSync(keyFilePath, randomBytes(32).toString("base64"), {
|
| 100 |
+
encoding: "utf8",
|
| 101 |
+
mode: 0o600,
|
| 102 |
+
});
|
| 103 |
+
try {
|
| 104 |
+
fs.chmodSync(keyFilePath, 0o600);
|
| 105 |
+
} catch {
|
| 106 |
+
// best effort
|
| 107 |
+
}
|
| 108 |
+
},
|
| 109 |
+
repairHint: "Run with --repair to create a local encrypted secrets key file",
|
| 110 |
+
},
|
| 111 |
+
config,
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
let raw: string;
|
| 116 |
+
try {
|
| 117 |
+
raw = fs.readFileSync(keyFilePath, "utf8");
|
| 118 |
+
} catch (err) {
|
| 119 |
+
return {
|
| 120 |
+
name: "Secrets adapter",
|
| 121 |
+
status: "fail",
|
| 122 |
+
message: `Could not read secrets key file: ${err instanceof Error ? err.message : String(err)}`,
|
| 123 |
+
canRepair: false,
|
| 124 |
+
repairHint: "Check file permissions or set PAPERCLIP_SECRETS_MASTER_KEY",
|
| 125 |
+
};
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
if (!decodeMasterKey(raw)) {
|
| 129 |
+
return {
|
| 130 |
+
name: "Secrets adapter",
|
| 131 |
+
status: "fail",
|
| 132 |
+
message: `Invalid key material in ${keyFilePath}`,
|
| 133 |
+
canRepair: false,
|
| 134 |
+
repairHint: "Replace with valid key material or delete it and run doctor --repair",
|
| 135 |
+
};
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
return withStrictModeNote(
|
| 139 |
+
{
|
| 140 |
+
name: "Secrets adapter",
|
| 141 |
+
status: "pass",
|
| 142 |
+
message: `Local encrypted provider configured with key file ${keyFilePath}`,
|
| 143 |
+
},
|
| 144 |
+
config,
|
| 145 |
+
);
|
| 146 |
+
}
|