Compare commits
526 Commits
paperclipa
...
paperclipa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
964e04369a | ||
|
|
873535fbf0 | ||
|
|
87c0bf9cdf | ||
|
|
56df8d3cf0 | ||
|
|
8808a33fe1 | ||
|
|
ac82cae39a | ||
|
|
9c6a913ef1 | ||
|
|
18f7092b71 | ||
|
|
c8b08e64d6 | ||
|
|
e6ff4eb8b2 | ||
|
|
7adc14ab50 | ||
|
|
aeafeba12b | ||
|
|
890ff39bdb | ||
|
|
55c145bff2 | ||
|
|
7809405e8f | ||
|
|
88916fd11b | ||
|
|
06b50ba161 | ||
|
|
f76a7ef408 | ||
|
|
448e9c192b | ||
|
|
1d5e5247e8 | ||
|
|
5f3f354b3a | ||
|
|
7df74b170d | ||
|
|
7e6a5682fa | ||
|
|
e6a684d96a | ||
|
|
c3cf4279fa | ||
|
|
d4d1b2e7f9 | ||
|
|
b7744a2215 | ||
|
|
f5c766beb9 | ||
|
|
3e8993b449 | ||
|
|
32bdcf1dca | ||
|
|
369dfa4397 | ||
|
|
905403c1af | ||
|
|
dc3f3776ea | ||
|
|
44396be7c1 | ||
|
|
c49e5e90be | ||
|
|
01180d3027 | ||
|
|
397e6d0915 | ||
|
|
778afd31b1 | ||
|
|
6fe7f7a510 | ||
|
|
088eaea0cb | ||
|
|
b1bf09970f | ||
|
|
6540084ddf | ||
|
|
cde3a8c604 | ||
|
|
57113b1075 | ||
|
|
cbe5cfe603 | ||
|
|
833ccb9921 | ||
|
|
bfbb42a9fc | ||
|
|
c4e64be4bc | ||
|
|
88b47c805c | ||
|
|
908e01655a | ||
|
|
ea54c018ad | ||
|
|
6c351cb37d | ||
|
|
ee3d8c1890 | ||
|
|
3b9da0ee95 | ||
|
|
6bfe0b8422 | ||
|
|
33c6d093ab | ||
|
|
d0b1079b9b | ||
|
|
7945e7e780 | ||
|
|
6e7266eeb4 | ||
|
|
d19ff3f4dd | ||
|
|
4435e14838 | ||
|
|
df121c61dc | ||
|
|
1f204e4d76 | ||
|
|
8194132996 | ||
|
|
f7cc292742 | ||
|
|
2efc3a3ef6 | ||
|
|
bb6e721567 | ||
|
|
e76adf6ed1 | ||
|
|
2b4d82bfdd | ||
|
|
5e9c223077 | ||
|
|
98ede67b9b | ||
|
|
f594edd39f | ||
|
|
487c86f58e | ||
|
|
b3e71ca562 | ||
|
|
ab2f9e90eb | ||
|
|
cb77b2eb7e | ||
|
|
6c9e639a68 | ||
|
|
6e4694716b | ||
|
|
87b8e21701 | ||
|
|
dd5d2c7c92 | ||
|
|
e168dc7b97 | ||
|
|
4670f60d3e | ||
|
|
472322de24 | ||
|
|
3770e94d56 | ||
|
|
d9492f02d6 | ||
|
|
57d8d01079 | ||
|
|
345c7f4a88 | ||
|
|
521b24da3d | ||
|
|
96e03b45b9 | ||
|
|
57dcdb51af | ||
|
|
a503d2c12c | ||
|
|
21d2b075e7 | ||
|
|
426b16987a | ||
|
|
92aef9bae8 | ||
|
|
5f76d03913 | ||
|
|
d3ac8722be | ||
|
|
183d71eb7c | ||
|
|
3273692944 | ||
|
|
b5935349ed | ||
|
|
4b49efa02e | ||
|
|
c2c63868e9 | ||
|
|
9d2800e691 | ||
|
|
3a003e11cc | ||
|
|
d388255e66 | ||
|
|
80d87d3b4e | ||
|
|
21eb904a4d | ||
|
|
d62b89cadd | ||
|
|
78207304d4 | ||
|
|
c799fca313 | ||
|
|
50db379db2 | ||
|
|
56aeddfa1c | ||
|
|
42c8aca5c0 | ||
|
|
00495d3d89 | ||
|
|
a613435249 | ||
|
|
576b408682 | ||
|
|
193b7c0570 | ||
|
|
93a8b55ff8 | ||
|
|
24a553c255 | ||
|
|
2332a79e0b | ||
|
|
65af1d77a4 | ||
|
|
b0b7ec779a | ||
|
|
859c82aa12 | ||
|
|
6fd29e05ad | ||
|
|
12216b5cc6 | ||
|
|
0c525febf2 | ||
|
|
b0fe48b730 | ||
|
|
f3a9b6de21 | ||
|
|
31561724f7 | ||
|
|
c363428966 | ||
|
|
f783f66866 | ||
|
|
deec68ab16 | ||
|
|
6733a6cd7e | ||
|
|
dfbb4f1ccb | ||
|
|
6956dad53a | ||
|
|
e9fc403b94 | ||
|
|
8eb8b16047 | ||
|
|
4e5f67ef96 | ||
|
|
ec445e4cc9 | ||
|
|
af97259a9c | ||
|
|
9c68c1b80b | ||
|
|
e94ce47ba5 | ||
|
|
6186eba098 | ||
|
|
b83a87f42f | ||
|
|
3120c72372 | ||
|
|
7934952a77 | ||
|
|
d9574fea71 | ||
|
|
83738b45cd | ||
|
|
4a67db6a4d | ||
|
|
0704854926 | ||
|
|
1959badde7 | ||
|
|
3ff07c23d2 | ||
|
|
dec02225f1 | ||
|
|
f6f5fee200 | ||
|
|
49b9511889 | ||
|
|
1a53567cb6 | ||
|
|
9248881d42 | ||
|
|
ef978dd601 | ||
|
|
fbf9d5714f | ||
|
|
8ac064499f | ||
|
|
cbbf695c35 | ||
|
|
7e8908afa2 | ||
|
|
58d4d04e99 | ||
|
|
c672b71f7f | ||
|
|
01c5a6f198 | ||
|
|
8a7b7a2383 | ||
|
|
64f5c3f837 | ||
|
|
c62266aa6a | ||
|
|
5dd1e6335a | ||
|
|
469bfe3953 | ||
|
|
d20341c797 | ||
|
|
756ddb6cf7 | ||
|
|
200dd66f63 | ||
|
|
9859bac440 | ||
|
|
8d6b20b47b | ||
|
|
a418106005 | ||
|
|
84ef17bf85 | ||
|
|
23dec980e2 | ||
|
|
03c37f8dea | ||
|
|
8360b2e3e3 | ||
|
|
d9ba4790e9 | ||
|
|
3ec96fdb73 | ||
|
|
eecb780dd7 | ||
|
|
632079ae3b | ||
|
|
7d8d6a5caf | ||
|
|
948080fee9 | ||
|
|
af0e05f38c | ||
|
|
8d53800c19 | ||
|
|
422f57b160 | ||
|
|
31c947bf7f | ||
|
|
f5bf743745 | ||
|
|
0a8b96cdb3 | ||
|
|
a47ea343ba | ||
|
|
0781b7a15c | ||
|
|
30ee59c324 | ||
|
|
aa2b11d528 | ||
|
|
e1ddcbb71f | ||
|
|
df94c98494 | ||
|
|
a7cfd9f24b | ||
|
|
e48beafc90 | ||
|
|
e6e41dba9d | ||
|
|
f4a9788f2d | ||
|
|
ccd501ea02 | ||
|
|
d7b98a72b4 | ||
|
|
210715117c | ||
|
|
38cb2bf3c4 | ||
|
|
f2a0a0b804 | ||
|
|
035e1a9333 | ||
|
|
ec4667c8b2 | ||
|
|
f32b76f213 | ||
|
|
ee7fddf8d5 | ||
|
|
77e04407b9 | ||
|
|
1a75e6d15c | ||
|
|
5e18ccace7 | ||
|
|
7b70713fcb | ||
|
|
ad55af04cc | ||
|
|
57406dbc90 | ||
|
|
e35e2c4343 | ||
|
|
d58f269281 | ||
|
|
2a7043d677 | ||
|
|
31b5ff1c61 | ||
|
|
c674462a02 | ||
|
|
e3ff0c8e1b | ||
|
|
17b10c43fe | ||
|
|
343d4e5877 | ||
|
|
1078c7dd2b | ||
|
|
4c630bc66e | ||
|
|
f5190f28d1 | ||
|
|
edfc6be63c | ||
|
|
61551ffea3 | ||
|
|
0fedd8a395 | ||
|
|
b090c33ca1 | ||
|
|
3fb96506bd | ||
|
|
dcf879f6fb | ||
|
|
4e01633202 | ||
|
|
ff3f04ff48 | ||
|
|
91fda5d04f | ||
|
|
77e06c57f9 | ||
|
|
0f75c35392 | ||
|
|
45473b3e72 | ||
|
|
a96556b8f4 | ||
|
|
ce8fe38ffc | ||
|
|
6e86f69f95 | ||
|
|
7661fae4b3 | ||
|
|
ba080cb4dd | ||
|
|
3860812323 | ||
|
|
2639184f46 | ||
|
|
61966fba1f | ||
|
|
54b512f9e0 | ||
|
|
667d23e79e | ||
|
|
416177ae4c | ||
|
|
72cc748aa8 | ||
|
|
9299660388 | ||
|
|
2cb82f326f | ||
|
|
f81d2ebcc4 | ||
|
|
048e2b1bfe | ||
|
|
5fae7d4de7 | ||
|
|
0f32fffe79 | ||
|
|
0233525e99 | ||
|
|
20b171bd16 | ||
|
|
3f2274cd8d | ||
|
|
c59e059976 | ||
|
|
9933039094 | ||
|
|
b886eb3cf0 | ||
|
|
53c944e8bc | ||
|
|
977f5570be | ||
|
|
609b55f530 | ||
|
|
2223afa0e9 | ||
|
|
3479ea6e80 | ||
|
|
63a876ca3c | ||
|
|
df0f101fbd | ||
|
|
0abb6a1205 | ||
|
|
d52f1d4b44 | ||
|
|
e27ec5de8c | ||
|
|
83488b4ed0 | ||
|
|
271a632f1c | ||
|
|
9a0e3a8425 | ||
|
|
1c1b86f495 | ||
|
|
1420b86aa7 | ||
|
|
22053d18e4 | ||
|
|
3b4db7a3bc | ||
|
|
db15dfaf5e | ||
|
|
1afadd7354 | ||
|
|
9ac2e71187 | ||
|
|
3bde21bb06 | ||
|
|
672d769c68 | ||
|
|
46c343f81d | ||
|
|
17058dd751 | ||
|
|
346152f67d | ||
|
|
dd14643848 | ||
|
|
1dac0ec7cf | ||
|
|
7c0a3efea6 | ||
|
|
671a8ae554 | ||
|
|
baa71d6a08 | ||
|
|
638f2303bb | ||
|
|
a4d0901e89 | ||
|
|
f85f2fbcc2 | ||
|
|
fbcd80948e | ||
|
|
9d6a83dcca | ||
|
|
a251a53571 | ||
|
|
63afce3692 | ||
|
|
e07646bade | ||
|
|
ddb7101fa5 | ||
|
|
3f42357e5f | ||
|
|
3b08d4d582 | ||
|
|
049f768bc7 | ||
|
|
19c295ec03 | ||
|
|
a6b5f12daf | ||
|
|
4bd6961020 | ||
|
|
fd0799fd71 | ||
|
|
b91820afd3 | ||
|
|
0315e4cdc2 | ||
|
|
654463c28f | ||
|
|
f1ad727f8e | ||
|
|
10cccc07cd | ||
|
|
a498c268c5 | ||
|
|
fa8499719a | ||
|
|
1fcc6900ff | ||
|
|
45708a06f1 | ||
|
|
792397c2a9 | ||
|
|
36e4e67025 | ||
|
|
6077ae6064 | ||
|
|
eb7f690ceb | ||
|
|
ef0e08b8ed | ||
|
|
3bcdf3e3ad | ||
|
|
fccec94805 | ||
|
|
bee9fdd207 | ||
|
|
0ae5d81deb | ||
|
|
ffc59f5b08 | ||
|
|
f5f8c4a883 | ||
|
|
e693e3d466 | ||
|
|
e4928f3a10 | ||
|
|
514dc43923 | ||
|
|
b539462319 | ||
|
|
aa7e069044 | ||
|
|
3b0ff94e3f | ||
|
|
5ab1c18530 | ||
|
|
36013c35d9 | ||
|
|
b155415d7d | ||
|
|
d7f68ec1c9 | ||
|
|
af09510f6a | ||
|
|
a2bdfb0dd3 | ||
|
|
67247b5d6a | ||
|
|
5f2dfcb94e | ||
|
|
67491483b7 | ||
|
|
54a4f784a4 | ||
|
|
5aecb148a2 | ||
|
|
f49a003bd9 | ||
|
|
feb384acca | ||
|
|
c9718dc27a | ||
|
|
0b42045053 | ||
|
|
d8f7c6bf81 | ||
|
|
c8bd578415 | ||
|
|
5dfd9a2429 | ||
|
|
0324259da3 | ||
|
|
7af9aa61fa | ||
|
|
55bb3012ea | ||
|
|
ca919d73f9 | ||
|
|
70051735f6 | ||
|
|
2ad616780f | ||
|
|
fa43e5b0dd | ||
|
|
1d42b6e726 | ||
|
|
a3493dbb74 | ||
|
|
59a07324ec | ||
|
|
4d8663ebc8 | ||
|
|
2e7bf85e7a | ||
|
|
35e4897256 | ||
|
|
68ee3f8ea0 | ||
|
|
cf1ccd1e14 | ||
|
|
f56901b473 | ||
|
|
f99f174e2d | ||
|
|
cec372f9bb | ||
|
|
8355dd7905 | ||
|
|
8151331375 | ||
|
|
b06e41bed2 | ||
|
|
1179d7e75a | ||
|
|
2ec2dcf9c6 | ||
|
|
cbce8bfbc3 | ||
|
|
0f895a8cf9 | ||
|
|
c3ac209e5f | ||
|
|
192d76678e | ||
|
|
7bcf994064 | ||
|
|
e670324334 | ||
|
|
c23ddbad3f | ||
|
|
e6339e911d | ||
|
|
c0c64fe682 | ||
|
|
ae60879507 | ||
|
|
de60519ef6 | ||
|
|
44a00596a4 | ||
|
|
a57732f7dd | ||
|
|
63c0e22a2a | ||
|
|
2405851436 | ||
|
|
d9d2ad209d | ||
|
|
e1d4e37776 | ||
|
|
08ac2bc9a7 | ||
|
|
b213eb695b | ||
|
|
494448dcf7 | ||
|
|
854e818b74 | ||
|
|
38d3d5fa59 | ||
|
|
86bd26ee8a | ||
|
|
9cacf4a981 | ||
|
|
9184cf92dd | ||
|
|
38b9a55eab | ||
|
|
3369a9e685 | ||
|
|
553c939f1f | ||
|
|
67bc601258 | ||
|
|
9d570b3ed7 | ||
|
|
d4eb502389 | ||
|
|
50276ed981 | ||
|
|
2d21045424 | ||
|
|
eb607f7df8 | ||
|
|
eb033a221f | ||
|
|
5f6e68e7aa | ||
|
|
88682632f9 | ||
|
|
264d40e6ca | ||
|
|
de7d6294ea | ||
|
|
f41373dc46 | ||
|
|
1bbb98aaa9 | ||
|
|
cecb94213d | ||
|
|
0cdc9547d9 | ||
|
|
4c1504872f | ||
|
|
7086ad00ae | ||
|
|
222e0624a8 | ||
|
|
81bc8c7313 | ||
|
|
5134cac993 | ||
|
|
e401979851 | ||
|
|
4569d57f5b | ||
|
|
eff0c506fa | ||
|
|
c486bad2dd | ||
|
|
0e387426fa | ||
|
|
6ee4315eef | ||
|
|
7c07b16f80 | ||
|
|
77500b50d9 | ||
|
|
0cc75c6e10 | ||
|
|
82d97418b2 | ||
|
|
35a7acc058 | ||
|
|
bd32c871b7 | ||
|
|
8e63dd44b6 | ||
|
|
4eedf15870 | ||
|
|
a0e6ad0b7d | ||
|
|
4b90784183 | ||
|
|
ab6ec999c5 | ||
|
|
babea25649 | ||
|
|
e9ffde610b | ||
|
|
a05aa99c7e | ||
|
|
690149d555 | ||
|
|
ffd1631b14 | ||
|
|
185317c153 | ||
|
|
988f1244e5 | ||
|
|
38b855e495 | ||
|
|
0ed0c0abdb | ||
|
|
7a2ecff4f0 | ||
|
|
bee24e880f | ||
|
|
7ab5b8a0c2 | ||
|
|
089a2d08bf | ||
|
|
d8fb93edcf | ||
|
|
201d91b4f5 | ||
|
|
9da1803f29 | ||
|
|
1b98c2b279 | ||
|
|
a85511dad2 | ||
|
|
f75a4d9589 | ||
|
|
0d36cf00f8 | ||
|
|
4b8e880a96 | ||
|
|
1e5e09f0fa | ||
|
|
57db28e9e6 | ||
|
|
c610951a71 | ||
|
|
e5049a448e | ||
|
|
1f261d90f3 | ||
|
|
d2dd8d0cc5 | ||
|
|
e08362b667 | ||
|
|
2c809d55c0 | ||
|
|
529d53acc0 | ||
|
|
fd73d6fcab | ||
|
|
cdf63d0024 | ||
|
|
09a8ecbded | ||
|
|
6f98c5f25c | ||
|
|
70e41150c5 | ||
|
|
bc765b0867 | ||
|
|
9dbd72cffd | ||
|
|
084c0a19a2 | ||
|
|
85f95c4542 | ||
|
|
732ae4e46c | ||
|
|
c1a92d8520 | ||
|
|
69b2875060 | ||
|
|
7cb46d97f6 | ||
|
|
e31d77bc47 | ||
|
|
90d39b9cbd | ||
|
|
bc68c3a504 | ||
|
|
59bc52f527 | ||
|
|
d37e1d3dc3 | ||
|
|
34d9122b45 | ||
|
|
1f7218640c | ||
|
|
0078fa66a3 | ||
|
|
f4f9d6fd3f | ||
|
|
69c453b274 | ||
|
|
d54ee6c4dc | ||
|
|
b48f0314e7 | ||
|
|
e1b24c1d5c | ||
|
|
1c9b7ef918 | ||
|
|
8f70e79240 | ||
|
|
eabfd9d9f6 | ||
|
|
6a101e0da1 | ||
|
|
426c1044b6 | ||
|
|
875924a7f3 | ||
|
|
e835c5cee9 | ||
|
|
db54f77b73 | ||
|
|
67eb5e5734 | ||
|
|
758a5538c5 | ||
|
|
3ae112acff | ||
|
|
9454f76c0c | ||
|
|
944263f44b | ||
|
|
a1944fceab | ||
|
|
8d5c9fde3b | ||
|
|
d8688bbd93 | ||
|
|
306cd65353 | ||
|
|
8a85173150 | ||
|
|
b4a02ebc3f | ||
|
|
1f57577c54 | ||
|
|
ec0b7daca2 | ||
|
|
bdc0480e62 | ||
|
|
c145074daf | ||
|
|
f6a09bcbea | ||
|
|
d4a2fc6464 | ||
|
|
be50daba42 | ||
|
|
7b334ff2b7 | ||
|
|
5bbfddf70d | ||
|
|
358467a506 |
202
.agents/skills/pr-report/SKILL.md
Normal file
202
.agents/skills/pr-report/SKILL.md
Normal file
@@ -0,0 +1,202 @@
|
||||
---
|
||||
name: pr-report
|
||||
description: >
|
||||
Review a pull request or contribution deeply, explain it tutorial-style for a
|
||||
maintainer, and produce a polished report artifact such as HTML or Markdown.
|
||||
Use when asked to analyze a PR, explain a contributor's design decisions,
|
||||
compare it with similar systems, or prepare a merge recommendation.
|
||||
---
|
||||
|
||||
# PR Report Skill
|
||||
|
||||
Produce a maintainer-grade review of a PR, branch, or large contribution.
|
||||
|
||||
Default posture:
|
||||
|
||||
- understand the change before judging it
|
||||
- explain the system as built, not just the diff
|
||||
- separate architectural problems from product-scope objections
|
||||
- make a concrete recommendation, not a vague impression
|
||||
|
||||
## When to Use
|
||||
|
||||
Use this skill when the user asks for things like:
|
||||
|
||||
- "review this PR deeply"
|
||||
- "explain this contribution to me"
|
||||
- "make me a report or webpage for this PR"
|
||||
- "compare this design to similar systems"
|
||||
- "should I merge this?"
|
||||
|
||||
## Outputs
|
||||
|
||||
Common outputs:
|
||||
|
||||
- standalone HTML report in `tmp/reports/...`
|
||||
- Markdown report in `report/` or another requested folder
|
||||
- short maintainer summary in chat
|
||||
|
||||
If the user asks for a webpage, build a polished standalone HTML artifact with
|
||||
clear sections and readable visual hierarchy.
|
||||
|
||||
Resources bundled with this skill:
|
||||
|
||||
- `references/style-guide.md` for visual direction and report presentation rules
|
||||
- `assets/html-report-starter.html` for a reusable standalone HTML/CSS starter
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Acquire and frame the target
|
||||
|
||||
Work from local code when possible, not just the GitHub PR page.
|
||||
|
||||
Gather:
|
||||
|
||||
- target branch or worktree
|
||||
- diff size and changed subsystems
|
||||
- relevant repo docs, specs, and invariants
|
||||
- contributor intent if it is documented in PR text or design docs
|
||||
|
||||
Start by answering: what is this change *trying* to become?
|
||||
|
||||
### 2. Build a mental model of the system
|
||||
|
||||
Do not stop at file-by-file notes. Reconstruct the design:
|
||||
|
||||
- what new runtime or contract exists
|
||||
- which layers changed: db, shared types, server, UI, CLI, docs
|
||||
- lifecycle: install, startup, execution, UI, failure, disablement
|
||||
- trust boundary: what code runs where, under what authority
|
||||
|
||||
For large contributions, include a tutorial-style section that teaches the
|
||||
system from first principles.
|
||||
|
||||
### 3. Review like a maintainer
|
||||
|
||||
Findings come first. Order by severity.
|
||||
|
||||
Prioritize:
|
||||
|
||||
- behavioral regressions
|
||||
- trust or security gaps
|
||||
- misleading abstractions
|
||||
- lifecycle and operational risks
|
||||
- coupling that will be hard to unwind
|
||||
- missing tests or unverifiable claims
|
||||
|
||||
Always cite concrete file references when possible.
|
||||
|
||||
### 4. Distinguish the objection type
|
||||
|
||||
Be explicit about whether a concern is:
|
||||
|
||||
- product direction
|
||||
- architecture
|
||||
- implementation quality
|
||||
- rollout strategy
|
||||
- documentation honesty
|
||||
|
||||
Do not hide an architectural objection inside a scope objection.
|
||||
|
||||
### 5. Compare to external precedents when needed
|
||||
|
||||
If the contribution introduces a framework or platform concept, compare it to
|
||||
similar open-source systems.
|
||||
|
||||
When comparing:
|
||||
|
||||
- prefer official docs or source
|
||||
- focus on extension boundaries, context passing, trust model, and UI ownership
|
||||
- extract lessons, not just similarities
|
||||
|
||||
Good comparison questions:
|
||||
|
||||
- Who owns lifecycle?
|
||||
- Who owns UI composition?
|
||||
- Is context explicit or ambient?
|
||||
- Are plugins trusted code or sandboxed code?
|
||||
- Are extension points named and typed?
|
||||
|
||||
### 6. Make the recommendation actionable
|
||||
|
||||
Do not stop at "merge" or "do not merge."
|
||||
|
||||
Choose one:
|
||||
|
||||
- merge as-is
|
||||
- merge after specific redesign
|
||||
- salvage specific pieces
|
||||
- keep as design research
|
||||
|
||||
If rejecting or narrowing, say what should be kept.
|
||||
|
||||
Useful recommendation buckets:
|
||||
|
||||
- keep the protocol/type model
|
||||
- redesign the UI boundary
|
||||
- narrow the initial surface area
|
||||
- defer third-party execution
|
||||
- ship a host-owned extension-point model first
|
||||
|
||||
### 7. Build the artifact
|
||||
|
||||
Suggested report structure:
|
||||
|
||||
1. Executive summary
|
||||
2. What the PR actually adds
|
||||
3. Tutorial: how the system works
|
||||
4. Strengths
|
||||
5. Main findings
|
||||
6. Comparisons
|
||||
7. Recommendation
|
||||
|
||||
For HTML reports:
|
||||
|
||||
- use intentional typography and color
|
||||
- make navigation easy for long reports
|
||||
- favor strong section headings and small reference labels
|
||||
- avoid generic dashboard styling
|
||||
|
||||
Before building from scratch, read `references/style-guide.md`.
|
||||
If a fast polished starter is helpful, begin from `assets/html-report-starter.html`
|
||||
and replace the placeholder content with the actual report.
|
||||
|
||||
### 8. Verify before handoff
|
||||
|
||||
Check:
|
||||
|
||||
- artifact path exists
|
||||
- findings still match the actual code
|
||||
- any requested forbidden strings are absent from generated output
|
||||
- if tests were not run, say so explicitly
|
||||
|
||||
## Review Heuristics
|
||||
|
||||
### Plugin and platform work
|
||||
|
||||
Watch closely for:
|
||||
|
||||
- docs claiming sandboxing while runtime executes trusted host processes
|
||||
- module-global state used to smuggle React context
|
||||
- hidden dependence on render order
|
||||
- plugins reaching into host internals instead of using explicit APIs
|
||||
- "capabilities" that are really policy labels on top of fully trusted code
|
||||
|
||||
### Good signs
|
||||
|
||||
- typed contracts shared across layers
|
||||
- explicit extension points
|
||||
- host-owned lifecycle
|
||||
- honest trust model
|
||||
- narrow first rollout with room to grow
|
||||
|
||||
## Final Response
|
||||
|
||||
In chat, summarize:
|
||||
|
||||
- where the report is
|
||||
- your overall call
|
||||
- the top one or two reasons
|
||||
- whether verification or tests were skipped
|
||||
|
||||
Keep the chat summary shorter than the report itself.
|
||||
426
.agents/skills/pr-report/assets/html-report-starter.html
Normal file
426
.agents/skills/pr-report/assets/html-report-starter.html
Normal file
@@ -0,0 +1,426 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PR Report Starter</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Newsreader:opsz,wght@6..72,500;6..72,700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4efe5;
|
||||
--paper: rgba(255, 251, 244, 0.88);
|
||||
--paper-strong: #fffaf1;
|
||||
--ink: #1f1b17;
|
||||
--muted: #6a6257;
|
||||
--line: rgba(31, 27, 23, 0.12);
|
||||
--accent: #9c4729;
|
||||
--accent-soft: rgba(156, 71, 41, 0.1);
|
||||
--good: #2f6a42;
|
||||
--warn: #946200;
|
||||
--bad: #8c2f25;
|
||||
--shadow: 0 22px 60px rgba(52, 37, 19, 0.1);
|
||||
--radius: 20px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: "IBM Plex Sans", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(156, 71, 41, 0.12), transparent 34rem),
|
||||
radial-gradient(circle at top right, rgba(47, 106, 66, 0.08), transparent 28rem),
|
||||
linear-gradient(180deg, #efe6d6 0%, var(--bg) 48%, #ece5d8 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1360px, calc(100vw - 32px));
|
||||
margin: 24px auto;
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--paper);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
align-self: start;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav h1,
|
||||
.hero h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: "Newsreader", serif;
|
||||
line-height: 0.96;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav h1 {
|
||||
font-size: 2rem;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.nav p {
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nav ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 18px 0 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
display: block;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
border-color: var(--line);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.meta-block {
|
||||
margin-top: 20px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 26px 28px 28px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 28px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto -3rem -6rem auto;
|
||||
width: 18rem;
|
||||
height: 18rem;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(156, 71, 41, 0.14), transparent 68%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(2.6rem, 5vw, 4.6rem);
|
||||
max-width: 12ch;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin-top: 16px;
|
||||
max-width: 70ch;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.65;
|
||||
color: #2b2723;
|
||||
}
|
||||
|
||||
.hero-grid,
|
||||
.card-grid,
|
||||
.two-col {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
margin-top: 24px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.two-col {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric,
|
||||
.card,
|
||||
.finding {
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.metric .label {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.metric .value {
|
||||
margin-top: 8px;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.badge-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 18px 0 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.badge.good {
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
.badge.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.badge.bad {
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.quote {
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: 14px;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.severity {
|
||||
display: inline-flex;
|
||||
margin-bottom: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.severity.high {
|
||||
background: rgba(140, 47, 37, 0.12);
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.severity.medium {
|
||||
background: rgba(148, 98, 0, 0.12);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.severity.low {
|
||||
background: rgba(47, 106, 66, 0.12);
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
.ref {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.hero-grid,
|
||||
.card-grid,
|
||||
.two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="panel nav">
|
||||
<div class="eyebrow">Maintainer Report</div>
|
||||
<h1>Report Title</h1>
|
||||
<p>Replace this with a concise description of what the report covers.</p>
|
||||
<ul>
|
||||
<li><a href="#summary">Summary</a></li>
|
||||
<li><a href="#tutorial">Tutorial</a></li>
|
||||
<li><a href="#findings">Findings</a></li>
|
||||
<li><a href="#recommendation">Recommendation</a></li>
|
||||
</ul>
|
||||
<div class="meta-block">
|
||||
Replace with project metadata, review date, or scope notes.
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<section class="panel hero" id="summary">
|
||||
<div class="eyebrow">Executive Summary</div>
|
||||
<h1>Use the hero for the clearest one-line judgment.</h1>
|
||||
<p class="lede">
|
||||
Replace this with the short explanation of what the contribution does, why it matters,
|
||||
and what the core maintainer question is.
|
||||
</p>
|
||||
<div class="badge-row">
|
||||
<span class="badge good">Strength</span>
|
||||
<span class="badge warn">Tradeoff</span>
|
||||
<span class="badge bad">Risk</span>
|
||||
</div>
|
||||
<div class="hero-grid">
|
||||
<div class="metric">
|
||||
<div class="label">Overall Call</div>
|
||||
<div class="value">Placeholder</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">Main Concern</div>
|
||||
<div class="value">Placeholder</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">Best Part</div>
|
||||
<div class="value">Placeholder</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">Weakest Part</div>
|
||||
<div class="value">Placeholder</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quote">
|
||||
Use this block for the thesis, a sharp takeaway, or a key cited point.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="tutorial">
|
||||
<h2>Tutorial Section</h2>
|
||||
<div class="two-col">
|
||||
<div class="card">
|
||||
<h3>Concept Card</h3>
|
||||
<p>Use cards for mental models, subsystems, or comparison slices.</p>
|
||||
<div class="ref">path/to/file.ts:10</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Second Card</h3>
|
||||
<p>Keep cards fairly dense. This template is about style, not fixed structure.</p>
|
||||
<div class="ref">path/to/file.ts:20</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="findings">
|
||||
<h2>Findings</h2>
|
||||
<article class="finding">
|
||||
<div class="severity high">High</div>
|
||||
<h3>Finding Title</h3>
|
||||
<p>Use findings for the sharpest judgment calls and risks.</p>
|
||||
<div class="ref">path/to/file.ts:30</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="recommendation">
|
||||
<h2>Recommendation</h2>
|
||||
<div class="card-grid">
|
||||
<div class="card">
|
||||
<h3>Path Forward</h3>
|
||||
<p>Use this area for merge guidance, salvage plan, or rollout advice.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>What To Keep</h3>
|
||||
<p>Call out the parts worth preserving even if the whole proposal should not land.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
149
.agents/skills/pr-report/references/style-guide.md
Normal file
149
.agents/skills/pr-report/references/style-guide.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# PR Report Style Guide
|
||||
|
||||
Use this guide when the user wants a report artifact, especially a webpage.
|
||||
|
||||
## Goal
|
||||
|
||||
Make the report feel like an editorial review, not an internal admin dashboard.
|
||||
The page should make a long technical argument easy to scan without looking
|
||||
generic or overdesigned.
|
||||
|
||||
## Visual Direction
|
||||
|
||||
Preferred tone:
|
||||
|
||||
- editorial
|
||||
- warm
|
||||
- serious
|
||||
- high-contrast
|
||||
- handcrafted, not corporate SaaS
|
||||
|
||||
Avoid:
|
||||
|
||||
- default app-shell layouts
|
||||
- purple gradients on white
|
||||
- generic card dashboards
|
||||
- cramped pages with weak hierarchy
|
||||
- novelty fonts that hurt readability
|
||||
|
||||
## Typography
|
||||
|
||||
Recommended pattern:
|
||||
|
||||
- one expressive serif or display face for major headings
|
||||
- one sturdy sans-serif for body copy and UI labels
|
||||
|
||||
Good combinations:
|
||||
|
||||
- Newsreader + IBM Plex Sans
|
||||
- Source Serif 4 + Instrument Sans
|
||||
- Fraunces + Public Sans
|
||||
- Libre Baskerville + Work Sans
|
||||
|
||||
Rules:
|
||||
|
||||
- headings should feel deliberate and large
|
||||
- body copy should stay comfortable for long reading
|
||||
- reference labels and badges should use smaller dense sans text
|
||||
|
||||
## Layout
|
||||
|
||||
Recommended structure:
|
||||
|
||||
- a sticky side or top navigation for long reports
|
||||
- one strong hero summary at the top
|
||||
- panel or paper-like sections for each major topic
|
||||
- multi-column card grids for comparisons and strengths
|
||||
- single-column body text for findings and recommendations
|
||||
|
||||
Use generous spacing. Long-form technical reports need breathing room.
|
||||
|
||||
## Color
|
||||
|
||||
Prefer muted paper-like backgrounds with one warm accent and one cool counterweight.
|
||||
|
||||
Suggested token categories:
|
||||
|
||||
- `--bg`
|
||||
- `--paper`
|
||||
- `--ink`
|
||||
- `--muted`
|
||||
- `--line`
|
||||
- `--accent`
|
||||
- `--good`
|
||||
- `--warn`
|
||||
- `--bad`
|
||||
|
||||
The accent should highlight navigation, badges, and important labels. Do not
|
||||
let accent colors dominate body text.
|
||||
|
||||
## Useful UI Elements
|
||||
|
||||
Include small reusable styles for:
|
||||
|
||||
- summary metrics
|
||||
- badges
|
||||
- quotes or callouts
|
||||
- finding cards
|
||||
- severity labels
|
||||
- reference labels
|
||||
- comparison cards
|
||||
- responsive two-column sections
|
||||
|
||||
## Motion
|
||||
|
||||
Keep motion restrained.
|
||||
|
||||
Good:
|
||||
|
||||
- soft fade/slide-in on first load
|
||||
- hover response on nav items or cards
|
||||
|
||||
Bad:
|
||||
|
||||
- constant animation
|
||||
- floating blobs
|
||||
- decorative motion with no reading benefit
|
||||
|
||||
## Content Presentation
|
||||
|
||||
Even when the user wants design polish, clarity stays primary.
|
||||
|
||||
Good structure for long reports:
|
||||
|
||||
1. executive summary
|
||||
2. what changed
|
||||
3. tutorial explanation
|
||||
4. strengths
|
||||
5. findings
|
||||
6. comparisons
|
||||
7. recommendation
|
||||
|
||||
The exact headings can change. The important thing is to separate explanation
|
||||
from judgment.
|
||||
|
||||
## References
|
||||
|
||||
Reference labels should be visually quiet but easy to spot.
|
||||
|
||||
Good pattern:
|
||||
|
||||
- small muted text
|
||||
- monospace or compact sans
|
||||
- keep them close to the paragraph they support
|
||||
|
||||
## Starter Usage
|
||||
|
||||
If you need a fast polished base, start from:
|
||||
|
||||
- `assets/html-report-starter.html`
|
||||
|
||||
Customize:
|
||||
|
||||
- fonts
|
||||
- color tokens
|
||||
- hero copy
|
||||
- section ordering
|
||||
- card density
|
||||
|
||||
Do not preserve the placeholder sections if they do not fit the actual report.
|
||||
178
.agents/skills/release-changelog/SKILL.md
Normal file
178
.agents/skills/release-changelog/SKILL.md
Normal file
@@ -0,0 +1,178 @@
|
||||
---
|
||||
name: release-changelog
|
||||
description: >
|
||||
Generate the stable Paperclip release changelog at releases/v{version}.md by
|
||||
reading commits, changesets, and merged PR context since the last stable tag.
|
||||
---
|
||||
|
||||
# Release Changelog Skill
|
||||
|
||||
Generate the user-facing changelog for the **stable** Paperclip release.
|
||||
|
||||
Output:
|
||||
|
||||
- `releases/v{version}.md`
|
||||
|
||||
Important rule:
|
||||
|
||||
- even if there are canary releases such as `1.2.3-canary.0`, the changelog file stays `releases/v1.2.3.md`
|
||||
|
||||
## Step 0 — Idempotency Check
|
||||
|
||||
Before generating anything, check whether the file already exists:
|
||||
|
||||
```bash
|
||||
ls releases/v{version}.md 2>/dev/null
|
||||
```
|
||||
|
||||
If it exists:
|
||||
|
||||
1. read it first
|
||||
2. present it to the reviewer
|
||||
3. ask whether to keep it, regenerate it, or update specific sections
|
||||
4. never overwrite it silently
|
||||
|
||||
## Step 1 — Determine the Stable Range
|
||||
|
||||
Find the last stable tag:
|
||||
|
||||
```bash
|
||||
git tag --list 'v*' --sort=-version:refname | head -1
|
||||
git log v{last}..HEAD --oneline --no-merges
|
||||
```
|
||||
|
||||
The planned stable version comes from one of:
|
||||
|
||||
- an explicit maintainer request
|
||||
- the chosen bump type applied to the last stable tag
|
||||
- the release plan already agreed in `doc/RELEASING.md`
|
||||
|
||||
Do not derive the changelog version from a canary tag or prerelease suffix.
|
||||
|
||||
## Step 2 — Gather the Raw Inputs
|
||||
|
||||
Collect release data from:
|
||||
|
||||
1. git commits since the last stable tag
|
||||
2. `.changeset/*.md` files
|
||||
3. merged PRs via `gh` when available
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
git log v{last}..HEAD --oneline --no-merges
|
||||
git log v{last}..HEAD --format="%H %s" --no-merges
|
||||
ls .changeset/*.md | grep -v README.md
|
||||
gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels
|
||||
```
|
||||
|
||||
## Step 3 — Detect Breaking Changes
|
||||
|
||||
Look for:
|
||||
|
||||
- destructive migrations
|
||||
- removed or changed API fields/endpoints
|
||||
- renamed or removed config keys
|
||||
- `major` changesets
|
||||
- `BREAKING:` or `BREAKING CHANGE:` commit signals
|
||||
|
||||
Key commands:
|
||||
|
||||
```bash
|
||||
git diff --name-only v{last}..HEAD -- packages/db/src/migrations/
|
||||
git diff v{last}..HEAD -- packages/db/src/schema/
|
||||
git diff v{last}..HEAD -- server/src/routes/ server/src/api/
|
||||
git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
|
||||
```
|
||||
|
||||
If the requested bump is lower than the minimum required bump, flag that before the release proceeds.
|
||||
|
||||
## Step 4 — Categorize for Users
|
||||
|
||||
Use these stable changelog sections:
|
||||
|
||||
- `Breaking Changes`
|
||||
- `Highlights`
|
||||
- `Improvements`
|
||||
- `Fixes`
|
||||
- `Upgrade Guide` when needed
|
||||
|
||||
Exclude purely internal refactors, CI changes, and docs-only work unless they materially affect users.
|
||||
|
||||
Guidelines:
|
||||
|
||||
- group related commits into one user-facing entry
|
||||
- write from the user perspective
|
||||
- keep highlights short and concrete
|
||||
- spell out upgrade actions for breaking changes
|
||||
|
||||
### Inline PR and contributor attribution
|
||||
|
||||
When a bullet item clearly maps to a merged pull request, add inline attribution at the
|
||||
end of the entry in this format:
|
||||
|
||||
```
|
||||
- **Feature name** — Description. ([#123](https://github.com/paperclipai/paperclip/pull/123), @contributor1, @contributor2)
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Only add a PR link when you can confidently trace the bullet to a specific merged PR.
|
||||
Use merge commit messages (`Merge pull request #N from user/branch`) to map PRs.
|
||||
- List the contributor(s) who authored the PR. Use GitHub usernames, not real names or emails.
|
||||
- If multiple PRs contributed to a single bullet, list them all: `([#10](url), [#12](url), @user1, @user2)`.
|
||||
- If you cannot determine the PR number or contributor with confidence, omit the attribution
|
||||
parenthetical — do not guess.
|
||||
- Core maintainer commits that don't have an external PR can omit the parenthetical.
|
||||
|
||||
## Step 5 — Write the File
|
||||
|
||||
Template:
|
||||
|
||||
```markdown
|
||||
# v{version}
|
||||
|
||||
> Released: {YYYY-MM-DD}
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
## Highlights
|
||||
|
||||
## Improvements
|
||||
|
||||
## Fixes
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
## Contributors
|
||||
|
||||
Thank you to everyone who contributed to this release!
|
||||
|
||||
@username1, @username2, @username3
|
||||
```
|
||||
|
||||
Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist.
|
||||
|
||||
The `Contributors` section should always be included. List every person who authored
|
||||
commits in the release range, @-mentioning them by their **GitHub username** (not their
|
||||
real name or email). To find GitHub usernames:
|
||||
|
||||
1. Extract usernames from merge commit messages: `git log v{last}..HEAD --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
|
||||
2. For noreply emails like `user@users.noreply.github.com`, the username is the part before `@`.
|
||||
3. For contributors whose username is ambiguous, check `gh api users/{guess}` or the PR page.
|
||||
|
||||
**Never expose contributor email addresses.** Use `@username` only.
|
||||
|
||||
Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list. List contributors
|
||||
in alphabetical order by GitHub username (case-insensitive).
|
||||
|
||||
## Step 6 — Review Before Release
|
||||
|
||||
Before handing it off:
|
||||
|
||||
1. confirm the heading is the stable version only
|
||||
2. confirm there is no `-canary` language in the title or filename
|
||||
3. confirm any breaking changes have an upgrade path
|
||||
4. present the draft for human sign-off
|
||||
|
||||
This skill never publishes anything. It only prepares the stable changelog artifact.
|
||||
261
.agents/skills/release/SKILL.md
Normal file
261
.agents/skills/release/SKILL.md
Normal file
@@ -0,0 +1,261 @@
|
||||
---
|
||||
name: release
|
||||
description: >
|
||||
Coordinate a full Paperclip release across engineering verification, npm,
|
||||
GitHub, website publishing, and announcement follow-up. Use when leadership
|
||||
asks to ship a release, not merely to discuss version bumps.
|
||||
---
|
||||
|
||||
# Release Coordination Skill
|
||||
|
||||
Run the full Paperclip release as a maintainer workflow, not just an npm publish.
|
||||
|
||||
This skill coordinates:
|
||||
|
||||
- stable changelog drafting via `release-changelog`
|
||||
- release-train setup via `scripts/release-start.sh`
|
||||
- prerelease canary publishing via `scripts/release.sh --canary`
|
||||
- Docker smoke testing via `scripts/docker-onboard-smoke.sh`
|
||||
- stable publishing via `scripts/release.sh`
|
||||
- pushing the stable branch commit and tag
|
||||
- GitHub Release creation via `scripts/create-github-release.sh`
|
||||
- website / announcement follow-up tasks
|
||||
|
||||
## Trigger
|
||||
|
||||
Use this skill when leadership asks for:
|
||||
|
||||
- "do a release"
|
||||
- "ship the next patch/minor/major"
|
||||
- "release vX.Y.Z"
|
||||
|
||||
## Preconditions
|
||||
|
||||
Before proceeding, verify all of the following:
|
||||
|
||||
1. `.agents/skills/release-changelog/SKILL.md` exists and is usable.
|
||||
2. The repo working tree is clean, including untracked files.
|
||||
3. There are commits since the last stable tag.
|
||||
4. The release SHA has passed the verification gate or is about to.
|
||||
5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the release branch is cut.
|
||||
6. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing.
|
||||
7. If running through Paperclip, you have issue context for status updates and follow-up task creation.
|
||||
|
||||
If any precondition fails, stop and report the blocker.
|
||||
|
||||
## Inputs
|
||||
|
||||
Collect these inputs up front:
|
||||
|
||||
- requested bump: `patch`, `minor`, or `major`
|
||||
- whether this run is a dry run or live release
|
||||
- whether the release is being run locally or from GitHub Actions
|
||||
- release issue / company context for website and announcement follow-up
|
||||
|
||||
## Step 0 — Release Model
|
||||
|
||||
Paperclip now uses this release model:
|
||||
|
||||
1. Start or resume `release/X.Y.Z`
|
||||
2. Draft the **stable** changelog as `releases/vX.Y.Z.md`
|
||||
3. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0`
|
||||
4. Smoke test the canary via Docker
|
||||
5. Publish the stable version `X.Y.Z`
|
||||
6. Push the stable branch commit and tag
|
||||
7. Create the GitHub Release
|
||||
8. Merge `release/X.Y.Z` back to `master` without squash or rebase
|
||||
9. Complete website and announcement surfaces
|
||||
|
||||
Critical consequence:
|
||||
|
||||
- Canaries do **not** use promote-by-dist-tag anymore.
|
||||
- The changelog remains stable-only. Do not create `releases/vX.Y.Z-canary.N.md`.
|
||||
|
||||
## Step 1 — Decide the Stable Version
|
||||
|
||||
Start the release train first:
|
||||
|
||||
```bash
|
||||
./scripts/release-start.sh {patch|minor|major}
|
||||
```
|
||||
|
||||
Then run release preflight:
|
||||
|
||||
```bash
|
||||
./scripts/release-preflight.sh canary {patch|minor|major}
|
||||
# or
|
||||
./scripts/release-preflight.sh stable {patch|minor|major}
|
||||
```
|
||||
|
||||
Then use the last stable tag as the base:
|
||||
|
||||
```bash
|
||||
LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1)
|
||||
git log "${LAST_TAG}..HEAD" --oneline --no-merges
|
||||
git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/
|
||||
git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/
|
||||
git log "${LAST_TAG}..HEAD" --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
|
||||
```
|
||||
|
||||
Bump policy:
|
||||
|
||||
- destructive migrations, removed APIs, breaking config changes -> `major`
|
||||
- additive migrations or clearly user-visible features -> at least `minor`
|
||||
- fixes only -> `patch`
|
||||
|
||||
If the requested bump is too low, escalate it and explain why.
|
||||
|
||||
## Step 2 — Draft the Stable Changelog
|
||||
|
||||
Invoke `release-changelog` and generate:
|
||||
|
||||
- `releases/vX.Y.Z.md`
|
||||
|
||||
Rules:
|
||||
|
||||
- review the draft with a human before publish
|
||||
- preserve manual edits if the file already exists
|
||||
- keep the heading and filename stable-only, for example `v1.2.3`
|
||||
- do not create a separate canary changelog file
|
||||
|
||||
## Step 3 — Verify the Release SHA
|
||||
|
||||
Run the standard gate:
|
||||
|
||||
```bash
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
|
||||
If the release will be run through GitHub Actions, the workflow can rerun this gate. Still report whether the local tree currently passes.
|
||||
|
||||
The GitHub Actions release workflow installs with `pnpm install --frozen-lockfile`. Treat that as a release invariant, not a nuisance: if manifests changed and the lockfile refresh PR has not landed yet, stop and wait for `master` to contain the committed lockfile before shipping.
|
||||
|
||||
## Step 4 — Publish a Canary
|
||||
|
||||
Run from the `release/X.Y.Z` branch:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh {patch|minor|major} --canary --dry-run
|
||||
./scripts/release.sh {patch|minor|major} --canary
|
||||
```
|
||||
|
||||
What this means:
|
||||
|
||||
- npm receives `X.Y.Z-canary.N` under dist-tag `canary`
|
||||
- `latest` remains unchanged
|
||||
- no git tag is created
|
||||
- the script cleans the working tree afterward
|
||||
|
||||
Guard:
|
||||
|
||||
- if the current stable is `0.2.7`, the next patch canary is `0.2.8-canary.0`
|
||||
- the tooling must never publish `0.2.7-canary.N` after `0.2.7` is already stable
|
||||
|
||||
After publish, verify:
|
||||
|
||||
```bash
|
||||
npm view paperclipai@canary version
|
||||
```
|
||||
|
||||
The user install path is:
|
||||
|
||||
```bash
|
||||
npx paperclipai@canary onboard
|
||||
```
|
||||
|
||||
## Step 5 — Smoke Test the Canary
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
1. install succeeds
|
||||
2. onboarding completes
|
||||
3. server boots
|
||||
4. UI loads
|
||||
5. basic company/dashboard flow works
|
||||
|
||||
If smoke testing fails:
|
||||
|
||||
- stop the stable release
|
||||
- fix the issue
|
||||
- publish another canary
|
||||
- repeat the smoke test
|
||||
|
||||
Each retry should create a higher canary ordinal, while the stable target version can stay the same.
|
||||
|
||||
## Step 6 — Publish Stable
|
||||
|
||||
Once the SHA is vetted, run:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh {patch|minor|major} --dry-run
|
||||
./scripts/release.sh {patch|minor|major}
|
||||
```
|
||||
|
||||
Stable publish does this:
|
||||
|
||||
- publishes `X.Y.Z` to npm under `latest`
|
||||
- creates the local release commit
|
||||
- creates the local git tag `vX.Y.Z`
|
||||
|
||||
Stable publish does **not** push the release for you.
|
||||
|
||||
## Step 7 — Push and Create GitHub Release
|
||||
|
||||
After stable publish succeeds:
|
||||
|
||||
```bash
|
||||
git push public-gh HEAD --follow-tags
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
```
|
||||
|
||||
Use the stable changelog file as the GitHub Release notes source.
|
||||
|
||||
Then open the PR from `release/X.Y.Z` back to `master` and merge without squash or rebase.
|
||||
|
||||
## Step 8 — Finish the Other Surfaces
|
||||
|
||||
Create or verify follow-up work for:
|
||||
|
||||
- website changelog publishing
|
||||
- launch post / social announcement
|
||||
- any release summary in Paperclip issue context
|
||||
|
||||
These should reference the stable release, not the canary.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
If the canary is bad:
|
||||
|
||||
- publish another canary, do not ship stable
|
||||
|
||||
If stable npm publish succeeds but push or GitHub release creation fails:
|
||||
|
||||
- fix the git/GitHub issue immediately from the same checkout
|
||||
- do not republish the same version
|
||||
|
||||
If `latest` is bad after stable publish:
|
||||
|
||||
```bash
|
||||
./scripts/rollback-latest.sh <last-good-version>
|
||||
```
|
||||
|
||||
Then fix forward with a new patch release.
|
||||
|
||||
## Output
|
||||
|
||||
When the skill completes, provide:
|
||||
|
||||
- stable version and, if relevant, the final canary version tested
|
||||
- verification status
|
||||
- npm status
|
||||
- git tag / GitHub Release status
|
||||
- website / announcement follow-up status
|
||||
- rollback recommendation if anything is still partially complete
|
||||
44
.github/workflows/e2e.yml
vendored
Normal file
44
.github/workflows/e2e.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_llm:
|
||||
description: "Skip LLM-dependent assertions (default: true)"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run e2e tests
|
||||
run: pnpm run test:e2e
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
tests/e2e/playwright-report/
|
||||
tests/e2e/test-results/
|
||||
retention-days: 14
|
||||
70
.github/workflows/pr-policy.yml
vendored
Normal file
70
.github/workflows/pr-policy.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: PR Policy
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: pr-policy-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
policy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Enforce lockfile policy when manifests change
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
changed="$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[].filename')"
|
||||
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
|
||||
|
||||
manifest_changed=false
|
||||
lockfile_changed=false
|
||||
|
||||
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
|
||||
manifest_changed=true
|
||||
fi
|
||||
|
||||
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
|
||||
lockfile_changed=true
|
||||
fi
|
||||
|
||||
if [ "$lockfile_changed" = true ] && [ "$manifest_changed" != true ]; then
|
||||
echo "pnpm-lock.yaml changed without a dependency manifest change." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$manifest_changed" = true ]; then
|
||||
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
if ! git diff --quiet -- pnpm-lock.yaml; then
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
|
||||
echo "pnpm-lock.yaml is stale for this PR. Wait for the Refresh Lockfile workflow to push the bot commit, then rerun checks." >&2
|
||||
else
|
||||
echo "pnpm-lock.yaml is stale for this fork PR. Run pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile and commit pnpm-lock.yaml." >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
42
.github/workflows/pr-verify.yml
vendored
Normal file
42
.github/workflows/pr-verify.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: PR Verify
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: pr-verify-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
111
.github/workflows/refresh-lockfile-pr.yml
vendored
Normal file
111
.github/workflows/refresh-lockfile-pr.yml
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
name: Refresh Lockfile
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
concurrency:
|
||||
group: refresh-lockfile-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Detect dependency manifest changes
|
||||
id: changes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
changed="$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[].filename')"
|
||||
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
|
||||
|
||||
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
|
||||
echo "manifest_changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "manifest_changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
|
||||
echo "same_repo=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "same_repo=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout pull request head
|
||||
if: steps.changes.outputs.manifest_changed == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.changes.outputs.manifest_changed == 'true'
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.changes.outputs.manifest_changed == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Refresh pnpm lockfile
|
||||
if: steps.changes.outputs.manifest_changed == 'true'
|
||||
run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
- name: Fail on unexpected file changes
|
||||
if: steps.changes.outputs.manifest_changed == 'true'
|
||||
run: |
|
||||
changed="$(git status --porcelain)"
|
||||
if [ -z "$changed" ]; then
|
||||
echo "Lockfile is already up to date."
|
||||
exit 0
|
||||
fi
|
||||
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
|
||||
echo "Unexpected files changed during lockfile refresh:"
|
||||
echo "$changed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Commit refreshed lockfile to same-repo PR branch
|
||||
if: steps.changes.outputs.manifest_changed == 'true' && steps.changes.outputs.same_repo == 'true'
|
||||
run: |
|
||||
if git diff --quiet -- pnpm-lock.yaml; then
|
||||
echo "Lockfile unchanged, nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "lockfile-bot"
|
||||
git config user.email "lockfile-bot@users.noreply.github.com"
|
||||
git add pnpm-lock.yaml
|
||||
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
|
||||
git push origin "HEAD:${{ github.event.pull_request.head.ref }}"
|
||||
|
||||
- name: Fail fork PRs that need a lockfile refresh
|
||||
if: steps.changes.outputs.manifest_changed == 'true' && steps.changes.outputs.same_repo != 'true'
|
||||
run: |
|
||||
if git diff --quiet -- pnpm-lock.yaml; then
|
||||
echo "Lockfile unchanged, nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "This fork PR changes dependency manifests and requires a refreshed pnpm-lock.yaml." >&2
|
||||
echo "Run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile" >&2
|
||||
echo "Then commit pnpm-lock.yaml to the PR branch." >&2
|
||||
exit 1
|
||||
132
.github/workflows/release.yml
vendored
Normal file
132
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Release channel
|
||||
required: true
|
||||
type: choice
|
||||
default: canary
|
||||
options:
|
||||
- canary
|
||||
- stable
|
||||
bump:
|
||||
description: Semantic version bump
|
||||
required: true
|
||||
type: choice
|
||||
default: patch
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
dry_run:
|
||||
description: Preview the release without publishing
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
if: startsWith(github.ref, 'refs/heads/release/')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
publish:
|
||||
if: startsWith(github.ref, 'refs/heads/release/')
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Run release script
|
||||
env:
|
||||
GITHUB_ACTIONS: "true"
|
||||
run: |
|
||||
args=("${{ inputs.bump }}")
|
||||
if [ "${{ inputs.channel }}" = "canary" ]; then
|
||||
args+=("--canary")
|
||||
fi
|
||||
if [ "${{ inputs.dry_run }}" = "true" ]; then
|
||||
args+=("--dry-run")
|
||||
fi
|
||||
./scripts/release.sh "${args[@]}"
|
||||
|
||||
- name: Push stable release branch commit and tag
|
||||
if: inputs.channel == 'stable' && !inputs.dry_run
|
||||
run: git push origin "HEAD:${GITHUB_REF_NAME}" --follow-tags
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: inputs.channel == 'stable' && !inputs.dry_run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')"
|
||||
if [ -z "$version" ]; then
|
||||
echo "Error: no v* tag points at HEAD after stable release." >&2
|
||||
exit 1
|
||||
fi
|
||||
./scripts/create-github-release.sh "$version"
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -36,4 +36,8 @@ tmp/
|
||||
*.tmp
|
||||
.vscode/
|
||||
.claude/settings.local.json
|
||||
.paperclip-local/
|
||||
.paperclip-local/
|
||||
|
||||
# Playwright
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
41
CONTRIBUTING.md
Normal file
41
CONTRIBUTING.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Contributing Guide
|
||||
|
||||
Thanks for wanting to contribute!
|
||||
|
||||
We really appreciate both small fixes and thoughtful larger changes.
|
||||
|
||||
## Two Paths to Get Your Pull Request Accepted
|
||||
|
||||
### Path 1: Small, Focused Changes (Fastest way to get merged)
|
||||
- Pick **one** clear thing to fix/improve
|
||||
- Touch the **smallest possible number of files**
|
||||
- Make sure the change is very targeted and easy to review
|
||||
- All automated checks pass (including Greptile comments)
|
||||
- No new lint/test failures
|
||||
|
||||
These almost always get merged quickly when they're clean.
|
||||
|
||||
### Path 2: Bigger or Impactful Changes
|
||||
- **First** talk about it in Discord → #dev channel
|
||||
→ Describe what you're trying to solve
|
||||
→ Share rough ideas / approach
|
||||
- Once there's rough agreement, build it
|
||||
- In your PR include:
|
||||
- Before / After screenshots (or short video if UI/behavior change)
|
||||
- Clear description of what & why
|
||||
- Proof it works (manual testing notes)
|
||||
- All tests passing
|
||||
- All Greptile + other PR comments addressed
|
||||
|
||||
PRs that follow this path are **much** more likely to be accepted, even when they're large.
|
||||
|
||||
## General Rules (both paths)
|
||||
- Write clear commit messages
|
||||
- Keep PR title + description meaningful
|
||||
- One PR = one logical change (unless it's a small related group)
|
||||
- Run tests locally first
|
||||
- Be kind in discussions 😄
|
||||
|
||||
Questions? Just ask in #dev — we're happy to help.
|
||||
|
||||
Happy hacking!
|
||||
22
Dockerfile
22
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM node:20-bookworm-slim AS base
|
||||
FROM node:lts-trixie-slim AS base
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -15,19 +15,28 @@ COPY packages/db/package.json packages/db/
|
||||
COPY packages/adapter-utils/package.json packages/adapter-utils/
|
||||
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
|
||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
||||
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
|
||||
COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/
|
||||
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
||||
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
||||
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app /app
|
||||
COPY . .
|
||||
RUN pnpm --filter @paperclip/ui build
|
||||
RUN pnpm --filter @paperclip/server build
|
||||
RUN pnpm --filter @paperclipai/ui build
|
||||
RUN pnpm --filter @paperclipai/server build
|
||||
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
|
||||
|
||||
FROM base AS production
|
||||
WORKDIR /app
|
||||
COPY --from=build /app /app
|
||||
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest
|
||||
COPY --chown=node:node --from=build /app /app
|
||||
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
|
||||
&& mkdir -p /paperclip \
|
||||
&& chown node:node /paperclip
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOME=/paperclip \
|
||||
@@ -37,10 +46,11 @@ ENV NODE_ENV=production \
|
||||
PAPERCLIP_HOME=/paperclip \
|
||||
PAPERCLIP_INSTANCE_ID=default \
|
||||
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
|
||||
PAPERCLIP_DEPLOYMENT_MODE=local_trusted \
|
||||
PAPERCLIP_DEPLOYMENT_MODE=authenticated \
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE=private
|
||||
|
||||
VOLUME ["/paperclip"]
|
||||
EXPOSE 3100
|
||||
|
||||
USER node
|
||||
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Paperclip AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
19
README.md
19
README.md
@@ -218,7 +218,8 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm dev # Full dev (API + UI)
|
||||
pnpm dev # Full dev (API + UI, watch mode)
|
||||
pnpm dev:once # Full dev without file watching
|
||||
pnpm dev:server # Server only
|
||||
pnpm build # Build all
|
||||
pnpm typecheck # Type checking
|
||||
@@ -233,9 +234,13 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- 🛒 **Clipmart** — Download and share entire company architectures
|
||||
- 🧩 **Plugin System** — Embed custom plugins (e.g. Reporting, Knowledge Base) into Paperclip
|
||||
- ☁️ **Cloud Agent Adapters** — Add more adapters for cloud-hosted agents
|
||||
- ⚪ Get OpenClaw onboarding easier
|
||||
- ⚪ Get cloud agents working e.g. Cursor / e2b agents
|
||||
- ⚪ ClipMart - buy and sell entire agent companies
|
||||
- ⚪ Easy agent configurations / easier to understand
|
||||
- ⚪ Better support for harness engineering
|
||||
- ⚪ Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
|
||||
- ⚪ Better docs
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -243,8 +248,6 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||
|
||||
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
|
||||
|
||||
<!-- TODO: add CONTRIBUTING.md -->
|
||||
|
||||
<br/>
|
||||
|
||||
## Community
|
||||
@@ -259,6 +262,10 @@ We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for deta
|
||||
|
||||
MIT © 2026 Paperclip
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left)
|
||||
|
||||
<br/>
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# paperclipai
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6077ae6]
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.3.0
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
- @paperclipai/adapter-claude-local@0.3.0
|
||||
- @paperclipai/adapter-codex-local@0.3.0
|
||||
- @paperclipai/adapter-cursor-local@0.3.0
|
||||
- @paperclipai/adapter-openclaw-gateway@0.3.0
|
||||
- @paperclipai/adapter-opencode-local@0.3.0
|
||||
- @paperclipai/adapter-pi-local@0.3.0
|
||||
- @paperclipai/db@0.3.0
|
||||
- @paperclipai/server@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.2.7
|
||||
- @paperclipai/adapter-utils@0.2.7
|
||||
- @paperclipai/db@0.2.7
|
||||
- @paperclipai/adapter-claude-local@0.2.7
|
||||
- @paperclipai/adapter-codex-local@0.2.7
|
||||
- @paperclipai/adapter-openclaw@0.2.7
|
||||
- @paperclipai/server@0.2.7
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -21,7 +21,7 @@ const workspacePaths = [
|
||||
"packages/adapter-utils",
|
||||
"packages/adapters/claude-local",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/openclaw",
|
||||
"packages/adapters/openclaw-gateway",
|
||||
];
|
||||
|
||||
// Workspace packages that should NOT be bundled — they'll be published
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperclipai",
|
||||
"version": "0.2.6",
|
||||
"version": "0.3.0",
|
||||
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -36,7 +36,11 @@
|
||||
"@clack/prompts": "^0.10.0",
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-gemini-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
"@paperclipai/server": "workspace:*",
|
||||
@@ -44,6 +48,7 @@
|
||||
"drizzle-orm": "0.38.4",
|
||||
"dotenv": "^17.0.1",
|
||||
"commander": "^13.1.0",
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -21,6 +21,12 @@ function writeBaseConfig(configPath: string) {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: "/tmp/paperclip-db",
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: "/tmp/paperclip-backups",
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
@@ -36,6 +42,7 @@ function writeBaseConfig(configPath: string) {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
@@ -68,4 +75,3 @@ describe("allowed-hostname command", () => {
|
||||
expect(raw.server.allowedHostnames).toEqual(["dotta-macbook-pro"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
99
cli/src/__tests__/doctor.test.ts
Normal file
99
cli/src/__tests__/doctor.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { doctor } from "../commands/doctor.js";
|
||||
import { writeConfig } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function createTempConfig(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-"));
|
||||
const configPath = path.join(root, ".paperclip", "config.json");
|
||||
const runtimeRoot = path.join(root, "runtime");
|
||||
|
||||
const config: PaperclipConfig = {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
|
||||
embeddedPostgresPort: 55432,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(runtimeRoot, "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(runtimeRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3199,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(runtimeRoot, "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
writeConfig(config, configPath);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
describe("doctor", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it("re-runs repairable checks so repaired failures do not remain blocking", async () => {
|
||||
const configPath = createTempConfig();
|
||||
|
||||
const summary = await doctor({
|
||||
config: configPath,
|
||||
repair: true,
|
||||
yes: true,
|
||||
});
|
||||
|
||||
expect(summary.failed).toBe(0);
|
||||
expect(summary.warned).toBe(0);
|
||||
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy();
|
||||
});
|
||||
});
|
||||
405
cli/src/__tests__/worktree.test.ts
Normal file
405
cli/src/__tests__/worktree.test.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
copyGitHooksToWorktreeGitDir,
|
||||
copySeededSecretsKey,
|
||||
rebindWorkspaceCwd,
|
||||
resolveGitWorktreeAddArgs,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeInitCommand,
|
||||
worktreeMakeCommand,
|
||||
} from "../commands/worktree.js";
|
||||
import {
|
||||
buildWorktreeConfig,
|
||||
buildWorktreeEnvEntries,
|
||||
formatShellExports,
|
||||
resolveWorktreeSeedPlan,
|
||||
resolveWorktreeLocalPaths,
|
||||
rewriteLocalUrlPort,
|
||||
sanitizeWorktreeInstanceId,
|
||||
} from "../commands/worktree-lib.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in ORIGINAL_ENV)) delete process.env[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function buildSourceConfig(): PaperclipConfig {
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-09T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: "/tmp/main/db",
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: "/tmp/main/backups",
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: "/tmp/main/logs",
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "authenticated",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: ["localhost"],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "explicit",
|
||||
publicBaseUrl: "http://127.0.0.1:3100",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: "/tmp/main/storage",
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: "/tmp/main/secrets/master.key",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("worktree helpers", () => {
|
||||
it("sanitizes instance ids", () => {
|
||||
expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support");
|
||||
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
|
||||
});
|
||||
|
||||
it("resolves worktree:make target paths under the user home directory", () => {
|
||||
expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe(
|
||||
path.resolve(os.homedir(), "paperclip-pr-432"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects worktree:make names that are not safe directory/branch names", () => {
|
||||
expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow(
|
||||
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds git worktree add args for new and existing branches", () => {
|
||||
expect(
|
||||
resolveGitWorktreeAddArgs({
|
||||
branchName: "feature-branch",
|
||||
targetPath: "/tmp/feature-branch",
|
||||
branchExists: false,
|
||||
}),
|
||||
).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]);
|
||||
|
||||
expect(
|
||||
resolveGitWorktreeAddArgs({
|
||||
branchName: "feature-branch",
|
||||
targetPath: "/tmp/feature-branch",
|
||||
branchExists: true,
|
||||
}),
|
||||
).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]);
|
||||
});
|
||||
|
||||
it("builds git worktree add args with a start point", () => {
|
||||
expect(
|
||||
resolveGitWorktreeAddArgs({
|
||||
branchName: "my-worktree",
|
||||
targetPath: "/tmp/my-worktree",
|
||||
branchExists: false,
|
||||
startPoint: "public-gh/master",
|
||||
}),
|
||||
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]);
|
||||
});
|
||||
|
||||
it("uses start point even when a local branch with the same name exists", () => {
|
||||
expect(
|
||||
resolveGitWorktreeAddArgs({
|
||||
branchName: "my-worktree",
|
||||
targetPath: "/tmp/my-worktree",
|
||||
branchExists: true,
|
||||
startPoint: "origin/main",
|
||||
}),
|
||||
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
|
||||
});
|
||||
|
||||
it("rewrites loopback auth URLs to the new port only", () => {
|
||||
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
|
||||
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
||||
});
|
||||
|
||||
it("builds isolated config and env paths for a worktree", () => {
|
||||
const paths = resolveWorktreeLocalPaths({
|
||||
cwd: "/tmp/paperclip-feature",
|
||||
homeDir: "/tmp/paperclip-worktrees",
|
||||
instanceId: "feature-worktree-support",
|
||||
});
|
||||
const config = buildWorktreeConfig({
|
||||
sourceConfig: buildSourceConfig(),
|
||||
paths,
|
||||
serverPort: 3110,
|
||||
databasePort: 54339,
|
||||
now: new Date("2026-03-09T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(config.database.embeddedPostgresDataDir).toBe(
|
||||
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"),
|
||||
);
|
||||
expect(config.database.embeddedPostgresPort).toBe(54339);
|
||||
expect(config.server.port).toBe(3110);
|
||||
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/");
|
||||
expect(config.storage.localDisk.baseDir).toBe(
|
||||
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
|
||||
);
|
||||
|
||||
const env = buildWorktreeEnvEntries(paths);
|
||||
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
|
||||
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
|
||||
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
|
||||
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
||||
});
|
||||
|
||||
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
|
||||
const minimal = resolveWorktreeSeedPlan("minimal");
|
||||
const full = resolveWorktreeSeedPlan("full");
|
||||
|
||||
expect(minimal.excludedTables).toContain("heartbeat_runs");
|
||||
expect(minimal.excludedTables).toContain("heartbeat_run_events");
|
||||
expect(minimal.excludedTables).toContain("workspace_runtime_services");
|
||||
expect(minimal.excludedTables).toContain("agent_task_sessions");
|
||||
expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]);
|
||||
|
||||
expect(full.excludedTables).toEqual([]);
|
||||
expect(full.nullifyColumns).toEqual({});
|
||||
});
|
||||
|
||||
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
|
||||
const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
try {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key");
|
||||
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
|
||||
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
|
||||
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
|
||||
|
||||
const sourceConfig = buildSourceConfig();
|
||||
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
|
||||
|
||||
copySeededSecretsKey({
|
||||
sourceConfigPath,
|
||||
sourceConfig,
|
||||
sourceEnvEntries: {},
|
||||
targetKeyFilePath: targetKeyPath,
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key");
|
||||
} finally {
|
||||
if (originalInlineMasterKey === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY = originalInlineMasterKey;
|
||||
}
|
||||
if (originalKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = originalKeyFile;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("writes the source inline secrets master key into the seeded worktree instance", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
|
||||
try {
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
|
||||
|
||||
copySeededSecretsKey({
|
||||
sourceConfigPath,
|
||||
sourceConfig: buildSourceConfig(),
|
||||
sourceEnvEntries: {
|
||||
PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key",
|
||||
},
|
||||
targetKeyFilePath: targetKeyPath,
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key");
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists the current agent jwt secret into the worktree env file", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const originalCwd = process.cwd();
|
||||
const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret";
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await worktreeInitCommand({
|
||||
seed: false,
|
||||
fromConfig: path.join(tempRoot, "missing", "config.json"),
|
||||
home: path.join(tempRoot, ".paperclip-worktrees"),
|
||||
});
|
||||
|
||||
const envPath = path.join(repoRoot, ".paperclip", ".env");
|
||||
expect(fs.readFileSync(envPath, "utf8")).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalJwtSecret === undefined) {
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
} else {
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rebinds same-repo workspace paths onto the current worktree root", () => {
|
||||
expect(
|
||||
rebindWorkspaceCwd({
|
||||
sourceRepoRoot: "/Users/example/paperclip",
|
||||
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
||||
workspaceCwd: "/Users/example/paperclip",
|
||||
}),
|
||||
).toBe("/Users/example/paperclip-pr-432");
|
||||
|
||||
expect(
|
||||
rebindWorkspaceCwd({
|
||||
sourceRepoRoot: "/Users/example/paperclip",
|
||||
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
||||
workspaceCwd: "/Users/example/paperclip/packages/db",
|
||||
}),
|
||||
).toBe("/Users/example/paperclip-pr-432/packages/db");
|
||||
});
|
||||
|
||||
it("does not rebind paths outside the source repo root", () => {
|
||||
expect(
|
||||
rebindWorkspaceCwd({
|
||||
sourceRepoRoot: "/Users/example/paperclip",
|
||||
targetRepoRoot: "/Users/example/paperclip-pr-432",
|
||||
workspaceCwd: "/Users/example/other-project",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("copies shared git hooks into a linked worktree git dir", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const worktreePath = path.join(tempRoot, "repo-feature");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||
|
||||
const sourceHooksDir = path.join(repoRoot, ".git", "hooks");
|
||||
const sourceHookPath = path.join(sourceHooksDir, "pre-commit");
|
||||
const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt");
|
||||
fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 });
|
||||
fs.chmodSync(sourceHookPath, 0o755);
|
||||
fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8");
|
||||
|
||||
execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" });
|
||||
|
||||
const copied = copyGitHooksToWorktreeGitDir(worktreePath);
|
||||
const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
|
||||
cwd: worktreePath,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir);
|
||||
const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks"));
|
||||
const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit");
|
||||
const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt");
|
||||
|
||||
expect(copied).toMatchObject({
|
||||
sourceHooksPath: resolvedSourceHooksDir,
|
||||
targetHooksPath: resolvedTargetHooksDir,
|
||||
copied: true,
|
||||
});
|
||||
expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n");
|
||||
expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0);
|
||||
expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n");
|
||||
} finally {
|
||||
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates and initializes a worktree from the top-level worktree:make command", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const fakeHome = path.join(tempRoot, "home");
|
||||
const worktreePath = path.join(fakeHome, "paperclip-make-test");
|
||||
const originalCwd = process.cwd();
|
||||
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
fs.mkdirSync(fakeHome, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await worktreeMakeCommand("paperclip-make-test", {
|
||||
seed: false,
|
||||
home: path.join(tempRoot, ".paperclip-worktrees"),
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
homedirSpy.mockRestore();
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
||||
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||
import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
|
||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
|
||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
||||
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
||||
import { processCLIAdapter } from "./process/index.js";
|
||||
import { httpCLIAdapter } from "./http/index.js";
|
||||
|
||||
@@ -15,13 +19,43 @@ const codexLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printCodexStreamEvent,
|
||||
};
|
||||
|
||||
const openclawCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw",
|
||||
formatStdoutEvent: printOpenClawStreamEvent,
|
||||
const openCodeLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "opencode_local",
|
||||
formatStdoutEvent: printOpenCodeStreamEvent,
|
||||
};
|
||||
|
||||
const piLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "pi_local",
|
||||
formatStdoutEvent: printPiStreamEvent,
|
||||
};
|
||||
|
||||
const cursorLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "cursor",
|
||||
formatStdoutEvent: printCursorStreamEvent,
|
||||
};
|
||||
|
||||
const geminiLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "gemini_local",
|
||||
formatStdoutEvent: printGeminiStreamEvent,
|
||||
};
|
||||
|
||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
[claudeLocalCLIAdapter, codexLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
||||
[
|
||||
claudeLocalCLIAdapter,
|
||||
codexLocalCLIAdapter,
|
||||
openCodeLocalCLIAdapter,
|
||||
piLocalCLIAdapter,
|
||||
cursorLocalCLIAdapter,
|
||||
geminiLocalCLIAdapter,
|
||||
openclawGatewayCLIAdapter,
|
||||
processCLIAdapter,
|
||||
httpCLIAdapter,
|
||||
].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getCLIAdapter(type: string): CLIAdapterModule {
|
||||
|
||||
@@ -104,8 +104,10 @@ export class PaperclipApiClient {
|
||||
|
||||
function buildUrl(apiBase: string, path: string): string {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const [pathname, query] = normalizedPath.split("?");
|
||||
const url = new URL(apiBase);
|
||||
url.pathname = `${url.pathname.replace(/\/+$/, "")}${normalizedPath}`;
|
||||
url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`;
|
||||
if (query) url.search = query;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ export async function addAllowedHostname(host: string, opts: { config?: string }
|
||||
p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`);
|
||||
} else {
|
||||
p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
|
||||
p.log.message(
|
||||
pc.dim("Restart the Paperclip server for this change to take effect."),
|
||||
);
|
||||
}
|
||||
|
||||
if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
|
||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
@@ -13,7 +14,8 @@ function createInviteToken() {
|
||||
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
function resolveDbUrl(configPath?: string) {
|
||||
function resolveDbUrl(configPath?: string, explicitDbUrl?: string) {
|
||||
if (explicitDbUrl) return explicitDbUrl;
|
||||
const config = readConfig(configPath);
|
||||
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
||||
if (config?.database.mode === "postgres" && config.database.connectionString) {
|
||||
@@ -28,6 +30,12 @@ function resolveDbUrl(configPath?: string) {
|
||||
|
||||
function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
|
||||
if (explicitBaseUrl) return explicitBaseUrl.replace(/\/+$/, "");
|
||||
const fromEnv =
|
||||
process.env.PAPERCLIP_PUBLIC_URL ??
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
|
||||
process.env.BETTER_AUTH_URL ??
|
||||
process.env.BETTER_AUTH_BASE_URL;
|
||||
if (fromEnv?.trim()) return fromEnv.trim().replace(/\/+$/, "");
|
||||
const config = readConfig(configPath);
|
||||
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
|
||||
return config.auth.publicBaseUrl.replace(/\/+$/, "");
|
||||
@@ -43,8 +51,10 @@ export async function bootstrapCeoInvite(opts: {
|
||||
force?: boolean;
|
||||
expiresHours?: number;
|
||||
baseUrl?: string;
|
||||
dbUrl?: string;
|
||||
}) {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
loadPaperclipEnvFile(configPath);
|
||||
const config = readConfig(configPath);
|
||||
if (!config) {
|
||||
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
|
||||
@@ -56,7 +66,7 @@ export async function bootstrapCeoInvite(opts: {
|
||||
return;
|
||||
}
|
||||
|
||||
const dbUrl = resolveDbUrl(configPath);
|
||||
const dbUrl = resolveDbUrl(configPath, opts.dbUrl);
|
||||
if (!dbUrl) {
|
||||
p.log.error(
|
||||
"Could not resolve database connection for bootstrap.",
|
||||
@@ -65,6 +75,11 @@ export async function bootstrapCeoInvite(opts: {
|
||||
}
|
||||
|
||||
const db = createDb(dbUrl);
|
||||
const closableDb = db as typeof db & {
|
||||
$client?: {
|
||||
end?: (options?: { timeout?: number }) => Promise<void>;
|
||||
};
|
||||
};
|
||||
try {
|
||||
const existingAdminCount = await db
|
||||
.select()
|
||||
@@ -112,5 +127,7 @@ export async function bootstrapCeoInvite(opts: {
|
||||
} catch (err) {
|
||||
p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`);
|
||||
p.log.info("If using embedded-postgres, start the Paperclip server and run this command again.");
|
||||
} finally {
|
||||
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Command } from "commander";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
formatInlineRecord,
|
||||
@@ -13,6 +17,107 @@ interface AgentListOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
interface AgentLocalCliOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
keyName?: string;
|
||||
installSkills?: boolean;
|
||||
}
|
||||
|
||||
interface CreatedAgentKey {
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface SkillsInstallSummary {
|
||||
tool: "codex" | "claude";
|
||||
target: string;
|
||||
linked: string[];
|
||||
skipped: string[];
|
||||
failed: Array<{ name: string; error: string }>;
|
||||
}
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills
|
||||
path.resolve(process.cwd(), "skills"),
|
||||
];
|
||||
|
||||
function codexSkillsHome(): string {
|
||||
const fromEnv = process.env.CODEX_HOME?.trim();
|
||||
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex");
|
||||
return path.join(base, "skills");
|
||||
}
|
||||
|
||||
function claudeSkillsHome(): string {
|
||||
const fromEnv = process.env.CLAUDE_HOME?.trim();
|
||||
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude");
|
||||
return path.join(base, "skills");
|
||||
}
|
||||
|
||||
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function installSkillsForTarget(
|
||||
sourceSkillsDir: string,
|
||||
targetSkillsDir: string,
|
||||
tool: "codex" | "claude",
|
||||
): Promise<SkillsInstallSummary> {
|
||||
const summary: SkillsInstallSummary = {
|
||||
tool,
|
||||
target: targetSkillsDir,
|
||||
linked: [],
|
||||
skipped: [],
|
||||
failed: [],
|
||||
};
|
||||
|
||||
await fs.mkdir(targetSkillsDir, { recursive: true });
|
||||
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(sourceSkillsDir, entry.name);
|
||||
const target = path.join(targetSkillsDir, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) {
|
||||
summary.skipped.push(entry.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
summary.linked.push(entry.name);
|
||||
} catch (err) {
|
||||
summary.failed.push({
|
||||
name: entry.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
function buildAgentEnvExports(input: {
|
||||
apiBase: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
apiKey: string;
|
||||
}): string {
|
||||
const escaped = (value: string) => value.replace(/'/g, "'\"'\"'");
|
||||
return [
|
||||
`export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`,
|
||||
`export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`,
|
||||
`export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`,
|
||||
`export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function registerAgentCommands(program: Command): void {
|
||||
const agent = program.command("agent").description("Agent operations");
|
||||
|
||||
@@ -71,4 +176,102 @@ export function registerAgentCommands(program: Command): void {
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
agent
|
||||
.command("local-cli")
|
||||
.description(
|
||||
"Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports",
|
||||
)
|
||||
.argument("<agentRef>", "Agent ID or shortname/url-key")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.option("--key-name <name>", "API key label", "local-cli")
|
||||
.option(
|
||||
"--no-install-skills",
|
||||
"Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills",
|
||||
)
|
||||
.action(async (agentRef: string, opts: AgentLocalCliOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const query = new URLSearchParams({ companyId: ctx.companyId ?? "" });
|
||||
const agentRow = await ctx.api.get<Agent>(
|
||||
`/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`,
|
||||
);
|
||||
if (!agentRow) {
|
||||
throw new Error(`Agent not found: ${agentRef}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString().replaceAll(":", "-");
|
||||
const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`;
|
||||
const key = await ctx.api.post<CreatedAgentKey>(`/api/agents/${agentRow.id}/keys`, { name: keyName });
|
||||
if (!key) {
|
||||
throw new Error("Failed to create API key");
|
||||
}
|
||||
|
||||
const installSummaries: SkillsInstallSummary[] = [];
|
||||
if (opts.installSkills !== false) {
|
||||
const skillsDir = await resolvePaperclipSkillsDir();
|
||||
if (!skillsDir) {
|
||||
throw new Error(
|
||||
"Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.",
|
||||
);
|
||||
}
|
||||
|
||||
installSummaries.push(
|
||||
await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"),
|
||||
await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"),
|
||||
);
|
||||
}
|
||||
|
||||
const exportsText = buildAgentEnvExports({
|
||||
apiBase: ctx.api.apiBase,
|
||||
companyId: agentRow.companyId,
|
||||
agentId: agentRow.id,
|
||||
apiKey: key.token,
|
||||
});
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(
|
||||
{
|
||||
agent: {
|
||||
id: agentRow.id,
|
||||
name: agentRow.name,
|
||||
urlKey: agentRow.urlKey,
|
||||
companyId: agentRow.companyId,
|
||||
},
|
||||
key: {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
createdAt: key.createdAt,
|
||||
token: key.token,
|
||||
},
|
||||
skills: installSummaries,
|
||||
exports: exportsText,
|
||||
},
|
||||
{ json: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Agent: ${agentRow.name} (${agentRow.id})`);
|
||||
console.log(`API key created: ${key.name} (${key.id})`);
|
||||
if (installSummaries.length > 0) {
|
||||
for (const summary of installSummaries) {
|
||||
console.log(
|
||||
`${summary.tool}: linked=${summary.linked.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
|
||||
);
|
||||
for (const failed of summary.failed) {
|
||||
console.log(` failed ${failed.name}: ${failed.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
console.log("# Run this in your shell before launching codex/claude:");
|
||||
console.log(exportsText);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { defaultSecretsConfig, promptSecrets } from "../prompts/secrets.js";
|
||||
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
|
||||
import { promptServer } from "../prompts/server.js";
|
||||
import {
|
||||
resolveDefaultBackupDir,
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
resolveDefaultLogsDir,
|
||||
resolvePaperclipInstanceId,
|
||||
@@ -39,6 +40,12 @@ function defaultConfig(): PaperclipConfig {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: resolveDefaultBackupDir(instanceId),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
@@ -54,6 +61,7 @@ function defaultConfig(): PaperclipConfig {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
@@ -118,7 +126,7 @@ export async function configure(opts: {
|
||||
|
||||
switch (section) {
|
||||
case "database":
|
||||
config.database = await promptDatabase();
|
||||
config.database = await promptDatabase(config.database);
|
||||
break;
|
||||
case "llm": {
|
||||
const llm = await promptLlm();
|
||||
|
||||
102
cli/src/commands/db-backup.ts
Normal file
102
cli/src/commands/db-backup.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import path from "node:path";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { formatDatabaseBackupResult, runDatabaseBackup } from "@paperclipai/db";
|
||||
import {
|
||||
expandHomePrefix,
|
||||
resolveDefaultBackupDir,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
|
||||
type DbBackupOptions = {
|
||||
config?: string;
|
||||
dir?: string;
|
||||
retentionDays?: number;
|
||||
filenamePrefix?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
function resolveConnectionString(configPath?: string): { value: string; source: string } {
|
||||
const envUrl = process.env.DATABASE_URL?.trim();
|
||||
if (envUrl) return { value: envUrl, source: "DATABASE_URL" };
|
||||
|
||||
const config = readConfig(configPath);
|
||||
if (config?.database.mode === "postgres" && config.database.connectionString?.trim()) {
|
||||
return { value: config.database.connectionString.trim(), source: "config.database.connectionString" };
|
||||
}
|
||||
|
||||
const port = config?.database.embeddedPostgresPort ?? 54329;
|
||||
return {
|
||||
value: `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`,
|
||||
source: `embedded-postgres@${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRetentionDays(value: number | undefined, fallback: number): number {
|
||||
const candidate = value ?? fallback;
|
||||
if (!Number.isInteger(candidate) || candidate < 1) {
|
||||
throw new Error(`Invalid retention days '${String(candidate)}'. Use a positive integer.`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function resolveBackupDir(raw: string): string {
|
||||
return path.resolve(expandHomePrefix(raw.trim()));
|
||||
}
|
||||
|
||||
export async function dbBackupCommand(opts: DbBackupOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclip db:backup ")));
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const config = readConfig(opts.config);
|
||||
const connection = resolveConnectionString(opts.config);
|
||||
const defaultDir = resolveDefaultBackupDir(resolvePaperclipInstanceId());
|
||||
const configuredDir = opts.dir?.trim() || config?.database.backup.dir || defaultDir;
|
||||
const backupDir = resolveBackupDir(configuredDir);
|
||||
const retentionDays = normalizeRetentionDays(
|
||||
opts.retentionDays,
|
||||
config?.database.backup.retentionDays ?? 30,
|
||||
);
|
||||
const filenamePrefix = opts.filenamePrefix?.trim() || "paperclip";
|
||||
|
||||
p.log.message(pc.dim(`Config: ${configPath}`));
|
||||
p.log.message(pc.dim(`Connection source: ${connection.source}`));
|
||||
p.log.message(pc.dim(`Backup dir: ${backupDir}`));
|
||||
p.log.message(pc.dim(`Retention: ${retentionDays} day(s)`));
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start("Creating database backup...");
|
||||
try {
|
||||
const result = await runDatabaseBackup({
|
||||
connectionString: connection.value,
|
||||
backupDir,
|
||||
retentionDays,
|
||||
filenamePrefix,
|
||||
});
|
||||
spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
backupFile: result.backupFile,
|
||||
sizeBytes: result.sizeBytes,
|
||||
prunedCount: result.prunedCount,
|
||||
backupDir,
|
||||
retentionDays,
|
||||
connectionSource: connection.source,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
p.outro(pc.green("Backup completed."));
|
||||
} catch (err) {
|
||||
spinner.stop(pc.red("Backup failed."));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
storageCheck,
|
||||
type CheckResult,
|
||||
} from "../checks/index.js";
|
||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
|
||||
const STATUS_ICON = {
|
||||
@@ -31,6 +32,7 @@ export async function doctor(opts: {
|
||||
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
loadPaperclipEnvFile(configPath);
|
||||
const results: CheckResult[] = [];
|
||||
|
||||
// 1. Config check (must pass before others)
|
||||
@@ -64,28 +66,40 @@ export async function doctor(opts: {
|
||||
printResult(deploymentAuthResult);
|
||||
|
||||
// 3. Agent JWT check
|
||||
const jwtResult = agentJwtSecretCheck(opts.config);
|
||||
results.push(jwtResult);
|
||||
printResult(jwtResult);
|
||||
await maybeRepair(jwtResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => agentJwtSecretCheck(opts.config),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 4. Secrets adapter check
|
||||
const secretsResult = secretsCheck(config, configPath);
|
||||
results.push(secretsResult);
|
||||
printResult(secretsResult);
|
||||
await maybeRepair(secretsResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => secretsCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 5. Storage check
|
||||
const storageResult = storageCheck(config, configPath);
|
||||
results.push(storageResult);
|
||||
printResult(storageResult);
|
||||
await maybeRepair(storageResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => storageCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 6. Database check
|
||||
const dbResult = await databaseCheck(config, configPath);
|
||||
results.push(dbResult);
|
||||
printResult(dbResult);
|
||||
await maybeRepair(dbResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => databaseCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 7. LLM check
|
||||
const llmResult = await llmCheck(config);
|
||||
@@ -93,10 +107,13 @@ export async function doctor(opts: {
|
||||
printResult(llmResult);
|
||||
|
||||
// 8. Log directory check
|
||||
const logResult = logCheck(config, configPath);
|
||||
results.push(logResult);
|
||||
printResult(logResult);
|
||||
await maybeRepair(logResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => logCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 9. Port check
|
||||
const portResult = await portCheck(config);
|
||||
@@ -118,9 +135,9 @@ function printResult(result: CheckResult): void {
|
||||
async function maybeRepair(
|
||||
result: CheckResult,
|
||||
opts: { repair?: boolean; yes?: boolean },
|
||||
): Promise<void> {
|
||||
if (result.status === "pass" || !result.canRepair || !result.repair) return;
|
||||
if (!opts.repair) return;
|
||||
): Promise<boolean> {
|
||||
if (result.status === "pass" || !result.canRepair || !result.repair) return false;
|
||||
if (!opts.repair) return false;
|
||||
|
||||
let shouldRepair = opts.yes;
|
||||
if (!shouldRepair) {
|
||||
@@ -128,7 +145,7 @@ async function maybeRepair(
|
||||
message: `Repair "${result.name}"?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (p.isCancel(answer)) return;
|
||||
if (p.isCancel(answer)) return false;
|
||||
shouldRepair = answer;
|
||||
}
|
||||
|
||||
@@ -136,10 +153,30 @@ async function maybeRepair(
|
||||
try {
|
||||
await result.repair();
|
||||
p.log.success(`Repaired: ${result.name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
p.log.error(`Repair failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runRepairableCheck(input: {
|
||||
run: () => CheckResult | Promise<CheckResult>;
|
||||
configPath: string;
|
||||
opts: { repair?: boolean; yes?: boolean };
|
||||
}): Promise<CheckResult> {
|
||||
let result = await input.run();
|
||||
printResult(result);
|
||||
|
||||
const repaired = await maybeRepair(result, input.opts);
|
||||
if (!repaired) return result;
|
||||
|
||||
// Repairs may create/update the adjacent .env file or other local resources.
|
||||
loadPaperclipEnvFile(input.configPath);
|
||||
result = await input.run();
|
||||
printResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } {
|
||||
|
||||
@@ -118,6 +118,29 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
|
||||
const dbUrl = process.env.DATABASE_URL ?? config?.database?.connectionString ?? "";
|
||||
const databaseMode = config?.database?.mode ?? "embedded-postgres";
|
||||
const dbUrlSource: EnvSource = process.env.DATABASE_URL ? "env" : config?.database?.connectionString ? "config" : "missing";
|
||||
const publicUrl =
|
||||
process.env.PAPERCLIP_PUBLIC_URL ??
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
|
||||
process.env.BETTER_AUTH_URL ??
|
||||
process.env.BETTER_AUTH_BASE_URL ??
|
||||
config?.auth?.publicBaseUrl ??
|
||||
"";
|
||||
const publicUrlSource: EnvSource =
|
||||
process.env.PAPERCLIP_PUBLIC_URL
|
||||
? "env"
|
||||
: process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL || process.env.BETTER_AUTH_URL || process.env.BETTER_AUTH_BASE_URL
|
||||
? "env"
|
||||
: config?.auth?.publicBaseUrl
|
||||
? "config"
|
||||
: "missing";
|
||||
let trustedOriginsDefault = "";
|
||||
if (publicUrl) {
|
||||
try {
|
||||
trustedOriginsDefault = new URL(publicUrl).origin;
|
||||
} catch {
|
||||
trustedOriginsDefault = "";
|
||||
}
|
||||
}
|
||||
|
||||
const heartbeatInterval = process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS;
|
||||
const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true";
|
||||
@@ -192,6 +215,24 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
|
||||
required: false,
|
||||
note: "HTTP listen port",
|
||||
},
|
||||
{
|
||||
key: "PAPERCLIP_PUBLIC_URL",
|
||||
value: publicUrl,
|
||||
source: publicUrlSource,
|
||||
required: false,
|
||||
note: "Canonical public URL for auth/callback/invite origin wiring",
|
||||
},
|
||||
{
|
||||
key: "BETTER_AUTH_TRUSTED_ORIGINS",
|
||||
value: process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? trustedOriginsDefault,
|
||||
source: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
? "env"
|
||||
: trustedOriginsDefault
|
||||
? "default"
|
||||
: "missing",
|
||||
required: false,
|
||||
note: "Comma-separated auth origin allowlist (auto-derived from PAPERCLIP_PUBLIC_URL when possible)",
|
||||
},
|
||||
{
|
||||
key: "PAPERCLIP_AGENT_JWT_TTL_SECONDS",
|
||||
value: process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS ?? DEFAULT_AGENT_JWT_TTL_SECONDS,
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import path from "node:path";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
AUTH_BASE_URL_MODES,
|
||||
DEPLOYMENT_EXPOSURES,
|
||||
DEPLOYMENT_MODES,
|
||||
SECRET_PROVIDERS,
|
||||
STORAGE_PROVIDERS,
|
||||
type AuthBaseUrlMode,
|
||||
type DeploymentExposure,
|
||||
type DeploymentMode,
|
||||
type SecretProvider,
|
||||
type StorageProvider,
|
||||
} from "@paperclipai/shared";
|
||||
import { configExists, readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js";
|
||||
@@ -12,6 +25,8 @@ import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
|
||||
import { promptServer } from "../prompts/server.js";
|
||||
import {
|
||||
describeLocalInstancePaths,
|
||||
expandHomePrefix,
|
||||
resolveDefaultBackupDir,
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
resolveDefaultLogsDir,
|
||||
resolvePaperclipInstanceId,
|
||||
@@ -28,32 +43,194 @@ type OnboardOptions = {
|
||||
invokedByRun?: boolean;
|
||||
};
|
||||
|
||||
function quickstartDefaults(): Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets"> {
|
||||
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
|
||||
|
||||
const ONBOARD_ENV_KEYS = [
|
||||
"PAPERCLIP_PUBLIC_URL",
|
||||
"DATABASE_URL",
|
||||
"PAPERCLIP_DB_BACKUP_ENABLED",
|
||||
"PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES",
|
||||
"PAPERCLIP_DB_BACKUP_RETENTION_DAYS",
|
||||
"PAPERCLIP_DB_BACKUP_DIR",
|
||||
"PAPERCLIP_DEPLOYMENT_MODE",
|
||||
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
||||
"HOST",
|
||||
"PORT",
|
||||
"SERVE_UI",
|
||||
"PAPERCLIP_ALLOWED_HOSTNAMES",
|
||||
"PAPERCLIP_AUTH_BASE_URL_MODE",
|
||||
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
|
||||
"BETTER_AUTH_URL",
|
||||
"BETTER_AUTH_BASE_URL",
|
||||
"PAPERCLIP_STORAGE_PROVIDER",
|
||||
"PAPERCLIP_STORAGE_LOCAL_DIR",
|
||||
"PAPERCLIP_STORAGE_S3_BUCKET",
|
||||
"PAPERCLIP_STORAGE_S3_REGION",
|
||||
"PAPERCLIP_STORAGE_S3_ENDPOINT",
|
||||
"PAPERCLIP_STORAGE_S3_PREFIX",
|
||||
"PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE",
|
||||
"PAPERCLIP_SECRETS_PROVIDER",
|
||||
"PAPERCLIP_SECRETS_STRICT_MODE",
|
||||
"PAPERCLIP_SECRETS_MASTER_KEY_FILE",
|
||||
] as const;
|
||||
|
||||
function parseBooleanFromEnv(rawValue: string | undefined): boolean | null {
|
||||
if (rawValue === undefined) return null;
|
||||
const lower = rawValue.trim().toLowerCase();
|
||||
if (lower === "true" || lower === "1" || lower === "yes") return true;
|
||||
if (lower === "false" || lower === "0" || lower === "no") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseNumberFromEnv(rawValue: string | undefined): number | null {
|
||||
if (!rawValue) return null;
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseEnumFromEnv<T extends string>(rawValue: string | undefined, allowedValues: readonly T[]): T | null {
|
||||
if (!rawValue) return null;
|
||||
return allowedValues.includes(rawValue as T) ? (rawValue as T) : null;
|
||||
}
|
||||
|
||||
function resolvePathFromEnv(rawValue: string | undefined): string | null {
|
||||
if (!rawValue || rawValue.trim().length === 0) return null;
|
||||
return path.resolve(expandHomePrefix(rawValue.trim()));
|
||||
}
|
||||
|
||||
function quickstartDefaultsFromEnv(): {
|
||||
defaults: OnboardDefaults;
|
||||
usedEnvKeys: string[];
|
||||
ignoredEnvKeys: Array<{ key: string; reason: string }>;
|
||||
} {
|
||||
const instanceId = resolvePaperclipInstanceId();
|
||||
return {
|
||||
const defaultStorage = defaultStorageConfig();
|
||||
const defaultSecrets = defaultSecretsConfig();
|
||||
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
|
||||
const publicUrl =
|
||||
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
|
||||
process.env.BETTER_AUTH_URL?.trim() ||
|
||||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
|
||||
undefined;
|
||||
const deploymentMode =
|
||||
parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted";
|
||||
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
|
||||
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE,
|
||||
DEPLOYMENT_EXPOSURES,
|
||||
);
|
||||
const deploymentExposure =
|
||||
deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private");
|
||||
const authPublicBaseUrl = publicUrl;
|
||||
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
|
||||
process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
|
||||
AUTH_BASE_URL_MODES,
|
||||
);
|
||||
const authBaseUrlMode = authBaseUrlModeFromEnv ?? (authPublicBaseUrl ? "explicit" : "auto");
|
||||
const allowedHostnamesFromEnv = process.env.PAPERCLIP_ALLOWED_HOSTNAMES
|
||||
? process.env.PAPERCLIP_ALLOWED_HOSTNAMES
|
||||
.split(",")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => value.length > 0)
|
||||
: [];
|
||||
const hostnameFromPublicUrl = publicUrl
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(publicUrl).hostname.trim().toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
const storageProvider =
|
||||
parseEnumFromEnv<StorageProvider>(process.env.PAPERCLIP_STORAGE_PROVIDER, STORAGE_PROVIDERS) ??
|
||||
defaultStorage.provider;
|
||||
const secretsProvider =
|
||||
parseEnumFromEnv<SecretProvider>(process.env.PAPERCLIP_SECRETS_PROVIDER, SECRET_PROVIDERS) ??
|
||||
defaultSecrets.provider;
|
||||
const databaseBackupEnabled = parseBooleanFromEnv(process.env.PAPERCLIP_DB_BACKUP_ENABLED) ?? true;
|
||||
const databaseBackupIntervalMinutes = Math.max(
|
||||
1,
|
||||
parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES) ?? 60,
|
||||
);
|
||||
const databaseBackupRetentionDays = Math.max(
|
||||
1,
|
||||
parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) ?? 30,
|
||||
);
|
||||
const defaults: OnboardDefaults = {
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
mode: databaseUrl ? "postgres" : "embedded-postgres",
|
||||
...(databaseUrl ? { connectionString: databaseUrl } : {}),
|
||||
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: databaseBackupEnabled,
|
||||
intervalMinutes: databaseBackupIntervalMinutes,
|
||||
retentionDays: databaseBackupRetentionDays,
|
||||
dir: resolvePathFromEnv(process.env.PAPERCLIP_DB_BACKUP_DIR) ?? resolveDefaultBackupDir(instanceId),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: resolveDefaultLogsDir(instanceId),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
deploymentMode,
|
||||
exposure: deploymentExposure,
|
||||
host: process.env.HOST ?? "127.0.0.1",
|
||||
port: Number(process.env.PORT) || 3100,
|
||||
allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])),
|
||||
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
baseUrlMode: authBaseUrlMode,
|
||||
disableSignUp: false,
|
||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
},
|
||||
storage: {
|
||||
provider: storageProvider,
|
||||
localDisk: {
|
||||
baseDir:
|
||||
resolvePathFromEnv(process.env.PAPERCLIP_STORAGE_LOCAL_DIR) ?? defaultStorage.localDisk.baseDir,
|
||||
},
|
||||
s3: {
|
||||
bucket: process.env.PAPERCLIP_STORAGE_S3_BUCKET ?? defaultStorage.s3.bucket,
|
||||
region: process.env.PAPERCLIP_STORAGE_S3_REGION ?? defaultStorage.s3.region,
|
||||
endpoint: process.env.PAPERCLIP_STORAGE_S3_ENDPOINT ?? defaultStorage.s3.endpoint,
|
||||
prefix: process.env.PAPERCLIP_STORAGE_S3_PREFIX ?? defaultStorage.s3.prefix,
|
||||
forcePathStyle:
|
||||
parseBooleanFromEnv(process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE) ??
|
||||
defaultStorage.s3.forcePathStyle,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: secretsProvider,
|
||||
strictMode: parseBooleanFromEnv(process.env.PAPERCLIP_SECRETS_STRICT_MODE) ?? defaultSecrets.strictMode,
|
||||
localEncrypted: {
|
||||
keyFilePath:
|
||||
resolvePathFromEnv(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
|
||||
defaultSecrets.localEncrypted.keyFilePath,
|
||||
},
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
};
|
||||
const ignoredEnvKeys: Array<{ key: string; reason: string }> = [];
|
||||
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) {
|
||||
ignoredEnvKeys.push({
|
||||
key: "PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
||||
reason: "Ignored because deployment mode local_trusted always forces private exposure",
|
||||
});
|
||||
}
|
||||
|
||||
const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key));
|
||||
const usedEnvKeys = ONBOARD_ENV_KEYS.filter(
|
||||
(key) => process.env[key] !== undefined && !ignoredKeySet.has(key),
|
||||
);
|
||||
return { defaults, usedEnvKeys, ignoredEnvKeys };
|
||||
}
|
||||
|
||||
function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "database" | "server">): boolean {
|
||||
return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres";
|
||||
}
|
||||
|
||||
export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
@@ -109,6 +286,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
}
|
||||
|
||||
let llm: PaperclipConfig["llm"] | undefined;
|
||||
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
|
||||
let {
|
||||
database,
|
||||
logging,
|
||||
@@ -116,11 +294,11 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
auth,
|
||||
storage,
|
||||
secrets,
|
||||
} = quickstartDefaults();
|
||||
} = derivedDefaults;
|
||||
|
||||
if (setupMode === "advanced") {
|
||||
p.log.step(pc.bold("Database"));
|
||||
database = await promptDatabase();
|
||||
database = await promptDatabase(database);
|
||||
|
||||
if (database.mode === "postgres" && database.connectionString) {
|
||||
const s = p.spinner();
|
||||
@@ -184,13 +362,20 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
logging = await promptLogging();
|
||||
|
||||
p.log.step(pc.bold("Server"));
|
||||
({ server, auth } = await promptServer());
|
||||
({ server, auth } = await promptServer({ currentServer: server, currentAuth: auth }));
|
||||
|
||||
p.log.step(pc.bold("Storage"));
|
||||
storage = await promptStorage(defaultStorageConfig());
|
||||
storage = await promptStorage(storage);
|
||||
|
||||
p.log.step(pc.bold("Secrets"));
|
||||
secrets = defaultSecretsConfig();
|
||||
const secretsDefaults = defaultSecretsConfig();
|
||||
secrets = {
|
||||
provider: secrets.provider ?? secretsDefaults.provider,
|
||||
strictMode: secrets.strictMode ?? secretsDefaults.strictMode,
|
||||
localEncrypted: {
|
||||
keyFilePath: secrets.localEncrypted?.keyFilePath ?? secretsDefaults.localEncrypted.keyFilePath,
|
||||
},
|
||||
};
|
||||
p.log.message(
|
||||
pc.dim(
|
||||
`Using defaults: provider=${secrets.provider}, strictMode=${secrets.strictMode}, keyFile=${secrets.localEncrypted.keyFilePath}`,
|
||||
@@ -198,9 +383,17 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
);
|
||||
} else {
|
||||
p.log.step(pc.bold("Quickstart"));
|
||||
p.log.message(
|
||||
pc.dim("Using local defaults: embedded database, no LLM provider, file storage, and local encrypted secrets."),
|
||||
);
|
||||
p.log.message(pc.dim("Using quickstart defaults."));
|
||||
if (usedEnvKeys.length > 0) {
|
||||
p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`));
|
||||
} else {
|
||||
p.log.message(
|
||||
pc.dim("No environment overrides detected: embedded database, file storage, local encrypted secrets."),
|
||||
);
|
||||
}
|
||||
for (const ignored of ignoredEnvKeys) {
|
||||
p.log.message(pc.dim(`Ignored ${ignored.key}: ${ignored.reason}`));
|
||||
}
|
||||
}
|
||||
|
||||
const jwtSecret = ensureAgentJwtSecret(configPath);
|
||||
@@ -261,7 +454,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
"Next commands",
|
||||
);
|
||||
|
||||
if (server.deploymentMode === "authenticated") {
|
||||
if (canCreateBootstrapInviteImmediately({ database, server })) {
|
||||
p.log.step("Generating bootstrap CEO invite");
|
||||
await bootstrapCeoInvite({ config: configPath });
|
||||
}
|
||||
@@ -284,5 +477,15 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") {
|
||||
p.log.info(
|
||||
[
|
||||
"Bootstrap CEO invite will be created after the server starts.",
|
||||
`Next: ${pc.cyan("paperclipai run")}`,
|
||||
`Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
p.outro("You're all set!");
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
|
||||
import { onboard } from "./onboard.js";
|
||||
import { doctor } from "./doctor.js";
|
||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||
import { configExists, resolveConfigPath } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { readConfig } from "../config/store.js";
|
||||
import {
|
||||
describeLocalInstancePaths,
|
||||
resolvePaperclipHomeDir,
|
||||
@@ -19,6 +23,13 @@ interface RunOptions {
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
interface StartedServer {
|
||||
apiUrl: string;
|
||||
databaseUrl: string;
|
||||
host: string;
|
||||
listenPort: number;
|
||||
}
|
||||
|
||||
export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
const instanceId = resolvePaperclipInstanceId(opts.instance);
|
||||
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
||||
@@ -31,6 +42,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
process.env.PAPERCLIP_CONFIG = configPath;
|
||||
loadPaperclipEnvFile(configPath);
|
||||
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
|
||||
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
|
||||
@@ -60,8 +72,41 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = readConfig(configPath);
|
||||
if (!config) {
|
||||
p.log.error(`No config found at ${configPath}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
p.log.step("Starting Paperclip server...");
|
||||
await importServerEntry();
|
||||
const startedServer = await importServerEntry();
|
||||
|
||||
if (shouldGenerateBootstrapInviteAfterStart(config)) {
|
||||
p.log.step("Generating bootstrap CEO invite");
|
||||
await bootstrapCeoInvite({
|
||||
config: configPath,
|
||||
dbUrl: startedServer.databaseUrl,
|
||||
baseUrl: resolveBootstrapInviteBaseUrl(config, startedServer),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBootstrapInviteBaseUrl(
|
||||
config: PaperclipConfig,
|
||||
startedServer: StartedServer,
|
||||
): string {
|
||||
const explicitBaseUrl =
|
||||
process.env.PAPERCLIP_PUBLIC_URL ??
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
|
||||
process.env.BETTER_AUTH_URL ??
|
||||
process.env.BETTER_AUTH_BASE_URL ??
|
||||
(config.auth.baseUrlMode === "explicit" ? config.auth.publicBaseUrl : undefined);
|
||||
|
||||
if (typeof explicitBaseUrl === "string" && explicitBaseUrl.trim().length > 0) {
|
||||
return explicitBaseUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
return startedServer.apiUrl.replace(/\/api$/, "");
|
||||
}
|
||||
|
||||
function formatError(err: unknown): string {
|
||||
@@ -101,19 +146,20 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function importServerEntry(): Promise<void> {
|
||||
async function importServerEntry(): Promise<StartedServer> {
|
||||
// Dev mode: try local workspace path (monorepo with tsx)
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
|
||||
if (fs.existsSync(devEntry)) {
|
||||
maybeEnableUiDevMiddleware(devEntry);
|
||||
await import(pathToFileURL(devEntry).href);
|
||||
return;
|
||||
const mod = await import(pathToFileURL(devEntry).href);
|
||||
return await startServerFromModule(mod, devEntry);
|
||||
}
|
||||
|
||||
// Production mode: import the published @paperclipai/server package
|
||||
try {
|
||||
await import("@paperclipai/server");
|
||||
const mod = await import("@paperclipai/server");
|
||||
return await startServerFromModule(mod, "@paperclipai/server");
|
||||
} catch (err) {
|
||||
const missingSpecifier = getMissingModuleSpecifier(err);
|
||||
const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server";
|
||||
@@ -130,3 +176,15 @@ async function importServerEntry(): Promise<void> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldGenerateBootstrapInviteAfterStart(config: PaperclipConfig): boolean {
|
||||
return config.server.deploymentMode === "authenticated" && config.database.mode === "embedded-postgres";
|
||||
}
|
||||
|
||||
async function startServerFromModule(mod: unknown, label: string): Promise<StartedServer> {
|
||||
const startServer = (mod as { startServer?: () => Promise<StartedServer> }).startServer;
|
||||
if (typeof startServer !== "function") {
|
||||
throw new Error(`Paperclip server entrypoint did not export startServer(): ${label}`);
|
||||
}
|
||||
return await startServer();
|
||||
}
|
||||
|
||||
218
cli/src/commands/worktree-lib.ts
Normal file
218
cli/src/commands/worktree-lib.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import path from "node:path";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { expandHomePrefix } from "../config/home.js";
|
||||
|
||||
export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees";
|
||||
export const WORKTREE_SEED_MODES = ["minimal", "full"] as const;
|
||||
|
||||
export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number];
|
||||
|
||||
export type WorktreeSeedPlan = {
|
||||
mode: WorktreeSeedMode;
|
||||
excludedTables: string[];
|
||||
nullifyColumns: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const MINIMAL_WORKTREE_EXCLUDED_TABLES = [
|
||||
"activity_log",
|
||||
"agent_runtime_state",
|
||||
"agent_task_sessions",
|
||||
"agent_wakeup_requests",
|
||||
"cost_events",
|
||||
"heartbeat_run_events",
|
||||
"heartbeat_runs",
|
||||
"workspace_runtime_services",
|
||||
];
|
||||
|
||||
const MINIMAL_WORKTREE_NULLIFIED_COLUMNS: Record<string, string[]> = {
|
||||
issues: ["checkout_run_id", "execution_run_id"],
|
||||
};
|
||||
|
||||
export type WorktreeLocalPaths = {
|
||||
cwd: string;
|
||||
repoConfigDir: string;
|
||||
configPath: string;
|
||||
envPath: string;
|
||||
homeDir: string;
|
||||
instanceId: string;
|
||||
instanceRoot: string;
|
||||
contextPath: string;
|
||||
embeddedPostgresDataDir: string;
|
||||
backupDir: string;
|
||||
logDir: string;
|
||||
secretsKeyFilePath: string;
|
||||
storageDir: string;
|
||||
};
|
||||
|
||||
export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode {
|
||||
return (WORKTREE_SEED_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPlan {
|
||||
if (mode === "full") {
|
||||
return {
|
||||
mode,
|
||||
excludedTables: [],
|
||||
nullifyColumns: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
excludedTables: [...MINIMAL_WORKTREE_EXCLUDED_TABLES],
|
||||
nullifyColumns: {
|
||||
...MINIMAL_WORKTREE_NULLIFIED_COLUMNS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function nonEmpty(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const value = hostname.trim().toLowerCase();
|
||||
return value === "127.0.0.1" || value === "localhost" || value === "::1";
|
||||
}
|
||||
|
||||
export function sanitizeWorktreeInstanceId(rawValue: string): string {
|
||||
const trimmed = rawValue.trim().toLowerCase();
|
||||
const normalized = trimmed
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-_]+|[-_]+$/g, "");
|
||||
return normalized || "worktree";
|
||||
}
|
||||
|
||||
export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string): string {
|
||||
return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd));
|
||||
}
|
||||
|
||||
export function resolveWorktreeLocalPaths(opts: {
|
||||
cwd: string;
|
||||
homeDir?: string;
|
||||
instanceId: string;
|
||||
}): WorktreeLocalPaths {
|
||||
const cwd = path.resolve(opts.cwd);
|
||||
const homeDir = path.resolve(expandHomePrefix(opts.homeDir ?? DEFAULT_WORKTREE_HOME));
|
||||
const instanceRoot = path.resolve(homeDir, "instances", opts.instanceId);
|
||||
const repoConfigDir = path.resolve(cwd, ".paperclip");
|
||||
return {
|
||||
cwd,
|
||||
repoConfigDir,
|
||||
configPath: path.resolve(repoConfigDir, "config.json"),
|
||||
envPath: path.resolve(repoConfigDir, ".env"),
|
||||
homeDir,
|
||||
instanceId: opts.instanceId,
|
||||
instanceRoot,
|
||||
contextPath: path.resolve(homeDir, "context.json"),
|
||||
embeddedPostgresDataDir: path.resolve(instanceRoot, "db"),
|
||||
backupDir: path.resolve(instanceRoot, "data", "backups"),
|
||||
logDir: path.resolve(instanceRoot, "logs"),
|
||||
secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"),
|
||||
storageDir: path.resolve(instanceRoot, "data", "storage"),
|
||||
};
|
||||
}
|
||||
|
||||
export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined {
|
||||
if (!rawUrl) return undefined;
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
|
||||
parsed.port = String(port);
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWorktreeConfig(input: {
|
||||
sourceConfig: PaperclipConfig | null;
|
||||
paths: WorktreeLocalPaths;
|
||||
serverPort: number;
|
||||
databasePort: number;
|
||||
now?: Date;
|
||||
}): PaperclipConfig {
|
||||
const { sourceConfig, paths, serverPort, databasePort } = input;
|
||||
const nowIso = (input.now ?? new Date()).toISOString();
|
||||
|
||||
const source = sourceConfig;
|
||||
const authPublicBaseUrl = rewriteLocalUrlPort(source?.auth.publicBaseUrl, serverPort);
|
||||
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: nowIso,
|
||||
source: "configure",
|
||||
},
|
||||
...(source?.llm ? { llm: source.llm } : {}),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: paths.embeddedPostgresDataDir,
|
||||
embeddedPostgresPort: databasePort,
|
||||
backup: {
|
||||
enabled: source?.database.backup.enabled ?? true,
|
||||
intervalMinutes: source?.database.backup.intervalMinutes ?? 60,
|
||||
retentionDays: source?.database.backup.retentionDays ?? 30,
|
||||
dir: paths.backupDir,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: source?.logging.mode ?? "file",
|
||||
logDir: paths.logDir,
|
||||
},
|
||||
server: {
|
||||
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
|
||||
exposure: source?.server.exposure ?? "private",
|
||||
host: source?.server.host ?? "127.0.0.1",
|
||||
port: serverPort,
|
||||
allowedHostnames: source?.server.allowedHostnames ?? [],
|
||||
serveUi: source?.server.serveUi ?? true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: source?.auth.baseUrlMode ?? "auto",
|
||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
disableSignUp: source?.auth.disableSignUp ?? false,
|
||||
},
|
||||
storage: {
|
||||
provider: source?.storage.provider ?? "local_disk",
|
||||
localDisk: {
|
||||
baseDir: paths.storageDir,
|
||||
},
|
||||
s3: {
|
||||
bucket: source?.storage.s3.bucket ?? "paperclip",
|
||||
region: source?.storage.s3.region ?? "us-east-1",
|
||||
endpoint: source?.storage.s3.endpoint,
|
||||
prefix: source?.storage.s3.prefix ?? "",
|
||||
forcePathStyle: source?.storage.s3.forcePathStyle ?? false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: source?.secrets.provider ?? "local_encrypted",
|
||||
strictMode: source?.secrets.strictMode ?? false,
|
||||
localEncrypted: {
|
||||
keyFilePath: paths.secretsKeyFilePath,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record<string, string> {
|
||||
return {
|
||||
PAPERCLIP_HOME: paths.homeDir,
|
||||
PAPERCLIP_INSTANCE_ID: paths.instanceId,
|
||||
PAPERCLIP_CONFIG: paths.configPath,
|
||||
PAPERCLIP_CONTEXT: paths.contextPath,
|
||||
PAPERCLIP_IN_WORKTREE: "true",
|
||||
};
|
||||
}
|
||||
|
||||
function shellEscape(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
export function formatShellExports(entries: Record<string, string>): string {
|
||||
return Object.entries(entries)
|
||||
.filter(([, value]) => typeof value === "string" && value.trim().length > 0)
|
||||
.map(([key, value]) => `export ${key}=${shellEscape(value)}`)
|
||||
.join("\n");
|
||||
}
|
||||
864
cli/src/commands/worktree.ts
Normal file
864
cli/src/commands/worktree.ts
Normal file
@@ -0,0 +1,864 @@
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
createDb,
|
||||
ensurePostgresDatabase,
|
||||
formatDatabaseBackupResult,
|
||||
projectWorkspaces,
|
||||
runDatabaseBackup,
|
||||
runDatabaseRestore,
|
||||
} from "@paperclipai/db";
|
||||
import type { Command } from "commander";
|
||||
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
|
||||
import { expandHomePrefix } from "../config/home.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
import { resolveRuntimeLikePath } from "../utils/path-resolver.js";
|
||||
import {
|
||||
buildWorktreeConfig,
|
||||
buildWorktreeEnvEntries,
|
||||
DEFAULT_WORKTREE_HOME,
|
||||
formatShellExports,
|
||||
isWorktreeSeedMode,
|
||||
resolveSuggestedWorktreeName,
|
||||
resolveWorktreeSeedPlan,
|
||||
resolveWorktreeLocalPaths,
|
||||
sanitizeWorktreeInstanceId,
|
||||
type WorktreeSeedMode,
|
||||
type WorktreeLocalPaths,
|
||||
} from "./worktree-lib.js";
|
||||
|
||||
type WorktreeInitOptions = {
|
||||
name?: string;
|
||||
instance?: string;
|
||||
home?: string;
|
||||
fromConfig?: string;
|
||||
fromDataDir?: string;
|
||||
fromInstance?: string;
|
||||
serverPort?: number;
|
||||
dbPort?: number;
|
||||
seed?: boolean;
|
||||
seedMode?: string;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
type WorktreeMakeOptions = WorktreeInitOptions & {
|
||||
startPoint?: string;
|
||||
};
|
||||
|
||||
type WorktreeEnvOptions = {
|
||||
config?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
type EmbeddedPostgresHandle = {
|
||||
port: number;
|
||||
startedByThisProcess: boolean;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
type GitWorkspaceInfo = {
|
||||
root: string;
|
||||
commonDir: string;
|
||||
gitDir: string;
|
||||
hooksPath: string;
|
||||
};
|
||||
|
||||
type CopiedGitHooksResult = {
|
||||
sourceHooksPath: string;
|
||||
targetHooksPath: string;
|
||||
copied: boolean;
|
||||
};
|
||||
|
||||
type SeedWorktreeDatabaseResult = {
|
||||
backupSummary: string;
|
||||
reboundWorkspaces: Array<{
|
||||
name: string;
|
||||
fromCwd: string;
|
||||
toCwd: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function nonEmpty(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isCurrentSourceConfigPath(sourceConfigPath: string): boolean {
|
||||
const currentConfigPath = process.env.PAPERCLIP_CONFIG;
|
||||
if (!currentConfigPath || currentConfigPath.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath);
|
||||
}
|
||||
|
||||
function resolveWorktreeMakeName(name: string): string {
|
||||
const value = nonEmpty(name);
|
||||
if (!value) {
|
||||
throw new Error("Worktree name is required.");
|
||||
}
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(value)) {
|
||||
throw new Error(
|
||||
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolveWorktreeMakeTargetPath(name: string): string {
|
||||
return path.resolve(os.homedir(), resolveWorktreeMakeName(name));
|
||||
}
|
||||
|
||||
function extractExecSyncErrorMessage(error: unknown): string | null {
|
||||
if (!error || typeof error !== "object") {
|
||||
return error instanceof Error ? error.message : null;
|
||||
}
|
||||
|
||||
const stderr = "stderr" in error ? error.stderr : null;
|
||||
if (typeof stderr === "string") {
|
||||
return nonEmpty(stderr);
|
||||
}
|
||||
if (stderr instanceof Buffer) {
|
||||
return nonEmpty(stderr.toString("utf8"));
|
||||
}
|
||||
|
||||
return error instanceof Error ? nonEmpty(error.message) : null;
|
||||
}
|
||||
|
||||
function localBranchExists(cwd: string, branchName: string): boolean {
|
||||
try {
|
||||
execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
|
||||
cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGitWorktreeAddArgs(input: {
|
||||
branchName: string;
|
||||
targetPath: string;
|
||||
branchExists: boolean;
|
||||
startPoint?: string;
|
||||
}): string[] {
|
||||
if (input.branchExists && !input.startPoint) {
|
||||
return ["worktree", "add", input.targetPath, input.branchName];
|
||||
}
|
||||
const commitish = input.startPoint ?? "HEAD";
|
||||
return ["worktree", "add", "-b", input.branchName, input.targetPath, commitish];
|
||||
}
|
||||
|
||||
function readPidFilePort(postmasterPidFile: string): number | null {
|
||||
if (!existsSync(postmasterPidFile)) return null;
|
||||
try {
|
||||
const lines = readFileSync(postmasterPidFile, "utf8").split("\n");
|
||||
const port = Number(lines[3]?.trim());
|
||||
return Number.isInteger(port) && port > 0 ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
|
||||
if (!existsSync(postmasterPidFile)) return null;
|
||||
try {
|
||||
const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
|
||||
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||
process.kill(pid, 0);
|
||||
return pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortAvailable(port: number): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findAvailablePort(preferredPort: number, reserved = new Set<number>()): Promise<number> {
|
||||
let port = Math.max(1, Math.trunc(preferredPort));
|
||||
while (reserved.has(port) || !(await isPortAvailable(port))) {
|
||||
port += 1;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function detectGitBranchName(cwd: string): string | null {
|
||||
try {
|
||||
const value = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return nonEmpty(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
|
||||
try {
|
||||
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return {
|
||||
root: path.resolve(root),
|
||||
commonDir: path.resolve(root, commonDirRaw),
|
||||
gitDir: path.resolve(root, gitDirRaw),
|
||||
hooksPath: path.resolve(root, hooksPathRaw),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirectoryContents(sourceDir: string, targetDir: string): boolean {
|
||||
if (!existsSync(sourceDir)) return false;
|
||||
|
||||
const entries = readdirSync(sourceDir, { withFileTypes: true });
|
||||
if (entries.length === 0) return false;
|
||||
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
let copied = false;
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.resolve(sourceDir, entry.name);
|
||||
const targetPath = path.resolve(targetDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
mkdirSync(targetPath, { recursive: true });
|
||||
copyDirectoryContents(sourcePath, targetPath);
|
||||
copied = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
rmSync(targetPath, { recursive: true, force: true });
|
||||
symlinkSync(readlinkSync(sourcePath), targetPath);
|
||||
copied = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
copyFileSync(sourcePath, targetPath);
|
||||
try {
|
||||
chmodSync(targetPath, statSync(sourcePath).mode & 0o777);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
copied = true;
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
export function copyGitHooksToWorktreeGitDir(cwd: string): CopiedGitHooksResult | null {
|
||||
const workspace = detectGitWorkspaceInfo(cwd);
|
||||
if (!workspace) return null;
|
||||
|
||||
const sourceHooksPath = workspace.hooksPath;
|
||||
const targetHooksPath = path.resolve(workspace.gitDir, "hooks");
|
||||
|
||||
if (sourceHooksPath === targetHooksPath) {
|
||||
return {
|
||||
sourceHooksPath,
|
||||
targetHooksPath,
|
||||
copied: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sourceHooksPath,
|
||||
targetHooksPath,
|
||||
copied: copyDirectoryContents(sourceHooksPath, targetHooksPath),
|
||||
};
|
||||
}
|
||||
|
||||
export function rebindWorkspaceCwd(input: {
|
||||
sourceRepoRoot: string;
|
||||
targetRepoRoot: string;
|
||||
workspaceCwd: string;
|
||||
}): string | null {
|
||||
const sourceRepoRoot = path.resolve(input.sourceRepoRoot);
|
||||
const targetRepoRoot = path.resolve(input.targetRepoRoot);
|
||||
const workspaceCwd = path.resolve(input.workspaceCwd);
|
||||
const relative = path.relative(sourceRepoRoot, workspaceCwd);
|
||||
if (!relative || relative === "") {
|
||||
return targetRepoRoot;
|
||||
}
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return null;
|
||||
}
|
||||
return path.resolve(targetRepoRoot, relative);
|
||||
}
|
||||
|
||||
async function rebindSeededProjectWorkspaces(input: {
|
||||
targetConnectionString: string;
|
||||
currentCwd: string;
|
||||
}): Promise<SeedWorktreeDatabaseResult["reboundWorkspaces"]> {
|
||||
const targetRepo = detectGitWorkspaceInfo(input.currentCwd);
|
||||
if (!targetRepo) return [];
|
||||
|
||||
const db = createDb(input.targetConnectionString);
|
||||
const closableDb = db as typeof db & {
|
||||
$client?: { end?: (opts?: { timeout?: number }) => Promise<void> };
|
||||
};
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: projectWorkspaces.id,
|
||||
name: projectWorkspaces.name,
|
||||
cwd: projectWorkspaces.cwd,
|
||||
})
|
||||
.from(projectWorkspaces);
|
||||
|
||||
const rebound: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
|
||||
for (const row of rows) {
|
||||
const workspaceCwd = nonEmpty(row.cwd);
|
||||
if (!workspaceCwd) continue;
|
||||
|
||||
const sourceRepo = detectGitWorkspaceInfo(workspaceCwd);
|
||||
if (!sourceRepo) continue;
|
||||
if (sourceRepo.commonDir !== targetRepo.commonDir) continue;
|
||||
|
||||
const reboundCwd = rebindWorkspaceCwd({
|
||||
sourceRepoRoot: sourceRepo.root,
|
||||
targetRepoRoot: targetRepo.root,
|
||||
workspaceCwd,
|
||||
});
|
||||
if (!reboundCwd) continue;
|
||||
|
||||
const normalizedCurrent = path.resolve(workspaceCwd);
|
||||
if (reboundCwd === normalizedCurrent) continue;
|
||||
if (!existsSync(reboundCwd)) continue;
|
||||
|
||||
await db
|
||||
.update(projectWorkspaces)
|
||||
.set({
|
||||
cwd: reboundCwd,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(projectWorkspaces.id, row.id));
|
||||
|
||||
rebound.push({
|
||||
name: row.name,
|
||||
fromCwd: normalizedCurrent,
|
||||
toCwd: reboundCwd,
|
||||
});
|
||||
}
|
||||
|
||||
return rebound;
|
||||
} finally {
|
||||
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
|
||||
if (opts.fromConfig) return path.resolve(opts.fromConfig);
|
||||
const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip"));
|
||||
const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default");
|
||||
return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json");
|
||||
}
|
||||
|
||||
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
|
||||
if (config.database.mode === "postgres") {
|
||||
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
"Source instance uses postgres mode but has no connection string in config or adjacent .env.",
|
||||
);
|
||||
}
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
const port = portOverride ?? config.database.embeddedPostgresPort;
|
||||
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
}
|
||||
|
||||
export function copySeededSecretsKey(input: {
|
||||
sourceConfigPath: string;
|
||||
sourceConfig: PaperclipConfig;
|
||||
sourceEnvEntries: Record<string, string>;
|
||||
targetKeyFilePath: string;
|
||||
}): void {
|
||||
if (input.sourceConfig.secrets.provider !== "local_encrypted") {
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true });
|
||||
|
||||
const allowProcessEnvFallback = isCurrentSourceConfigPath(input.sourceConfigPath);
|
||||
const sourceInlineMasterKey =
|
||||
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ??
|
||||
(allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY) : null);
|
||||
if (sourceInlineMasterKey) {
|
||||
writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
try {
|
||||
chmodSync(input.targetKeyFilePath, 0o600);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceKeyFileOverride =
|
||||
nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
|
||||
(allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) : null);
|
||||
const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath;
|
||||
const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath);
|
||||
|
||||
if (!existsSync(sourceKeyFilePath)) {
|
||||
throw new Error(
|
||||
`Cannot seed worktree database because source local_encrypted secrets key was not found at ${sourceKeyFilePath}.`,
|
||||
);
|
||||
}
|
||||
|
||||
copyFileSync(sourceKeyFilePath, input.targetKeyFilePath);
|
||||
try {
|
||||
chmodSync(input.targetKeyFilePath, 0o600);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise<EmbeddedPostgresHandle> {
|
||||
const moduleName = "embedded-postgres";
|
||||
let EmbeddedPostgres: EmbeddedPostgresCtor;
|
||||
try {
|
||||
const mod = await import(moduleName);
|
||||
EmbeddedPostgres = mod.default as EmbeddedPostgresCtor;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
||||
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||
if (runningPid) {
|
||||
return {
|
||||
port: readPidFilePort(postmasterPidFile) ?? preferredPort,
|
||||
startedByThisProcess: false,
|
||||
stop: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const port = await findAvailablePort(preferredPort);
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
||||
await instance.initialise();
|
||||
}
|
||||
if (existsSync(postmasterPidFile)) {
|
||||
rmSync(postmasterPidFile, { force: true });
|
||||
}
|
||||
await instance.start();
|
||||
|
||||
return {
|
||||
port,
|
||||
startedByThisProcess: true,
|
||||
stop: async () => {
|
||||
await instance.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedWorktreeDatabase(input: {
|
||||
sourceConfigPath: string;
|
||||
sourceConfig: PaperclipConfig;
|
||||
targetConfig: PaperclipConfig;
|
||||
targetPaths: WorktreeLocalPaths;
|
||||
instanceId: string;
|
||||
seedMode: WorktreeSeedMode;
|
||||
}): Promise<SeedWorktreeDatabaseResult> {
|
||||
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
|
||||
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
|
||||
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
|
||||
copySeededSecretsKey({
|
||||
sourceConfigPath: input.sourceConfigPath,
|
||||
sourceConfig: input.sourceConfig,
|
||||
sourceEnvEntries,
|
||||
targetKeyFilePath: input.targetPaths.secretsKeyFilePath,
|
||||
});
|
||||
let sourceHandle: EmbeddedPostgresHandle | null = null;
|
||||
let targetHandle: EmbeddedPostgresHandle | null = null;
|
||||
|
||||
try {
|
||||
if (input.sourceConfig.database.mode === "embedded-postgres") {
|
||||
sourceHandle = await ensureEmbeddedPostgres(
|
||||
input.sourceConfig.database.embeddedPostgresDataDir,
|
||||
input.sourceConfig.database.embeddedPostgresPort,
|
||||
);
|
||||
}
|
||||
const sourceConnectionString = resolveSourceConnectionString(
|
||||
input.sourceConfig,
|
||||
sourceEnvEntries,
|
||||
sourceHandle?.port,
|
||||
);
|
||||
const backup = await runDatabaseBackup({
|
||||
connectionString: sourceConnectionString,
|
||||
backupDir: path.resolve(input.targetPaths.backupDir, "seed"),
|
||||
retentionDays: 7,
|
||||
filenamePrefix: `${input.instanceId}-seed`,
|
||||
includeMigrationJournal: true,
|
||||
excludeTables: seedPlan.excludedTables,
|
||||
nullifyColumns: seedPlan.nullifyColumns,
|
||||
});
|
||||
|
||||
targetHandle = await ensureEmbeddedPostgres(
|
||||
input.targetConfig.database.embeddedPostgresDataDir,
|
||||
input.targetConfig.database.embeddedPostgresPort,
|
||||
);
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const targetConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/paperclip`;
|
||||
await runDatabaseRestore({
|
||||
connectionString: targetConnectionString,
|
||||
backupFile: backup.backupFile,
|
||||
});
|
||||
await applyPendingMigrations(targetConnectionString);
|
||||
const reboundWorkspaces = await rebindSeededProjectWorkspaces({
|
||||
targetConnectionString,
|
||||
currentCwd: input.targetPaths.cwd,
|
||||
});
|
||||
|
||||
return {
|
||||
backupSummary: formatDatabaseBackupResult(backup),
|
||||
reboundWorkspaces,
|
||||
};
|
||||
} finally {
|
||||
if (targetHandle?.startedByThisProcess) {
|
||||
await targetHandle.stop();
|
||||
}
|
||||
if (sourceHandle?.startedByThisProcess) {
|
||||
await sourceHandle.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
const name = resolveSuggestedWorktreeName(
|
||||
cwd,
|
||||
opts.name ?? detectGitBranchName(cwd) ?? undefined,
|
||||
);
|
||||
const seedMode = opts.seedMode ?? "minimal";
|
||||
if (!isWorktreeSeedMode(seedMode)) {
|
||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||
}
|
||||
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
|
||||
const paths = resolveWorktreeLocalPaths({
|
||||
cwd,
|
||||
homeDir: opts.home ?? DEFAULT_WORKTREE_HOME,
|
||||
instanceId,
|
||||
});
|
||||
const sourceConfigPath = resolveSourceConfigPath(opts);
|
||||
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
|
||||
|
||||
if ((existsSync(paths.configPath) || existsSync(paths.instanceRoot)) && !opts.force) {
|
||||
throw new Error(
|
||||
`Worktree config already exists at ${paths.configPath} or instance data exists at ${paths.instanceRoot}. Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.force) {
|
||||
rmSync(paths.repoConfigDir, { recursive: true, force: true });
|
||||
rmSync(paths.instanceRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
|
||||
const serverPort = await findAvailablePort(preferredServerPort);
|
||||
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
|
||||
const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort]));
|
||||
const targetConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths,
|
||||
serverPort,
|
||||
databasePort,
|
||||
});
|
||||
|
||||
writeConfig(targetConfig, paths.configPath);
|
||||
const sourceEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(sourceConfigPath));
|
||||
const existingAgentJwtSecret =
|
||||
nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ??
|
||||
nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET);
|
||||
mergePaperclipEnvEntries(
|
||||
{
|
||||
...buildWorktreeEnvEntries(paths),
|
||||
...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}),
|
||||
},
|
||||
paths.envPath,
|
||||
);
|
||||
ensureAgentJwtSecret(paths.configPath);
|
||||
loadPaperclipEnvFile(paths.configPath);
|
||||
const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd);
|
||||
|
||||
let seedSummary: string | null = null;
|
||||
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
|
||||
if (opts.seed !== false) {
|
||||
if (!sourceConfig) {
|
||||
throw new Error(
|
||||
`Cannot seed worktree database because source config was not found at ${sourceConfigPath}. Use --no-seed or provide --from-config.`,
|
||||
);
|
||||
}
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`);
|
||||
try {
|
||||
const seeded = await seedWorktreeDatabase({
|
||||
sourceConfigPath,
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
targetPaths: paths,
|
||||
instanceId,
|
||||
seedMode,
|
||||
});
|
||||
seedSummary = seeded.backupSummary;
|
||||
reboundWorkspaceSummary = seeded.reboundWorkspaces;
|
||||
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
|
||||
} catch (error) {
|
||||
spinner.stop(pc.red("Failed to seed worktree database."));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
p.log.message(pc.dim(`Repo config: ${paths.configPath}`));
|
||||
p.log.message(pc.dim(`Repo env: ${paths.envPath}`));
|
||||
p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`));
|
||||
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
|
||||
p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`));
|
||||
if (copiedGitHooks?.copied) {
|
||||
p.log.message(
|
||||
pc.dim(`Mirrored git hooks: ${copiedGitHooks.sourceHooksPath} -> ${copiedGitHooks.targetHooksPath}`),
|
||||
);
|
||||
}
|
||||
if (seedSummary) {
|
||||
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
|
||||
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
|
||||
for (const rebound of reboundWorkspaceSummary) {
|
||||
p.log.message(
|
||||
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
p.outro(
|
||||
pc.green(
|
||||
`Worktree ready. Run Paperclip inside this repo and the CLI/server will use ${paths.instanceId} automatically.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
|
||||
await runWorktreeInit(opts);
|
||||
}
|
||||
|
||||
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
|
||||
|
||||
const name = resolveWorktreeMakeName(nameArg);
|
||||
const sourceCwd = process.cwd();
|
||||
const targetPath = resolveWorktreeMakeTargetPath(name);
|
||||
if (existsSync(targetPath)) {
|
||||
throw new Error(`Target path already exists: ${targetPath}`);
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
if (opts.startPoint) {
|
||||
const [remote] = opts.startPoint.split("/", 1);
|
||||
try {
|
||||
execFileSync("git", ["fetch", remote], {
|
||||
cwd: sourceCwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to fetch from remote "${remote}": ${extractExecSyncErrorMessage(error) ?? String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const worktreeArgs = resolveGitWorktreeAddArgs({
|
||||
branchName: name,
|
||||
targetPath,
|
||||
branchExists: !opts.startPoint && localBranchExists(sourceCwd, name),
|
||||
startPoint: opts.startPoint,
|
||||
});
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Creating git worktree at ${targetPath}...`);
|
||||
try {
|
||||
execFileSync("git", worktreeArgs, {
|
||||
cwd: sourceCwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
spinner.stop(`Created git worktree at ${targetPath}.`);
|
||||
} catch (error) {
|
||||
spinner.stop(pc.red("Failed to create git worktree."));
|
||||
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
|
||||
const installSpinner = p.spinner();
|
||||
installSpinner.start("Installing dependencies...");
|
||||
try {
|
||||
execFileSync("pnpm", ["install"], {
|
||||
cwd: targetPath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
installSpinner.stop("Installed dependencies.");
|
||||
} catch (error) {
|
||||
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
|
||||
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(targetPath);
|
||||
await runWorktreeInit({
|
||||
...opts,
|
||||
name,
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
}
|
||||
|
||||
export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void> {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const envPath = resolvePaperclipEnvFile(configPath);
|
||||
const envEntries = readPaperclipEnvEntries(envPath);
|
||||
const out = {
|
||||
PAPERCLIP_CONFIG: configPath,
|
||||
...(envEntries.PAPERCLIP_HOME ? { PAPERCLIP_HOME: envEntries.PAPERCLIP_HOME } : {}),
|
||||
...(envEntries.PAPERCLIP_INSTANCE_ID ? { PAPERCLIP_INSTANCE_ID: envEntries.PAPERCLIP_INSTANCE_ID } : {}),
|
||||
...(envEntries.PAPERCLIP_CONTEXT ? { PAPERCLIP_CONTEXT: envEntries.PAPERCLIP_CONTEXT } : {}),
|
||||
...envEntries,
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(formatShellExports(out));
|
||||
}
|
||||
|
||||
export function registerWorktreeCommands(program: Command): void {
|
||||
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
||||
|
||||
program
|
||||
.command("worktree:make")
|
||||
.description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it")
|
||||
.argument("<name>", "Worktree directory and branch name (created at ~/NAME)")
|
||||
.option("--start-point <ref>", "Remote ref to base the new branch on (e.g. origin/main)")
|
||||
.option("--instance <id>", "Explicit isolated instance id")
|
||||
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.option("--from-config <path>", "Source config.json to seed from")
|
||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
|
||||
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
|
||||
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
|
||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
||||
.option("--no-seed", "Skip database seeding from the source instance")
|
||||
.option("--force", "Replace existing repo-local config and isolated instance data", false)
|
||||
.action(worktreeMakeCommand);
|
||||
|
||||
worktree
|
||||
.command("init")
|
||||
.description("Create repo-local config/env and an isolated instance for this worktree")
|
||||
.option("--name <name>", "Display name used to derive the instance id")
|
||||
.option("--instance <id>", "Explicit isolated instance id")
|
||||
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.option("--from-config <path>", "Source config.json to seed from")
|
||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
|
||||
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
|
||||
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
|
||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
||||
.option("--no-seed", "Skip database seeding from the source instance")
|
||||
.option("--force", "Replace existing repo-local config and isolated instance data", false)
|
||||
.action(worktreeInitCommand);
|
||||
|
||||
worktree
|
||||
.command("env")
|
||||
.description("Print shell exports for the current worktree-local Paperclip instance")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("--json", "Print JSON instead of shell exports")
|
||||
.action(worktreeEnvCommand);
|
||||
}
|
||||
@@ -25,17 +25,25 @@ function parseEnvFile(contents: string) {
|
||||
function renderEnvFile(entries: Record<string, string>) {
|
||||
const lines = [
|
||||
"# Paperclip environment variables",
|
||||
"# Generated by `paperclipai onboard`",
|
||||
"# Generated by Paperclip CLI commands",
|
||||
...Object.entries(entries).map(([key, value]) => `${key}=${value}`),
|
||||
"",
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function resolvePaperclipEnvFile(configPath?: string): string {
|
||||
return resolveEnvFilePath(configPath);
|
||||
}
|
||||
|
||||
export function resolveAgentJwtEnvFile(configPath?: string): string {
|
||||
return resolveEnvFilePath(configPath);
|
||||
}
|
||||
|
||||
export function loadPaperclipEnvFile(configPath?: string): void {
|
||||
loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
|
||||
}
|
||||
|
||||
export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
|
||||
if (loadedEnvFiles.has(filePath)) return;
|
||||
|
||||
@@ -78,13 +86,33 @@ export function ensureAgentJwtSecret(configPath?: string): { secret: string; cre
|
||||
}
|
||||
|
||||
export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void {
|
||||
mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath);
|
||||
}
|
||||
|
||||
export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record<string, string> {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
return parseEnvFile(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
export function writePaperclipEnvEntries(entries: Record<string, string>, filePath = resolveEnvFilePath()): void {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const current = fs.existsSync(filePath) ? parseEnvFile(fs.readFileSync(filePath, "utf-8")) : {};
|
||||
current[JWT_SECRET_ENV_KEY] = secret;
|
||||
|
||||
fs.writeFileSync(filePath, renderEnvFile(current), {
|
||||
fs.writeFileSync(filePath, renderEnvFile(entries), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export function mergePaperclipEnvEntries(
|
||||
entries: Record<string, string>,
|
||||
filePath = resolveEnvFilePath(),
|
||||
): Record<string, string> {
|
||||
const current = readPaperclipEnvEntries(filePath);
|
||||
const next = {
|
||||
...current,
|
||||
...Object.fromEntries(
|
||||
Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0),
|
||||
),
|
||||
};
|
||||
writePaperclipEnvEntries(next, filePath);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ export function resolveDefaultStorageDir(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "storage");
|
||||
}
|
||||
|
||||
export function resolveDefaultBackupDir(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "backups");
|
||||
}
|
||||
|
||||
export function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
@@ -64,6 +68,7 @@ export function describeLocalInstancePaths(instanceId?: string) {
|
||||
instanceRoot,
|
||||
configPath: resolveDefaultConfigPath(resolvedInstanceId),
|
||||
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(resolvedInstanceId),
|
||||
backupDir: resolveDefaultBackupDir(resolvedInstanceId),
|
||||
logDir: resolveDefaultLogsDir(resolvedInstanceId),
|
||||
secretsKeyFilePath: resolveDefaultSecretsKeyFilePath(resolvedInstanceId),
|
||||
storageDir: resolveDefaultStorageDir(resolvedInstanceId),
|
||||
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
paperclipConfigSchema,
|
||||
configMetaSchema,
|
||||
llmConfigSchema,
|
||||
databaseBackupConfigSchema,
|
||||
databaseConfigSchema,
|
||||
loggingConfigSchema,
|
||||
serverConfigSchema,
|
||||
@@ -13,6 +14,7 @@ export {
|
||||
secretsLocalEncryptedConfigSchema,
|
||||
type PaperclipConfig,
|
||||
type LlmConfig,
|
||||
type DatabaseBackupConfig,
|
||||
type DatabaseConfig,
|
||||
type LoggingConfig,
|
||||
type ServerConfig,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { addAllowedHostname } from "./commands/allowed-hostname.js";
|
||||
import { heartbeatRun } from "./commands/heartbeat-run.js";
|
||||
import { runCommand } from "./commands/run.js";
|
||||
import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js";
|
||||
import { dbBackupCommand } from "./commands/db-backup.js";
|
||||
import { registerContextCommands } from "./commands/client/context.js";
|
||||
import { registerCompanyCommands } from "./commands/client/company.js";
|
||||
import { registerIssueCommands } from "./commands/client/issue.js";
|
||||
@@ -15,6 +16,8 @@ import { registerApprovalCommands } from "./commands/client/approval.js";
|
||||
import { registerActivityCommands } from "./commands/client/activity.js";
|
||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
@@ -23,7 +26,7 @@ const DATA_DIR_OPTION_HELP =
|
||||
program
|
||||
.name("paperclipai")
|
||||
.description("Paperclip CLI — setup, diagnose, and configure your instance")
|
||||
.version("0.2.6");
|
||||
.version("0.2.7");
|
||||
|
||||
program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
|
||||
@@ -32,6 +35,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
hasConfigOption: optionNames.has("config"),
|
||||
hasContextOption: optionNames.has("context"),
|
||||
});
|
||||
loadPaperclipEnvFile(options.config);
|
||||
});
|
||||
|
||||
program
|
||||
@@ -70,6 +74,19 @@ program
|
||||
.option("-s, --section <section>", "Section to configure (llm, database, logging, server, storage, secrets)")
|
||||
.action(configure);
|
||||
|
||||
program
|
||||
.command("db:backup")
|
||||
.description("Create a one-off database backup using current config")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.option("--dir <path>", "Backup output directory (overrides config)")
|
||||
.option("--retention-days <days>", "Retention window used for pruning", (value) => Number(value))
|
||||
.option("--filename-prefix <prefix>", "Backup filename prefix", "paperclip")
|
||||
.option("--json", "Print backup metadata as JSON")
|
||||
.action(async (opts) => {
|
||||
await dbBackupCommand(opts);
|
||||
});
|
||||
|
||||
program
|
||||
.command("allowed-hostname")
|
||||
.description("Allow a hostname for authenticated/private mode access")
|
||||
@@ -118,6 +135,7 @@ registerAgentCommands(program);
|
||||
registerApprovalCommands(program);
|
||||
registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
|
||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import type { DatabaseConfig } from "../config/schema.js";
|
||||
import { resolveDefaultEmbeddedPostgresDir, resolvePaperclipInstanceId } from "../config/home.js";
|
||||
import {
|
||||
resolveDefaultBackupDir,
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
|
||||
export async function promptDatabase(): Promise<DatabaseConfig> {
|
||||
const defaultEmbeddedDir = resolveDefaultEmbeddedPostgresDir(resolvePaperclipInstanceId());
|
||||
export async function promptDatabase(current?: DatabaseConfig): Promise<DatabaseConfig> {
|
||||
const instanceId = resolvePaperclipInstanceId();
|
||||
const defaultEmbeddedDir = resolveDefaultEmbeddedPostgresDir(instanceId);
|
||||
const defaultBackupDir = resolveDefaultBackupDir(instanceId);
|
||||
const base: DatabaseConfig = current ?? {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: defaultEmbeddedDir,
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: defaultBackupDir,
|
||||
},
|
||||
};
|
||||
|
||||
const mode = await p.select({
|
||||
message: "Database mode",
|
||||
@@ -11,6 +28,7 @@ export async function promptDatabase(): Promise<DatabaseConfig> {
|
||||
{ value: "embedded-postgres" as const, label: "Embedded PostgreSQL (managed locally)", hint: "recommended" },
|
||||
{ value: "postgres" as const, label: "PostgreSQL (external server)" },
|
||||
],
|
||||
initialValue: base.mode,
|
||||
});
|
||||
|
||||
if (p.isCancel(mode)) {
|
||||
@@ -18,9 +36,14 @@ export async function promptDatabase(): Promise<DatabaseConfig> {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let connectionString: string | undefined = base.connectionString;
|
||||
let embeddedPostgresDataDir = base.embeddedPostgresDataDir || defaultEmbeddedDir;
|
||||
let embeddedPostgresPort = base.embeddedPostgresPort || 54329;
|
||||
|
||||
if (mode === "postgres") {
|
||||
const connectionString = await p.text({
|
||||
const value = await p.text({
|
||||
message: "PostgreSQL connection string",
|
||||
defaultValue: base.connectionString ?? "",
|
||||
placeholder: "postgres://user:pass@localhost:5432/paperclip",
|
||||
validate: (val) => {
|
||||
if (!val) return "Connection string is required for PostgreSQL mode";
|
||||
@@ -28,48 +51,107 @@ export async function promptDatabase(): Promise<DatabaseConfig> {
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(connectionString)) {
|
||||
if (p.isCancel(value)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "postgres",
|
||||
connectionString,
|
||||
embeddedPostgresDataDir: defaultEmbeddedDir,
|
||||
embeddedPostgresPort: 54329,
|
||||
};
|
||||
connectionString = value;
|
||||
} else {
|
||||
const dataDir = await p.text({
|
||||
message: "Embedded PostgreSQL data directory",
|
||||
defaultValue: base.embeddedPostgresDataDir || defaultEmbeddedDir,
|
||||
placeholder: defaultEmbeddedDir,
|
||||
});
|
||||
|
||||
if (p.isCancel(dataDir)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
embeddedPostgresDataDir = dataDir || defaultEmbeddedDir;
|
||||
|
||||
const portValue = await p.text({
|
||||
message: "Embedded PostgreSQL port",
|
||||
defaultValue: String(base.embeddedPostgresPort || 54329),
|
||||
placeholder: "54329",
|
||||
validate: (val) => {
|
||||
const n = Number(val);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) return "Port must be an integer between 1 and 65535";
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(portValue)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
embeddedPostgresPort = Number(portValue || "54329");
|
||||
connectionString = undefined;
|
||||
}
|
||||
|
||||
const embeddedPostgresDataDir = await p.text({
|
||||
message: "Embedded PostgreSQL data directory",
|
||||
defaultValue: defaultEmbeddedDir,
|
||||
placeholder: defaultEmbeddedDir,
|
||||
const backupEnabled = await p.confirm({
|
||||
message: "Enable automatic database backups?",
|
||||
initialValue: base.backup.enabled,
|
||||
});
|
||||
|
||||
if (p.isCancel(embeddedPostgresDataDir)) {
|
||||
if (p.isCancel(backupEnabled)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const embeddedPostgresPort = await p.text({
|
||||
message: "Embedded PostgreSQL port",
|
||||
defaultValue: "54329",
|
||||
placeholder: "54329",
|
||||
const backupDirInput = await p.text({
|
||||
message: "Backup directory",
|
||||
defaultValue: base.backup.dir || defaultBackupDir,
|
||||
placeholder: defaultBackupDir,
|
||||
validate: (val) => (!val || val.trim().length === 0 ? "Backup directory is required" : undefined),
|
||||
});
|
||||
if (p.isCancel(backupDirInput)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const backupIntervalInput = await p.text({
|
||||
message: "Backup interval (minutes)",
|
||||
defaultValue: String(base.backup.intervalMinutes || 60),
|
||||
placeholder: "60",
|
||||
validate: (val) => {
|
||||
const n = Number(val);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) return "Port must be an integer between 1 and 65535";
|
||||
if (!Number.isInteger(n) || n < 1) return "Interval must be a positive integer";
|
||||
if (n > 10080) return "Interval must be 10080 minutes (7 days) or less";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(backupIntervalInput)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (p.isCancel(embeddedPostgresPort)) {
|
||||
const backupRetentionInput = await p.text({
|
||||
message: "Backup retention (days)",
|
||||
defaultValue: String(base.backup.retentionDays || 30),
|
||||
placeholder: "30",
|
||||
validate: (val) => {
|
||||
const n = Number(val);
|
||||
if (!Number.isInteger(n) || n < 1) return "Retention must be a positive integer";
|
||||
if (n > 3650) return "Retention must be 3650 days or less";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(backupRetentionInput)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: embeddedPostgresDataDir || defaultEmbeddedDir,
|
||||
embeddedPostgresPort: Number(embeddedPostgresPort || "54329"),
|
||||
mode,
|
||||
connectionString,
|
||||
embeddedPostgresDataDir,
|
||||
embeddedPostgresPort,
|
||||
backup: {
|
||||
enabled: backupEnabled,
|
||||
intervalMinutes: Number(backupIntervalInput || "60"),
|
||||
retentionDays: Number(backupRetentionInput || "30"),
|
||||
dir: backupDirInput || defaultBackupDir,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function promptServer(opts?: {
|
||||
}
|
||||
|
||||
const port = Number(portStr) || 3100;
|
||||
let auth: AuthConfig = { baseUrlMode: "auto" };
|
||||
let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false };
|
||||
if (deploymentMode === "authenticated" && exposure === "public") {
|
||||
const urlInput = await p.text({
|
||||
message: "Public base URL",
|
||||
@@ -139,18 +139,26 @@ export async function promptServer(opts?: {
|
||||
}
|
||||
auth = {
|
||||
baseUrlMode: "explicit",
|
||||
disableSignUp: false,
|
||||
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
|
||||
};
|
||||
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
|
||||
auth = {
|
||||
baseUrlMode: "explicit",
|
||||
disableSignUp: false,
|
||||
publicBaseUrl: currentAuth.publicBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
server: { deploymentMode, exposure, host: hostStr.trim(), port, allowedHostnames, serveUi: true },
|
||||
server: {
|
||||
deploymentMode,
|
||||
exposure,
|
||||
host: hostStr.trim(),
|
||||
port,
|
||||
allowedHostnames,
|
||||
serveUi: currentServer?.serveUi ?? true,
|
||||
},
|
||||
auth,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
14
doc/CLI.md
14
doc/CLI.md
@@ -116,6 +116,20 @@ pnpm paperclipai issue release <issue-id>
|
||||
```sh
|
||||
pnpm paperclipai agent list --company-id <company-id>
|
||||
pnpm paperclipai agent get <agent-id>
|
||||
pnpm paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>
|
||||
```
|
||||
|
||||
`agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent:
|
||||
|
||||
- creates a new long-lived agent API key
|
||||
- installs missing Paperclip skills into `~/.codex/skills` and `~/.claude/skills`
|
||||
- prints `export ...` lines for `PAPERCLIP_API_URL`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_API_KEY`
|
||||
|
||||
Example for shortname-based local setup:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||
```
|
||||
|
||||
## Approval Commands
|
||||
|
||||
@@ -19,6 +19,14 @@ That's it. On first start the server:
|
||||
|
||||
Data persists across restarts in `~/.paperclip/instances/default/db/`. To reset local dev data, delete that directory.
|
||||
|
||||
If you need to apply pending migrations manually, run:
|
||||
|
||||
```sh
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
When `DATABASE_URL` is unset, this command targets the current embedded PostgreSQL instance for your active Paperclip config/instance.
|
||||
|
||||
This mode is ideal for local development and one-command installs.
|
||||
|
||||
Docker note: the Docker quickstart image also uses embedded PostgreSQL by default. Persist `/paperclip` to keep DB state across container restarts (see `doc/DOCKER.md`).
|
||||
|
||||
@@ -15,6 +15,14 @@ Current implementation status:
|
||||
- Node.js 20+
|
||||
- pnpm 9+
|
||||
|
||||
## Dependency Lockfile Policy
|
||||
|
||||
GitHub Actions owns `pnpm-lock.yaml`.
|
||||
|
||||
- Same-repo pull requests that change dependency manifests are auto-refreshed by GitHub Actions before merge.
|
||||
- Fork pull requests that change dependency manifests must include the refreshed `pnpm-lock.yaml`.
|
||||
- Pull request CI validates lockfile freshness when manifests change and verifies with `--frozen-lockfile`.
|
||||
|
||||
## Start Dev
|
||||
|
||||
From repo root:
|
||||
@@ -29,6 +37,8 @@ This starts:
|
||||
- API server: `http://localhost:3100`
|
||||
- UI: served by the API server in dev middleware mode (same origin as API)
|
||||
|
||||
`pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching.
|
||||
|
||||
Tailscale/private-auth dev mode:
|
||||
|
||||
```sh
|
||||
@@ -114,6 +124,113 @@ When a local agent run has no resolved project/session workspace, Paperclip fall
|
||||
|
||||
This path honors `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` in non-default setups.
|
||||
|
||||
## Worktree-local Instances
|
||||
|
||||
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.
|
||||
|
||||
Instead, create a repo-local Paperclip config plus an isolated instance for the worktree:
|
||||
|
||||
```sh
|
||||
paperclipai worktree init
|
||||
# or create the git worktree and initialize it in one step:
|
||||
pnpm paperclipai worktree:make paperclip-pr-432
|
||||
```
|
||||
|
||||
This command:
|
||||
|
||||
- writes repo-local files at `.paperclip/config.json` and `.paperclip/.env`
|
||||
- creates an isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`
|
||||
- when run inside a linked git worktree, mirrors the effective git hooks into that worktree's private git dir
|
||||
- picks a free app port and embedded PostgreSQL port
|
||||
- by default seeds the isolated DB in `minimal` mode from your main instance via a logical SQL snapshot
|
||||
|
||||
Seed modes:
|
||||
|
||||
- `minimal` keeps core app state like companies, projects, issues, comments, approvals, and auth state, preserves schema for all tables, but omits row data from heavy operational history such as heartbeat runs, wake requests, activity logs, runtime services, and agent session state
|
||||
- `full` makes a full logical clone of the source instance
|
||||
- `--no-seed` creates an empty isolated instance
|
||||
|
||||
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
|
||||
|
||||
That repo-local env also sets `PAPERCLIP_IN_WORKTREE=true`, which the server can use for worktree-specific UI behavior such as an alternate favicon.
|
||||
|
||||
Print shell exports explicitly when needed:
|
||||
|
||||
```sh
|
||||
paperclipai worktree env
|
||||
# or:
|
||||
eval "$(paperclipai worktree env)"
|
||||
```
|
||||
|
||||
### Worktree CLI Reference
|
||||
|
||||
**`pnpm paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--name <name>` | Display name used to derive the instance id |
|
||||
| `--instance <id>` | Explicit isolated instance id |
|
||||
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
|
||||
| `--from-config <path>` | Source config.json to seed from |
|
||||
| `--from-data-dir <path>` | Source PAPERCLIP_HOME used when deriving the source config |
|
||||
| `--from-instance <id>` | Source instance id (default: `default`) |
|
||||
| `--server-port <port>` | Preferred server port |
|
||||
| `--db-port <port>` | Preferred embedded Postgres port |
|
||||
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
|
||||
| `--no-seed` | Skip database seeding from the source instance |
|
||||
| `--force` | Replace existing repo-local config and isolated instance data |
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
paperclipai worktree init --no-seed
|
||||
paperclipai worktree init --seed-mode full
|
||||
paperclipai worktree init --from-instance default
|
||||
paperclipai worktree init --from-data-dir ~/.paperclip
|
||||
paperclipai worktree init --force
|
||||
```
|
||||
|
||||
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--start-point <ref>` | Remote ref to base the new branch on (e.g. `origin/main`) |
|
||||
| `--instance <id>` | Explicit isolated instance id |
|
||||
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
|
||||
| `--from-config <path>` | Source config.json to seed from |
|
||||
| `--from-data-dir <path>` | Source PAPERCLIP_HOME used when deriving the source config |
|
||||
| `--from-instance <id>` | Source instance id (default: `default`) |
|
||||
| `--server-port <port>` | Preferred server port |
|
||||
| `--db-port <port>` | Preferred embedded Postgres port |
|
||||
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
|
||||
| `--no-seed` | Skip database seeding from the source instance |
|
||||
| `--force` | Replace existing repo-local config and isolated instance data |
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree:make paperclip-pr-432
|
||||
pnpm paperclipai worktree:make my-feature --start-point origin/main
|
||||
pnpm paperclipai worktree:make experiment --no-seed
|
||||
```
|
||||
|
||||
**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `-c, --config <path>` | Path to config file |
|
||||
| `--json` | Print JSON instead of shell exports |
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai worktree env
|
||||
pnpm paperclipai worktree env --json
|
||||
eval "$(pnpm paperclipai worktree env)"
|
||||
```
|
||||
|
||||
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
|
||||
|
||||
## Quick Health Checks
|
||||
|
||||
In another terminal:
|
||||
@@ -141,6 +258,36 @@ pnpm dev
|
||||
|
||||
If you set `DATABASE_URL`, the server will use that instead of embedded PostgreSQL.
|
||||
|
||||
## Automatic DB Backups
|
||||
|
||||
Paperclip can run automatic DB backups on a timer. Defaults:
|
||||
|
||||
- enabled
|
||||
- every 60 minutes
|
||||
- retain 30 days
|
||||
- backup dir: `~/.paperclip/instances/default/data/backups`
|
||||
|
||||
Configure these in:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai configure --section database
|
||||
```
|
||||
|
||||
Run a one-off backup manually:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai db:backup
|
||||
# or:
|
||||
pnpm db:backup
|
||||
```
|
||||
|
||||
Environment overrides:
|
||||
|
||||
- `PAPERCLIP_DB_BACKUP_ENABLED=true|false`
|
||||
- `PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES=<minutes>`
|
||||
- `PAPERCLIP_DB_BACKUP_RETENTION_DAYS=<days>`
|
||||
- `PAPERCLIP_DB_BACKUP_DIR=/absolute/or/~/path`
|
||||
|
||||
## Secrets in Dev
|
||||
|
||||
Agent env vars now support secret references. By default, secret values are stored with local encryption and only secret refs are persisted in agent config.
|
||||
@@ -216,5 +363,61 @@ Agent-oriented invite onboarding now exposes machine-readable API docs:
|
||||
|
||||
- `GET /api/invites/:token` returns invite summary plus onboarding and skills index links.
|
||||
- `GET /api/invites/:token/onboarding` returns onboarding manifest details (registration endpoint, claim endpoint template, skill install hints).
|
||||
- `GET /api/invites/:token/onboarding.txt` returns a plain-text onboarding doc intended for both human operators and agents (llm.txt-style handoff), including optional inviter message and suggested network host candidates.
|
||||
- `GET /api/skills/index` lists available skill documents.
|
||||
- `GET /api/skills/paperclip` returns the Paperclip heartbeat skill markdown.
|
||||
|
||||
## OpenClaw Join Smoke Test
|
||||
|
||||
Run the end-to-end OpenClaw join smoke harness:
|
||||
|
||||
```sh
|
||||
pnpm smoke:openclaw-join
|
||||
```
|
||||
|
||||
What it validates:
|
||||
|
||||
- invite creation for agent-only join
|
||||
- agent join request using `adapterType=openclaw`
|
||||
- board approval + one-time API key claim semantics
|
||||
- callback delivery on wakeup to a dockerized OpenClaw-style webhook receiver
|
||||
|
||||
Required permissions:
|
||||
|
||||
- This script performs board-governed actions (create invite, approve join, wakeup another agent).
|
||||
- In authenticated mode, run with board auth via `PAPERCLIP_AUTH_HEADER` or `PAPERCLIP_COOKIE`.
|
||||
|
||||
Optional auth flags (for authenticated mode):
|
||||
|
||||
- `PAPERCLIP_AUTH_HEADER` (for example `Bearer ...`)
|
||||
- `PAPERCLIP_COOKIE` (session cookie header value)
|
||||
|
||||
## OpenClaw Docker UI One-Command Script
|
||||
|
||||
To boot OpenClaw in Docker and print a host-browser dashboard URL in one command:
|
||||
|
||||
```sh
|
||||
pnpm smoke:openclaw-docker-ui
|
||||
```
|
||||
|
||||
This script lives at `scripts/smoke/openclaw-docker-ui.sh` and automates clone/build/config/start for Compose-based local OpenClaw UI testing.
|
||||
|
||||
Pairing behavior for this smoke script:
|
||||
|
||||
- default `OPENCLAW_DISABLE_DEVICE_AUTH=1` (no Control UI pairing prompt for local smoke; no extra pairing env vars required)
|
||||
- set `OPENCLAW_DISABLE_DEVICE_AUTH=0` to require standard device pairing
|
||||
|
||||
Model behavior for this smoke script:
|
||||
|
||||
- defaults to OpenAI models (`openai/gpt-5.2` + OpenAI fallback) so it does not require Anthropic auth by default
|
||||
|
||||
State behavior for this smoke script:
|
||||
|
||||
- defaults to isolated config dir `~/.openclaw-paperclip-smoke`
|
||||
- resets smoke agent state each run by default (`OPENCLAW_RESET_STATE=1`) to avoid stale provider/auth drift
|
||||
|
||||
Networking behavior for this smoke script:
|
||||
|
||||
- auto-detects and prints a Paperclip host URL reachable from inside OpenClaw Docker
|
||||
- default container-side host alias is `host.docker.internal` (override with `PAPERCLIP_HOST_FROM_CONTAINER` / `PAPERCLIP_HOST_PORT`)
|
||||
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `pnpm paperclipai allowed-hostname host.docker.internal` and restart Paperclip
|
||||
|
||||
@@ -42,6 +42,32 @@ Optional overrides:
|
||||
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=./data/pc docker compose -f docker-compose.quickstart.yml up --build
|
||||
```
|
||||
|
||||
If you change host port or use a non-local domain, set `PAPERCLIP_PUBLIC_URL` to the external URL you will use in browser/auth flows.
|
||||
|
||||
## Authenticated Compose (Single Public URL)
|
||||
|
||||
For authenticated deployments, set one canonical public URL and let Paperclip derive auth/callback defaults:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
paperclip:
|
||||
environment:
|
||||
PAPERCLIP_DEPLOYMENT_MODE: authenticated
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE: private
|
||||
PAPERCLIP_PUBLIC_URL: https://desk.koker.net
|
||||
```
|
||||
|
||||
`PAPERCLIP_PUBLIC_URL` is used as the primary source for:
|
||||
|
||||
- auth public base URL
|
||||
- Better Auth base URL defaults
|
||||
- bootstrap invite URL defaults
|
||||
- hostname allowlist defaults (hostname extracted from URL)
|
||||
|
||||
Granular overrides remain available if needed (`PAPERCLIP_AUTH_PUBLIC_BASE_URL`, `BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `PAPERCLIP_ALLOWED_HOSTNAMES`).
|
||||
|
||||
Set `PAPERCLIP_ALLOWED_HOSTNAMES` explicitly only when you need additional hostnames beyond the public URL host (for example Tailscale/LAN aliases or multiple private hostnames).
|
||||
|
||||
## Claude + Codex Local Adapters in Docker
|
||||
|
||||
The image pre-installs:
|
||||
@@ -96,5 +122,7 @@ Notes:
|
||||
- Container runtime user id defaults to your local `id -u` so the mounted data dir stays writable while avoiding root runtime.
|
||||
- Smoke script defaults to `authenticated/private` mode so `HOST=0.0.0.0` can be exposed to the host.
|
||||
- Smoke script defaults host port to `3131` to avoid conflicts with local Paperclip on `3100`.
|
||||
- Smoke script also defaults `PAPERCLIP_PUBLIC_URL` to `http://localhost:<HOST_PORT>` so bootstrap invite URLs and auth callbacks use the reachable host port instead of the container's internal `3100`.
|
||||
- In authenticated mode, the smoke script defaults `SMOKE_AUTO_BOOTSTRAP=true` and drives the real bootstrap path automatically: it signs up a real user, runs `paperclipai auth bootstrap-ceo` inside the container to mint a real bootstrap invite, accepts that invite over HTTP, and verifies board session access.
|
||||
- Run the script in the foreground to watch the onboarding flow; stop with `Ctrl+C` after validation.
|
||||
- The image definition is in `Dockerfile.onboard-smoke`.
|
||||
|
||||
94
doc/OPENCLAW_ONBOARDING.md
Normal file
94
doc/OPENCLAW_ONBOARDING.md
Normal file
@@ -0,0 +1,94 @@
|
||||
Use this exact checklist.
|
||||
|
||||
1. Start Paperclip in auth mode.
|
||||
```bash
|
||||
cd <paperclip-repo-root>
|
||||
pnpm dev --tailscale-auth
|
||||
```
|
||||
Then verify:
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:3100/api/health | jq
|
||||
```
|
||||
|
||||
2. Start a clean/stock OpenClaw Docker.
|
||||
```bash
|
||||
OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
|
||||
```
|
||||
Open the printed `Dashboard URL` (includes `#token=...`) in your browser.
|
||||
|
||||
3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`.
|
||||
|
||||
4. Use the OpenClaw invite prompt flow.
|
||||
- In the Invites section, click `Generate OpenClaw Invite Prompt`.
|
||||
- Copy the generated prompt from `OpenClaw Invite Prompt`.
|
||||
- Paste it into OpenClaw main chat as one message.
|
||||
- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.`
|
||||
|
||||
Security/control note:
|
||||
- The OpenClaw invite prompt is created from a controlled endpoint:
|
||||
- `POST /api/companies/{companyId}/openclaw/invite-prompt`
|
||||
- board users with invite permission can call it
|
||||
- agent callers are limited to the company CEO agent
|
||||
|
||||
5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents.
|
||||
|
||||
6. Gateway preflight (required before task tests).
|
||||
- Confirm the created agent uses `openclaw_gateway` (not `openclaw`).
|
||||
- Confirm gateway URL is `ws://...` or `wss://...`.
|
||||
- Confirm gateway token is non-trivial (not empty / not 1-char placeholder).
|
||||
- The OpenClaw Gateway adapter UI should not expose `disableDeviceAuth` for normal onboarding.
|
||||
- Confirm pairing mode is explicit:
|
||||
- required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem`
|
||||
- do not rely on `disableDeviceAuth` for normal onboarding
|
||||
- If you can run API checks with board auth:
|
||||
```bash
|
||||
AGENT_ID="<newly-created-agent-id>"
|
||||
curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT_ID" | jq '{adapterType,adapterConfig:{url:.adapterConfig.url,tokenLen:(.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // "" | length),disableDeviceAuth:(.adapterConfig.disableDeviceAuth // false),hasDeviceKey:(.adapterConfig.devicePrivateKeyPem // "" | length > 0)}}'
|
||||
```
|
||||
- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`.
|
||||
|
||||
Pairing handshake note:
|
||||
- Clean run expectation: first task should succeed without manual pairing commands.
|
||||
- The adapter attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid).
|
||||
- If auto-pair cannot complete (for example token mismatch or no pending request), the first gateway run may still return `pairing required`.
|
||||
- This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself.
|
||||
- Approve it in OpenClaw, then retry the task.
|
||||
- For local docker smoke, you can approve from host:
|
||||
```bash
|
||||
docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'openclaw devices approve --latest --json --url "ws://127.0.0.1:18789" --token "$(node -p \"require(process.env.HOME+\\\"/.openclaw/openclaw.json\\\").gateway.auth.token\")"'
|
||||
```
|
||||
- You can inspect pending vs paired devices:
|
||||
```bash
|
||||
docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'TOK="$(node -e \"const fs=require(\\\"fs\\\");const c=JSON.parse(fs.readFileSync(\\\"/home/node/.openclaw/openclaw.json\\\",\\\"utf8\\\"));process.stdout.write(c.gateway?.auth?.token||\\\"\\\");\")\"; openclaw devices list --json --url \"ws://127.0.0.1:18789\" --token \"$TOK\"'
|
||||
```
|
||||
|
||||
7. Case A (manual issue test).
|
||||
- Create an issue assigned to the OpenClaw agent.
|
||||
- Put instructions: “post comment `OPENCLAW_CASE_A_OK_<timestamp>` and mark done.”
|
||||
- Verify in UI: issue status becomes `done` and comment exists.
|
||||
|
||||
8. Case B (message tool test).
|
||||
- Create another issue assigned to OpenClaw.
|
||||
- Instructions: “send `OPENCLAW_CASE_B_OK_<timestamp>` to main webchat via message tool, then comment same marker on issue, then mark done.”
|
||||
- Verify both:
|
||||
- marker comment on issue
|
||||
- marker text appears in OpenClaw main chat
|
||||
|
||||
9. Case C (new session memory/skills test).
|
||||
- In OpenClaw, start `/new` session.
|
||||
- Ask it to create a new CLA issue in Paperclip with unique title `OPENCLAW_CASE_C_CREATED_<timestamp>`.
|
||||
- Verify in Paperclip UI that new issue exists.
|
||||
|
||||
10. Watch logs during test (optional but helpful):
|
||||
```bash
|
||||
docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.paperclip-openclaw.override.yml logs -f openclaw-gateway
|
||||
```
|
||||
|
||||
11. Expected pass criteria.
|
||||
- Preflight: `openclaw_gateway` + non-placeholder token (`tokenLen >= 16`).
|
||||
- Pairing mode: stable `devicePrivateKeyPem` configured with device auth enabled (default path).
|
||||
- Case A: `done` + marker comment.
|
||||
- Case B: `done` + marker comment + main-chat message visible.
|
||||
- Case C: original task done and new issue created from `/new` session.
|
||||
|
||||
If you want, I can also give you a single “observer mode” command that runs the stock smoke harness while you watch the same steps live in UI.
|
||||
@@ -1,196 +1,121 @@
|
||||
# Publishing to npm
|
||||
|
||||
This document covers how to build and publish the `paperclipai` CLI package to npm.
|
||||
Low-level reference for how Paperclip packages are built for npm.
|
||||
|
||||
## Prerequisites
|
||||
For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This document is only about packaging internals and the scripts that produce publishable artifacts.
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm 9.15+
|
||||
- An npm account with publish access to the `paperclipai` package
|
||||
- Logged in to npm: `npm login`
|
||||
## Current Release Entry Points
|
||||
|
||||
## One-Command Publish
|
||||
Use these scripts instead of older one-off publish commands:
|
||||
|
||||
The fastest way to publish — bumps version, builds, publishes, restores, commits, and tags in one shot:
|
||||
- [`scripts/release-start.sh`](../scripts/release-start.sh) to create or resume `release/X.Y.Z`
|
||||
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) before any canary or stable release
|
||||
- [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes
|
||||
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after pushing the stable branch tag
|
||||
|
||||
```bash
|
||||
./scripts/bump-and-publish.sh patch # 0.1.1 → 0.1.2
|
||||
./scripts/bump-and-publish.sh minor # 0.1.1 → 0.2.0
|
||||
./scripts/bump-and-publish.sh major # 0.1.1 → 1.0.0
|
||||
./scripts/bump-and-publish.sh 2.0.0 # set explicit version
|
||||
./scripts/bump-and-publish.sh patch --dry-run # everything except npm publish
|
||||
```
|
||||
## Why the CLI needs special packaging
|
||||
|
||||
The script runs all 6 steps below in order. It requires a clean working tree and an active `npm login` session (unless `--dry-run`). After it finishes, push:
|
||||
The CLI package, `paperclipai`, imports code from workspace packages such as:
|
||||
|
||||
```bash
|
||||
git push && git push origin v<version>
|
||||
```
|
||||
- `@paperclipai/server`
|
||||
- `@paperclipai/db`
|
||||
- `@paperclipai/shared`
|
||||
- adapter packages under `packages/adapters/`
|
||||
|
||||
## Manual Step-by-Step
|
||||
Those workspace references use `workspace:*` during development. npm cannot install those references directly for end users, so the release build has to transform the CLI into a publishable standalone package.
|
||||
|
||||
If you prefer to run each step individually:
|
||||
## `build-npm.sh`
|
||||
|
||||
### Quick Reference
|
||||
|
||||
```bash
|
||||
# Bump version
|
||||
./scripts/version-bump.sh patch # 0.1.0 → 0.1.1
|
||||
|
||||
# Build
|
||||
./scripts/build-npm.sh
|
||||
|
||||
# Preview what will be published
|
||||
cd cli && npm pack --dry-run
|
||||
|
||||
# Publish
|
||||
cd cli && npm publish --access public
|
||||
|
||||
# Restore dev package.json
|
||||
mv cli/package.dev.json cli/package.json
|
||||
```
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Bump the version
|
||||
|
||||
```bash
|
||||
./scripts/version-bump.sh <patch|minor|major|X.Y.Z>
|
||||
```
|
||||
|
||||
This updates the version in two places:
|
||||
|
||||
- `cli/package.json` — the source of truth
|
||||
- `cli/src/index.ts` — the Commander `.version()` call
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
./scripts/version-bump.sh patch # 0.1.0 → 0.1.1
|
||||
./scripts/version-bump.sh minor # 0.1.0 → 0.2.0
|
||||
./scripts/version-bump.sh major # 0.1.0 → 1.0.0
|
||||
./scripts/version-bump.sh 1.2.3 # set explicit version
|
||||
```
|
||||
|
||||
### 2. Build
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./scripts/build-npm.sh
|
||||
```
|
||||
|
||||
The build script runs five steps:
|
||||
This script does six things:
|
||||
|
||||
1. **Forbidden token check** — scans tracked files for tokens listed in `.git/hooks/forbidden-tokens.txt`. If the file is missing (e.g. on a contributor's machine), the check passes silently. The script never prints which tokens it's searching for.
|
||||
2. **TypeScript type-check** — runs `pnpm -r typecheck` across all workspace packages.
|
||||
3. **esbuild bundle** — bundles the CLI entry point (`cli/src/index.ts`) and all workspace package code (`@paperclipai/*`) into a single file at `cli/dist/index.js`. External npm dependencies (express, postgres, etc.) are kept as regular imports.
|
||||
4. **Generate publishable package.json** — replaces `cli/package.json` with a version that has real npm dependency ranges instead of `workspace:*` references (see [package.dev.json](#packagedevjson) below).
|
||||
5. **Summary** — prints the bundle size and next steps.
|
||||
1. Runs the forbidden token check unless `--skip-checks` is supplied
|
||||
2. Runs `pnpm -r typecheck`
|
||||
3. Bundles the CLI entrypoint with esbuild into `cli/dist/index.js`
|
||||
4. Verifies the bundled entrypoint with `node --check`
|
||||
5. Rewrites `cli/package.json` into a publishable npm manifest and stores the dev copy as `cli/package.dev.json`
|
||||
6. Copies the repo `README.md` into `cli/README.md` for npm package metadata
|
||||
|
||||
To skip the forbidden token check (e.g. in CI without the token list):
|
||||
`build-npm.sh` is used by the release script so that npm users install a real package rather than unresolved workspace dependencies.
|
||||
|
||||
## Publishable CLI layout
|
||||
|
||||
During development, [`cli/package.json`](../cli/package.json) contains workspace references.
|
||||
|
||||
During release preparation:
|
||||
|
||||
- `cli/package.json` becomes a publishable manifest with external npm dependency ranges
|
||||
- `cli/package.dev.json` stores the development manifest temporarily
|
||||
- `cli/dist/index.js` contains the bundled CLI entrypoint
|
||||
- `cli/README.md` is copied in for npm metadata
|
||||
|
||||
After release finalization, the release script restores the development manifest and removes the temporary README copy.
|
||||
|
||||
## Package discovery
|
||||
|
||||
The release tooling scans the workspace for public packages under:
|
||||
|
||||
- `packages/`
|
||||
- `server/`
|
||||
- `cli/`
|
||||
|
||||
`ui/` remains ignored for npm publishing because it is private.
|
||||
|
||||
This matters because all public packages are versioned and published together as one release unit.
|
||||
|
||||
## Canary packaging model
|
||||
|
||||
Canaries are published as semver prereleases such as:
|
||||
|
||||
- `1.2.3-canary.0`
|
||||
- `1.2.3-canary.1`
|
||||
|
||||
They are published under the npm dist-tag `canary`.
|
||||
|
||||
This means:
|
||||
|
||||
- `npx paperclipai@canary onboard` can install them explicitly
|
||||
- `npx paperclipai onboard` continues to resolve `latest`
|
||||
- the stable changelog can stay at `releases/v1.2.3.md`
|
||||
|
||||
## Stable packaging model
|
||||
|
||||
Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`.
|
||||
|
||||
The stable publish flow also creates the local release commit and git tag on `release/X.Y.Z`. Pushing that branch commit/tag, creating the GitHub Release, and merging the release branch back to `master` happen afterward as separate maintainer steps.
|
||||
|
||||
## Rollback model
|
||||
|
||||
Rollback does not unpublish packages.
|
||||
|
||||
Instead, the maintainer should move the `latest` dist-tag back to the previous good stable version with:
|
||||
|
||||
```bash
|
||||
./scripts/build-npm.sh --skip-checks
|
||||
./scripts/rollback-latest.sh <stable-version>
|
||||
```
|
||||
|
||||
### 3. Preview (optional)
|
||||
That keeps history intact while restoring the default install path quickly.
|
||||
|
||||
See what npm will publish:
|
||||
## Notes for CI
|
||||
|
||||
```bash
|
||||
cd cli && npm pack --dry-run
|
||||
```
|
||||
The repo includes a manual GitHub Actions release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
|
||||
|
||||
### 4. Publish
|
||||
Recommended CI release setup:
|
||||
|
||||
```bash
|
||||
cd cli && npm publish --access public
|
||||
```
|
||||
- use npm trusted publishing via GitHub OIDC
|
||||
- require approval through the `npm-release` environment
|
||||
- run releases from `release/X.Y.Z`
|
||||
- use canary first, then stable
|
||||
|
||||
### 5. Restore dev package.json
|
||||
## Related Files
|
||||
|
||||
After publishing, restore the workspace-aware `package.json`:
|
||||
|
||||
```bash
|
||||
mv cli/package.dev.json cli/package.json
|
||||
```
|
||||
|
||||
### 6. Commit and tag
|
||||
|
||||
```bash
|
||||
git add cli/package.json cli/src/index.ts
|
||||
git commit -m "chore: bump version to X.Y.Z"
|
||||
git tag vX.Y.Z
|
||||
```
|
||||
|
||||
## package.dev.json
|
||||
|
||||
During development, `cli/package.json` contains `workspace:*` references like:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@paperclipai/server": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These tell pnpm to resolve those packages from the local monorepo. This is great for development but **npm doesn't understand `workspace:*`** — publishing with these references would cause install failures for users.
|
||||
|
||||
The build script solves this with a two-file swap:
|
||||
|
||||
1. **Before building:** `cli/package.json` has `workspace:*` refs (the dev version).
|
||||
2. **During build (`build-npm.sh` step 4):**
|
||||
- The dev `package.json` is copied to `package.dev.json` as a backup.
|
||||
- `generate-npm-package-json.mjs` reads every workspace package's `package.json`, collects all their external npm dependencies, and writes a new `cli/package.json` with those real dependency ranges — no `workspace:*` refs.
|
||||
3. **After publishing:** you restore the dev version with `mv package.dev.json package.json`.
|
||||
|
||||
The generated publishable `package.json` looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "paperclipai",
|
||||
"version": "0.1.0",
|
||||
"bin": { "paperclipai": "./dist/index.js" },
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"postgres": "^3.4.5",
|
||||
"commander": "^13.1.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`package.dev.json` is listed in `.gitignore` — it only exists temporarily on disk during the build/publish cycle.
|
||||
|
||||
## How the bundle works
|
||||
|
||||
The CLI is a monorepo package that imports code from `@paperclipai/server`, `@paperclipai/db`, `@paperclipai/shared`, and several adapter packages. These workspace packages don't exist on npm.
|
||||
|
||||
**esbuild** bundles all workspace TypeScript code into a single `dist/index.js` file (~250kb). External npm packages (express, postgres, zod, etc.) are left as normal `import` statements — they get installed by npm when a user runs `npx paperclipai onboard`.
|
||||
|
||||
The esbuild configuration lives at `cli/esbuild.config.mjs`. It automatically reads every workspace package's `package.json` to determine which dependencies are external (real npm packages) vs. internal (workspace code to bundle).
|
||||
|
||||
## Forbidden token enforcement
|
||||
|
||||
The build process includes the same forbidden-token check used by the git pre-commit hook. This catches any accidentally committed tokens before they reach npm.
|
||||
|
||||
- Token list: `.git/hooks/forbidden-tokens.txt` (one token per line, `#` comments supported)
|
||||
- The file lives inside `.git/` and is never committed
|
||||
- If the file is missing, the check passes — contributors without the list can still build
|
||||
- The script never prints which tokens are being searched for
|
||||
- Matches are printed so you know which files to fix, but not which token triggered it
|
||||
|
||||
Run the check standalone:
|
||||
|
||||
```bash
|
||||
pnpm check:tokens
|
||||
```
|
||||
|
||||
## npm scripts reference
|
||||
|
||||
| Script | Command | Description |
|
||||
|---|---|---|
|
||||
| `bump-and-publish` | `pnpm bump-and-publish <type>` | One-command bump + build + publish + commit + tag |
|
||||
| `build:npm` | `pnpm build:npm` | Full build (check + typecheck + bundle + package.json) |
|
||||
| `version:bump` | `pnpm version:bump <type>` | Bump CLI version |
|
||||
| `check:tokens` | `pnpm check:tokens` | Run forbidden token check only |
|
||||
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
|
||||
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
|
||||
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
|
||||
- [`doc/RELEASING.md`](RELEASING.md)
|
||||
|
||||
422
doc/RELEASING.md
Normal file
422
doc/RELEASING.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# Releasing Paperclip
|
||||
|
||||
Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface.
|
||||
|
||||
The release model is branch-driven:
|
||||
|
||||
1. Start a release train on `release/X.Y.Z`
|
||||
2. Draft the stable changelog on that branch
|
||||
3. Publish one or more canaries from that branch
|
||||
4. Publish stable from that same branch head
|
||||
5. Push the branch commit and tag
|
||||
6. Create the GitHub Release
|
||||
7. Merge `release/X.Y.Z` back to `master` without squash or rebase
|
||||
|
||||
## Release Surfaces
|
||||
|
||||
Every release has four separate surfaces:
|
||||
|
||||
1. **Verification** — the exact git SHA passes typecheck, tests, and build
|
||||
2. **npm** — `paperclipai` and public workspace packages are published
|
||||
3. **GitHub** — the stable release gets a git tag and GitHub Release
|
||||
4. **Website / announcements** — the stable changelog is published externally and announced
|
||||
|
||||
A release is done only when all four surfaces are handled.
|
||||
|
||||
## Core Invariants
|
||||
|
||||
- Canary and stable for `X.Y.Z` must come from the same `release/X.Y.Z` branch.
|
||||
- The release scripts must run from the matching `release/X.Y.Z` branch.
|
||||
- Once `vX.Y.Z` exists locally, on GitHub, or on npm, that release train is frozen.
|
||||
- Do not squash-merge or rebase-merge a release branch PR back to `master`.
|
||||
- The stable changelog is always `releases/vX.Y.Z.md`. Never create canary changelog files.
|
||||
|
||||
The reason for the merge rule is simple: the tag must keep pointing at the exact published commit. Squash or rebase breaks that property.
|
||||
|
||||
## TL;DR
|
||||
|
||||
### 1. Start the release train
|
||||
|
||||
Use this to compute the next version, create or resume the branch, create or resume a dedicated worktree, and push the branch to GitHub.
|
||||
|
||||
```bash
|
||||
./scripts/release-start.sh patch
|
||||
```
|
||||
|
||||
That script:
|
||||
|
||||
- fetches the release remote and tags
|
||||
- computes the next stable version from the latest `v*` tag
|
||||
- creates or resumes `release/X.Y.Z`
|
||||
- creates or resumes a dedicated worktree
|
||||
- pushes the branch to the remote by default
|
||||
- refuses to reuse a frozen release train
|
||||
|
||||
### 2. Draft the stable changelog
|
||||
|
||||
From the release worktree:
|
||||
|
||||
```bash
|
||||
VERSION=X.Y.Z
|
||||
claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and .agents/skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
|
||||
```
|
||||
|
||||
### 3. Verify and publish a canary
|
||||
|
||||
```bash
|
||||
./scripts/release-preflight.sh canary patch
|
||||
./scripts/release.sh patch --canary --dry-run
|
||||
./scripts/release.sh patch --canary
|
||||
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
Users install canaries with:
|
||||
|
||||
```bash
|
||||
npx paperclipai@canary onboard
|
||||
```
|
||||
|
||||
### 4. Publish stable
|
||||
|
||||
```bash
|
||||
./scripts/release-preflight.sh stable patch
|
||||
./scripts/release.sh patch --dry-run
|
||||
./scripts/release.sh patch
|
||||
git push public-gh HEAD --follow-tags
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
```
|
||||
|
||||
Then open a PR from `release/X.Y.Z` to `master` and merge without squash or rebase.
|
||||
|
||||
## Release Branches
|
||||
|
||||
Paperclip uses one release branch per target stable version:
|
||||
|
||||
- `release/0.3.0`
|
||||
- `release/0.3.1`
|
||||
- `release/1.0.0`
|
||||
|
||||
Do not create separate per-canary branches like `canary/0.3.0-1`. A canary is just a prerelease snapshot of the same stable train.
|
||||
|
||||
## Script Entry Points
|
||||
|
||||
- [`scripts/release-start.sh`](../scripts/release-start.sh) — create or resume the release train branch/worktree
|
||||
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — validate branch, version plan, git/npm state, and verification gate
|
||||
- [`scripts/release.sh`](../scripts/release.sh) — publish canary or stable from the release branch
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after pushing the tag
|
||||
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable version
|
||||
|
||||
## Detailed Workflow
|
||||
|
||||
### 1. Start or resume the release train
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./scripts/release-start.sh <patch|minor|major>
|
||||
```
|
||||
|
||||
Useful options:
|
||||
|
||||
```bash
|
||||
./scripts/release-start.sh patch --dry-run
|
||||
./scripts/release-start.sh minor --worktree-dir ../paperclip-release-0.4.0
|
||||
./scripts/release-start.sh patch --no-push
|
||||
```
|
||||
|
||||
The script is intentionally idempotent:
|
||||
|
||||
- if `release/X.Y.Z` already exists locally, it reuses it
|
||||
- if the branch already exists on the remote, it resumes it locally
|
||||
- if the branch is already checked out in another worktree, it points you there
|
||||
- if `vX.Y.Z` already exists locally, remotely, or on npm, it refuses to reuse that train
|
||||
|
||||
### 2. Write the stable changelog early
|
||||
|
||||
Create or update:
|
||||
|
||||
- `releases/vX.Y.Z.md`
|
||||
|
||||
That file is for the eventual stable release. It should not include `-canary` in the filename or heading.
|
||||
|
||||
Recommended structure:
|
||||
|
||||
- `Breaking Changes` when needed
|
||||
- `Highlights`
|
||||
- `Improvements`
|
||||
- `Fixes`
|
||||
- `Upgrade Guide` when needed
|
||||
- `Contributors` — @-mention every contributor by GitHub username (no emails)
|
||||
|
||||
Package-level `CHANGELOG.md` files are generated as part of the release mechanics. They are not the main release narrative.
|
||||
|
||||
### 3. Run release preflight
|
||||
|
||||
From the `release/X.Y.Z` worktree:
|
||||
|
||||
```bash
|
||||
./scripts/release-preflight.sh canary <patch|minor|major>
|
||||
# or
|
||||
./scripts/release-preflight.sh stable <patch|minor|major>
|
||||
```
|
||||
|
||||
The preflight script now checks all of the following before it runs the verification gate:
|
||||
|
||||
- the worktree is clean, including untracked files
|
||||
- the current branch matches the computed `release/X.Y.Z`
|
||||
- the release train is not frozen
|
||||
- the target version is still free on npm
|
||||
- the target tag does not already exist locally or remotely
|
||||
- whether the remote release branch already exists
|
||||
- whether `releases/vX.Y.Z.md` is present
|
||||
|
||||
Then it runs:
|
||||
|
||||
```bash
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 4. Publish one or more canaries
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <patch|minor|major> --canary --dry-run
|
||||
./scripts/release.sh <patch|minor|major> --canary
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary`
|
||||
- `latest` is unchanged
|
||||
- no git tag is created
|
||||
- no GitHub Release is created
|
||||
- the worktree returns to clean after the script finishes
|
||||
|
||||
Guardrails:
|
||||
|
||||
- the script refuses to run from the wrong branch
|
||||
- the script refuses to publish from a frozen train
|
||||
- the canary is always derived from the next stable version
|
||||
- if the stable notes file is missing, the script warns before you forget it
|
||||
|
||||
Concrete example:
|
||||
|
||||
- if the latest stable is `0.2.7`, a patch canary targets `0.2.8-canary.0`
|
||||
- `0.2.7-canary.N` is invalid because `0.2.7` is already stable
|
||||
|
||||
### 5. Smoke test the canary
|
||||
|
||||
Run the actual install path in Docker:
|
||||
|
||||
```bash
|
||||
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
Useful isolated variants:
|
||||
|
||||
```bash
|
||||
HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
If you want to exercise onboarding from the current committed ref instead of npm, use:
|
||||
|
||||
```bash
|
||||
./scripts/clean-onboard-ref.sh
|
||||
PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh
|
||||
./scripts/clean-onboard-ref.sh HEAD
|
||||
```
|
||||
|
||||
Minimum checks:
|
||||
|
||||
- `npx paperclipai@canary onboard` installs
|
||||
- onboarding completes without crashes
|
||||
- the server boots
|
||||
- the UI loads
|
||||
- basic company creation and dashboard load work
|
||||
|
||||
If smoke testing fails:
|
||||
|
||||
1. stop the stable release
|
||||
2. fix the issue on the same `release/X.Y.Z` branch
|
||||
3. publish another canary
|
||||
4. rerun smoke testing
|
||||
|
||||
### 6. Publish stable from the same release branch
|
||||
|
||||
Once the branch head is vetted, run:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <patch|minor|major> --dry-run
|
||||
./scripts/release.sh <patch|minor|major>
|
||||
```
|
||||
|
||||
Stable publish:
|
||||
|
||||
- publishes `X.Y.Z` to npm under `latest`
|
||||
- creates the local release commit
|
||||
- creates the local tag `vX.Y.Z`
|
||||
|
||||
Stable publish refuses to proceed if:
|
||||
|
||||
- the current branch is not `release/X.Y.Z`
|
||||
- the remote release branch does not exist yet
|
||||
- the stable notes file is missing
|
||||
- the target tag already exists locally or remotely
|
||||
- the stable version already exists on npm
|
||||
|
||||
Those checks intentionally freeze the train after stable publish.
|
||||
|
||||
### 7. Push the stable branch commit and tag
|
||||
|
||||
After stable publish succeeds:
|
||||
|
||||
```bash
|
||||
git push public-gh HEAD --follow-tags
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
```
|
||||
|
||||
The GitHub Release notes come from:
|
||||
|
||||
- `releases/vX.Y.Z.md`
|
||||
|
||||
### 8. Merge the release branch back to `master`
|
||||
|
||||
Open a PR:
|
||||
|
||||
- base: `master`
|
||||
- head: `release/X.Y.Z`
|
||||
|
||||
Merge rule:
|
||||
|
||||
- allowed: merge commit or fast-forward
|
||||
- forbidden: squash merge
|
||||
- forbidden: rebase merge
|
||||
|
||||
Post-merge verification:
|
||||
|
||||
```bash
|
||||
git fetch public-gh --tags
|
||||
git merge-base --is-ancestor "vX.Y.Z" "public-gh/master"
|
||||
```
|
||||
|
||||
That command must succeed. If it fails, the published tagged commit is not reachable from `master`, which means the merge strategy was wrong.
|
||||
|
||||
### 9. Finish the external surfaces
|
||||
|
||||
After GitHub is correct:
|
||||
|
||||
- publish the changelog on the website
|
||||
- write and send the announcement copy
|
||||
- ensure public docs and install guidance point to the stable version
|
||||
|
||||
## GitHub Actions Release
|
||||
|
||||
There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
|
||||
|
||||
Use it from the Actions tab on the relevant `release/X.Y.Z` branch:
|
||||
|
||||
1. Choose `Release`
|
||||
2. Choose `channel`: `canary` or `stable`
|
||||
3. Choose `bump`: `patch`, `minor`, or `major`
|
||||
4. Choose whether this is a `dry_run`
|
||||
5. Run it from the release branch, not from `master`
|
||||
|
||||
The workflow:
|
||||
|
||||
- reruns `typecheck`, `test:run`, and `build`
|
||||
- gates publish behind the `npm-release` environment
|
||||
- can publish canaries without touching `latest`
|
||||
- can publish stable, push the stable branch commit and tag, and create the GitHub Release
|
||||
|
||||
It does not merge the release branch back to `master` for you.
|
||||
|
||||
## Release Checklist
|
||||
|
||||
### Before any publish
|
||||
|
||||
- [ ] The release train exists on `release/X.Y.Z`
|
||||
- [ ] The working tree is clean, including untracked files
|
||||
- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the train is cut
|
||||
- [ ] The required verification gate passed on the exact branch head you want to publish
|
||||
- [ ] The bump type is correct for the user-visible impact
|
||||
- [ ] The stable changelog file exists or is ready at `releases/vX.Y.Z.md`
|
||||
- [ ] You know which previous stable version you would roll back to if needed
|
||||
|
||||
### Before a stable
|
||||
|
||||
- [ ] The candidate has already passed smoke testing
|
||||
- [ ] The remote `release/X.Y.Z` branch exists
|
||||
- [ ] You are ready to push the stable branch commit and tag immediately after npm publish
|
||||
- [ ] You are ready to create the GitHub Release immediately after the push
|
||||
- [ ] You are ready to open the PR back to `master`
|
||||
|
||||
### After a stable
|
||||
|
||||
- [ ] `npm view paperclipai@latest version` matches the new stable version
|
||||
- [ ] The git tag exists on GitHub
|
||||
- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md`
|
||||
- [ ] `vX.Y.Z` is reachable from `master`
|
||||
- [ ] The website changelog is updated
|
||||
- [ ] Announcement copy matches the stable release, not the canary
|
||||
|
||||
## Failure Playbooks
|
||||
|
||||
### If the canary publishes but the smoke test fails
|
||||
|
||||
Do not publish stable.
|
||||
|
||||
Instead:
|
||||
|
||||
1. fix the issue on `release/X.Y.Z`
|
||||
2. publish another canary
|
||||
3. rerun smoke testing
|
||||
|
||||
### If stable npm publish succeeds but push or GitHub release creation fails
|
||||
|
||||
This is a partial release. npm is already live.
|
||||
|
||||
Do this immediately:
|
||||
|
||||
1. fix the git or GitHub issue from the same checkout
|
||||
2. push the stable branch commit and tag
|
||||
3. create the GitHub Release
|
||||
|
||||
Do not republish the same version.
|
||||
|
||||
### If `latest` is broken after stable publish
|
||||
|
||||
Preview:
|
||||
|
||||
```bash
|
||||
./scripts/rollback-latest.sh X.Y.Z --dry-run
|
||||
```
|
||||
|
||||
Roll back:
|
||||
|
||||
```bash
|
||||
./scripts/rollback-latest.sh X.Y.Z
|
||||
```
|
||||
|
||||
This does not unpublish anything. It only moves the `latest` dist-tag back to the last good stable release.
|
||||
|
||||
Then fix forward with a new patch release.
|
||||
|
||||
### If the GitHub Release notes are wrong
|
||||
|
||||
Re-run:
|
||||
|
||||
```bash
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
```
|
||||
|
||||
If the release already exists, the script updates it.
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals
|
||||
- [.agents/skills/release/SKILL.md](../.agents/skills/release/SKILL.md) — maintainer release coordination workflow
|
||||
- [.agents/skills/release-changelog/SKILL.md](../.agents/skills/release-changelog/SKILL.md) — stable changelog drafting workflow
|
||||
@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
|
||||
| Visibility | Full visibility to board and all agents in same company |
|
||||
| Communication | Tasks + comments only (no separate chat system) |
|
||||
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
|
||||
| Recovery | No automatic reassignment; stale work is surfaced, not silently fixed |
|
||||
| Recovery | No automatic reassignment; work recovery stays manual/explicit |
|
||||
| Agent adapters | Built-in `process` and `http` adapters |
|
||||
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
|
||||
| Budget period | Monthly UTC calendar window |
|
||||
@@ -106,7 +106,6 @@ A lightweight scheduler/worker in the server process handles:
|
||||
- heartbeat trigger checks
|
||||
- stuck run detection
|
||||
- budget threshold checks
|
||||
- stale task reporting generation
|
||||
|
||||
Separate queue infrastructure is not required for V1.
|
||||
|
||||
@@ -502,7 +501,6 @@ Dashboard payload must include:
|
||||
- open/in-progress/blocked/done issue counts
|
||||
- month-to-date spend and budget utilization
|
||||
- pending approvals count
|
||||
- stale task count
|
||||
|
||||
## 10.9 Error Semantics
|
||||
|
||||
@@ -681,7 +679,6 @@ Required UX behaviors:
|
||||
- global company selector
|
||||
- quick actions: pause/resume agent, create task, approve/reject request
|
||||
- conflict toasts on atomic checkout failure
|
||||
- clear stale-task indicators
|
||||
- no silent background failures; every failed run visible in UI
|
||||
|
||||
## 15. Operational Requirements
|
||||
@@ -780,7 +777,6 @@ A release candidate is blocked unless these pass:
|
||||
|
||||
- add company selector and org chart view
|
||||
- add approvals and cost pages
|
||||
- add operational dashboard and stale-task surfacing
|
||||
|
||||
## Milestone 6: Hardening and Release
|
||||
|
||||
|
||||
62
doc/experimental/issue-worktree-support.md
Normal file
62
doc/experimental/issue-worktree-support.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Issue worktree support
|
||||
|
||||
Status: experimental, runtime-only, not shipping as a user-facing feature yet.
|
||||
|
||||
This branch contains the runtime and seeding work needed for issue-scoped worktrees:
|
||||
|
||||
- project execution workspace policy support
|
||||
- issue-level execution workspace settings
|
||||
- git worktree realization for isolated issue execution
|
||||
- optional command-based worktree provisioning
|
||||
- seeded worktree fixes for secrets key compatibility
|
||||
- seeded project workspace rebinding to the current git worktree
|
||||
|
||||
We are intentionally not shipping the UI for this yet. The runtime code remains in place, but the main UI entrypoints are hard-gated off for now.
|
||||
|
||||
## What works today
|
||||
|
||||
- projects can carry execution workspace policy in the backend
|
||||
- issues can carry execution workspace settings in the backend
|
||||
- heartbeat execution can realize isolated git worktrees
|
||||
- runtime can run a project-defined provision command inside the derived worktree
|
||||
- seeded worktree instances can keep local-encrypted secrets working
|
||||
- seeded worktree instances can rebind same-repo project workspace paths onto the current git worktree
|
||||
|
||||
## Hidden UI entrypoints
|
||||
|
||||
These are the current user-facing UI surfaces for the feature, now intentionally disabled:
|
||||
|
||||
- project settings:
|
||||
- `ui/src/components/ProjectProperties.tsx`
|
||||
- execution workspace policy controls
|
||||
- git worktree base ref / branch template / parent dir
|
||||
- provision / teardown command inputs
|
||||
|
||||
- issue creation:
|
||||
- `ui/src/components/NewIssueDialog.tsx`
|
||||
- isolated issue checkout toggle
|
||||
- defaulting issue execution workspace settings from project policy
|
||||
|
||||
- issue editing:
|
||||
- `ui/src/components/IssueProperties.tsx`
|
||||
- issue-level workspace mode toggle
|
||||
- defaulting issue execution workspace settings when project changes
|
||||
|
||||
- agent/runtime settings:
|
||||
- `ui/src/adapters/runtime-json-fields.tsx`
|
||||
- runtime services JSON field, which is part of the broader workspace-runtime support surface
|
||||
|
||||
## Why the UI is hidden
|
||||
|
||||
- the runtime behavior is still being validated
|
||||
- the workflow and operator ergonomics are not final
|
||||
- we do not want to expose a partially-baked user-facing feature in issues, projects, or settings
|
||||
|
||||
## Re-enable plan
|
||||
|
||||
When this is ready to ship:
|
||||
|
||||
- re-enable the gated UI sections in the files above
|
||||
- review wording and defaults for project and issue controls
|
||||
- decide which agent/runtime settings should remain advanced-only
|
||||
- add end-to-end product-level verification for the full UI workflow
|
||||
329
doc/plans/agent-chat-ui-and-issue-backed-conversations.md
Normal file
329
doc/plans/agent-chat-ui-and-issue-backed-conversations.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Agent Chat UI and Issue-Backed Conversations
|
||||
|
||||
## Context
|
||||
|
||||
`PAP-475` asks two related questions:
|
||||
|
||||
1. What UI kit should Paperclip use if we add a chat surface with an agent?
|
||||
2. How should chat fit the product without breaking the current issue-centric model?
|
||||
|
||||
This is not only a component-library decision. In Paperclip today:
|
||||
|
||||
- V1 explicitly says communication is `tasks + comments only`, with no separate chat system.
|
||||
- Issues already carry assignment, audit trail, billing code, project linkage, goal linkage, and active run linkage.
|
||||
- Live run streaming already exists on issue detail pages.
|
||||
- Agent sessions already persist by `taskKey`, and today `taskKey` falls back to `issueId`.
|
||||
- The OpenClaw gateway adapter already supports an issue-scoped session key strategy.
|
||||
|
||||
That means the cheapest useful path is not "add a second messaging product inside Paperclip." It is "add a better conversational UI on top of issue and run primitives we already have."
|
||||
|
||||
## Current Constraints From the Codebase
|
||||
|
||||
### Durable work object
|
||||
|
||||
The durable object in Paperclip is the issue, not a chat thread.
|
||||
|
||||
- `IssueDetail` already combines comments, linked runs, live runs, and activity into one timeline.
|
||||
- `CommentThread` already renders markdown comments and supports reply/reassignment flows.
|
||||
- `LiveRunWidget` already renders streaming assistant/tool/system output for active runs.
|
||||
|
||||
### Session behavior
|
||||
|
||||
Session continuity is already task-shaped.
|
||||
|
||||
- `heartbeat.ts` derives `taskKey` from `taskKey`, then `taskId`, then `issueId`.
|
||||
- `agent_task_sessions` stores session state per company + agent + adapter + task key.
|
||||
- OpenClaw gateway supports `sessionKeyStrategy=issue|fixed|run`, and `issue` already matches the Paperclip mental model well.
|
||||
|
||||
That means "chat with the CEO about this issue" naturally maps to one durable session per issue today without inventing a second session system.
|
||||
|
||||
### Billing behavior
|
||||
|
||||
Billing is already issue-aware.
|
||||
|
||||
- `cost_events` can attach to `issueId`, `projectId`, `goalId`, and `billingCode`.
|
||||
- heartbeat context already propagates issue linkage into runs and cost rollups.
|
||||
|
||||
If chat leaves the issue model, Paperclip would need a second billing story. That is avoidable.
|
||||
|
||||
## UI Kit Recommendation
|
||||
|
||||
## Recommendation: `assistant-ui`
|
||||
|
||||
Use `assistant-ui` as the chat presentation layer.
|
||||
|
||||
Why it fits Paperclip:
|
||||
|
||||
- It is a real chat UI kit, not just a hook.
|
||||
- It is composable and aligned with shadcn-style primitives, which matches the current UI stack well.
|
||||
- It explicitly supports custom backends, which matters because Paperclip talks to agents through issue comments, heartbeats, and run streams rather than direct provider calls.
|
||||
- It gives us polished chat affordances quickly: message list, composer, streaming text, attachments, thread affordances, and markdown-oriented rendering.
|
||||
|
||||
Why not make "the Vercel one" the primary choice:
|
||||
|
||||
- Vercel AI SDK is stronger today than the older "just `useChat` over `/api/chat`" framing. Its transport layer is flexible and can support custom protocols.
|
||||
- But AI SDK is still better understood here as a transport/runtime protocol layer than as the best end-user chat surface for Paperclip.
|
||||
- Paperclip does not need Vercel to own message state, persistence, or the backend contract. Paperclip already has its own issue, run, and session model.
|
||||
|
||||
So the clean split is:
|
||||
|
||||
- `assistant-ui` for UI primitives
|
||||
- Paperclip-owned runtime/store for state, persistence, and transport
|
||||
- optional AI SDK usage later only if we want its stream protocol or client transport abstraction
|
||||
|
||||
## Product Options
|
||||
|
||||
### Option A: Separate chat object
|
||||
|
||||
Create a new top-level chat/thread model unrelated to issues.
|
||||
|
||||
Pros:
|
||||
|
||||
- clean mental model if users want freeform conversation
|
||||
- easy to hide from issue boards
|
||||
|
||||
Cons:
|
||||
|
||||
- breaks the current V1 product decision that communication is issue-centric
|
||||
- needs new persistence, billing, session, permissions, activity, and wakeup rules
|
||||
- creates a second "why does this exist?" object beside issues
|
||||
- makes "pick up an old chat" a separate retrieval problem
|
||||
|
||||
Verdict: not recommended for V1.
|
||||
|
||||
### Option B: Every chat is an issue
|
||||
|
||||
Treat chat as a UI mode over an issue. The issue remains the durable record.
|
||||
|
||||
Pros:
|
||||
|
||||
- matches current product spec
|
||||
- billing, runs, comments, approvals, and activity already work
|
||||
- sessions already resume on issue identity
|
||||
- works with all adapters, including OpenClaw, without new agent auth or a second API surface
|
||||
|
||||
Cons:
|
||||
|
||||
- some chats are not really "tasks" in a board sense
|
||||
- onboarding and review conversations may clutter normal issue lists
|
||||
|
||||
Verdict: best V1 foundation.
|
||||
|
||||
### Option C: Hybrid with hidden conversation issues
|
||||
|
||||
Back every conversation with an issue, but allow a conversation-flavored issue mode that is hidden from default execution boards unless promoted.
|
||||
|
||||
Pros:
|
||||
|
||||
- preserves the issue-centric backend
|
||||
- gives onboarding/review chat a cleaner UX
|
||||
- preserves billing and session continuity
|
||||
|
||||
Cons:
|
||||
|
||||
- requires extra UI rules and possibly a small schema or filtering addition
|
||||
- can become a disguised second system if not kept narrow
|
||||
|
||||
Verdict: likely the right product shape after a basic issue-backed MVP.
|
||||
|
||||
## Recommended Product Model
|
||||
|
||||
### Phase 1 product decision
|
||||
|
||||
For the first implementation, chat should be issue-backed.
|
||||
|
||||
More specifically:
|
||||
|
||||
- the board opens a chat surface for an issue
|
||||
- sending a message is a comment mutation on that issue
|
||||
- the assigned agent is woken through the existing issue-comment flow
|
||||
- streaming output comes from the existing live run stream for that issue
|
||||
- durable assistant output remains comments and run history, not an extra transcript store
|
||||
|
||||
This keeps Paperclip honest about what it is:
|
||||
|
||||
- the control plane stays issue-centric
|
||||
- chat is a better way to interact with issue work, not a new collaboration product
|
||||
|
||||
### Onboarding and CEO conversations
|
||||
|
||||
For onboarding, weekly reviews, and "chat with the CEO", use a conversation issue rather than a global chat tab.
|
||||
|
||||
Suggested shape:
|
||||
|
||||
- create a board-initiated issue assigned to the CEO
|
||||
- mark it as conversation-flavored in UI treatment
|
||||
- optionally hide it from normal issue boards by default later
|
||||
- keep all cost/run/session linkage on that issue
|
||||
|
||||
This solves several concerns at once:
|
||||
|
||||
- no separate API key or direct provider wiring is needed
|
||||
- the same CEO adapter is used
|
||||
- old conversations are recovered through normal issue history
|
||||
- the CEO can still create or update real child issues from the conversation
|
||||
|
||||
## Session Model
|
||||
|
||||
### V1
|
||||
|
||||
Use one durable conversation session per issue.
|
||||
|
||||
That already matches current behavior:
|
||||
|
||||
- adapter task sessions persist against `taskKey`
|
||||
- `taskKey` already falls back to `issueId`
|
||||
- OpenClaw already supports an issue-scoped session key
|
||||
|
||||
This means "resume the CEO conversation later" works by reopening the same issue and waking the same agent on the same issue.
|
||||
|
||||
### What not to add yet
|
||||
|
||||
Do not add multi-thread-per-issue chat in the first pass.
|
||||
|
||||
If Paperclip later needs several parallel threads on one issue, then add an explicit conversation identity and derive:
|
||||
|
||||
- `taskKey = issue:<issueId>:conversation:<conversationId>`
|
||||
- OpenClaw `sessionKey = paperclip:conversation:<conversationId>`
|
||||
|
||||
Until that requirement becomes real, one issue == one durable conversation is the simpler and better rule.
|
||||
|
||||
## Billing Model
|
||||
|
||||
Chat should not invent a separate billing pipeline.
|
||||
|
||||
All chat cost should continue to roll up through the issue:
|
||||
|
||||
- `cost_events.issueId`
|
||||
- project and goal rollups through existing relationships
|
||||
- issue `billingCode` when present
|
||||
|
||||
If a conversation is important enough to exist, it is important enough to have a durable issue-backed audit and cost trail.
|
||||
|
||||
This is another reason ephemeral freeform chat should not be the default.
|
||||
|
||||
## UI Architecture
|
||||
|
||||
### Recommended stack
|
||||
|
||||
1. Keep Paperclip as the source of truth for message history and run state.
|
||||
2. Add `assistant-ui` as the rendering/composer layer.
|
||||
3. Build a Paperclip runtime adapter that maps:
|
||||
- issue comments -> user/assistant messages
|
||||
- live run deltas -> streaming assistant messages
|
||||
- issue attachments -> chat attachments
|
||||
4. Keep current markdown rendering and code-block support where possible.
|
||||
|
||||
### Interaction flow
|
||||
|
||||
1. Board opens issue detail in "Chat" mode.
|
||||
2. Existing comment history is mapped into chat messages.
|
||||
3. When the board sends a message:
|
||||
- `POST /api/issues/{id}/comments`
|
||||
- optionally interrupt the active run if the UX wants "send and replace current response"
|
||||
4. Existing issue comment wakeup logic wakes the assignee.
|
||||
5. Existing `/issues/{id}/live-runs` and `/issues/{id}/active-run` data feeds drive streaming.
|
||||
6. When the run completes, durable state remains in comments/runs/activity as it does now.
|
||||
|
||||
### Why this fits the current code
|
||||
|
||||
Paperclip already has most of the backend pieces:
|
||||
|
||||
- issue comments
|
||||
- run timeline
|
||||
- run log and event streaming
|
||||
- markdown rendering
|
||||
- attachment support
|
||||
- assignee wakeups on comments
|
||||
|
||||
The missing piece is mostly the presentation and the mapping layer, not a new backend domain.
|
||||
|
||||
## Agent Scope
|
||||
|
||||
Do not launch this as "chat with every agent."
|
||||
|
||||
Start narrower:
|
||||
|
||||
- onboarding chat with CEO
|
||||
- workflow/review chat with CEO
|
||||
- maybe selected exec roles later
|
||||
|
||||
Reasons:
|
||||
|
||||
- it keeps the feature from becoming a second inbox/chat product
|
||||
- it limits permission and UX questions early
|
||||
- it matches the stated product demand
|
||||
|
||||
If direct chat with other agents becomes useful later, the same issue-backed pattern can expand cleanly.
|
||||
|
||||
## Recommended Delivery Phases
|
||||
|
||||
### Phase 1: Chat UI on existing issues
|
||||
|
||||
- add a chat presentation mode to issue detail
|
||||
- use `assistant-ui`
|
||||
- map comments + live runs into the chat surface
|
||||
- no schema change
|
||||
- no new API surface
|
||||
|
||||
This is the highest-leverage step because it tests whether the UX is actually useful before product model expansion.
|
||||
|
||||
### Phase 2: Conversation-flavored issues for CEO chat
|
||||
|
||||
- add a lightweight conversation classification
|
||||
- support creation of CEO conversation issues from onboarding and workflow entry points
|
||||
- optionally hide these from normal backlog/board views by default
|
||||
|
||||
The smallest implementation could be a label or issue metadata flag. If it becomes important enough, then promote it to a first-class issue subtype later.
|
||||
|
||||
### Phase 3: Promotion and thread splitting only if needed
|
||||
|
||||
Only if we later see a real need:
|
||||
|
||||
- allow promoting a conversation to a formal task issue
|
||||
- allow several threads per issue with explicit conversation identity
|
||||
|
||||
This should be demand-driven, not designed up front.
|
||||
|
||||
## Clear Recommendation
|
||||
|
||||
If the question is "what should we use?", the answer is:
|
||||
|
||||
- use `assistant-ui` for the chat UI
|
||||
- do not treat raw Vercel AI SDK UI hooks as the main product answer
|
||||
- keep chat issue-backed in V1
|
||||
- use the current issue comment + run + session + billing model rather than inventing a parallel chat subsystem
|
||||
|
||||
If the question is "how should we think about chat in Paperclip?", the answer is:
|
||||
|
||||
- chat is a mode of interacting with issue-backed agent work
|
||||
- not a separate product silo
|
||||
- not an excuse to stop tracing work, cost, and session history back to the issue
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Immediate implementation target
|
||||
|
||||
The most defensible first build is:
|
||||
|
||||
- add a chat tab or chat-focused layout on issue detail
|
||||
- back it with the currently assigned agent on that issue
|
||||
- use `assistant-ui` primitives over existing comments and live run events
|
||||
|
||||
### Defer these until proven necessary
|
||||
|
||||
- standalone global chat objects
|
||||
- multi-thread chat inside one issue
|
||||
- chat with every agent in the org
|
||||
- a second persistence layer for message history
|
||||
- separate cost tracking for chats
|
||||
|
||||
## References
|
||||
|
||||
- V1 communication model: `doc/SPEC-implementation.md`
|
||||
- Current issue/comment/run UI: `ui/src/pages/IssueDetail.tsx`, `ui/src/components/CommentThread.tsx`, `ui/src/components/LiveRunWidget.tsx`
|
||||
- Session persistence and task key derivation: `server/src/services/heartbeat.ts`, `packages/db/src/schema/agent_task_sessions.ts`
|
||||
- OpenClaw session routing: `packages/adapters/openclaw-gateway/README.md`
|
||||
- assistant-ui docs: <https://www.assistant-ui.com/docs>
|
||||
- assistant-ui repo: <https://github.com/assistant-ui/assistant-ui>
|
||||
- AI SDK transport docs: <https://ai-sdk.dev/docs/ai-sdk-ui/transport>
|
||||
1335
doc/plans/workspace-strategy-and-git-worktrees.md
Normal file
1335
doc/plans/workspace-strategy-and-git-worktrees.md
Normal file
File diff suppressed because it is too large
Load Diff
1617
doc/plugins/PLUGIN_SPEC.md
Normal file
1617
doc/plugins/PLUGIN_SPEC.md
Normal file
File diff suppressed because it is too large
Load Diff
1738
doc/plugins/ideas-from-opencode.md
Normal file
1738
doc/plugins/ideas-from-opencode.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -249,7 +249,7 @@ Runs local `claude` CLI directly.
|
||||
"cwd": "/absolute/or/relative/path",
|
||||
"promptTemplate": "You are agent {{agent.id}} ...",
|
||||
"model": "optional-model-id",
|
||||
"maxTurnsPerRun": 80,
|
||||
"maxTurnsPerRun": 300,
|
||||
"dangerouslySkipPermissions": true,
|
||||
"env": {"KEY": "VALUE"},
|
||||
"extraArgs": [],
|
||||
|
||||
@@ -114,7 +114,7 @@ No section header — these are always at the top, below the company header.
|
||||
My Issues
|
||||
```
|
||||
|
||||
- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, stale tasks, budget alerts, failed heartbeats. The number is the total unread/unresolved count.
|
||||
- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, budget alerts, failed heartbeats. The number is the total unread/unresolved count.
|
||||
- **My Issues** — issues created by or assigned to the board operator.
|
||||
|
||||
### 3.3 Work Section
|
||||
|
||||
@@ -10,5 +10,9 @@ services:
|
||||
PAPERCLIP_HOME: "/paperclip"
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||
PAPERCLIP_DEPLOYMENT_MODE: "authenticated"
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private"
|
||||
PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}"
|
||||
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}"
|
||||
volumes:
|
||||
- "${PAPERCLIP_DATA_DIR:-./data/docker-paperclip}:/paperclip"
|
||||
|
||||
@@ -5,6 +5,11 @@ services:
|
||||
POSTGRES_USER: paperclip
|
||||
POSTGRES_PASSWORD: paperclip
|
||||
POSTGRES_DB: paperclip
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U paperclip -d paperclip"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
@@ -18,8 +23,16 @@ services:
|
||||
DATABASE_URL: postgres://paperclip:paperclip@db:5432/paperclip
|
||||
PORT: "3100"
|
||||
SERVE_UI: "true"
|
||||
PAPERCLIP_DEPLOYMENT_MODE: "authenticated"
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private"
|
||||
PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}"
|
||||
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}"
|
||||
volumes:
|
||||
- paperclip-data:/paperclip
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
paperclip-data:
|
||||
|
||||
8
docker/openclaw-smoke/Dockerfile
Normal file
8
docker/openclaw-smoke/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY server.mjs /app/server.mjs
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["node", "/app/server.mjs"]
|
||||
103
docker/openclaw-smoke/server.mjs
Normal file
103
docker/openclaw-smoke/server.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
import http from "node:http";
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? "8787", 10);
|
||||
const webhookPath = process.env.OPENCLAW_SMOKE_PATH?.trim() || "/webhook";
|
||||
const expectedAuthHeader = process.env.OPENCLAW_SMOKE_AUTH?.trim() || "";
|
||||
const maxBodyBytes = 1_000_000;
|
||||
const maxEvents = 200;
|
||||
|
||||
const events = [];
|
||||
let nextId = 1;
|
||||
|
||||
function writeJson(res, status, payload) {
|
||||
res.statusCode = status;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
req.on("data", (chunk) => {
|
||||
total += chunk.length;
|
||||
if (total > maxBodyBytes) {
|
||||
reject(new Error("payload_too_large"));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
resolve(Buffer.concat(chunks).toString("utf8"));
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function trimEvents() {
|
||||
if (events.length <= maxEvents) return;
|
||||
events.splice(0, events.length - maxEvents);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const method = req.method ?? "GET";
|
||||
const url = req.url ?? "/";
|
||||
|
||||
if (method === "GET" && url === "/health") {
|
||||
writeJson(res, 200, { ok: true, webhookPath, events: events.length });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url === "/events") {
|
||||
writeJson(res, 200, { count: events.length, events });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && url === "/reset") {
|
||||
events.length = 0;
|
||||
writeJson(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && url === webhookPath) {
|
||||
const authorization = req.headers.authorization ?? "";
|
||||
if (expectedAuthHeader && authorization !== expectedAuthHeader) {
|
||||
writeJson(res, 401, { error: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readBody(req);
|
||||
let body = null;
|
||||
try {
|
||||
body = raw.length > 0 ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
body = { raw };
|
||||
}
|
||||
|
||||
const event = {
|
||||
id: `evt-${nextId++}`,
|
||||
receivedAt: new Date().toISOString(),
|
||||
method,
|
||||
path: url,
|
||||
authorizationPresent: Boolean(authorization),
|
||||
body,
|
||||
};
|
||||
events.push(event);
|
||||
trimEvents();
|
||||
writeJson(res, 200, { ok: true, received: true, eventId: event.id, count: events.length });
|
||||
} catch (err) {
|
||||
const code = err instanceof Error && err.message === "payload_too_large" ? 413 : 500;
|
||||
writeJson(res, code, { error: err instanceof Error ? err.message : "unknown_error" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(res, 404, { error: "not_found" });
|
||||
});
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[openclaw-smoke] listening on :${port} path=${webhookPath}`);
|
||||
});
|
||||
@@ -20,7 +20,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports
|
||||
| `env` | object | No | Environment variables (supports secret refs) |
|
||||
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
||||
| `graceSec` | number | No | Grace period before force-kill |
|
||||
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat |
|
||||
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) |
|
||||
| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (dev only) |
|
||||
|
||||
## Prompt Templates
|
||||
@@ -47,6 +47,14 @@ If resume fails with an unknown session error, the adapter automatically retries
|
||||
|
||||
The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory.
|
||||
|
||||
For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||
```
|
||||
|
||||
This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent.
|
||||
|
||||
## Environment Test
|
||||
|
||||
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
|
||||
|
||||
@@ -30,6 +30,14 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes
|
||||
|
||||
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
|
||||
|
||||
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||
```
|
||||
|
||||
This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent.
|
||||
|
||||
## Environment Test
|
||||
|
||||
The environment test checks:
|
||||
|
||||
45
docs/adapters/gemini-local.md
Normal file
45
docs/adapters/gemini-local.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Gemini Local
|
||||
summary: Gemini CLI local adapter setup and configuration
|
||||
---
|
||||
|
||||
The `gemini_local` adapter runs Google's Gemini CLI locally. It supports session persistence with `--resume`, skills injection, and structured `stream-json` output parsing.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Gemini CLI installed (`gemini` command available)
|
||||
- `GEMINI_API_KEY` or `GOOGLE_API_KEY` set, or local Gemini CLI auth configured
|
||||
|
||||
## Configuration Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) |
|
||||
| `model` | string | No | Gemini model to use. Defaults to `auto`. |
|
||||
| `promptTemplate` | string | No | Prompt used for all runs |
|
||||
| `instructionsFilePath` | string | No | Markdown instructions file prepended to the prompt |
|
||||
| `env` | object | No | Environment variables (supports secret refs) |
|
||||
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
||||
| `graceSec` | number | No | Grace period before force-kill |
|
||||
| `yolo` | boolean | No | Pass `--approval-mode yolo` for unattended operation |
|
||||
|
||||
## Session Persistence
|
||||
|
||||
The adapter persists Gemini session IDs between heartbeats. On the next wake, it resumes the existing conversation with `--resume` so the agent retains context.
|
||||
|
||||
Session resume is cwd-aware: if the working directory changed since the last run, a fresh session starts instead.
|
||||
|
||||
If resume fails with an unknown session error, the adapter automatically retries with a fresh session.
|
||||
|
||||
## Skills Injection
|
||||
|
||||
The adapter symlinks Paperclip skills into the Gemini global skills directory (`~/.gemini/skills`). Existing user skills are not overwritten.
|
||||
|
||||
## Environment Test
|
||||
|
||||
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
|
||||
|
||||
- Gemini CLI is installed and accessible
|
||||
- Working directory is absolute and available (auto-created if missing and permitted)
|
||||
- API key/auth hints (`GEMINI_API_KEY` or `GOOGLE_API_KEY`)
|
||||
- A live hello probe (`gemini --output-format json "Respond with hello."`) to verify CLI readiness
|
||||
@@ -20,6 +20,9 @@ When a heartbeat fires, Paperclip:
|
||||
|---------|----------|-------------|
|
||||
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
||||
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
|
||||
| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally |
|
||||
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
|
||||
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
|
||||
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
||||
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
||||
|
||||
@@ -52,7 +55,7 @@ Three registries consume these modules:
|
||||
|
||||
## Choosing an Adapter
|
||||
|
||||
- **Need a coding agent?** Use `claude_local` or `codex_local`
|
||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local`
|
||||
- **Need to run a script or command?** Use `process`
|
||||
- **Need to call an external service?** Use `http`
|
||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
||||
|
||||
@@ -123,6 +123,18 @@ GET /api/companies/{companyId}/org
|
||||
|
||||
Returns the full organizational tree for the company.
|
||||
|
||||
## List Adapter Models
|
||||
|
||||
```
|
||||
GET /api/companies/{companyId}/adapters/{adapterType}/models
|
||||
```
|
||||
|
||||
Returns selectable models for an adapter type.
|
||||
|
||||
- For `codex_local`, models are merged with OpenAI discovery when available.
|
||||
- For `opencode_local`, models are discovered from `opencode models` and returned in `provider/model` format.
|
||||
- `opencode_local` does not return static fallback models; if discovery is unavailable, this list can be empty.
|
||||
|
||||
## Config Revisions
|
||||
|
||||
```
|
||||
|
||||
@@ -48,12 +48,20 @@ pnpm dev --tailscale-auth
|
||||
|
||||
This binds the server to `0.0.0.0` for private-network access.
|
||||
|
||||
Alias:
|
||||
|
||||
```sh
|
||||
pnpm dev --authenticated-private
|
||||
```
|
||||
|
||||
Allow additional private hostnames:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai allowed-hostname dotta-macbook-pro
|
||||
```
|
||||
|
||||
For full setup and troubleshooting, see [Tailscale Private Access](/deploy/tailscale-private-access).
|
||||
|
||||
## Health Checks
|
||||
|
||||
```sh
|
||||
|
||||
77
docs/deploy/tailscale-private-access.md
Normal file
77
docs/deploy/tailscale-private-access.md
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: Tailscale Private Access
|
||||
summary: Run Paperclip with Tailscale-friendly host binding and connect from other devices
|
||||
---
|
||||
|
||||
Use this when you want to access Paperclip over Tailscale (or a private LAN/VPN) instead of only `localhost`.
|
||||
|
||||
## 1. Start Paperclip in private authenticated mode
|
||||
|
||||
```sh
|
||||
pnpm dev --tailscale-auth
|
||||
```
|
||||
|
||||
This configures:
|
||||
|
||||
- `PAPERCLIP_DEPLOYMENT_MODE=authenticated`
|
||||
- `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`
|
||||
- `PAPERCLIP_AUTH_BASE_URL_MODE=auto`
|
||||
- `HOST=0.0.0.0` (bind on all interfaces)
|
||||
|
||||
Equivalent flag:
|
||||
|
||||
```sh
|
||||
pnpm dev --authenticated-private
|
||||
```
|
||||
|
||||
## 2. Find your reachable Tailscale address
|
||||
|
||||
From the machine running Paperclip:
|
||||
|
||||
```sh
|
||||
tailscale ip -4
|
||||
```
|
||||
|
||||
You can also use your Tailscale MagicDNS hostname (for example `my-macbook.tailnet.ts.net`).
|
||||
|
||||
## 3. Open Paperclip from another device
|
||||
|
||||
Use the Tailscale IP or MagicDNS host with the Paperclip port:
|
||||
|
||||
```txt
|
||||
http://<tailscale-host-or-ip>:3100
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```txt
|
||||
http://my-macbook.tailnet.ts.net:3100
|
||||
```
|
||||
|
||||
## 4. Allow custom private hostnames when needed
|
||||
|
||||
If you access Paperclip with a custom private hostname, add it to the allowlist:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai allowed-hostname my-macbook.tailnet.ts.net
|
||||
```
|
||||
|
||||
## 5. Verify the server is reachable
|
||||
|
||||
From a remote Tailscale-connected device:
|
||||
|
||||
```sh
|
||||
curl http://<tailscale-host-or-ip>:3100/api/health
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
```json
|
||||
{"status":"ok"}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Login or redirect errors on a private hostname: add it with `paperclipai allowed-hostname`.
|
||||
- App only works on `localhost`: make sure you started with `--tailscale-auth` (or set `HOST=0.0.0.0` in private mode).
|
||||
- Can connect locally but not remotely: verify both devices are on the same Tailscale network and port `3100` is reachable.
|
||||
@@ -73,6 +73,7 @@
|
||||
"pages": [
|
||||
"deploy/overview",
|
||||
"deploy/local-development",
|
||||
"deploy/tailscale-private-access",
|
||||
"deploy/docker",
|
||||
"deploy/deployment-modes",
|
||||
"deploy/database",
|
||||
|
||||
@@ -27,6 +27,14 @@ Create agents from the Agents page. Each agent requires:
|
||||
- **Adapter config** — runtime-specific settings (working directory, model, prompt, etc.)
|
||||
- **Capabilities** — short description of what this agent does
|
||||
|
||||
Common adapter choices:
|
||||
- `claude_local` / `codex_local` / `opencode_local` for local coding agents
|
||||
- `openclaw` / `http` for webhook-based external agents
|
||||
- `process` for generic local command execution
|
||||
|
||||
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).
|
||||
Paperclip validates the selected model against live `opencode models` output.
|
||||
|
||||
## Agent Hiring via Governance
|
||||
|
||||
Agents can request to hire subordinates. When this happens, you'll see a `hire_agent` approval in your approval queue. Review the proposed agent config and approve or reject.
|
||||
|
||||
@@ -2,6 +2,97 @@
|
||||
|
||||
How to get OpenClaw running in a Docker container for local development and testing the Paperclip OpenClaw adapter integration.
|
||||
|
||||
## Automated Join Smoke Test (Recommended First)
|
||||
|
||||
Paperclip includes an end-to-end join smoke harness:
|
||||
|
||||
```bash
|
||||
pnpm smoke:openclaw-join
|
||||
```
|
||||
|
||||
The harness automates:
|
||||
|
||||
- invite creation (`allowedJoinTypes=agent`)
|
||||
- OpenClaw agent join request (`adapterType=openclaw`)
|
||||
- board approval
|
||||
- one-time API key claim (including invalid/replay claim checks)
|
||||
- wakeup callback delivery to a dockerized OpenClaw-style webhook receiver
|
||||
|
||||
By default, this uses a preconfigured Docker receiver image (`docker/openclaw-smoke`) so the run is deterministic and requires no manual OpenClaw config edits.
|
||||
|
||||
Permissions note:
|
||||
|
||||
- The harness performs board-governed actions (invite creation, join approval, wakeup of the new agent).
|
||||
- In authenticated mode, provide board/operator auth or the run exits early with an explicit permissions error.
|
||||
|
||||
## One-Command OpenClaw Gateway UI (Manual Docker Flow)
|
||||
|
||||
To spin up OpenClaw in Docker and print a host-browser dashboard URL in one command:
|
||||
|
||||
```bash
|
||||
pnpm smoke:openclaw-docker-ui
|
||||
```
|
||||
|
||||
Default behavior is zero-flag: you can run the command as-is with no pairing-related env vars.
|
||||
|
||||
What this command does:
|
||||
|
||||
- clones/updates `openclaw/openclaw` in `/tmp/openclaw-docker`
|
||||
- builds `openclaw:local` (unless `OPENCLAW_BUILD=0`)
|
||||
- writes isolated smoke config under `~/.openclaw-paperclip-smoke/openclaw.json` and Docker `.env`
|
||||
- pins agent model defaults to OpenAI (`openai/gpt-5.2` with OpenAI fallback)
|
||||
- starts `openclaw-gateway` via Compose (with required `/tmp` tmpfs override)
|
||||
- probes and prints a Paperclip host URL that is reachable from inside OpenClaw Docker
|
||||
- waits for health and prints:
|
||||
- `http://127.0.0.1:18789/#token=...`
|
||||
- disables Control UI device pairing by default for local smoke ergonomics
|
||||
|
||||
Environment knobs:
|
||||
|
||||
- `OPENAI_API_KEY` (required; loaded from env or `~/.secrets`)
|
||||
- `OPENCLAW_DOCKER_DIR` (default `/tmp/openclaw-docker`)
|
||||
- `OPENCLAW_GATEWAY_PORT` (default `18789`)
|
||||
- `OPENCLAW_GATEWAY_TOKEN` (default random)
|
||||
- `OPENCLAW_BUILD=0` to skip rebuild
|
||||
- `OPENCLAW_OPEN_BROWSER=1` to auto-open the URL on macOS
|
||||
- `OPENCLAW_DISABLE_DEVICE_AUTH=1` (default) disables Control UI device pairing for local smoke
|
||||
- `OPENCLAW_DISABLE_DEVICE_AUTH=0` keeps pairing enabled (then approve browser with `devices` CLI commands)
|
||||
- `OPENCLAW_MODEL_PRIMARY` (default `openai/gpt-5.2`)
|
||||
- `OPENCLAW_MODEL_FALLBACK` (default `openai/gpt-5.2-chat-latest`)
|
||||
- `OPENCLAW_CONFIG_DIR` (default `~/.openclaw-paperclip-smoke`)
|
||||
- `OPENCLAW_RESET_STATE=1` (default) resets smoke agent state on each run to avoid stale auth/session drift
|
||||
- `PAPERCLIP_HOST_PORT` (default `3100`)
|
||||
- `PAPERCLIP_HOST_FROM_CONTAINER` (default `host.docker.internal`)
|
||||
|
||||
### Authenticated mode
|
||||
|
||||
If your Paperclip deployment is `authenticated`, provide auth context:
|
||||
|
||||
```bash
|
||||
PAPERCLIP_AUTH_HEADER="Bearer <token>" pnpm smoke:openclaw-join
|
||||
# or
|
||||
PAPERCLIP_COOKIE="your_session_cookie=..." pnpm smoke:openclaw-join
|
||||
```
|
||||
|
||||
### Network topology tips
|
||||
|
||||
- Local same-host smoke: default callback uses `http://127.0.0.1:<port>/webhook`.
|
||||
- Inside OpenClaw Docker, `127.0.0.1` points to the container itself, not your host Paperclip server.
|
||||
- For invite/onboarding URLs consumed by OpenClaw in Docker, use the script-printed Paperclip URL (typically `http://host.docker.internal:3100`).
|
||||
- If Paperclip rejects the container-visible host with a hostname error, allow it from host:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai allowed-hostname host.docker.internal
|
||||
```
|
||||
|
||||
Then restart Paperclip and rerun the smoke script.
|
||||
- Docker/remote OpenClaw: prefer a reachable hostname (Docker host alias, Tailscale hostname, or public domain).
|
||||
- Authenticated/private mode: ensure hostnames are in the allowed list when required:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai allowed-hostname <host>
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker Desktop v29+** (with Docker Sandbox support)
|
||||
|
||||
524
docs/specs/cliphub-plan.md
Normal file
524
docs/specs/cliphub-plan.md
Normal file
@@ -0,0 +1,524 @@
|
||||
# ClipHub: Marketplace for Paperclip Team Configurations
|
||||
|
||||
> The "app store" for whole-company AI teams — pre-built Paperclip configurations, agent blueprints, skills, and governance templates that ship real work from day one.
|
||||
|
||||
## 1. Vision & Positioning
|
||||
|
||||
**ClipHub** sells **entire team configurations** — org charts, agent roles, inter-agent workflows, governance rules, and project templates — for Paperclip-managed companies.
|
||||
|
||||
| Dimension | ClipHub |
|
||||
|---|---|
|
||||
| Unit of sale | Team blueprint (multi-agent org) |
|
||||
| Buyer | Founder / team lead spinning up an AI company |
|
||||
| Install target | Paperclip company (agents, projects, governance) |
|
||||
| Value prop | "Skip org design — get a shipping team in minutes" |
|
||||
| Price range | $0–$499 per blueprint (+ individual add-ons) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Product Taxonomy
|
||||
|
||||
### 2.1 Team Blueprints (primary product)
|
||||
|
||||
A complete Paperclip company configuration:
|
||||
|
||||
- **Org chart**: Agents with roles, titles, reporting chains, capabilities
|
||||
- **Agent configs**: Adapter type, model, prompt templates, instructions paths
|
||||
- **Governance rules**: Approval flows, budget limits, escalation chains
|
||||
- **Project templates**: Pre-configured projects with workspace settings
|
||||
- **Skills & instructions**: AGENTS.md / skill files bundled per agent
|
||||
|
||||
**Examples:**
|
||||
- "SaaS Startup Team" — CEO, CTO, Engineer, CMO, Designer ($199)
|
||||
- "Content Agency" — Editor-in-Chief, 3 Writers, SEO Analyst, Social Manager ($149)
|
||||
- "Dev Shop" — CTO, 2 Engineers, QA, DevOps ($99)
|
||||
- "Solo Founder + Crew" — CEO agent + 3 ICs across eng/marketing/ops ($79)
|
||||
|
||||
### 2.2 Agent Blueprints (individual agents within a team context)
|
||||
|
||||
Single-agent configurations designed to plug into a Paperclip org:
|
||||
|
||||
- Role definition, prompt template, adapter config
|
||||
- Reporting chain expectations (who they report to)
|
||||
- Skill bundles included
|
||||
- Governance defaults (budget, permissions)
|
||||
|
||||
**Examples:**
|
||||
- "Staff Engineer" — ships production code, manages PRs ($29)
|
||||
- "Growth Marketer" — content pipeline, SEO, social ($39)
|
||||
- "DevOps Agent" — CI/CD, deployment, monitoring ($29)
|
||||
|
||||
### 2.3 Skills (modular capabilities)
|
||||
|
||||
Portable skill files that any Paperclip agent can use:
|
||||
|
||||
- Markdown skill files with instructions
|
||||
- Tool configurations and shell scripts
|
||||
- Compatible with Paperclip's skill loading system
|
||||
|
||||
**Examples:**
|
||||
- "Git PR Workflow" — standardized PR creation and review (Free)
|
||||
- "Deployment Pipeline" — Cloudflare/Vercel deploy skill ($9)
|
||||
- "Customer Support Triage" — ticket classification and routing ($19)
|
||||
|
||||
### 2.4 Governance Templates
|
||||
|
||||
Pre-built approval flows and policies:
|
||||
|
||||
- Budget thresholds and approval chains
|
||||
- Cross-team delegation rules
|
||||
- Escalation procedures
|
||||
- Billing code structures
|
||||
|
||||
**Examples:**
|
||||
- "Startup Governance" — lightweight, CEO approves > $50 (Free)
|
||||
- "Enterprise Governance" — multi-tier approval, audit trail ($49)
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Schemas
|
||||
|
||||
### 3.1 Listing
|
||||
|
||||
```typescript
|
||||
interface Listing {
|
||||
id: string;
|
||||
slug: string; // URL-friendly identifier
|
||||
type: 'team_blueprint' | 'agent_blueprint' | 'skill' | 'governance_template';
|
||||
title: string;
|
||||
tagline: string; // Short pitch (≤120 chars)
|
||||
description: string; // Markdown, full details
|
||||
|
||||
// Pricing
|
||||
price: number; // Cents (0 = free)
|
||||
currency: 'usd';
|
||||
|
||||
// Creator
|
||||
creatorId: string;
|
||||
creatorName: string;
|
||||
creatorAvatar: string | null;
|
||||
|
||||
// Categorization
|
||||
categories: string[]; // e.g. ['saas', 'engineering', 'marketing']
|
||||
tags: string[]; // e.g. ['claude', 'startup', '5-agent']
|
||||
agentCount: number | null; // For team blueprints
|
||||
|
||||
// Content
|
||||
previewImages: string[]; // Screenshots / org chart visuals
|
||||
readmeMarkdown: string; // Full README shown on detail page
|
||||
includedFiles: string[]; // List of files in the bundle
|
||||
|
||||
// Compatibility
|
||||
compatibleAdapters: string[]; // ['claude_local', 'codex_local', ...]
|
||||
requiredModels: string[]; // ['claude-opus-4-6', 'claude-sonnet-4-6']
|
||||
paperclipVersionMin: string; // Minimum Paperclip version
|
||||
|
||||
// Social proof
|
||||
installCount: number;
|
||||
rating: number | null; // 1.0–5.0
|
||||
reviewCount: number;
|
||||
|
||||
// Metadata
|
||||
version: string; // Semver
|
||||
publishedAt: string;
|
||||
updatedAt: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Team Blueprint Bundle
|
||||
|
||||
```typescript
|
||||
interface TeamBlueprint {
|
||||
listingId: string;
|
||||
|
||||
// Org structure
|
||||
agents: AgentBlueprint[];
|
||||
reportingChain: { agentSlug: string; reportsTo: string | null }[];
|
||||
|
||||
// Governance
|
||||
governance: {
|
||||
approvalRules: ApprovalRule[];
|
||||
budgetDefaults: { role: string; monthlyCents: number }[];
|
||||
escalationChain: string[]; // Agent slugs in escalation order
|
||||
};
|
||||
|
||||
// Projects
|
||||
projects: ProjectTemplate[];
|
||||
|
||||
// Company-level config
|
||||
companyDefaults: {
|
||||
name: string;
|
||||
defaultModel: string;
|
||||
defaultAdapter: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AgentBlueprint {
|
||||
slug: string; // e.g. 'cto', 'engineer-1'
|
||||
name: string;
|
||||
role: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
capabilities: string;
|
||||
promptTemplate: string;
|
||||
adapterType: string;
|
||||
adapterConfig: Record<string, any>;
|
||||
instructionsPath: string | null; // Path to AGENTS.md or similar
|
||||
skills: SkillBundle[];
|
||||
budgetMonthlyCents: number;
|
||||
permissions: {
|
||||
canCreateAgents: boolean;
|
||||
canApproveHires: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProjectTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
workspace: {
|
||||
cwd: string | null;
|
||||
repoUrl: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ApprovalRule {
|
||||
trigger: string; // e.g. 'hire_agent', 'budget_exceed'
|
||||
threshold: number | null;
|
||||
approverRole: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Creator / Seller
|
||||
|
||||
```typescript
|
||||
interface Creator {
|
||||
id: string;
|
||||
userId: string; // Auth provider ID
|
||||
displayName: string;
|
||||
bio: string;
|
||||
avatarUrl: string | null;
|
||||
website: string | null;
|
||||
listings: string[]; // Listing IDs
|
||||
totalInstalls: number;
|
||||
totalRevenue: number; // Cents earned
|
||||
joinedAt: string;
|
||||
verified: boolean;
|
||||
payoutMethod: 'stripe_connect';
|
||||
stripeAccountId: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Purchase / Install
|
||||
|
||||
```typescript
|
||||
interface Purchase {
|
||||
id: string;
|
||||
listingId: string;
|
||||
buyerUserId: string;
|
||||
buyerCompanyId: string | null; // Target Paperclip company
|
||||
pricePaidCents: number;
|
||||
paymentIntentId: string | null; // Stripe
|
||||
installedAt: string | null; // When deployed to company
|
||||
status: 'pending' | 'completed' | 'refunded';
|
||||
createdAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Review
|
||||
|
||||
```typescript
|
||||
interface Review {
|
||||
id: string;
|
||||
listingId: string;
|
||||
authorUserId: string;
|
||||
authorDisplayName: string;
|
||||
rating: number; // 1–5
|
||||
title: string;
|
||||
body: string; // Markdown
|
||||
verifiedPurchase: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Pages & Routes
|
||||
|
||||
### 4.1 Public Pages
|
||||
|
||||
| Route | Page | Description |
|
||||
|---|---|---|
|
||||
| `/` | Homepage | Hero, featured blueprints, popular skills, how it works |
|
||||
| `/browse` | Marketplace browse | Filterable grid of all listings |
|
||||
| `/browse?type=team_blueprint` | Team blueprints | Filtered to team configs |
|
||||
| `/browse?type=agent_blueprint` | Agent blueprints | Single-agent configs |
|
||||
| `/browse?type=skill` | Skills | Skill listings |
|
||||
| `/browse?type=governance_template` | Governance | Policy templates |
|
||||
| `/listings/:slug` | Listing detail | Full product page |
|
||||
| `/creators/:slug` | Creator profile | Bio, all listings, stats |
|
||||
| `/about` | About ClipHub | Mission, how it works |
|
||||
| `/pricing` | Pricing & fees | Creator revenue share, buyer info |
|
||||
|
||||
### 4.2 Authenticated Pages
|
||||
|
||||
| Route | Page | Description |
|
||||
|---|---|---|
|
||||
| `/dashboard` | Buyer dashboard | Purchased items, installed blueprints |
|
||||
| `/dashboard/purchases` | Purchase history | All transactions |
|
||||
| `/dashboard/installs` | Installations | Deployed blueprints with status |
|
||||
| `/creator` | Creator dashboard | Listing management, analytics |
|
||||
| `/creator/listings/new` | Create listing | Multi-step listing wizard |
|
||||
| `/creator/listings/:id/edit` | Edit listing | Modify existing listing |
|
||||
| `/creator/analytics` | Analytics | Revenue, installs, views |
|
||||
| `/creator/payouts` | Payouts | Stripe Connect payout history |
|
||||
|
||||
### 4.3 API Routes
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/listings` | Browse listings (filters: type, category, price range, sort) |
|
||||
| `GET` | `/api/listings/:slug` | Get listing detail |
|
||||
| `POST` | `/api/listings` | Create listing (creator auth) |
|
||||
| `PATCH` | `/api/listings/:id` | Update listing |
|
||||
| `DELETE` | `/api/listings/:id` | Archive listing |
|
||||
| `POST` | `/api/listings/:id/purchase` | Purchase listing (Stripe checkout) |
|
||||
| `POST` | `/api/listings/:id/install` | Install to Paperclip company |
|
||||
| `GET` | `/api/listings/:id/reviews` | Get reviews |
|
||||
| `POST` | `/api/listings/:id/reviews` | Submit review |
|
||||
| `GET` | `/api/creators/:slug` | Creator profile |
|
||||
| `GET` | `/api/creators/me` | Current creator profile |
|
||||
| `POST` | `/api/creators` | Register as creator |
|
||||
| `GET` | `/api/purchases` | Buyer's purchase history |
|
||||
| `GET` | `/api/analytics` | Creator analytics |
|
||||
|
||||
---
|
||||
|
||||
## 5. User Flows
|
||||
|
||||
### 5.1 Buyer: Browse → Purchase → Install
|
||||
|
||||
```
|
||||
Homepage → Browse marketplace → Filter by type/category
|
||||
→ Click listing → Read details, reviews, preview org chart
|
||||
→ Click "Buy" → Stripe checkout (or free install)
|
||||
→ Post-purchase: "Install to Company" button
|
||||
→ Select target Paperclip company (or create new)
|
||||
→ ClipHub API calls Paperclip API to:
|
||||
1. Create agents with configs from blueprint
|
||||
2. Set up reporting chains
|
||||
3. Create projects with workspace configs
|
||||
4. Apply governance rules
|
||||
5. Deploy skill files to agent instruction paths
|
||||
→ Redirect to Paperclip dashboard with new team running
|
||||
```
|
||||
|
||||
### 5.2 Creator: Build → Publish → Earn
|
||||
|
||||
```
|
||||
Sign up as creator → Connect Stripe
|
||||
→ "New Listing" wizard:
|
||||
Step 1: Type (team/agent/skill/governance)
|
||||
Step 2: Basic info (title, tagline, description, categories)
|
||||
Step 3: Upload bundle (JSON config + skill files + README)
|
||||
Step 4: Preview & org chart visualization
|
||||
Step 5: Pricing ($0–$499)
|
||||
Step 6: Publish
|
||||
→ Live on marketplace immediately
|
||||
→ Track installs, revenue, reviews on creator dashboard
|
||||
```
|
||||
|
||||
### 5.3 Creator: Export from Paperclip → Publish
|
||||
|
||||
```
|
||||
Running Paperclip company → "Export as Blueprint" (CLI or UI)
|
||||
→ Paperclip exports:
|
||||
- Agent configs (sanitized — no secrets)
|
||||
- Org chart / reporting chains
|
||||
- Governance rules
|
||||
- Project templates
|
||||
- Skill files
|
||||
→ Upload to ClipHub as new listing
|
||||
→ Edit details, set price, publish
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. UI Design Direction
|
||||
|
||||
### 6.1 Visual Language
|
||||
|
||||
- **Color palette**: Dark ink primary, warm sand backgrounds, accent color for CTAs (Paperclip brand blue/purple)
|
||||
- **Typography**: Clean sans-serif, strong hierarchy, monospace for technical details
|
||||
- **Cards**: Rounded corners, subtle shadows, clear pricing badges
|
||||
- **Org chart visuals**: Interactive tree/graph showing agent relationships in team blueprints
|
||||
|
||||
### 6.2 Key Design Elements
|
||||
|
||||
| Element | ClipHub |
|
||||
|---|---|
|
||||
| Product card | Org chart mini-preview + agent count badge |
|
||||
| Detail page | Interactive org chart + per-agent breakdown |
|
||||
| Install flow | One-click deploy to Paperclip company |
|
||||
| Social proof | "X companies running this blueprint" |
|
||||
| Preview | Live demo sandbox (stretch goal) |
|
||||
|
||||
### 6.3 Listing Card Design
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [Org Chart Mini-Preview] │
|
||||
│ ┌─CEO─┐ │
|
||||
│ ├─CTO─┤ │
|
||||
│ └─ENG──┘ │
|
||||
│ │
|
||||
│ SaaS Startup Team │
|
||||
│ "Ship your MVP with a 5-agent │
|
||||
│ engineering + marketing team" │
|
||||
│ │
|
||||
│ 👥 5 agents ⬇ 234 installs │
|
||||
│ ★ 4.7 (12 reviews) │
|
||||
│ │
|
||||
│ By @masinov $199 [Buy] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.4 Detail Page Sections
|
||||
|
||||
1. **Hero**: Title, tagline, price, install button, creator info
|
||||
2. **Org Chart**: Interactive visualization of agent hierarchy
|
||||
3. **Agent Breakdown**: Expandable cards for each agent — role, capabilities, model, skills
|
||||
4. **Governance**: Approval flows, budget structure, escalation chain
|
||||
5. **Included Projects**: Project templates with workspace configs
|
||||
6. **README**: Full markdown documentation
|
||||
7. **Reviews**: Star ratings + written reviews
|
||||
8. **Related Blueprints**: Cross-sell similar team configs
|
||||
9. **Creator Profile**: Mini bio, other listings
|
||||
|
||||
---
|
||||
|
||||
## 7. Installation Mechanics
|
||||
|
||||
### 7.1 Install API Flow
|
||||
|
||||
When a buyer clicks "Install to Company":
|
||||
|
||||
```
|
||||
POST /api/listings/:id/install
|
||||
{
|
||||
"targetCompanyId": "uuid", // Existing Paperclip company
|
||||
"overrides": { // Optional customization
|
||||
"agentModel": "claude-sonnet-4-6", // Override default model
|
||||
"budgetScale": 0.5, // Scale budgets
|
||||
"skipProjects": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The install handler:
|
||||
|
||||
1. Validates buyer owns the purchase
|
||||
2. Validates target company access
|
||||
3. For each agent in blueprint:
|
||||
- `POST /api/companies/:id/agents` (if `paperclip-create-agent` supports it, or via approval flow)
|
||||
- Sets adapter config, prompt template, instructions path
|
||||
4. Sets reporting chains
|
||||
5. Creates projects and workspaces
|
||||
6. Applies governance rules
|
||||
7. Deploys skill files to configured paths
|
||||
8. Returns summary of created resources
|
||||
|
||||
### 7.2 Conflict Resolution
|
||||
|
||||
- **Agent name collision**: Append `-2`, `-3` suffix
|
||||
- **Project name collision**: Prompt buyer to rename or skip
|
||||
- **Adapter mismatch**: Warn if blueprint requires adapter not available locally
|
||||
- **Model availability**: Warn if required model not configured
|
||||
|
||||
---
|
||||
|
||||
## 8. Revenue Model
|
||||
|
||||
| Fee | Amount | Notes |
|
||||
|---|---|---|
|
||||
| Creator revenue share | 90% of sale price | Minus Stripe processing (~2.9% + $0.30) |
|
||||
| Platform fee | 10% of sale price | ClipHub's cut |
|
||||
| Free listings | $0 | No fees for free listings |
|
||||
| Stripe Connect | Standard rates | Handled by Stripe |
|
||||
|
||||
---
|
||||
|
||||
## 9. Technical Architecture
|
||||
|
||||
### 9.1 Stack
|
||||
|
||||
- **Frontend**: Next.js (React), Tailwind CSS, same UI framework as Paperclip
|
||||
- **Backend**: Node.js API (or extend Paperclip server)
|
||||
- **Database**: Postgres (can share Paperclip's DB or separate)
|
||||
- **Payments**: Stripe Connect (marketplace mode)
|
||||
- **Storage**: S3/R2 for listing bundles and images
|
||||
- **Auth**: Shared with Paperclip auth (or OAuth2)
|
||||
|
||||
### 9.2 Integration with Paperclip
|
||||
|
||||
ClipHub can be:
|
||||
- **Option A**: A separate app that calls Paperclip's API to install blueprints
|
||||
- **Option B**: A built-in section of the Paperclip UI (`/marketplace` route)
|
||||
|
||||
Option B is simpler for MVP — adds routes to the existing Paperclip UI and API.
|
||||
|
||||
### 9.3 Bundle Format
|
||||
|
||||
Listing bundles are ZIP/tar archives containing:
|
||||
|
||||
```
|
||||
blueprint/
|
||||
├── manifest.json # Listing metadata + agent configs
|
||||
├── README.md # Documentation
|
||||
├── org-chart.json # Agent hierarchy
|
||||
├── governance.json # Approval rules, budgets
|
||||
├── agents/
|
||||
│ ├── ceo/
|
||||
│ │ ├── prompt.md # Prompt template
|
||||
│ │ ├── AGENTS.md # Instructions
|
||||
│ │ └── skills/ # Skill files
|
||||
│ ├── cto/
|
||||
│ │ ├── prompt.md
|
||||
│ │ ├── AGENTS.md
|
||||
│ │ └── skills/
|
||||
│ └── engineer/
|
||||
│ ├── prompt.md
|
||||
│ ├── AGENTS.md
|
||||
│ └── skills/
|
||||
└── projects/
|
||||
└── default/
|
||||
└── workspace.json # Project workspace config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. MVP Scope
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [ ] Listing schema and CRUD API
|
||||
- [ ] Browse page with filters (type, category, price)
|
||||
- [ ] Listing detail page with org chart visualization
|
||||
- [ ] Creator registration and listing creation wizard
|
||||
- [ ] Free installs only (no payments yet)
|
||||
- [ ] Install flow: blueprint → Paperclip company
|
||||
|
||||
### Phase 2: Payments & Social
|
||||
- [ ] Stripe Connect integration
|
||||
- [ ] Purchase flow
|
||||
- [ ] Review system
|
||||
- [ ] Creator analytics dashboard
|
||||
- [ ] "Export from Paperclip" CLI command
|
||||
|
||||
### Phase 3: Growth
|
||||
- [ ] Search with relevance ranking
|
||||
- [ ] Featured/trending listings
|
||||
- [ ] Creator verification program
|
||||
- [ ] Blueprint versioning and update notifications
|
||||
- [ ] Live demo sandbox
|
||||
- [ ] API for programmatic publishing
|
||||
18
package.json
18
package.json
@@ -3,8 +3,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-runner.mjs dev",
|
||||
"dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch",
|
||||
"dev": "node scripts/dev-runner.mjs watch",
|
||||
"dev:watch": "node scripts/dev-runner.mjs watch",
|
||||
"dev:once": "node scripts/dev-runner.mjs dev",
|
||||
"dev:server": "pnpm --filter @paperclipai/server dev",
|
||||
"dev:ui": "pnpm --filter @paperclipai/ui dev",
|
||||
"build": "pnpm -r build",
|
||||
@@ -17,14 +18,25 @@
|
||||
"db:backup": "./scripts/backup-db.sh",
|
||||
"paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts",
|
||||
"build:npm": "./scripts/build-npm.sh",
|
||||
"release:start": "./scripts/release-start.sh",
|
||||
"release": "./scripts/release.sh",
|
||||
"release:preflight": "./scripts/release-preflight.sh",
|
||||
"release:github": "./scripts/create-github-release.sh",
|
||||
"release:rollback": "./scripts/rollback-latest.sh",
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version",
|
||||
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
|
||||
"docs:dev": "cd docs && npx mintlify dev"
|
||||
"docs:dev": "cd docs && npx mintlify dev",
|
||||
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
|
||||
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
|
||||
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
|
||||
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
|
||||
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"esbuild": "^0.27.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @paperclipai/adapter-utils
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-utils",
|
||||
"version": "0.2.6",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -30,6 +30,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export type {
|
||||
AdapterRuntime,
|
||||
UsageSummary,
|
||||
AdapterBillingType,
|
||||
AdapterRuntimeServiceReport,
|
||||
AdapterExecutionResult,
|
||||
AdapterInvocationMeta,
|
||||
AdapterExecutionContext,
|
||||
@@ -13,9 +14,17 @@ export type {
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterSessionCodec,
|
||||
AdapterModel,
|
||||
HireApprovedPayload,
|
||||
HireApprovedHookResult,
|
||||
ServerAdapterModule,
|
||||
TranscriptEntry,
|
||||
StdoutLineParser,
|
||||
CLIAdapterModule,
|
||||
CreateConfigValues,
|
||||
} from "./types.js";
|
||||
export {
|
||||
REDACTED_HOME_PATH_USER,
|
||||
redactHomePathUserSegments,
|
||||
redactHomePathUserSegmentsInValue,
|
||||
redactTranscriptEntryPaths,
|
||||
} from "./log-redaction.js";
|
||||
|
||||
81
packages/adapter-utils/src/log-redaction.ts
Normal file
81
packages/adapter-utils/src/log-redaction.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { TranscriptEntry } from "./types.js";
|
||||
|
||||
export const REDACTED_HOME_PATH_USER = "[]";
|
||||
|
||||
const HOME_PATH_PATTERNS = [
|
||||
{
|
||||
regex: /\/Users\/[^/\\\s]+/g,
|
||||
replace: `/Users/${REDACTED_HOME_PATH_USER}`,
|
||||
},
|
||||
{
|
||||
regex: /\/home\/[^/\\\s]+/g,
|
||||
replace: `/home/${REDACTED_HOME_PATH_USER}`,
|
||||
},
|
||||
{
|
||||
regex: /([A-Za-z]:\\Users\\)[^\\/\s]+/g,
|
||||
replace: `$1${REDACTED_HOME_PATH_USER}`,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
export function redactHomePathUserSegments(text: string): string {
|
||||
let result = text;
|
||||
for (const pattern of HOME_PATH_PATTERNS) {
|
||||
result = result.replace(pattern.regex, pattern.replace);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function redactHomePathUserSegmentsInValue<T>(value: T): T {
|
||||
if (typeof value === "string") {
|
||||
return redactHomePathUserSegments(value) as T;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redactHomePathUserSegmentsInValue(entry)) as T;
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
redacted[key] = redactHomePathUserSegmentsInValue(entry);
|
||||
}
|
||||
return redacted as T;
|
||||
}
|
||||
|
||||
export function redactTranscriptEntryPaths(entry: TranscriptEntry): TranscriptEntry {
|
||||
switch (entry.kind) {
|
||||
case "assistant":
|
||||
case "thinking":
|
||||
case "user":
|
||||
case "stderr":
|
||||
case "system":
|
||||
case "stdout":
|
||||
return { ...entry, text: redactHomePathUserSegments(entry.text) };
|
||||
case "tool_call":
|
||||
return { ...entry, name: redactHomePathUserSegments(entry.name), input: redactHomePathUserSegmentsInValue(entry.input) };
|
||||
case "tool_result":
|
||||
return { ...entry, content: redactHomePathUserSegments(entry.content) };
|
||||
case "init":
|
||||
return {
|
||||
...entry,
|
||||
model: redactHomePathUserSegments(entry.model),
|
||||
sessionId: redactHomePathUserSegments(entry.sessionId),
|
||||
};
|
||||
case "result":
|
||||
return {
|
||||
...entry,
|
||||
text: redactHomePathUserSegments(entry.text),
|
||||
subtype: redactHomePathUserSegments(entry.subtype),
|
||||
errors: entry.errors.map((error) => redactHomePathUserSegments(error)),
|
||||
};
|
||||
default:
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,19 @@ interface RunningProcess {
|
||||
graceSec: number;
|
||||
}
|
||||
|
||||
interface SpawnTarget {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
type ChildProcessWithEvents = ChildProcess & {
|
||||
on(event: "error", listener: (err: Error) => void): ChildProcess;
|
||||
on(
|
||||
event: "close",
|
||||
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
||||
): ChildProcess;
|
||||
};
|
||||
|
||||
export const runningProcesses = new Map<string, RunningProcess>();
|
||||
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
||||
export const MAX_EXCERPT_BYTES = 32 * 1024;
|
||||
@@ -117,6 +130,78 @@ export function defaultPathForPlatform() {
|
||||
return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
}
|
||||
|
||||
function windowsPathExts(env: NodeJS.ProcessEnv): string[] {
|
||||
return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
|
||||
}
|
||||
|
||||
async function pathExists(candidate: string) {
|
||||
try {
|
||||
await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise<string | null> {
|
||||
const hasPathSeparator = command.includes("/") || command.includes("\\");
|
||||
if (hasPathSeparator) {
|
||||
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
|
||||
return (await pathExists(absolute)) ? absolute : null;
|
||||
}
|
||||
|
||||
const pathValue = env.PATH ?? env.Path ?? "";
|
||||
const delimiter = process.platform === "win32" ? ";" : ":";
|
||||
const dirs = pathValue.split(delimiter).filter(Boolean);
|
||||
const exts = process.platform === "win32" ? windowsPathExts(env) : [""];
|
||||
const hasExtension = process.platform === "win32" && path.extname(command).length > 0;
|
||||
|
||||
for (const dir of dirs) {
|
||||
const candidates =
|
||||
process.platform === "win32"
|
||||
? hasExtension
|
||||
? [path.join(dir, command)]
|
||||
: exts.map((ext) => path.join(dir, `${command}${ext}`))
|
||||
: [path.join(dir, command)];
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExists(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function quoteForCmd(arg: string) {
|
||||
if (!arg.length) return '""';
|
||||
const escaped = arg.replace(/"/g, '""');
|
||||
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
|
||||
}
|
||||
|
||||
async function resolveSpawnTarget(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<SpawnTarget> {
|
||||
const resolved = await resolveCommandPath(command, cwd, env);
|
||||
const executable = resolved ?? command;
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
if (/\.(cmd|bat)$/i.test(executable)) {
|
||||
const shell = env.ComSpec || process.env.ComSpec || "cmd.exe";
|
||||
const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" ");
|
||||
return {
|
||||
command: shell,
|
||||
args: ["/d", "/s", "/c", commandLine],
|
||||
};
|
||||
}
|
||||
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
if (typeof env.PATH === "string" && env.PATH.length > 0) return env;
|
||||
if (typeof env.Path === "string" && env.Path.length > 0) return env;
|
||||
@@ -161,36 +246,12 @@ export async function ensureAbsoluteDirectory(
|
||||
}
|
||||
|
||||
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||
const hasPathSeparator = command.includes("/") || command.includes("\\");
|
||||
if (hasPathSeparator) {
|
||||
const resolved = await resolveCommandPath(command, cwd, env);
|
||||
if (resolved) return;
|
||||
if (command.includes("/") || command.includes("\\")) {
|
||||
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
|
||||
try {
|
||||
await fs.access(absolute, fsConstants.X_OK);
|
||||
} catch {
|
||||
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
|
||||
}
|
||||
return;
|
||||
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
|
||||
}
|
||||
|
||||
const pathValue = env.PATH ?? env.Path ?? "";
|
||||
const delimiter = process.platform === "win32" ? ";" : ":";
|
||||
const dirs = pathValue.split(delimiter).filter(Boolean);
|
||||
const windowsExt = process.platform === "win32"
|
||||
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
|
||||
: [""];
|
||||
|
||||
for (const dir of dirs) {
|
||||
for (const ext of windowsExt) {
|
||||
const candidate = path.join(dir, process.platform === "win32" ? `${command}${ext}` : command);
|
||||
try {
|
||||
await fs.access(candidate, fsConstants.X_OK);
|
||||
return;
|
||||
} catch {
|
||||
// continue scanning PATH
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Command not found in PATH: "${command}"`);
|
||||
}
|
||||
|
||||
@@ -211,79 +272,100 @@ export async function runChildProcess(
|
||||
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
|
||||
|
||||
return new Promise<RunProcessResult>((resolve, reject) => {
|
||||
const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env });
|
||||
const child = spawn(command, args, {
|
||||
cwd: opts.cwd,
|
||||
env: mergedEnv,
|
||||
shell: false,
|
||||
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||
});
|
||||
const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env };
|
||||
|
||||
if (opts.stdin != null && child.stdin) {
|
||||
child.stdin.write(opts.stdin);
|
||||
child.stdin.end();
|
||||
// Strip Claude Code nesting-guard env vars so spawned `claude` processes
|
||||
// don't refuse to start with "cannot be launched inside another session".
|
||||
// These vars leak in when the Paperclip server itself is started from
|
||||
// within a Claude Code session (e.g. `npx paperclipai run` in a terminal
|
||||
// owned by Claude Code) or when cron inherits a contaminated shell env.
|
||||
const CLAUDE_CODE_NESTING_VARS = [
|
||||
"CLAUDECODE",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_SESSION",
|
||||
"CLAUDE_CODE_PARENT_SESSION",
|
||||
] as const;
|
||||
for (const key of CLAUDE_CODE_NESTING_VARS) {
|
||||
delete rawMerged[key];
|
||||
}
|
||||
|
||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
||||
const mergedEnv = ensurePathInEnv(rawMerged);
|
||||
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv)
|
||||
.then((target) => {
|
||||
const child = spawn(target.command, target.args, {
|
||||
cwd: opts.cwd,
|
||||
env: mergedEnv,
|
||||
shell: false,
|
||||
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||
}) as ChildProcessWithEvents;
|
||||
|
||||
let timedOut = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let logChain: Promise<void> = Promise.resolve();
|
||||
if (opts.stdin != null && child.stdin) {
|
||||
child.stdin.write(opts.stdin);
|
||||
child.stdin.end();
|
||||
}
|
||||
|
||||
const timeout =
|
||||
opts.timeoutSec > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, Math.max(1, opts.graceSec) * 1000);
|
||||
}, opts.timeoutSec * 1000)
|
||||
: null;
|
||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
const text = String(chunk);
|
||||
stdout = appendWithCap(stdout, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stdout", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
|
||||
});
|
||||
let timedOut = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let logChain: Promise<void> = Promise.resolve();
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
const text = String(chunk);
|
||||
stderr = appendWithCap(stderr, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stderr", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
|
||||
});
|
||||
const timeout =
|
||||
opts.timeoutSec > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, Math.max(1, opts.graceSec) * 1000);
|
||||
}, opts.timeoutSec * 1000)
|
||||
: null;
|
||||
|
||||
child.on("error", (err) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
const errno = (err as NodeJS.ErrnoException).code;
|
||||
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
||||
const msg =
|
||||
errno === "ENOENT"
|
||||
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
|
||||
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
|
||||
reject(new Error(msg));
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
void logChain.finally(() => {
|
||||
resolve({
|
||||
exitCode: code,
|
||||
signal,
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr,
|
||||
child.stdout?.on("data", (chunk: unknown) => {
|
||||
const text = String(chunk);
|
||||
stdout = appendWithCap(stdout, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stdout", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: unknown) => {
|
||||
const text = String(chunk);
|
||||
stderr = appendWithCap(stderr, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stderr", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
|
||||
});
|
||||
|
||||
child.on("error", (err: Error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
const errno = (err as NodeJS.ErrnoException).code;
|
||||
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
||||
const msg =
|
||||
errno === "ENOENT"
|
||||
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
|
||||
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
|
||||
reject(new Error(msg));
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
void logChain.finally(() => {
|
||||
resolve({
|
||||
exitCode: code,
|
||||
signal,
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +32,27 @@ export interface UsageSummary {
|
||||
|
||||
export type AdapterBillingType = "api" | "subscription" | "unknown";
|
||||
|
||||
export interface AdapterRuntimeServiceReport {
|
||||
id?: string | null;
|
||||
projectId?: string | null;
|
||||
projectWorkspaceId?: string | null;
|
||||
issueId?: string | null;
|
||||
scopeType?: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||
scopeId?: string | null;
|
||||
serviceName: string;
|
||||
status?: "starting" | "running" | "stopped" | "failed";
|
||||
lifecycle?: "shared" | "ephemeral";
|
||||
reuseKey?: string | null;
|
||||
command?: string | null;
|
||||
cwd?: string | null;
|
||||
port?: number | null;
|
||||
url?: string | null;
|
||||
providerRef?: string | null;
|
||||
ownerAgentId?: string | null;
|
||||
stopPolicy?: Record<string, unknown> | null;
|
||||
healthStatus?: "unknown" | "healthy" | "unhealthy";
|
||||
}
|
||||
|
||||
export interface AdapterExecutionResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
@@ -51,8 +72,17 @@ export interface AdapterExecutionResult {
|
||||
billingType?: AdapterBillingType | null;
|
||||
costUsd?: number | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
runtimeServices?: AdapterRuntimeServiceReport[];
|
||||
summary?: string | null;
|
||||
clearSession?: boolean;
|
||||
question?: {
|
||||
prompt: string;
|
||||
choices: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AdapterSessionCodec {
|
||||
@@ -119,6 +149,27 @@ export interface AdapterEnvironmentTestContext {
|
||||
};
|
||||
}
|
||||
|
||||
/** Payload for the onHireApproved adapter lifecycle hook (e.g. join-request or hire_agent approval). */
|
||||
export interface HireApprovedPayload {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
adapterType: string;
|
||||
/** "join_request" | "approval" */
|
||||
source: "join_request" | "approval";
|
||||
sourceId: string;
|
||||
approvedAt: string;
|
||||
/** Canonical operator-facing message for cloud adapters to show the user. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Result of onHireApproved hook; failures are non-fatal to the approval flow. */
|
||||
export interface HireApprovedHookResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ServerAdapterModule {
|
||||
type: string;
|
||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||
@@ -128,6 +179,14 @@ export interface ServerAdapterModule {
|
||||
models?: AdapterModel[];
|
||||
listModels?: () => Promise<AdapterModel[]>;
|
||||
agentConfigurationDoc?: string;
|
||||
/**
|
||||
* Optional lifecycle hook when an agent is approved/hired (join-request or hire_agent approval).
|
||||
* adapterConfig is the agent's adapter config so the adapter can e.g. send a callback to a configured URL.
|
||||
*/
|
||||
onHireApproved?: (
|
||||
payload: HireApprovedPayload,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) => Promise<HireApprovedHookResult>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -135,10 +194,10 @@ export interface ServerAdapterModule {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TranscriptEntry =
|
||||
| { kind: "assistant"; ts: string; text: string }
|
||||
| { kind: "thinking"; ts: string; text: string }
|
||||
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
|
||||
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
|
||||
| { kind: "user"; ts: string; text: string }
|
||||
| { kind: "tool_call"; ts: string; name: string; input: unknown }
|
||||
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
|
||||
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
||||
| { kind: "init"; ts: string; model: string; sessionId: string }
|
||||
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
|
||||
@@ -179,6 +238,12 @@ export interface CreateConfigValues {
|
||||
envBindings: Record<string, unknown>;
|
||||
url: string;
|
||||
bootstrapPrompt: string;
|
||||
payloadTemplateJson?: string;
|
||||
workspaceStrategyType?: string;
|
||||
workspaceBaseRef?: string;
|
||||
workspaceBranchTemplate?: string;
|
||||
worktreeParentDir?: string;
|
||||
runtimeServicesJson?: string;
|
||||
maxTurnsPerRun: number;
|
||||
heartbeatEnabled: boolean;
|
||||
intervalSec: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# @paperclipai/adapter-claude-local
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.7
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-claude-local",
|
||||
"version": "0.2.6",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -45,6 +45,7 @@
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export const label = "Claude Code (local)";
|
||||
|
||||
export const models = [
|
||||
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
|
||||
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
|
||||
];
|
||||
@@ -23,8 +25,13 @@ Core fields:
|
||||
- command (string, optional): defaults to "claude"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||
`;
|
||||
|
||||
@@ -115,14 +115,28 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
|
||||
? context.paperclipRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
|
||||
? context.paperclipRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
@@ -183,6 +197,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
@@ -192,9 +209,24 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
|
||||
@@ -50,6 +50,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
@@ -70,6 +82,18 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||
if (v.workspaceStrategyType === "git_worktree") {
|
||||
ac.workspaceStrategy = {
|
||||
type: "git_worktree",
|
||||
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
||||
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
||||
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
||||
};
|
||||
}
|
||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||
ac.workspaceRuntime = runtimeServices;
|
||||
}
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
|
||||
@@ -71,6 +71,12 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: typeof block.name === "string" ? block.name : "unknown",
|
||||
toolUseId:
|
||||
typeof block.id === "string"
|
||||
? block.id
|
||||
: typeof block.tool_use_id === "string"
|
||||
? block.tool_use_id
|
||||
: undefined,
|
||||
input: block.input ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# @paperclipai/adapter-codex-local
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.7
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-codex-local",
|
||||
"version": "0.2.6",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -45,6 +45,7 @@
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex";
|
||||
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
|
||||
|
||||
export const models = [
|
||||
{ id: "gpt-5.4", label: "gpt-5.4" },
|
||||
{ id: DEFAULT_CODEX_LOCAL_MODEL, label: DEFAULT_CODEX_LOCAL_MODEL },
|
||||
{ id: "gpt-5.3-codex-spark", label: "gpt-5.3-codex-spark" },
|
||||
{ id: "gpt-5", label: "gpt-5" },
|
||||
@@ -30,6 +31,8 @@ Core fields:
|
||||
- command (string, optional): defaults to "codex"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
@@ -39,4 +42,5 @@ Notes:
|
||||
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
||||
- Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills.
|
||||
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
||||
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||
`;
|
||||
|
||||
@@ -126,14 +126,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "");
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
|
||||
? context.paperclipRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
|
||||
? context.paperclipRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
@@ -192,6 +206,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
@@ -201,9 +218,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
@@ -76,6 +88,18 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
||||
typeof v.dangerouslyBypassSandbox === "boolean"
|
||||
? v.dangerouslyBypassSandbox
|
||||
: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
if (v.workspaceStrategyType === "git_worktree") {
|
||||
ac.workspaceStrategy = {
|
||||
type: "git_worktree",
|
||||
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
||||
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
||||
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
||||
};
|
||||
}
|
||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||
ac.workspaceRuntime = runtimeServices;
|
||||
}
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
redactHomePathUserSegments,
|
||||
redactHomePathUserSegmentsInValue,
|
||||
type TranscriptEntry,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
@@ -39,12 +43,12 @@ function errorText(value: unknown): string {
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "string") return redactHomePathUserSegments(value);
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
return JSON.stringify(redactHomePathUserSegmentsInValue(value), null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
return redactHomePathUserSegments(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,22 +61,24 @@ function parseCommandExecutionItem(
|
||||
const command = asString(item.command);
|
||||
const status = asString(item.status);
|
||||
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
|
||||
const output = asString(item.aggregated_output).replace(/\s+$/, "");
|
||||
const safeCommand = redactHomePathUserSegments(command);
|
||||
const output = redactHomePathUserSegments(asString(item.aggregated_output)).replace(/\s+$/, "");
|
||||
|
||||
if (phase === "started") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: "command_execution",
|
||||
toolUseId: id || command || "command_execution",
|
||||
input: {
|
||||
id,
|
||||
command,
|
||||
command: safeCommand,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (command) lines.push(`command: ${command}`);
|
||||
if (safeCommand) lines.push(`command: ${safeCommand}`);
|
||||
if (status) lines.push(`status: ${status}`);
|
||||
if (exitCode !== null) lines.push(`exit_code: ${exitCode}`);
|
||||
if (output) {
|
||||
@@ -103,7 +109,7 @@ function parseFileChangeItem(item: Record<string, unknown>, ts: string): Transcr
|
||||
.filter((change): change is Record<string, unknown> => Boolean(change))
|
||||
.map((change) => {
|
||||
const kind = asString(change.kind, "update");
|
||||
const path = asString(change.path, "unknown");
|
||||
const path = redactHomePathUserSegments(asString(change.path, "unknown"));
|
||||
return `${kind} ${path}`;
|
||||
});
|
||||
|
||||
@@ -125,13 +131,13 @@ function parseCodexItem(
|
||||
|
||||
if (itemType === "agent_message") {
|
||||
const text = asString(item.text);
|
||||
if (text) return [{ kind: "assistant", ts, text }];
|
||||
if (text) return [{ kind: "assistant", ts, text: redactHomePathUserSegments(text) }];
|
||||
return [];
|
||||
}
|
||||
|
||||
if (itemType === "reasoning") {
|
||||
const text = asString(item.text);
|
||||
if (text) return [{ kind: "thinking", ts, text }];
|
||||
if (text) return [{ kind: "thinking", ts, text: redactHomePathUserSegments(text) }];
|
||||
return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }];
|
||||
}
|
||||
|
||||
@@ -147,8 +153,9 @@ function parseCodexItem(
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: asString(item.name, "unknown"),
|
||||
input: item.input ?? {},
|
||||
name: redactHomePathUserSegments(asString(item.name, "unknown")),
|
||||
toolUseId: asString(item.id),
|
||||
input: redactHomePathUserSegmentsInValue(item.input ?? {}),
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -160,24 +167,28 @@ function parseCodexItem(
|
||||
asString(item.result) ||
|
||||
stringifyUnknown(item.content ?? item.output ?? item.result);
|
||||
const isError = item.is_error === true || asString(item.status) === "error";
|
||||
return [{ kind: "tool_result", ts, toolUseId, content, isError }];
|
||||
return [{ kind: "tool_result", ts, toolUseId, content: redactHomePathUserSegments(content), isError }];
|
||||
}
|
||||
|
||||
if (itemType === "error" && phase === "completed") {
|
||||
const text = errorText(item.message ?? item.error ?? item);
|
||||
return [{ kind: "stderr", ts, text: text || "error" }];
|
||||
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(text || "error") }];
|
||||
}
|
||||
|
||||
const id = asString(item.id);
|
||||
const status = asString(item.status);
|
||||
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
|
||||
return [{ kind: "system", ts, text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}` }];
|
||||
return [{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: redactHomePathUserSegments(`item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`),
|
||||
}];
|
||||
}
|
||||
|
||||
export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
@@ -187,8 +198,8 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
|
||||
return [{
|
||||
kind: "init",
|
||||
ts,
|
||||
model: asString(parsed.model, "codex"),
|
||||
sessionId: threadId,
|
||||
model: redactHomePathUserSegments(asString(parsed.model, "codex")),
|
||||
sessionId: redactHomePathUserSegments(threadId),
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -210,15 +221,15 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: asString(parsed.result),
|
||||
text: redactHomePathUserSegments(asString(parsed.result)),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd),
|
||||
subtype: asString(parsed.subtype),
|
||||
subtype: redactHomePathUserSegments(asString(parsed.subtype)),
|
||||
isError: parsed.is_error === true,
|
||||
errors: Array.isArray(parsed.errors)
|
||||
? parsed.errors.map(errorText).filter(Boolean)
|
||||
? parsed.errors.map(errorText).map(redactHomePathUserSegments).filter(Boolean)
|
||||
: [],
|
||||
}];
|
||||
}
|
||||
@@ -232,21 +243,21 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: asString(parsed.result),
|
||||
text: redactHomePathUserSegments(asString(parsed.result)),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd),
|
||||
subtype: asString(parsed.subtype, "turn.failed"),
|
||||
subtype: redactHomePathUserSegments(asString(parsed.subtype, "turn.failed")),
|
||||
isError: true,
|
||||
errors: message ? [message] : [],
|
||||
errors: message ? [redactHomePathUserSegments(message)] : [],
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = errorText(parsed.message ?? parsed.error ?? parsed);
|
||||
return [{ kind: "stderr", ts, text: message || line }];
|
||||
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(message || line) }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
|
||||
18
packages/adapters/cursor-local/CHANGELOG.md
Normal file
18
packages/adapters/cursor-local/CHANGELOG.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @paperclipai/adapter-cursor-local
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Stable release preparation for 0.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.0
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Added initial `cursor` adapter package for local Cursor CLI execution
|
||||
51
packages/adapters/cursor-local/package.json
Normal file
51
packages/adapters/cursor-local/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-cursor-local",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
317
packages/adapters/cursor-local/src/cli/format-event.ts
Normal file
317
packages/adapters/cursor-local/src/cli/format-event.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import pc from "picocolors";
|
||||
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function printUserMessage(messageRaw: unknown): void {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
if (text) console.log(pc.gray(`user: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return;
|
||||
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) console.log(pc.gray(`user: ${directText}`));
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
if (type !== "output_text" && type !== "text") continue;
|
||||
const text = asString(part.text).trim();
|
||||
if (text) console.log(pc.gray(`user: ${text}`));
|
||||
}
|
||||
}
|
||||
|
||||
function printAssistantMessage(messageRaw: unknown): void {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return;
|
||||
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) console.log(pc.green(`assistant: ${directText}`));
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
|
||||
if (type === "output_text" || type === "text") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const name = asString(part.name, asString(part.tool, "tool"));
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
const input = part.input ?? part.arguments ?? part.args;
|
||||
if (input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(input)));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result") {
|
||||
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||
const contentText =
|
||||
asString(part.output) ||
|
||||
asString(part.text) ||
|
||||
asString(part.result) ||
|
||||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part);
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
if (contentText) console.log((isError ? pc.red : pc.gray)(contentText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printToolCallEventTopLevel(parsed: Record<string, unknown>): void {
|
||||
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||
const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "")));
|
||||
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
||||
if (!toolCall) {
|
||||
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const [toolName] = Object.keys(toolCall);
|
||||
if (!toolName) {
|
||||
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
|
||||
return;
|
||||
}
|
||||
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||
const args = payload.args ?? asRecord(payload.function)?.arguments;
|
||||
const result =
|
||||
payload.result ??
|
||||
payload.output ??
|
||||
payload.error ??
|
||||
asRecord(payload.function)?.result ??
|
||||
asRecord(payload.function)?.output;
|
||||
const isError =
|
||||
parsed.is_error === true ||
|
||||
payload.is_error === true ||
|
||||
subtype === "failed" ||
|
||||
subtype === "error" ||
|
||||
subtype === "cancelled" ||
|
||||
payload.error !== undefined;
|
||||
|
||||
if (subtype === "started" || subtype === "start") {
|
||||
console.log(pc.yellow(`tool_call: ${toolName}${callId ? ` (${callId})` : ""}`));
|
||||
if (args !== undefined) {
|
||||
console.log(pc.gray(stringifyUnknown(args)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||
const header = `tool_result${isError ? " (error)" : ""}${callId ? ` (${callId})` : ""}`;
|
||||
console.log((isError ? pc.red : pc.cyan)(header));
|
||||
if (result !== undefined) {
|
||||
console.log((isError ? pc.red : pc.gray)(stringifyUnknown(result)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`));
|
||||
}
|
||||
|
||||
function printLegacyToolEvent(part: Record<string, unknown>): void {
|
||||
const tool = asString(part.tool, "tool");
|
||||
const callId = asString(part.callID, asString(part.id, ""));
|
||||
const state = asRecord(part.state);
|
||||
const status = asString(state?.status);
|
||||
const input = state?.input;
|
||||
const output = asString(state?.output).replace(/\s+$/, "");
|
||||
const metadata = asRecord(state?.metadata);
|
||||
const exit = asNumber(metadata?.exit, NaN);
|
||||
const isError =
|
||||
status === "failed" ||
|
||||
status === "error" ||
|
||||
status === "cancelled" ||
|
||||
(Number.isFinite(exit) && exit !== 0);
|
||||
|
||||
console.log(pc.yellow(`tool_call: ${tool}${callId ? ` (${callId})` : ""}`));
|
||||
if (input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(input)));
|
||||
}
|
||||
}
|
||||
|
||||
if (status || output) {
|
||||
const summary = [
|
||||
"tool_result",
|
||||
status ? `status=${status}` : "",
|
||||
Number.isFinite(exit) ? `exit=${exit}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
console.log((isError ? pc.red : pc.cyan)(summary));
|
||||
if (output) {
|
||||
console.log((isError ? pc.red : pc.gray)(output));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function printCursorStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = normalizeCursorStreamLine(raw).line;
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(parsed.subtype);
|
||||
if (subtype === "init") {
|
||||
const sessionId =
|
||||
asString(parsed.session_id) ||
|
||||
asString(parsed.sessionId) ||
|
||||
asString(parsed.sessionID);
|
||||
const model = asString(parsed.model);
|
||||
const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
console.log(pc.blue(`Cursor init${details ? ` (${details})` : ""}`));
|
||||
return;
|
||||
}
|
||||
console.log(pc.blue(`system: ${subtype || "event"}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
printAssistantMessage(parsed.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
printUserMessage(parsed.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
printToolCallEventTopLevel(parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const input = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
|
||||
const output = asNumber(usage?.output_tokens, asNumber(usage?.outputTokens));
|
||||
const cached = asNumber(
|
||||
usage?.cached_input_tokens,
|
||||
asNumber(usage?.cachedInputTokens, asNumber(usage?.cache_read_input_tokens)),
|
||||
);
|
||||
const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
|
||||
const subtype = asString(parsed.subtype, "result");
|
||||
const isError = parsed.is_error === true || subtype === "error" || subtype === "failed";
|
||||
|
||||
console.log(pc.blue(`result: subtype=${subtype}`));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
const resultText = asString(parsed.result).trim();
|
||||
if (resultText) console.log((isError ? pc.red : pc.green)(`assistant: ${resultText}`));
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map((value) => stringifyUnknown(value)).filter(Boolean) : [];
|
||||
if (errors.length > 0) console.log(pc.red(`errors: ${errors.join(" | ")}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = asString(parsed.message) || stringifyUnknown(parsed.error ?? parsed.detail) || line;
|
||||
console.log(pc.red(`error: ${message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Compatibility with older stream-json event shapes.
|
||||
if (type === "step_start") {
|
||||
const sessionId = asString(parsed.sessionID);
|
||||
console.log(pc.blue(`step started${sessionId ? ` (session: ${sessionId})` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text);
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
const part = asRecord(parsed.part);
|
||||
if (part) {
|
||||
printLegacyToolEvent(part);
|
||||
} else {
|
||||
console.log(pc.yellow("tool_use"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = asRecord(parsed.part);
|
||||
const tokens = asRecord(part?.tokens);
|
||||
const cache = asRecord(tokens?.cache);
|
||||
const reason = asString(part?.reason, "step_finish");
|
||||
const input = asNumber(tokens?.input);
|
||||
const output = asNumber(tokens?.output);
|
||||
const cached = asNumber(cache?.read);
|
||||
const cost = asNumber(part?.cost);
|
||||
console.log(pc.blue(`step finished: reason=${reason}`));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
}
|
||||
1
packages/adapters/cursor-local/src/cli/index.ts
Normal file
1
packages/adapters/cursor-local/src/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { printCursorStreamEvent } from "./format-event.js";
|
||||
83
packages/adapters/cursor-local/src/index.ts
Normal file
83
packages/adapters/cursor-local/src/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export const type = "cursor";
|
||||
export const label = "Cursor CLI (local)";
|
||||
export const DEFAULT_CURSOR_LOCAL_MODEL = "auto";
|
||||
|
||||
const CURSOR_FALLBACK_MODEL_IDS = [
|
||||
"auto",
|
||||
"composer-1.5",
|
||||
"composer-1",
|
||||
"gpt-5.3-codex-low",
|
||||
"gpt-5.3-codex-low-fast",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.3-codex-fast",
|
||||
"gpt-5.3-codex-high",
|
||||
"gpt-5.3-codex-high-fast",
|
||||
"gpt-5.3-codex-xhigh",
|
||||
"gpt-5.3-codex-xhigh-fast",
|
||||
"gpt-5.3-codex-spark-preview",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-codex-low",
|
||||
"gpt-5.2-codex-low-fast",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.2-codex-fast",
|
||||
"gpt-5.2-codex-high",
|
||||
"gpt-5.2-codex-high-fast",
|
||||
"gpt-5.2-codex-xhigh",
|
||||
"gpt-5.2-codex-xhigh-fast",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-max-high",
|
||||
"gpt-5.2-high",
|
||||
"gpt-5.1-high",
|
||||
"gpt-5.1-codex-mini",
|
||||
"opus-4.6-thinking",
|
||||
"opus-4.6",
|
||||
"opus-4.5",
|
||||
"opus-4.5-thinking",
|
||||
"sonnet-4.6",
|
||||
"sonnet-4.6-thinking",
|
||||
"sonnet-4.5",
|
||||
"sonnet-4.5-thinking",
|
||||
"gemini-3.1-pro",
|
||||
"gemini-3-pro",
|
||||
"gemini-3-flash",
|
||||
"grok",
|
||||
"kimi-k2.5",
|
||||
];
|
||||
|
||||
export const models = CURSOR_FALLBACK_MODEL_IDS.map((id) => ({ id, label: id }));
|
||||
|
||||
export const agentConfigurationDoc = `# cursor agent configuration
|
||||
|
||||
Adapter: cursor
|
||||
|
||||
Use when:
|
||||
- You want Paperclip to run Cursor Agent CLI locally as the agent runtime
|
||||
- You want Cursor chat session resume across heartbeats via --resume
|
||||
- You want structured stream output in run logs via --output-format stream-json
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use openclaw_gateway or http)
|
||||
- You only need one-shot shell commands (use process)
|
||||
- Cursor Agent CLI is not installed on the machine
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- model (string, optional): Cursor model id (for example auto or gpt-5.3-codex)
|
||||
- mode (string, optional): Cursor execution mode passed as --mode (plan|ask). Leave unset for normal autonomous runs.
|
||||
- command (string, optional): defaults to "agent"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- Runs are executed with: agent -p --output-format stream-json ...
|
||||
- Prompts are piped to Cursor via stdin.
|
||||
- Sessions are resumed with --resume when stored session cwd matches current cwd.
|
||||
- Paperclip auto-injects local skills into "~/.cursor/skills" when missing, so Cursor can discover "$paperclip" and related skills on local runs.
|
||||
- Paperclip auto-adds --yolo unless one of --trust/--yolo/-f is already present in extraArgs.
|
||||
`;
|
||||
485
packages/adapters/cursor-local/src/server/execute.ts
Normal file
485
packages/adapters/cursor-local/src/server/execute.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
||||
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
||||
import { hasCursorTrustBypassArg } from "../shared/trust.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"),
|
||||
path.resolve(__moduleDir, "../../../../../skills"),
|
||||
];
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveCursorBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasNonEmptyEnvValue(env, "CURSOR_API_KEY") || hasNonEmptyEnvValue(env, "OPENAI_API_KEY")
|
||||
? "api"
|
||||
: "subscription";
|
||||
}
|
||||
|
||||
function resolveProviderFromModel(model: string): string | null {
|
||||
const trimmed = model.trim().toLowerCase();
|
||||
if (!trimmed) return null;
|
||||
const slash = trimmed.indexOf("/");
|
||||
if (slash > 0) return trimmed.slice(0, slash);
|
||||
if (trimmed.includes("sonnet") || trimmed.includes("claude")) return "anthropic";
|
||||
if (trimmed.startsWith("gpt") || trimmed.startsWith("o")) return "openai";
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeMode(rawMode: string): "plan" | "ask" | null {
|
||||
const mode = rawMode.trim().toLowerCase();
|
||||
if (mode === "plan" || mode === "ask") return mode;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPaperclipEnvNote(env: Record<string, string>): string {
|
||||
const paperclipKeys = Object.keys(env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort();
|
||||
if (paperclipKeys.length === 0) return "";
|
||||
return [
|
||||
"Paperclip runtime note:",
|
||||
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
|
||||
"Do not assume these variables are missing without checking your shell environment.",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function cursorSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".cursor", "skills");
|
||||
}
|
||||
|
||||
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type EnsureCursorSkillsInjectedOptions = {
|
||||
skillsDir?: string | null;
|
||||
skillsHome?: string;
|
||||
linkSkill?: (source: string, target: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export async function ensureCursorSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
options: EnsureCursorSkillsInjectedOptions = {},
|
||||
) {
|
||||
const skillsDir = options.skillsDir ?? await resolvePaperclipSkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
const skillsHome = options.skillsHome ?? cursorSkillsHome();
|
||||
try {
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to prepare Cursor skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target));
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) continue;
|
||||
|
||||
try {
|
||||
await linkSkill(source, target);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Injected Cursor skill "${entry.name}" into ${skillsHome}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to inject Cursor skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
const command = asString(config.command, "agent");
|
||||
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
||||
const mode = normalizeMode(asString(config.mode, ""));
|
||||
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureCursorSkillsInjected(onLog);
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
if (wakeTaskId) {
|
||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
}
|
||||
if (wakeReason) {
|
||||
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
}
|
||||
if (approvalStatus) {
|
||||
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
}
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const billingType = resolveCursorBillingType(env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
const autoTrustEnabled = !hasCursorTrustBypassArg(extraArgs);
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Cursor session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const commandNotes = (() => {
|
||||
const notes: string[] = [];
|
||||
if (autoTrustEnabled) {
|
||||
notes.push("Auto-added --yolo to bypass interactive prompts.");
|
||||
}
|
||||
notes.push("Prompt is piped to Cursor via stdin.");
|
||||
if (!instructionsFilePath) return notes;
|
||||
if (instructionsPrefix.length > 0) {
|
||||
notes.push(
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||
);
|
||||
return notes;
|
||||
}
|
||||
notes.push(
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
);
|
||||
return notes;
|
||||
})();
|
||||
|
||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||
const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["-p", "--output-format", "stream-json", "--workspace", cwd];
|
||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||
if (model) args.push("--model", model);
|
||||
if (mode) args.push("--mode", mode);
|
||||
if (autoTrustEnabled) args.push("--yolo");
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
return args;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "cursor",
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args,
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
let stdoutLineBuffer = "";
|
||||
const emitNormalizedStdoutLine = async (rawLine: string) => {
|
||||
const normalized = normalizeCursorStreamLine(rawLine);
|
||||
if (!normalized.line) return;
|
||||
await onLog(normalized.stream ?? "stdout", `${normalized.line}\n`);
|
||||
};
|
||||
const flushStdoutChunk = async (chunk: string, finalize = false) => {
|
||||
const combined = `${stdoutLineBuffer}${chunk}`;
|
||||
const lines = combined.split(/\r?\n/);
|
||||
stdoutLineBuffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
await emitNormalizedStdoutLine(line);
|
||||
}
|
||||
|
||||
if (finalize) {
|
||||
const trailing = stdoutLineBuffer.trim();
|
||||
stdoutLineBuffer = "";
|
||||
if (trailing) {
|
||||
await emitNormalizedStdoutLine(trailing);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
stdin: prompt,
|
||||
onLog: async (stream, chunk) => {
|
||||
if (stream !== "stdout") {
|
||||
await onLog(stream, chunk);
|
||||
return;
|
||||
}
|
||||
await flushStdoutChunk(chunk);
|
||||
},
|
||||
});
|
||||
await flushStdoutChunk("", true);
|
||||
|
||||
return {
|
||||
proc,
|
||||
parsed: parseCursorJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const providerFromModel = resolveProviderFromModel(model);
|
||||
|
||||
const toResult = (
|
||||
attempt: {
|
||||
proc: {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
parsed: ReturnType<typeof parseCursorJsonl>;
|
||||
},
|
||||
clearSessionOnMissingSession = false,
|
||||
): AdapterExecutionResult => {
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
stderrLine ||
|
||||
`Cursor exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
(attempt.proc.exitCode ?? 0) === 0
|
||||
? null
|
||||
: fallbackErrorMessage,
|
||||
usage: attempt.parsed.usage,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: providerFromModel,
|
||||
model,
|
||||
billingType,
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
resultJson: {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionId);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isCursorUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Cursor resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true);
|
||||
}
|
||||
|
||||
return toResult(initial);
|
||||
}
|
||||
64
packages/adapters/cursor-local/src/server/index.ts
Normal file
64
packages/adapters/cursor-local/src/server/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export { execute, ensureCursorSkillsInjected } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId =
|
||||
readNonEmptyString(record.sessionId) ??
|
||||
readNonEmptyString(record.session_id) ??
|
||||
readNonEmptyString(record.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return (
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID)
|
||||
);
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user