AUXteam commited on
Commit
cf9339a
·
verified ·
1 Parent(s): e9a6e6c

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .claude/skills/design-guide/SKILL.md +351 -0
  2. .claude/skills/design-guide/references/component-index.md +323 -0
  3. .dockerignore +10 -5
  4. .env.example +3 -0
  5. .gitattributes +2 -0
  6. .github/CODEOWNERS +10 -0
  7. .github/workflows/e2e.yml +44 -0
  8. .github/workflows/pr-policy.yml +49 -0
  9. .github/workflows/pr-verify.yml +48 -0
  10. .github/workflows/refresh-lockfile.yml +93 -0
  11. .github/workflows/release.yml +260 -0
  12. .gitignore +45 -22
  13. .mailmap +1 -0
  14. .npmrc +1 -0
  15. AGENTS.md +145 -0
  16. Agent.md +168 -0
  17. CONTRIBUTING.md +74 -0
  18. Dockerfile +51 -50
  19. Dockerfile.onboard-smoke +40 -0
  20. LICENSE +2 -2
  21. README.md +281 -239
  22. cli/CHANGELOG.md +138 -0
  23. cli/esbuild.config.mjs +65 -0
  24. cli/package.json +62 -0
  25. cli/src/__tests__/agent-jwt-env.test.ts +79 -0
  26. cli/src/__tests__/allowed-hostname.test.ts +77 -0
  27. cli/src/__tests__/common.test.ts +98 -0
  28. cli/src/__tests__/company-delete.test.ts +91 -0
  29. cli/src/__tests__/context.test.ts +70 -0
  30. cli/src/__tests__/data-dir.test.ts +79 -0
  31. cli/src/__tests__/doctor.test.ts +99 -0
  32. cli/src/__tests__/home-paths.test.ts +44 -0
  33. cli/src/__tests__/http.test.ts +61 -0
  34. cli/src/__tests__/worktree.test.ts +472 -0
  35. cli/src/adapters/http/format-event.ts +4 -0
  36. cli/src/adapters/http/index.ts +7 -0
  37. cli/src/adapters/index.ts +2 -0
  38. cli/src/adapters/process/format-event.ts +4 -0
  39. cli/src/adapters/process/index.ts +7 -0
  40. cli/src/adapters/registry.ts +63 -0
  41. cli/src/checks/agent-jwt-secret-check.ts +40 -0
  42. cli/src/checks/config-check.ts +33 -0
  43. cli/src/checks/database-check.ts +59 -0
  44. cli/src/checks/deployment-auth-check.ts +91 -0
  45. cli/src/checks/index.ts +18 -0
  46. cli/src/checks/llm-check.ts +82 -0
  47. cli/src/checks/log-check.ts +30 -0
  48. cli/src/checks/path-resolver.ts +1 -0
  49. cli/src/checks/port-check.ts +24 -0
  50. 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
- dist-server/
2
- cli/
3
- node_modules/
4
- plandex-server
5
- plandex-cloud
 
 
 
 
 
 
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
- __pycache__/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
- .aider.*
23
- *.code-workspace
 
 
 
 
 
24
 
25
- __pycache__/
 
26
 
27
- .repo_ignore
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 golang:1.23.3
2
-
3
- # Update and install necessary packages including build tools for Tree-sitter and Postgres
4
- RUN apt-get update && \
5
- apt-get install -y git gcc g++ make python3 python3-venv postgresql postgresql-contrib
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
- # Copy go.mod and go.sum for shared and server, and install dependencies
19
- COPY ./app/shared/go.mod ./app/shared/go.sum ./app/shared/
20
- RUN cd app/shared && go mod download
21
-
22
- COPY ./app/server/go.mod ./app/server/go.sum ./app/server/
23
- RUN cd app/server && go mod download
24
-
25
- # Copy the actual source code
26
- COPY ./app/server ./app/server
27
- COPY ./app/shared ./app/shared
28
- COPY ./app/scripts /scripts
29
-
30
- # Set working directory to server
31
- WORKDIR /app/app/server
32
-
33
- # Build the application
34
- RUN rm -f plandex-server && go build -o plandex-server .
35
-
36
- # Setup entrypoint script to start postgres and the server
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
- # Command to run the entrypoint script
55
- CMD ["/app/entrypoint.sh"]
 
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 PlandexAI Inc.
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: Plandex
3
- emoji: 🚀
4
- colorFrom: blue
5
- colorTo: indigo
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- <h1 align="center">
11
- <a href="https://plandex.ai">
12
- <picture>
13
- <source media="(prefers-color-scheme: dark)" srcset="images/plandex-logo-dark-v2.png"/>
14
- <source media="(prefers-color-scheme: light)" srcset="images/plandex-logo-light-v2.png"/>
15
- <img width="400" src="images/plandex-logo-dark-bg-v2.png"/>
16
- </a>
17
- <br />
18
- </h1>
19
- <br />
20
-
21
- <div align="center">
22
-
23
- <p align="center">
24
- <!-- Call to Action Links -->
25
- <a href="#install">
26
- <b>30-Second Install</b>
27
- </a>
28
- ·
29
- <a href="https://plandex.ai">
30
- <b>Website</b>
31
- </a>
32
- ·
33
- <a href="https://docs.plandex.ai/">
34
- <b>Docs</b>
35
- </a>
36
- ·
37
- <a href="#examples-">
38
- <b>Examples</b>
39
- </a>
40
- ·
41
- <a href="https://docs.plandex.ai/hosting/self-hosting/local-mode-quickstart">
42
- <b>Local Self-Hosted Mode</b>
43
- </a>
44
- </p>
45
-
46
- <br>
47
-
48
- [![Discord](https://img.shields.io/discord/1214825831973785600.svg?style=flat&logo=discord&label=Discord&refresh=1)](https://discord.gg/plandex-ai)
49
- [![GitHub Repo stars](https://img.shields.io/github/stars/plandex-ai/plandex?style=social)](https://github.com/plandex-ai/plandex)
50
- [![Twitter Follow](https://img.shields.io/twitter/follow/PlandexAI?style=social)](https://twitter.com/PlandexAI)
51
-
52
- </div>
53
-
54
- <p align="center">
55
- <!-- Badges -->
56
- <a href="https://github.com/plandex-ai/plandex/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a> <a href="https://github.com/plandex-ai/plandex/releases?q=cli"><img src="https://img.shields.io/github/v/release/plandex-ai/plandex?filter=cli*" alt="Release" /></a>
57
- <a href="https://github.com/plandex-ai/plandex/releases?q=server"><img src="https://img.shields.io/github/v/release/plandex-ai/plandex?filter=server*" alt="Release" /></a>
58
-
59
- <!-- <a href="https://github.com/your_username/your_project/issues">
60
- <img src="https://img.shields.io/github/issues-closed/your_username/your_project.svg" alt="Issues Closed" />
61
- </a> -->
62
-
63
- </p>
64
-
65
- <br />
66
-
67
- <div align="center">
68
- <a href="https://trendshift.io/repositories/8994" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8994" alt="plandex-ai%2Fplandex | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
69
- </div>
70
-
71
- <br>
72
-
73
- <h1 align="center" >
74
- An AI coding agent designed for large tasks and real world projects.<br/><br/>
75
- </h1>
76
-
77
- <!-- <h2 align="center">
78
- Designed for large tasks and real world projects.<br/><br/>
79
- </h2> -->
80
- <br/>
81
-
82
- <div align="center">
83
- <a href="https://www.youtube.com/watch?v=SFSu2vNmlLk">
84
- <img src="images/plandex-v2-yt.png" alt="Plandex v2 Demo Video" width="800">
85
- </a>
86
- </div>
87
-
88
- <br/>
89
-
90
- 💻  Plandex is a terminal-based AI development tool that can **plan and execute** large coding tasks that span many steps and touch dozens of files. It can handle up to 2M tokens of context directly (~100k per file), and can index directories with 20M tokens or more using tree-sitter project maps.
91
-
92
- 🔬  **A cumulative diff review sandbox** keeps AI-generated changes separate from your project files until they are ready to go. Command execution is controlled so you can easily roll back and debug. Plandex helps you get the most out of AI without leaving behind a mess in your project.
93
-
94
- 🧠  **Combine the best models** from Anthropic, OpenAI, Google, and open source providers to build entire features and apps with a robust terminal-based workflow.
95
-
96
- 🚀  Plandex is capable of <strong>full autonomy</strong>—it can load relevant files, plan and implement changes, execute commands, and automatically debug—but it's also highly flexible and configurable, giving developers fine-grained control and a step-by-step review process when needed.
97
-
98
- 💪  Plandex is designed to be resilient to <strong>large projects and files</strong>. If you've found that others tools struggle once your project gets past a certain size or the changes are too complex, give Plandex a shot.
99
-
100
- ## Smart context management that works in big projects
101
-
102
- - 🐘 **2M token effective context window** with default model pack. Plandex loads only what's needed for each step.
103
-
104
- - 🗄️ **Reliable in large projects and files.** Easily generate, review, revise, and apply changes spanning dozens of files.
105
-
106
- - 🗺️ **Fast project map generation** and syntax validation with tree-sitter. Supports 30+ languages.
107
-
108
- - 💰 **Context caching** is used across the board for OpenAI, Anthropic, and Google models, reducing costs and latency.
109
-
110
- ## Tight control or full autonomy—it's up to you
111
-
112
- - 🚦 **Configurable autonomy:** go from full auto mode to fine-grained control depending on the task.
113
-
114
- - 🐞 **Automated debugging** of terminal commands (like builds, linters, tests, deployments, and scripts). If you have Chrome installed, you can also automatically debug browser applications.
115
-
116
- ## Tools that help you get production-ready results
117
-
118
- - 💬 **A project-aware chat mode** that helps you flesh out ideas before moving to implementation. Also great for asking questions and learning about a codebase.
119
-
120
- - 🧠 **Easily try + combine models** from multiple providers. Curated model packs offer different tradeoffs of capability, cost, and speed, as well as open source and provider-specific packs.
121
-
122
- - 🛡️ **Reliable file edits** that prioritize correctness. While most edits are quick and cheap, Plandex validates both syntax and logic as needed, with multiple fallback layers when there are problems.
123
-
124
- - 🔀 **Full-fledged version control** for every update to the plan, including branches for exploring multiple paths or comparing different models.
125
-
126
- - 📂 **Git integration** with commit message generation and optional automatic commits.
127
-
128
- ## Dev-friendly, easy to install
129
-
130
- - 🧑‍💻 **REPL mode** with fuzzy auto-complete for commands and file loading. Just run `plandex` in any project to get started.
131
-
132
- - 🛠️ **CLI interface** for scripting or piping data into context.
133
-
134
- - 📦 **One-line, zero dependency CLI install**. Dockerized local mode for easily self-hosting the server. Cloud-hosting options for extra reliability and convenience.
135
-
136
- ## Workflow  🔄
137
-
138
- <img src="images/plandex-workflow.png" alt="Plandex workflow" width="100%"/>
139
-
140
- ## Examples  🎥
141
-
142
- <br/>
143
-
144
- <div align="center">
145
- <a href="https://www.youtube.com/watch?v=g-_76U_nK0Y">
146
- <img src="images/plandex-browser-debug-yt.png" alt="Plandex Browser Debugging Example" width="800">
147
- </a>
148
- </div>
149
-
150
- <br/>
151
-
152
- ## Install  📥
153
-
154
- ```bash
155
- curl -sL https://plandex.ai/install.sh | bash
156
- ```
157
-
158
- **Note:** Windows is supported via [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). Plandex only works correctly on Windows in the WSL shell. It doesn't work in the Windows CMD prompt or PowerShell.
159
-
160
- [More installation options.](https://docs.plandex.ai/install)
161
-
162
- ## Hosting  ⚖️
163
-
164
- | Option | Description |
165
- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
166
- | **Plandex Cloud** | Winding down as of 10/3/2025 and no longer accepting new users. <a href="https://plandex.ai/blog/winding-down">Learn more.</a> |
167
- | **Self-hosted/Local Mode** | • Run Plandex locally with Docker or host on your own server.<br/>• Use your own [OpenRouter.ai](https://openrouter.ai) key (or [other model provider](https://docs.plandex.ai/models/model-providers) accounts and API keys).<br/>• Follow the [local-mode quickstart](https://docs.plandex.ai/hosting/self-hosting/local-mode-quickstart) to get started. |
168
-
169
- ## Provider keys  🔑
170
-
171
- <!-- If you're going with a 'BYO API Key' option above (whether cloud or self-hosted), you'll need to set API keys for the [model providers](https://docs.plandex.ai/models/model-providers) you're using: -->
172
-
173
- ```bash
174
- export OPENROUTER_API_KEY=... # if using OpenRouter.ai
175
- ```
176
-
177
- <br/>
178
-
179
- ## Claude Pro/Max subscription  🖇️
180
-
181
- If you have a Claude Pro or Max subscription, Plandex can use it when calling Anthropic models. You'll be asked if you want to connect a subscription the first time you run Plandex.
182
-
183
- <br/>
184
-
185
- ## Get started  🚀
186
-
187
- First, `cd` into a **project directory** where you want to get something done or chat about the project. Make a new directory first with `mkdir your-project-dir` if you're starting on a new project.
188
-
189
- ```bash
190
- cd your-project-dir
191
- ```
192
-
193
- For a new project, you might also want to initialize a git repo. Plandex doesn't require that your project is in a git repo, but it does integrate well with git if you use it.
194
-
195
- ```bash
196
- git init
197
- ```
198
-
199
- Now start the Plandex REPL in your project:
200
-
201
- ```bash
202
- plandex
203
- ```
204
-
205
- or for short:
206
-
207
- ```bash
208
- pdx
209
- ```
210
-
211
- <!-- ☁️ _If you're using Plandex Cloud, you'll be prompted at this point to start a trial._
212
-
213
- Then just give the REPL help text a quick read, and you're ready go. The REPL starts in _chat mode_ by default, which is good for fleshing out ideas before moving to implementation. Once the task is clear, Plandex will prompt you to switch to _tell mode_ to make a detailed plan and start writing code. -->
214
-
215
- <br/>
216
-
217
- ## Docs  🛠️
218
-
219
- ### [👉  Full documentation.](https://docs.plandex.ai/)
220
-
221
- <br/>
222
-
223
- ## Discussion and discord  💬
224
-
225
- Please feel free to give your feedback, ask questions, report a bug, or just hang out:
226
-
227
- - [Discord](https://discord.gg/plandex-ai)
228
- - [Discussions](https://github.com/plandex-ai/plandex/discussions)
229
- - [Issues](https://github.com/plandex-ai/plandex/issues)
230
-
231
- ## Follow and subscribe
232
-
233
- - [Follow @PlandexAI](https://x.com/PlandexAI)
234
- - [Follow @Danenania](https://x.com/Danenania) (Plandex's creator)
235
- - [Subscribe on YouTube](https://x.com/PlandexAI)
236
-
237
- <br/>
238
-
239
- ## Contributors  👥
240
-
241
- ⭐️  Please star, fork, explore, and contribute to Plandex. There's a lot of work to do and so much that can be improved.
242
-
243
- [Here's an overview on setting up a development environment.](https://docs.plandex.ai/development)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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> &middot;
13
+ <a href="https://paperclip.ing/docs"><strong>Docs</strong></a> &middot;
14
+ <a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> &middot;
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 &copy; 2026 Paperclip
270
+
271
+ ## Star History
272
+
273
+ [![Star History Chart](https://api.star-history.com/image?repos=paperclipai/paperclip&type=date&legend=top-left)](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left)
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
+ }