mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
Compare commits
1032 Commits
public/wor
...
PAPA-45-up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
347f38019f | ||
|
|
25615407a4 | ||
|
|
f843a45a84 | ||
|
|
36049beeea | ||
|
|
c041fee6fc | ||
|
|
82290451d4 | ||
|
|
fb3b57ab1f | ||
|
|
ca8d35fd99 | ||
|
|
81a7f79dfd | ||
|
|
ad1ef6a8c6 | ||
|
|
833842b391 | ||
|
|
fd6cfc7149 | ||
|
|
50e9f69010 | ||
|
|
38a0cd275e | ||
|
|
bd6d07d0b4 | ||
|
|
3ab7d52f00 | ||
|
|
909e8cd4c8 | ||
|
|
36376968af | ||
|
|
29d0e82dce | ||
|
|
1c1040e219 | ||
|
|
0ec8257563 | ||
|
|
38833304d4 | ||
|
|
85e6371cb6 | ||
|
|
daea94a2ed | ||
|
|
c18b3cb414 | ||
|
|
af844b778e | ||
|
|
53dbcd185e | ||
|
|
f16de6026d | ||
|
|
34044cdfce | ||
|
|
ca5659f734 | ||
|
|
d12e3e3d1a | ||
|
|
c0d0d03bce | ||
|
|
3db6bdfc3c | ||
|
|
6524dbe08f | ||
|
|
2c1883fc77 | ||
|
|
4abd53c089 | ||
|
|
3c99ab8d01 | ||
|
|
9d6d159209 | ||
|
|
26069682ee | ||
|
|
1e24e6e84c | ||
|
|
9d89d74d70 | ||
|
|
056a5ee32a | ||
|
|
dedd972e3d | ||
|
|
6a7830b07e | ||
|
|
f9cebe9b73 | ||
|
|
9e1ee925cd | ||
|
|
6c2c63e0f1 | ||
|
|
461779a960 | ||
|
|
6aa3ead238 | ||
|
|
e0f64c04e7 | ||
|
|
e5b2e8b29b | ||
|
|
62d8b39474 | ||
|
|
420cd4fd8d | ||
|
|
5b479652f2 | ||
|
|
99296f95db | ||
|
|
92e03ac4e3 | ||
|
|
ce8d9eb323 | ||
|
|
06cf00129f | ||
|
|
ebc6888e7d | ||
|
|
9f1bb350fe | ||
|
|
46ce546174 | ||
|
|
90889c12d8 | ||
|
|
761dce559d | ||
|
|
41f261eaf5 | ||
|
|
8427043431 | ||
|
|
19aaa54ae4 | ||
|
|
d134d5f3a1 | ||
|
|
98337f5b03 | ||
|
|
477ef78fed | ||
|
|
b0e0f8cd91 | ||
|
|
ccb5cce4ac | ||
|
|
5575399af1 | ||
|
|
2c75c8a1ec | ||
|
|
d8814e938c | ||
|
|
a7cfbc98f3 | ||
|
|
5e65bb2b92 | ||
|
|
d7d01e9819 | ||
|
|
88e742a129 | ||
|
|
db4e146551 | ||
|
|
9684e7bf30 | ||
|
|
a3e125f796 | ||
|
|
2b18fc4007 | ||
|
|
ec1210caaa | ||
|
|
3c66683169 | ||
|
|
c610192c53 | ||
|
|
4d61dbfd34 | ||
|
|
26a974da17 | ||
|
|
8a368e8721 | ||
|
|
c8ab70f2ce | ||
|
|
29da357c5b | ||
|
|
4120016d30 | ||
|
|
fceefe7f09 | ||
|
|
2d31c71fbe | ||
|
|
b5efd8b435 | ||
|
|
fc2be204e2 | ||
|
|
92ebad3d42 | ||
|
|
5310bbd4d8 | ||
|
|
c54b985d9f | ||
|
|
70702ce74f | ||
|
|
b1b3408efa | ||
|
|
57357991e4 | ||
|
|
50577b8c63 | ||
|
|
1871a602df | ||
|
|
facf994694 | ||
|
|
403aeff7f6 | ||
|
|
7d81e4cb2a | ||
|
|
44f052f4c5 | ||
|
|
c33dcbd202 | ||
|
|
bc61eb84df | ||
|
|
74687553f3 | ||
|
|
4226e15128 | ||
|
|
cfb7dd4818 | ||
|
|
52bb4ea37a | ||
|
|
3986eb615c | ||
|
|
0f9faa297b | ||
|
|
d917375e35 | ||
|
|
ce4536d1fa | ||
|
|
4fd62a3d91 | ||
|
|
25066c967b | ||
|
|
1534b39ee3 | ||
|
|
826da2973d | ||
|
|
4426d96610 | ||
|
|
c8956094ad | ||
|
|
2ec4ba629e | ||
|
|
182b459235 | ||
|
|
94d6ae4049 | ||
|
|
b3d61a7561 | ||
|
|
d9005405b9 | ||
|
|
e3f07aad55 | ||
|
|
2fea39b814 | ||
|
|
0356040a29 | ||
|
|
caa7550e9f | ||
|
|
84d4c328f5 | ||
|
|
11f08ea5d5 | ||
|
|
1f1fe9c989 | ||
|
|
f1ad07616c | ||
|
|
868cfa8c50 | ||
|
|
6793dde597 | ||
|
|
cadfcd1bc6 | ||
|
|
c114ff4dc6 | ||
|
|
84e35b801c | ||
|
|
cbeefbfa5a | ||
|
|
2de691f023 | ||
|
|
41f2a80aa8 | ||
|
|
bb1732dd11 | ||
|
|
15e0e2ece9 | ||
|
|
b7b5d8dae3 | ||
|
|
0ff778ec29 | ||
|
|
b69f0b7dc4 | ||
|
|
b75ac76b13 | ||
|
|
19b6adc415 | ||
|
|
54b05d6d68 | ||
|
|
f83a77f41f | ||
|
|
a3537a86e3 | ||
|
|
5d538d4792 | ||
|
|
dc3aa8f31f | ||
|
|
c98af52590 | ||
|
|
01fb97e8da | ||
|
|
6a72faf83b | ||
|
|
1fd40920db | ||
|
|
caef115b95 | ||
|
|
17e5322e28 | ||
|
|
582f4ceaf4 | ||
|
|
1583a2d65a | ||
|
|
9a70a4edaa | ||
|
|
0ac01a04e5 | ||
|
|
11ff24cd22 | ||
|
|
a5d47166e2 | ||
|
|
eb8c5d93e7 | ||
|
|
af5b980362 | ||
|
|
2e563ccd50 | ||
|
|
2c406d3b8c | ||
|
|
49c7fb7fbd | ||
|
|
995f5b0b66 | ||
|
|
b34fa3b273 | ||
|
|
9ddf960312 | ||
|
|
a8894799e4 | ||
|
|
76a692c260 | ||
|
|
5913706329 | ||
|
|
b944293eda | ||
|
|
3c1ebed539 | ||
|
|
ab0d04ff7a | ||
|
|
6073ac3145 | ||
|
|
3b329467eb | ||
|
|
aa5b2be907 | ||
|
|
dcb66eeae7 | ||
|
|
874fe5ec7d | ||
|
|
c916626cef | ||
|
|
555f026c24 | ||
|
|
e91da556ee | ||
|
|
ab82e3f022 | ||
|
|
c74cda1851 | ||
|
|
fcf3ba6974 | ||
|
|
ed62d58cb2 | ||
|
|
dd8c1ca3b2 | ||
|
|
5ee4cd98e8 | ||
|
|
a6ca3a9418 | ||
|
|
0fd75aa579 | ||
|
|
eaa765118f | ||
|
|
ed73547fb6 | ||
|
|
692105e202 | ||
|
|
01b550d61a | ||
|
|
c6364149b1 | ||
|
|
b0b9809732 | ||
|
|
844b6dfd70 | ||
|
|
0a32e3838a | ||
|
|
e186449f94 | ||
|
|
4bb42005ea | ||
|
|
66aa65f8f7 | ||
|
|
15f6079c6b | ||
|
|
9e9eec9af6 | ||
|
|
1a4ed8c953 | ||
|
|
bd60ea4909 | ||
|
|
6ebfc0ff3d | ||
|
|
083d7c9ac4 | ||
|
|
80766e589c | ||
|
|
c5c6c62bd7 | ||
|
|
1549799c1e | ||
|
|
af1b08fdf4 | ||
|
|
72bc4ab403 | ||
|
|
4c6b9c190b | ||
|
|
f6ac6e47c4 | ||
|
|
623ab1c3ea | ||
|
|
eeec52ad74 | ||
|
|
db3883d2e7 | ||
|
|
9637351880 | ||
|
|
d0e01d2863 | ||
|
|
cbca599625 | ||
|
|
b1d12d2f37 | ||
|
|
0a952dc93d | ||
|
|
ff8b839f42 | ||
|
|
fea892c8b3 | ||
|
|
1696ff0c3f | ||
|
|
4eecd23ea3 | ||
|
|
4da83296a9 | ||
|
|
0ce4134ce1 | ||
|
|
03f44d0089 | ||
|
|
d38d5e1a7b | ||
|
|
add6ca5648 | ||
|
|
04a07080af | ||
|
|
8bebc9599a | ||
|
|
6250d536a0 | ||
|
|
de5985bb75 | ||
|
|
331e1f0d06 | ||
|
|
58c511af9a | ||
|
|
4b668379bc | ||
|
|
f352f3f514 | ||
|
|
4ff460de38 | ||
|
|
06b85d62b2 | ||
|
|
3447e2087a | ||
|
|
44fbf83106 | ||
|
|
eb73fc747a | ||
|
|
5602576ae1 | ||
|
|
c4838cca6e | ||
|
|
67841a0c6d | ||
|
|
5561a9c17f | ||
|
|
a9dcea023b | ||
|
|
14ffbe30a0 | ||
|
|
98a5e287ef | ||
|
|
2735ef1f4a | ||
|
|
53f0988006 | ||
|
|
730a67bb20 | ||
|
|
59e29afab5 | ||
|
|
fd4df4db48 | ||
|
|
8ae954bb8f | ||
|
|
32c76e0012 | ||
|
|
70bd55a00f | ||
|
|
f92d2c3326 | ||
|
|
a3f4e6f56c | ||
|
|
08bdc3d28e | ||
|
|
7c54b6e9e3 | ||
|
|
a346ad2a73 | ||
|
|
e4e5b61596 | ||
|
|
eeb7e1a91a | ||
|
|
f2637e6972 | ||
|
|
c8f8f6752f | ||
|
|
87b3cacc8f | ||
|
|
4096db8053 | ||
|
|
fa084e1a16 | ||
|
|
22067c7d1d | ||
|
|
85d2c54d53 | ||
|
|
5222a49cc3 | ||
|
|
36574bd9c6 | ||
|
|
2cc2d4420d | ||
|
|
7576c5ecbc | ||
|
|
dd1d9bed80 | ||
|
|
92c29f27c3 | ||
|
|
55b26ed590 | ||
|
|
6960ab1106 | ||
|
|
c3f4e18a5e | ||
|
|
a3f568dec7 | ||
|
|
6f1ce3bd60 | ||
|
|
159c5b4360 | ||
|
|
b5fde733b0 | ||
|
|
f9927bdaaa | ||
|
|
dcead97650 | ||
|
|
9786ebb7ba | ||
|
|
66d84ccfa3 | ||
|
|
56a39fea3d | ||
|
|
2a6e1cf1fc | ||
|
|
c02dc73d3c | ||
|
|
06f5632d1a | ||
|
|
1246ccf250 | ||
|
|
a339b488ae | ||
|
|
ac376d0e5e | ||
|
|
220946b2a1 | ||
|
|
c41dd2e393 | ||
|
|
2e76a2a554 | ||
|
|
8fa4b6a5fb | ||
|
|
d8b408625e | ||
|
|
19154d0fec | ||
|
|
c0c1fd17cb | ||
|
|
2daae758b1 | ||
|
|
43b21c6033 | ||
|
|
0bb1ee3caa | ||
|
|
3b2cb3a699 | ||
|
|
1adfd30b3b | ||
|
|
a315838d43 | ||
|
|
75c7eb3868 | ||
|
|
eac3f3fa69 | ||
|
|
02c779b41d | ||
|
|
5a1e17f27f | ||
|
|
e0d2c4bddf | ||
|
|
d73c8df895 | ||
|
|
e73bc81a73 | ||
|
|
0b960b0739 | ||
|
|
bdecb1bad2 | ||
|
|
e61f00d4c1 | ||
|
|
42c8d9b660 | ||
|
|
bd0b76072b | ||
|
|
db42adf1bf | ||
|
|
0e8e162cd5 | ||
|
|
49ace2faf9 | ||
|
|
8232456ce8 | ||
|
|
cd7c6ee751 | ||
|
|
f8dd4dcb30 | ||
|
|
0b9f00346b | ||
|
|
ef0846e723 | ||
|
|
3a79d94050 | ||
|
|
b5610f66a6 | ||
|
|
119dd0eaa0 | ||
|
|
080c9e415d | ||
|
|
7f9a76411a | ||
|
|
01b6b7e66a | ||
|
|
298713fae7 | ||
|
|
37c2c4acc4 | ||
|
|
1376fc8f44 | ||
|
|
e6801123ca | ||
|
|
f23d611d0c | ||
|
|
5dfdbe91bb | ||
|
|
e6df9fa078 | ||
|
|
5a73556871 | ||
|
|
e204e03fa6 | ||
|
|
8b4850aaea | ||
|
|
f87db64ba9 | ||
|
|
f42aebdff8 | ||
|
|
4ebc12ab5a | ||
|
|
fdb20d5d08 | ||
|
|
5bf6fd1270 | ||
|
|
e3e7a92c77 | ||
|
|
640f527f8c | ||
|
|
49c1b8c2d8 | ||
|
|
93ba78362d | ||
|
|
2fdf953229 | ||
|
|
ebe00359d1 | ||
|
|
036e2b52db | ||
|
|
f4803291b8 | ||
|
|
d47ec56eca | ||
|
|
ae6aac044d | ||
|
|
da2c15905a | ||
|
|
13ca33aa4e | ||
|
|
e37e9df0d1 | ||
|
|
54b99d5096 | ||
|
|
fb63d61ae5 | ||
|
|
73ada45037 | ||
|
|
be911754c5 | ||
|
|
cff06c9a54 | ||
|
|
ad011fbf1e | ||
|
|
28a5f858b7 | ||
|
|
220a5ec5dd | ||
|
|
0ec79d4295 | ||
|
|
5e414ff4df | ||
|
|
a46dc4634b | ||
|
|
df64530333 | ||
|
|
8dc98db717 | ||
|
|
9093cfbe4f | ||
|
|
da9b31e393 | ||
|
|
99eb317600 | ||
|
|
652fa8223e | ||
|
|
4587627f3c | ||
|
|
17b6f6c8f7 | ||
|
|
de10269d10 | ||
|
|
dfb83295de | ||
|
|
61f53b6471 | ||
|
|
e3c92a20f1 | ||
|
|
a290d1d550 | ||
|
|
abf48cbbf9 | ||
|
|
d53714a145 | ||
|
|
07757a59e9 | ||
|
|
f0b5130b80 | ||
|
|
0ca479de9c | ||
|
|
553e7b6b30 | ||
|
|
1830216078 | ||
|
|
5140d7b0c4 | ||
|
|
a62c264ddf | ||
|
|
3db2d33e4c | ||
|
|
360a7fc17b | ||
|
|
13fd656e2b | ||
|
|
47449152ac | ||
|
|
9ee440b8e4 | ||
|
|
5b1e1239fd | ||
|
|
79652da520 | ||
|
|
0f4a5716ea | ||
|
|
8fc399f511 | ||
|
|
dd44f69e2b | ||
|
|
39878fcdfe | ||
|
|
3de7d63ea9 | ||
|
|
581a654748 | ||
|
|
888179f7f0 | ||
|
|
0bb6336eaf | ||
|
|
2d8c8abbfb | ||
|
|
6f7609daac | ||
|
|
b26b9cda7b | ||
|
|
fb760a63ab | ||
|
|
971513d3ae | ||
|
|
d6bb71f324 | ||
|
|
0f45999df9 | ||
|
|
bee814787a | ||
|
|
d22131ad0a | ||
|
|
7930e725af | ||
|
|
5fee484e85 | ||
|
|
d7a08c1db2 | ||
|
|
401b241570 | ||
|
|
bf5cfaaeab | ||
|
|
616a2bc8f9 | ||
|
|
4ab3e4f7ab | ||
|
|
2a33acce3a | ||
|
|
b2c2bbd96f | ||
|
|
b72279afe4 | ||
|
|
4c6e8e6053 | ||
|
|
f2c42aad12 | ||
|
|
6a568662b8 | ||
|
|
d07d86f778 | ||
|
|
8cc8540597 | ||
|
|
5f2b1b63c2 | ||
|
|
4fc80bdc16 | ||
|
|
dfdd3784b9 | ||
|
|
a0a28fce38 | ||
|
|
22b38b1956 | ||
|
|
4ffa2b15dc | ||
|
|
ee85028534 | ||
|
|
c844ca1a40 | ||
|
|
7f3fad64b8 | ||
|
|
d6c6aa5c49 | ||
|
|
f9d685344d | ||
|
|
bcc1d9f3d6 | ||
|
|
25af0a1532 | ||
|
|
72a0e256a8 | ||
|
|
9e21ef879f | ||
|
|
58a3cbd654 | ||
|
|
915a3ff3ce | ||
|
|
9c5a31ed45 | ||
|
|
14ee364190 | ||
|
|
2d7b9e95cb | ||
|
|
b20675b7b5 | ||
|
|
df8cc8136f | ||
|
|
b05d0c560e | ||
|
|
c5f20a9891 | ||
|
|
53249c00cf | ||
|
|
339c05c2d4 | ||
|
|
c7d05096ab | ||
|
|
21765f8118 | ||
|
|
9998cc0683 | ||
|
|
c39758a169 | ||
|
|
e341abb99c | ||
|
|
5caf43349b | ||
|
|
f7c766ff32 | ||
|
|
bdeaaeac9c | ||
|
|
a9802c1962 | ||
|
|
531945cfe2 | ||
|
|
6a7e2d3fce | ||
|
|
035cb8aec2 | ||
|
|
ca3fdb3957 | ||
|
|
301437e169 | ||
|
|
12c6584d30 | ||
|
|
efbcce27e4 | ||
|
|
54dd8f7ac8 | ||
|
|
ce69ebd2ec | ||
|
|
500d926da7 | ||
|
|
b1c4b2e420 | ||
|
|
1d1511e37c | ||
|
|
8f5196f7d6 | ||
|
|
8edff22c0b | ||
|
|
2f076f2add | ||
|
|
fff0600b1d | ||
|
|
16e221d03c | ||
|
|
cace79631e | ||
|
|
05c8a23a75 | ||
|
|
7a652b8998 | ||
|
|
6d564e0539 | ||
|
|
dbc9375256 | ||
|
|
b4e06c63e2 | ||
|
|
01afa92424 | ||
|
|
1cd61601f3 | ||
|
|
6eb9545a72 | ||
|
|
47a6d86174 | ||
|
|
aa854e7efe | ||
|
|
5536e6b91e | ||
|
|
f37e0aa7b3 | ||
|
|
b75e00e05d | ||
|
|
51ca713181 | ||
|
|
685c7549e1 | ||
|
|
8be868f0ab | ||
|
|
e28bcef4ad | ||
|
|
7b4a4f45ed | ||
|
|
87b17de0bd | ||
|
|
9ba47681c6 | ||
|
|
ef60ea0446 | ||
|
|
cd01ebb417 | ||
|
|
6000bb4ee2 | ||
|
|
e99fa66daf | ||
|
|
3b03ac1734 | ||
|
|
6ba5758d30 | ||
|
|
cfc53bf96b | ||
|
|
58d7f59477 | ||
|
|
b0524412c4 | ||
|
|
3689992965 | ||
|
|
55165f116d | ||
|
|
480174367d | ||
|
|
099c37c4b4 | ||
|
|
d84399aebe | ||
|
|
4f49c8a2b9 | ||
|
|
10f26cfad9 | ||
|
|
1e393bedb2 | ||
|
|
1ac85d837a | ||
|
|
9e19f1d005 | ||
|
|
731c9544b3 | ||
|
|
528f836e71 | ||
|
|
78c714c29a | ||
|
|
88da68d8a2 | ||
|
|
0d9fabb6ec | ||
|
|
ff16ff8d01 | ||
|
|
154a4a7ac1 | ||
|
|
493b0ca8d1 | ||
|
|
7730230aa9 | ||
|
|
2c05c2c0ac | ||
|
|
cc1620e4fe | ||
|
|
3e88afb64a | ||
|
|
3562cca743 | ||
|
|
9a4135c288 | ||
|
|
7140090d0b | ||
|
|
bfb1960703 | ||
|
|
22ae70649b | ||
|
|
c121f4d4a7 | ||
|
|
19f4a78f4a | ||
|
|
3e0e15394a | ||
|
|
5252568825 | ||
|
|
c7d31346e0 | ||
|
|
6b355e1acf | ||
|
|
f98d821213 | ||
|
|
8954512dad | ||
|
|
f598a556dc | ||
|
|
21f7adbe45 | ||
|
|
9d452eb120 | ||
|
|
4fdcfe5515 | ||
|
|
9df7fd019f | ||
|
|
0036f0e9a1 | ||
|
|
6ba9aea8ba | ||
|
|
f499c9e222 | ||
|
|
b59bc9a6de | ||
|
|
5cf841283a | ||
|
|
9176218d16 | ||
|
|
42c0ca669b | ||
|
|
9acf70baab | ||
|
|
62e8fd494f | ||
|
|
3921466aae | ||
|
|
f1a0460105 | ||
|
|
773f8dcf6d | ||
|
|
824a297c73 | ||
|
|
4d8c988dab | ||
|
|
48326da83f | ||
|
|
21c1235277 | ||
|
|
e980c2ef64 | ||
|
|
7b9718cbaa | ||
|
|
5965266cb8 | ||
|
|
2aa607c828 | ||
|
|
827b09d7a5 | ||
|
|
e2f26f039a | ||
|
|
71de1c5877 | ||
|
|
cd67bf1d3d | ||
|
|
2a15650341 | ||
|
|
b5aeae7e22 | ||
|
|
eaa7d54cb4 | ||
|
|
2a56f4134e | ||
|
|
b8a816ff8c | ||
|
|
2a7c44d314 | ||
|
|
108792ac51 | ||
|
|
4a5f6ec00a | ||
|
|
549e3b22e5 | ||
|
|
b2f7252b27 | ||
|
|
6ebfd3ccf1 | ||
|
|
4da13984e2 | ||
|
|
d974268e37 | ||
|
|
2c747402a8 | ||
|
|
e39ae5a400 | ||
|
|
4d9769c620 | ||
|
|
4323d4bbda | ||
|
|
5a9a4170e8 | ||
|
|
cebd62cbb7 | ||
|
|
bba36ab4c0 | ||
|
|
fee3df2e62 | ||
|
|
2539950ad7 | ||
|
|
d06cbb84f4 | ||
|
|
7c2f015f31 | ||
|
|
b2072518e0 | ||
|
|
10e37dd7e5 | ||
|
|
0fd7ed84fb | ||
|
|
54a28cc5b9 | ||
|
|
132e2bd0d9 | ||
|
|
517e90c13a | ||
|
|
228277d361 | ||
|
|
6c779fbd48 | ||
|
|
c539fcde8b | ||
|
|
7a08fbd370 | ||
|
|
71e1bc260d | ||
|
|
78342e384d | ||
|
|
e6269e5817 | ||
|
|
be9dac6723 | ||
|
|
ce105d32c3 | ||
|
|
59b1d1551a | ||
|
|
8abfe894e3 | ||
|
|
02bf0dd862 | ||
|
|
88bf1b23a3 | ||
|
|
5d1e39b651 | ||
|
|
ceb18c77db | ||
|
|
8d5af56fc5 | ||
|
|
6dd4cc2840 | ||
|
|
794ba59bb6 | ||
|
|
6a1c198c04 | ||
|
|
1309cc449d | ||
|
|
dd11e7aa7b | ||
|
|
81b4e4f826 | ||
|
|
3456808e1c | ||
|
|
0cfbc58842 | ||
|
|
79e0915a86 | ||
|
|
56f7807732 | ||
|
|
52978e84ba | ||
|
|
b339f923d6 | ||
|
|
9e843c4dec | ||
|
|
9a26974ba8 | ||
|
|
e079b8ebcf | ||
|
|
9e6cc0851b | ||
|
|
7e4aec9379 | ||
|
|
4220d6e057 | ||
|
|
5890b318c4 | ||
|
|
bb788d8360 | ||
|
|
04ceb1f619 | ||
|
|
bb46423969 | ||
|
|
8460fee380 | ||
|
|
bb7d1b2c71 | ||
|
|
eb113bff3d | ||
|
|
3d01217aef | ||
|
|
cca086b863 | ||
|
|
56985a320f | ||
|
|
0d4dd50b35 | ||
|
|
c578fb1575 | ||
|
|
8fbbc4ada6 | ||
|
|
d77630154a | ||
|
|
0c121b856f | ||
|
|
1990b29018 | ||
|
|
10d06bc1ca | ||
|
|
9b7b90521f | ||
|
|
728d9729ed | ||
|
|
0b76b1aced | ||
|
|
5f2c2ee0e2 | ||
|
|
411952573e | ||
|
|
76e6cc08a6 | ||
|
|
656b4659fc | ||
|
|
f383a37b01 | ||
|
|
3529ccfa85 | ||
|
|
7db3446a09 | ||
|
|
9d21380699 | ||
|
|
db20f4f46e | ||
|
|
bc991a96b4 | ||
|
|
56c9d95daa | ||
|
|
f14b6e449f | ||
|
|
82bc00a3ae | ||
|
|
94018e0239 | ||
|
|
fed94d18f3 | ||
|
|
3dc3347a58 | ||
|
|
0763e2eb20 | ||
|
|
1548b73b77 | ||
|
|
cf8bfe8d8e | ||
|
|
6eceb9b886 | ||
|
|
4dfd862f11 | ||
|
|
5d6dadda83 | ||
|
|
43fa4fc487 | ||
|
|
bf9b057670 | ||
|
|
4a5aba5bac | ||
|
|
0b829ea20b | ||
|
|
2d548a9da0 | ||
|
|
86bb3d25cc | ||
|
|
e538329b0a | ||
|
|
ad494e74ad | ||
|
|
bc8fde5433 | ||
|
|
8d0581ffb4 | ||
|
|
298cb4ab8a | ||
|
|
3572ef230d | ||
|
|
f8249af501 | ||
|
|
140c4e1feb | ||
|
|
617aeaae0e | ||
|
|
b116e04894 | ||
|
|
dc1bf7e9c6 | ||
|
|
1a5eaba622 | ||
|
|
5b44dbe9c4 | ||
|
|
3c31e379a1 | ||
|
|
4e146f0075 | ||
|
|
173e7915a7 | ||
|
|
e76fca138d | ||
|
|
45df62652b | ||
|
|
068441b01b | ||
|
|
7034ea5b01 | ||
|
|
ccb6729ec8 | ||
|
|
ca0169eb6c | ||
|
|
448fdaab96 | ||
|
|
4244047d4d | ||
|
|
b1e2a5615b | ||
|
|
b535860a50 | ||
|
|
2b478764a9 | ||
|
|
88cc8e495c | ||
|
|
88df0fecb0 | ||
|
|
34ec40211e | ||
|
|
52b12784a0 | ||
|
|
3bffe3e479 | ||
|
|
ef652a2766 | ||
|
|
cf30ddb924 | ||
|
|
2f7da835de | ||
|
|
c5cc191a08 | ||
|
|
c6ea491000 | ||
|
|
76d30ff835 | ||
|
|
16ab8c8303 | ||
|
|
2daa35cd3a | ||
|
|
597c4b1d45 | ||
|
|
6f931b8405 | ||
|
|
41e03bae61 | ||
|
|
d7f45eac14 | ||
|
|
94112b324c | ||
|
|
fe0d7d029a | ||
|
|
aea133ff9f | ||
|
|
c94132bc7e | ||
|
|
8468d347be | ||
|
|
cc40e1f8e9 | ||
|
|
61fd5486e8 | ||
|
|
675421f3a9 | ||
|
|
2162289bf3 | ||
|
|
eb647ab2db | ||
|
|
7675fd0856 | ||
|
|
82f253c310 | ||
|
|
5de5fb507a | ||
|
|
269dd6abbe | ||
|
|
2c35be0212 | ||
|
|
c44dbf79cb | ||
|
|
5814249ea9 | ||
|
|
bfaa4b4bdc | ||
|
|
e619e64433 | ||
|
|
b2c0f3f9a5 | ||
|
|
872807a6f8 | ||
|
|
f482433ddf | ||
|
|
825d2b4759 | ||
|
|
6c7ebaeb59 | ||
|
|
6d65800173 | ||
|
|
0d2380b7b1 | ||
|
|
ec261e9c7c | ||
|
|
5d52ce2e5e | ||
|
|
811e2b9909 | ||
|
|
8985ddaeed | ||
|
|
2dbb31ef3c | ||
|
|
648ee37a17 | ||
|
|
98e73acc3b | ||
|
|
0fb85e5729 | ||
|
|
7e3a04c76c | ||
|
|
e219761d95 | ||
|
|
0afd5d5630 | ||
|
|
4f8df1804d | ||
|
|
d0677dcd91 | ||
|
|
bc5d650248 | ||
|
|
2e3a0d027e | ||
|
|
b92f234d88 | ||
|
|
0f831e09c1 | ||
|
|
a6c7e09e2a | ||
|
|
30e2914424 | ||
|
|
6b17f7caa8 | ||
|
|
2dc3b4df24 | ||
|
|
b13c530024 | ||
|
|
dd828e96ad | ||
|
|
6e6d67372c | ||
|
|
7e43020a28 | ||
|
|
0851e81b47 | ||
|
|
325fcf8505 | ||
|
|
8cf85a5a50 | ||
|
|
4cfbeaba9d | ||
|
|
0605c9f229 | ||
|
|
22b8e90ba6 | ||
|
|
7c4b02f02b | ||
|
|
cfa4925075 | ||
|
|
280536092e | ||
|
|
ff4f326341 | ||
|
|
2ba0f5914f | ||
|
|
dcd8a47d4f | ||
|
|
0bf53bc513 | ||
|
|
eafb5b8fd9 | ||
|
|
30888759f2 | ||
|
|
2137c2f715 | ||
|
|
58a9259a2e | ||
|
|
1d8f514d10 | ||
|
|
9ed7092aab | ||
|
|
193a987513 | ||
|
|
3b25268c0b | ||
|
|
cb5d7e76fb | ||
|
|
bc12f08c66 | ||
|
|
a7a64f11be | ||
|
|
31e6e30fe3 | ||
|
|
ad7bf4288a | ||
|
|
16dfcb56a4 | ||
|
|
924762c073 | ||
|
|
abb70ca5c5 | ||
|
|
1e3a485408 | ||
|
|
07d13e1738 | ||
|
|
c8cd950a03 | ||
|
|
501ab4ffa9 | ||
|
|
d671a59306 | ||
|
|
8a201022c0 | ||
|
|
6fa1dd2197 | ||
|
|
56a34a8f8a | ||
|
|
271c2b9018 | ||
|
|
93faf6d361 | ||
|
|
eb0a74384e | ||
|
|
ab41fdbaee | ||
|
|
2975aa950b | ||
|
|
45998aa9a0 | ||
|
|
29b70e0c36 | ||
|
|
3f48b61bfa | ||
|
|
7a06a577ce | ||
|
|
dbb5bd48cc | ||
|
|
303c00b61b | ||
|
|
a39579dad3 | ||
|
|
fbb8d10305 | ||
|
|
920bc4c70f | ||
|
|
12ccfc2c9a | ||
|
|
9da5358bb3 | ||
|
|
80cdbdbd47 | ||
|
|
bcce5b7ec2 | ||
|
|
8eacc9c697 | ||
|
|
db81a06386 | ||
|
|
626a8f1976 | ||
|
|
aa799bba4c | ||
|
|
aaadbdc144 | ||
|
|
a393db78b4 | ||
|
|
c1430e7b06 | ||
|
|
7e288d20fc | ||
|
|
528505a04a | ||
|
|
e2a0347c6d | ||
|
|
cce9941464 | ||
|
|
d51c4b1a4c | ||
|
|
3b0d9a93f4 | ||
|
|
41eb8e51e3 | ||
|
|
cdebf7b538 | ||
|
|
32ab4f8e47 | ||
|
|
6365e03731 | ||
|
|
2b9de934e3 | ||
|
|
4a368f54d5 | ||
|
|
25d3bf2c64 | ||
|
|
7d1748b3a7 | ||
|
|
2246d5f1eb | ||
|
|
575a2fd83f | ||
|
|
c9259bbec0 | ||
|
|
f3c18db7dd | ||
|
|
43baf709dd | ||
|
|
24d6e3a543 | ||
|
|
0b8223b8b9 | ||
|
|
e2f0241533 | ||
|
|
89e247b410 | ||
|
|
216cb3fb28 | ||
|
|
84fc6d4a87 | ||
|
|
9c7d9ded1e | ||
|
|
dfe40ffcca | ||
|
|
f477f23738 | ||
|
|
752a53e38e | ||
|
|
29a743cb9e | ||
|
|
4e759da070 | ||
|
|
69b9e45eaf | ||
|
|
5c7d2116e9 | ||
|
|
284bd733b9 | ||
|
|
0f3e9937f6 | ||
|
|
c32d19415b | ||
|
|
0a0d74eb94 | ||
|
|
2c5e48993d | ||
|
|
77af1ae544 | ||
|
|
e24a116943 | ||
|
|
872d2434a9 | ||
|
|
fe764cac75 | ||
|
|
3d2abbde72 | ||
|
|
e84c0e8df2 | ||
|
|
4e354ad00d | ||
|
|
ff02220890 | ||
|
|
f81d37fbf7 | ||
|
|
83381f9c12 | ||
|
|
f7e1952a55 | ||
|
|
cf77ff927f | ||
|
|
fc8b5e3956 | ||
|
|
ed16d30afc | ||
|
|
402cef66e9 | ||
|
|
13c2ecd1d0 | ||
|
|
a2b7611d8d | ||
|
|
d14e656ec1 | ||
|
|
63c62e3ada | ||
|
|
964e04369a | ||
|
|
873535fbf0 | ||
|
|
5201222ce7 | ||
|
|
b888f92718 | ||
|
|
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 | ||
|
|
057e3a494c | ||
|
|
bb6e721567 | ||
|
|
e76adf6ed1 | ||
|
|
2b4d82bfdd | ||
|
|
5e9c223077 | ||
|
|
98ede67b9b | ||
|
|
f594edd39f | ||
|
|
487c86f58e | ||
|
|
b3e71ca562 | ||
|
|
ab2f9e90eb | ||
|
|
cb77b2eb7e | ||
|
|
6c9e639a68 | ||
|
|
6e4694716b | ||
|
|
87b8e21701 | ||
|
|
dd5d2c7c92 | ||
|
|
e168dc7b97 | ||
|
|
4670f60d3e | ||
|
|
97d628d784 | ||
|
|
426b16987a | ||
|
|
bc5b30eccf | ||
|
|
d114927814 | ||
|
|
b41c00a9ef | ||
|
|
9d2800e691 | ||
|
|
6956dad53a | ||
|
|
e9fc403b94 | ||
|
|
8eb8b16047 | ||
|
|
4e5f67ef96 | ||
|
|
ec445e4cc9 | ||
|
|
af97259a9c | ||
|
|
8a7b7a2383 | ||
|
|
1a75e6d15c | ||
|
|
5e18ccace7 | ||
|
|
432d7e72fa | ||
|
|
fb684f25e9 | ||
|
|
fa7acd2482 | ||
|
|
5114c32810 | ||
|
|
a765d342e0 | ||
|
|
a5fda1546b | ||
|
|
4dffdc4de2 | ||
|
|
f44efce265 | ||
|
|
6f16fc0a93 | ||
|
|
4599fc5a8d | ||
|
|
a4702e48f9 | ||
|
|
1448b55ca4 | ||
|
|
b19d0b6f3b | ||
|
|
f99f174e2d | ||
|
|
666ab53648 | ||
|
|
314288ff82 |
269
.agents/skills/company-creator/SKILL.md
Normal file
269
.agents/skills/company-creator/SKILL.md
Normal file
@@ -0,0 +1,269 @@
|
||||
---
|
||||
name: company-creator
|
||||
description: >
|
||||
Create agent company packages conforming to the Agent Companies specification
|
||||
(agentcompanies/v1). Use when a user wants to create a new agent company from
|
||||
scratch, build a company around an existing git repo or skills collection, or
|
||||
scaffold a team/department of agents. Triggers on: "create a company", "make me
|
||||
a company", "build a company from this repo", "set up an agent company",
|
||||
"create a team of agents", "hire some agents", or when given a repo URL and
|
||||
asked to turn it into a company. Do NOT use for importing an existing company
|
||||
package (use the CLI import command instead) or for modifying a company that
|
||||
is already running in Paperclip.
|
||||
---
|
||||
|
||||
# Company Creator
|
||||
|
||||
Create agent company packages that conform to the Agent Companies specification.
|
||||
|
||||
Spec references:
|
||||
|
||||
- Normative spec: `docs/companies/companies-spec.md` (read this before generating files)
|
||||
- Web spec: https://agentcompanies.io/specification
|
||||
- Protocol site: https://agentcompanies.io/
|
||||
|
||||
## Two Modes
|
||||
|
||||
### Mode 1: Company From Scratch
|
||||
|
||||
The user describes what they want. Interview them to flesh out the vision, then generate the package.
|
||||
|
||||
### Mode 2: Company From a Repo
|
||||
|
||||
The user provides a git repo URL, local path, or tweet. Analyze the repo, then create a company that wraps it.
|
||||
|
||||
See [references/from-repo-guide.md](references/from-repo-guide.md) for detailed repo analysis steps.
|
||||
|
||||
## Process
|
||||
|
||||
### Step 1: Gather Context
|
||||
|
||||
Determine which mode applies:
|
||||
|
||||
- **From scratch**: What kind of company or team? What domain? What should the agents do?
|
||||
- **From repo**: Clone/read the repo. Scan for existing skills, agent configs, README, source structure.
|
||||
|
||||
### Step 2: Interview (Use AskUserQuestion)
|
||||
|
||||
Do not skip this step. Use AskUserQuestion to align with the user before writing any files.
|
||||
|
||||
**For from-scratch companies**, ask about:
|
||||
|
||||
- Company purpose and domain (1-2 sentences is fine)
|
||||
- What agents they need - propose a hiring plan based on what they described
|
||||
- Whether this is a full company (needs a CEO) or a team/department (no CEO required)
|
||||
- Any specific skills the agents should have
|
||||
- How work flows through the organization (see "Workflow" below)
|
||||
- Whether they want projects and starter tasks
|
||||
|
||||
**For from-repo companies**, present your analysis and ask:
|
||||
|
||||
- Confirm the agents you plan to create and their roles
|
||||
- Whether to reference or vendor any discovered skills (default: reference)
|
||||
- Any additional agents or skills beyond what the repo provides
|
||||
- Company name and any customization
|
||||
- Confirm the workflow you inferred from the repo (see "Workflow" below)
|
||||
|
||||
**Workflow — how does work move through this company?**
|
||||
|
||||
A company is not just a list of agents with skills. It's an organization that takes ideas and turns them into work products. You need to understand the workflow so each agent knows:
|
||||
|
||||
- Who gives them work and in what form (a task, a branch, a question, a review request)
|
||||
- What they do with it
|
||||
- Who they hand off to when they're done, and what that handoff looks like
|
||||
- What "done" means for their role
|
||||
|
||||
**Not every company is a pipeline.** Infer the right workflow pattern from context:
|
||||
|
||||
- **Pipeline** — sequential stages, each agent hands off to the next. Use when the repo/domain has a clear linear process (e.g. plan → build → review → ship → QA, or content ideation → draft → edit → publish).
|
||||
- **Hub-and-spoke** — a manager delegates to specialists who report back independently. Use when agents do different kinds of work that don't feed into each other (e.g. a CEO who dispatches to a researcher, a marketer, and an analyst).
|
||||
- **Collaborative** — agents work together on the same things as peers. Use for small teams where everyone contributes to the same output (e.g. a design studio, a brainstorming team).
|
||||
- **On-demand** — agents are summoned as needed with no fixed flow. Use when agents are more like a toolbox of specialists the user calls directly.
|
||||
|
||||
For from-scratch companies, propose a workflow pattern based on what they described and ask if it fits.
|
||||
|
||||
For from-repo companies, infer the pattern from the repo's structure. If skills have a clear sequential dependency (like `plan-ceo-review → plan-eng-review → review → ship → qa`), that's a pipeline. If skills are independent capabilities, it's more likely hub-and-spoke or on-demand. State your inference in the interview so the user can confirm or adjust.
|
||||
|
||||
**Key interviewing principles:**
|
||||
|
||||
- Propose a concrete hiring plan. Don't ask open-ended "what agents do you want?" - suggest specific agents based on context and let the user adjust.
|
||||
- Keep it lean. Most users are new to agent companies. A few agents (3-5) is typical for a startup. Don't suggest 10+ agents unless the scope demands it.
|
||||
- From-scratch companies should start with a CEO who manages everyone. Teams/departments don't need one.
|
||||
- Ask 2-3 focused questions per round, not 10.
|
||||
|
||||
### Step 3: Read the Spec
|
||||
|
||||
Before generating any files, read the normative spec:
|
||||
|
||||
```
|
||||
docs/companies/companies-spec.md
|
||||
```
|
||||
|
||||
Also read the quick reference: [references/companies-spec.md](references/companies-spec.md)
|
||||
|
||||
And the example: [references/example-company.md](references/example-company.md)
|
||||
|
||||
### Step 4: Generate the Package
|
||||
|
||||
Create the directory structure and all files. Follow the spec's conventions exactly.
|
||||
|
||||
**Directory structure:**
|
||||
|
||||
```
|
||||
<company-slug>/
|
||||
├── COMPANY.md
|
||||
├── agents/
|
||||
│ └── <slug>/AGENTS.md
|
||||
├── teams/
|
||||
│ └── <slug>/TEAM.md (if teams are needed)
|
||||
├── projects/
|
||||
│ └── <slug>/PROJECT.md (if projects are needed)
|
||||
├── tasks/
|
||||
│ └── <slug>/TASK.md (if tasks are needed)
|
||||
├── skills/
|
||||
│ └── <slug>/SKILL.md (if custom skills are needed)
|
||||
└── .paperclip.yaml (Paperclip vendor extension)
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Slugs must be URL-safe, lowercase, hyphenated
|
||||
- COMPANY.md gets `schema: agentcompanies/v1` - other files inherit it
|
||||
- Agent instructions go in the AGENTS.md body, not in .paperclip.yaml
|
||||
- Skills referenced by shortname in AGENTS.md resolve to `skills/<shortname>/SKILL.md`
|
||||
- For external skills, use `sources` with `usage: referenced` (see spec section 12)
|
||||
- Do not export secrets, machine-local paths, or database IDs
|
||||
- Omit empty/default fields
|
||||
- For companies generated from a repo, add a references footer at the bottom of COMPANY.md body:
|
||||
`Generated from [repo-name](repo-url) with the company-creator skill from [Paperclip](https://github.com/paperclipai/paperclip)`
|
||||
|
||||
**Reporting structure:**
|
||||
|
||||
- Every agent except the CEO should have `reportsTo` set to their manager's slug
|
||||
- The CEO has `reportsTo: null`
|
||||
- For teams without a CEO, the top-level agent has `reportsTo: null`
|
||||
|
||||
**Writing workflow-aware agent instructions:**
|
||||
|
||||
Each AGENTS.md body should include not just what the agent does, but how they fit into the organization's workflow. Include:
|
||||
|
||||
1. **Where work comes from** — "You receive feature ideas from the user" or "You pick up tasks assigned to you by the CTO"
|
||||
2. **What you produce** — "You produce a technical plan with architecture diagrams" or "You produce a reviewed, approved branch ready for shipping"
|
||||
3. **Who you hand off to** — "When your plan is locked, hand off to the Staff Engineer for implementation" or "When review passes, hand off to the Release Engineer to ship"
|
||||
4. **What triggers you** — "You are activated when a new feature idea needs product-level thinking" or "You are activated when a branch is ready for pre-landing review"
|
||||
|
||||
This turns a collection of agents into an organization that actually works together. Without workflow context, agents operate in isolation — they do their job but don't know what happens before or after them.
|
||||
|
||||
### Step 5: Confirm Output Location
|
||||
|
||||
Ask the user where to write the package. Common options:
|
||||
|
||||
- A subdirectory in the current repo
|
||||
- A new directory the user specifies
|
||||
- The current directory (if it's empty or they confirm)
|
||||
|
||||
### Step 6: Write README.md and LICENSE
|
||||
|
||||
**README.md** — every company package gets a README. It should be a nice, readable introduction that someone browsing GitHub would appreciate. Include:
|
||||
|
||||
- Company name and what it does
|
||||
- The workflow / how the company operates
|
||||
- Org chart as a markdown list or table showing agents, titles, reporting structure, and skills
|
||||
- Brief description of each agent's role
|
||||
- Citations and references: link to the source repo (if from-repo), link to the Agent Companies spec (https://agentcompanies.io/specification), and link to Paperclip (https://github.com/paperclipai/paperclip)
|
||||
- A "Getting Started" section explaining how to import: `paperclipai company import --from <path>`
|
||||
|
||||
**LICENSE** — include a LICENSE file. The copyright holder is the user creating the company, not the upstream repo author (they made the skills, the user is making the company). Use the same license type as the source repo (if from-repo) or ask the user (if from-scratch). Default to MIT if unclear.
|
||||
|
||||
### Step 7: Write Files and Summarize
|
||||
|
||||
Write all files, then give a brief summary:
|
||||
|
||||
- Company name and what it does
|
||||
- Agent roster with roles and reporting structure
|
||||
- Skills (custom + referenced)
|
||||
- Projects and tasks if any
|
||||
- The output path
|
||||
|
||||
## .paperclip.yaml Guidelines
|
||||
|
||||
The `.paperclip.yaml` file is the Paperclip vendor extension. It configures adapters and env inputs per agent.
|
||||
|
||||
### Adapter Rules
|
||||
|
||||
**Do not specify an adapter unless the repo or user context warrants it.** If you don't know what adapter the user wants, omit the adapter block entirely — Paperclip will use its default. Specifying an unknown adapter type causes an import error.
|
||||
|
||||
Paperclip's supported adapter types (these are the ONLY valid values):
|
||||
- `claude_local` — Claude Code CLI
|
||||
- `codex_local` — Codex CLI
|
||||
- `opencode_local` — OpenCode CLI
|
||||
- `pi_local` — Pi CLI
|
||||
- `cursor` — Cursor
|
||||
- `gemini_local` — Gemini CLI
|
||||
- `openclaw_gateway` — OpenClaw gateway
|
||||
|
||||
Only set an adapter when:
|
||||
- The repo or its skills clearly target a specific runtime (e.g. gstack is built for Claude Code, so `claude_local` is appropriate)
|
||||
- The user explicitly requests a specific adapter
|
||||
- The agent's role requires a specific runtime capability
|
||||
|
||||
### Env Inputs Rules
|
||||
|
||||
**Do not add boilerplate env variables.** Only add env inputs that the agent actually needs based on its skills or role:
|
||||
- `GH_TOKEN` for agents that push code, create PRs, or interact with GitHub
|
||||
- API keys only when a skill explicitly requires them
|
||||
- Never set `ANTHROPIC_API_KEY` as a default empty env variable — the runtime handles this
|
||||
|
||||
Example with adapter (only when warranted):
|
||||
```yaml
|
||||
schema: paperclip/v1
|
||||
agents:
|
||||
release-engineer:
|
||||
adapter:
|
||||
type: claude_local
|
||||
config:
|
||||
model: claude-sonnet-4-6
|
||||
inputs:
|
||||
env:
|
||||
GH_TOKEN:
|
||||
kind: secret
|
||||
requirement: optional
|
||||
```
|
||||
|
||||
Example — only agents with actual overrides appear:
|
||||
```yaml
|
||||
schema: paperclip/v1
|
||||
agents:
|
||||
release-engineer:
|
||||
inputs:
|
||||
env:
|
||||
GH_TOKEN:
|
||||
kind: secret
|
||||
requirement: optional
|
||||
```
|
||||
|
||||
In this example, only `release-engineer` appears because it needs `GH_TOKEN`. The other agents (ceo, cto, etc.) have no overrides, so they are omitted entirely from `.paperclip.yaml`.
|
||||
|
||||
## External Skill References
|
||||
|
||||
When referencing skills from a GitHub repo, always use the references pattern:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
sources:
|
||||
- kind: github-file
|
||||
repo: owner/repo
|
||||
path: path/to/SKILL.md
|
||||
commit: <full SHA from git ls-remote or the repo>
|
||||
attribution: Owner or Org Name
|
||||
license: <from the repo's LICENSE>
|
||||
usage: referenced
|
||||
```
|
||||
|
||||
Get the commit SHA with:
|
||||
|
||||
```bash
|
||||
git ls-remote https://github.com/owner/repo HEAD
|
||||
```
|
||||
|
||||
Do NOT copy external skill content into the package unless the user explicitly asks.
|
||||
144
.agents/skills/company-creator/references/companies-spec.md
Normal file
144
.agents/skills/company-creator/references/companies-spec.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Agent Companies Specification Reference
|
||||
|
||||
The normative specification lives at:
|
||||
|
||||
- Web: https://agentcompanies.io/specification
|
||||
- Local: docs/companies/companies-spec.md
|
||||
|
||||
Read the local spec file before generating any package files. The spec defines the canonical format and all frontmatter fields. Below is a quick-reference summary for common authoring tasks.
|
||||
|
||||
## Package Kinds
|
||||
|
||||
| File | Kind | Purpose |
|
||||
| ---------- | ------- | ------------------------------------------------- |
|
||||
| COMPANY.md | company | Root entrypoint, org boundary and defaults |
|
||||
| TEAM.md | team | Reusable org subtree |
|
||||
| AGENTS.md | agent | One role, instructions, and attached skills |
|
||||
| PROJECT.md | project | Planned work grouping |
|
||||
| TASK.md | task | Portable starter task |
|
||||
| SKILL.md | skill | Agent Skills capability package (do not redefine) |
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
company-package/
|
||||
├── COMPANY.md
|
||||
├── agents/
|
||||
│ └── <slug>/AGENTS.md
|
||||
├── teams/
|
||||
│ └── <slug>/TEAM.md
|
||||
├── projects/
|
||||
│ └── <slug>/
|
||||
│ ├── PROJECT.md
|
||||
│ └── tasks/
|
||||
│ └── <slug>/TASK.md
|
||||
├── tasks/
|
||||
│ └── <slug>/TASK.md
|
||||
├── skills/
|
||||
│ └── <slug>/SKILL.md
|
||||
├── assets/
|
||||
├── scripts/
|
||||
├── references/
|
||||
└── .paperclip.yaml (optional vendor extension)
|
||||
```
|
||||
|
||||
## Common Frontmatter Fields
|
||||
|
||||
```yaml
|
||||
schema: agentcompanies/v1
|
||||
kind: company | team | agent | project | task
|
||||
slug: url-safe-stable-identity
|
||||
name: Human Readable Name
|
||||
description: Short description for discovery
|
||||
version: 0.1.0
|
||||
license: MIT
|
||||
authors:
|
||||
- name: Jane Doe
|
||||
tags: []
|
||||
metadata: {}
|
||||
sources: []
|
||||
```
|
||||
|
||||
- `schema` usually appears only at package root
|
||||
- `kind` is optional when filename makes it obvious
|
||||
- `slug` must be URL-safe and stable
|
||||
- exporters should omit empty or default-valued fields
|
||||
|
||||
## COMPANY.md Required Fields
|
||||
|
||||
```yaml
|
||||
name: Company Name
|
||||
description: What this company does
|
||||
slug: company-slug
|
||||
schema: agentcompanies/v1
|
||||
```
|
||||
|
||||
Optional: `version`, `license`, `authors`, `goals`, `includes`, `requirements.secrets`
|
||||
|
||||
## AGENTS.md Key Fields
|
||||
|
||||
```yaml
|
||||
name: Agent Name
|
||||
title: Role Title
|
||||
reportsTo: <agent-slug or null>
|
||||
skills:
|
||||
- skill-shortname
|
||||
```
|
||||
|
||||
- Body content is the agent's default instructions
|
||||
- Skills resolve by shortname: `skills/<shortname>/SKILL.md`
|
||||
- Do not export machine-specific paths or secrets
|
||||
|
||||
## TEAM.md Key Fields
|
||||
|
||||
```yaml
|
||||
name: Team Name
|
||||
description: What this team does
|
||||
slug: team-slug
|
||||
manager: ../agent-slug/AGENTS.md
|
||||
includes:
|
||||
- ../agent-slug/AGENTS.md
|
||||
- ../../skills/skill-slug/SKILL.md
|
||||
```
|
||||
|
||||
## PROJECT.md Key Fields
|
||||
|
||||
```yaml
|
||||
name: Project Name
|
||||
description: What this project delivers
|
||||
owner: agent-slug
|
||||
```
|
||||
|
||||
## TASK.md Key Fields
|
||||
|
||||
```yaml
|
||||
name: Task Name
|
||||
assignee: agent-slug
|
||||
project: project-slug
|
||||
schedule:
|
||||
timezone: America/Chicago
|
||||
startsAt: 2026-03-16T09:00:00-05:00
|
||||
recurrence:
|
||||
frequency: weekly
|
||||
interval: 1
|
||||
weekdays: [monday]
|
||||
time: { hour: 9, minute: 0 }
|
||||
```
|
||||
|
||||
## Source References (for external skills/content)
|
||||
|
||||
```yaml
|
||||
sources:
|
||||
- kind: github-file
|
||||
repo: owner/repo
|
||||
path: path/to/SKILL.md
|
||||
commit: <full-sha>
|
||||
sha256: <hash>
|
||||
attribution: Owner Name
|
||||
license: MIT
|
||||
usage: referenced
|
||||
```
|
||||
|
||||
Usage modes: `vendored` (bytes included), `referenced` (pointer only), `mirrored` (cached locally)
|
||||
|
||||
Default to `referenced` for third-party content.
|
||||
184
.agents/skills/company-creator/references/example-company.md
Normal file
184
.agents/skills/company-creator/references/example-company.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Example Company Package
|
||||
|
||||
A minimal but complete example of an agent company package.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
lean-dev-shop/
|
||||
├── COMPANY.md
|
||||
├── agents/
|
||||
│ ├── ceo/AGENTS.md
|
||||
│ ├── cto/AGENTS.md
|
||||
│ └── engineer/AGENTS.md
|
||||
├── teams/
|
||||
│ └── engineering/TEAM.md
|
||||
├── projects/
|
||||
│ └── q2-launch/
|
||||
│ ├── PROJECT.md
|
||||
│ └── tasks/
|
||||
│ └── monday-review/TASK.md
|
||||
├── tasks/
|
||||
│ └── weekly-standup/TASK.md
|
||||
├── skills/
|
||||
│ └── code-review/SKILL.md
|
||||
└── .paperclip.yaml
|
||||
```
|
||||
|
||||
## COMPANY.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Lean Dev Shop
|
||||
description: Small engineering-focused AI company that builds and ships software products
|
||||
slug: lean-dev-shop
|
||||
schema: agentcompanies/v1
|
||||
version: 1.0.0
|
||||
license: MIT
|
||||
authors:
|
||||
- name: Example Org
|
||||
goals:
|
||||
- Build and ship software products
|
||||
- Maintain high code quality
|
||||
---
|
||||
|
||||
Lean Dev Shop is a small, focused engineering company. The CEO oversees strategy and coordinates work. The CTO leads the engineering team. Engineers build and ship code.
|
||||
```
|
||||
|
||||
## agents/ceo/AGENTS.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: CEO
|
||||
title: Chief Executive Officer
|
||||
reportsTo: null
|
||||
skills:
|
||||
- paperclip
|
||||
---
|
||||
|
||||
You are the CEO of Lean Dev Shop. You oversee company strategy, coordinate work across the team, and ensure projects ship on time.
|
||||
|
||||
Your responsibilities:
|
||||
|
||||
- Review and prioritize work across projects
|
||||
- Coordinate with the CTO on technical decisions
|
||||
- Ensure the company goals are being met
|
||||
```
|
||||
|
||||
## agents/cto/AGENTS.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: CTO
|
||||
title: Chief Technology Officer
|
||||
reportsTo: ceo
|
||||
skills:
|
||||
- code-review
|
||||
- paperclip
|
||||
---
|
||||
|
||||
You are the CTO of Lean Dev Shop. You lead the engineering team and make technical decisions.
|
||||
|
||||
Your responsibilities:
|
||||
|
||||
- Set technical direction and architecture
|
||||
- Review code and ensure quality standards
|
||||
- Mentor engineers and unblock technical challenges
|
||||
```
|
||||
|
||||
## agents/engineer/AGENTS.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Engineer
|
||||
title: Software Engineer
|
||||
reportsTo: cto
|
||||
skills:
|
||||
- code-review
|
||||
- paperclip
|
||||
---
|
||||
|
||||
You are a software engineer at Lean Dev Shop. You write code, fix bugs, and ship features.
|
||||
|
||||
Your responsibilities:
|
||||
|
||||
- Implement features and fix bugs
|
||||
- Write tests and documentation
|
||||
- Participate in code reviews
|
||||
```
|
||||
|
||||
## teams/engineering/TEAM.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Engineering
|
||||
description: Product and platform engineering team
|
||||
slug: engineering
|
||||
schema: agentcompanies/v1
|
||||
manager: ../../agents/cto/AGENTS.md
|
||||
includes:
|
||||
- ../../agents/engineer/AGENTS.md
|
||||
- ../../skills/code-review/SKILL.md
|
||||
tags:
|
||||
- engineering
|
||||
---
|
||||
|
||||
The engineering team builds and maintains all software products.
|
||||
```
|
||||
|
||||
## projects/q2-launch/PROJECT.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Q2 Launch
|
||||
description: Ship the Q2 product launch
|
||||
slug: q2-launch
|
||||
owner: cto
|
||||
---
|
||||
|
||||
Deliver all features planned for the Q2 launch, including the new dashboard and API improvements.
|
||||
```
|
||||
|
||||
## projects/q2-launch/tasks/monday-review/TASK.md
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: Monday Review
|
||||
assignee: ceo
|
||||
project: q2-launch
|
||||
schedule:
|
||||
timezone: America/Chicago
|
||||
startsAt: 2026-03-16T09:00:00-05:00
|
||||
recurrence:
|
||||
frequency: weekly
|
||||
interval: 1
|
||||
weekdays:
|
||||
- monday
|
||||
time:
|
||||
hour: 9
|
||||
minute: 0
|
||||
---
|
||||
|
||||
Review the status of Q2 Launch project. Check progress on all open tasks, identify blockers, and update priorities for the week.
|
||||
```
|
||||
|
||||
## skills/code-review/SKILL.md (with external reference)
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-review
|
||||
description: Thorough code review skill for pull requests and diffs
|
||||
metadata:
|
||||
sources:
|
||||
- kind: github-file
|
||||
repo: anthropics/claude-code
|
||||
path: skills/code-review/SKILL.md
|
||||
commit: abc123def456
|
||||
sha256: 3b7e...9a
|
||||
attribution: Anthropic
|
||||
license: MIT
|
||||
usage: referenced
|
||||
---
|
||||
|
||||
Review code changes for correctness, style, and potential issues.
|
||||
```
|
||||
79
.agents/skills/company-creator/references/from-repo-guide.md
Normal file
79
.agents/skills/company-creator/references/from-repo-guide.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Creating a Company From an Existing Repository
|
||||
|
||||
When a user provides a git repo (URL, local path, or tweet linking to a repo), analyze it and create a company package that wraps its content.
|
||||
|
||||
## Analysis Steps
|
||||
|
||||
1. **Clone or read the repo** - Use `git clone` for URLs, read directly for local paths
|
||||
2. **Scan for existing agent/skill files** - Look for SKILL.md, AGENTS.md, CLAUDE.md, .claude/ directories, or similar agent configuration
|
||||
3. **Understand the repo's purpose** - Read README, package.json, main source files to understand what the project does
|
||||
4. **Identify natural agent roles** - Based on the repo's structure and purpose, determine what agents would be useful
|
||||
|
||||
## Handling Existing Skills
|
||||
|
||||
Many repos already contain skills (SKILL.md files). When you find them:
|
||||
|
||||
**Default behavior: use references, not copies.**
|
||||
|
||||
Instead of copying skill content into your company package, create a source reference:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
sources:
|
||||
- kind: github-file
|
||||
repo: owner/repo
|
||||
path: path/to/SKILL.md
|
||||
commit: <get the current HEAD commit SHA>
|
||||
attribution: <repo owner or org name>
|
||||
license: <from repo's LICENSE file>
|
||||
usage: referenced
|
||||
```
|
||||
|
||||
To get the commit SHA:
|
||||
```bash
|
||||
git ls-remote https://github.com/owner/repo HEAD
|
||||
```
|
||||
|
||||
Only vendor (copy) skills when:
|
||||
- The user explicitly asks to copy them
|
||||
- The skill is very small and tightly coupled to the company
|
||||
- The source repo is private or may become unavailable
|
||||
|
||||
## Handling Existing Agent Configurations
|
||||
|
||||
If the repo has agent configs (CLAUDE.md, .claude/ directories, codex configs, etc.):
|
||||
- Use them as inspiration for AGENTS.md instructions
|
||||
- Don't copy them verbatim - adapt them to the Agent Companies format
|
||||
- Preserve the intent and key instructions
|
||||
|
||||
## Repo-Only Skills (No Agents)
|
||||
|
||||
When a repo contains only skills and no agents:
|
||||
- Create agents that would naturally use those skills
|
||||
- The agents should be minimal - just enough to give the skills a runtime context
|
||||
- A single agent may use multiple skills from the repo
|
||||
- Name agents based on the domain the skills cover
|
||||
|
||||
Example: A repo with `code-review`, `testing`, and `deployment` skills might become:
|
||||
- A "Lead Engineer" agent with all three skills
|
||||
- Or separate "Reviewer", "QA Engineer", and "DevOps" agents if the skills are distinct enough
|
||||
|
||||
## Common Repo Patterns
|
||||
|
||||
### Developer Tools / CLI repos
|
||||
- Create agents for the tool's primary use cases
|
||||
- Reference any existing skills
|
||||
- Add a project maintainer or lead agent
|
||||
|
||||
### Library / Framework repos
|
||||
- Create agents for development, testing, documentation
|
||||
- Skills from the repo become agent capabilities
|
||||
|
||||
### Full Application repos
|
||||
- Map to departments: engineering, product, QA
|
||||
- Create a lean team structure appropriate to the project size
|
||||
|
||||
### Skills Collection repos (e.g. skills.sh repos)
|
||||
- Each skill or skill group gets an agent
|
||||
- Create a lightweight company or team wrapper
|
||||
- Keep the agent count proportional to the skill diversity
|
||||
201
.agents/skills/doc-maintenance/SKILL.md
Normal file
201
.agents/skills/doc-maintenance/SKILL.md
Normal file
@@ -0,0 +1,201 @@
|
||||
---
|
||||
name: doc-maintenance
|
||||
description: >
|
||||
Audit top-level documentation (README, SPEC, PRODUCT) against recent git
|
||||
history to find drift — shipped features missing from docs or features
|
||||
listed as upcoming that already landed. Proposes minimal edits, creates
|
||||
a branch, and opens a PR. Use when asked to review docs for accuracy,
|
||||
after major feature merges, or on a periodic schedule.
|
||||
---
|
||||
|
||||
# Doc Maintenance Skill
|
||||
|
||||
Detect documentation drift and fix it via PR — no rewrites, no churn.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Periodic doc review (e.g. weekly or after releases)
|
||||
- After major feature merges
|
||||
- When asked "are our docs up to date?"
|
||||
- When asked to audit README / SPEC / PRODUCT accuracy
|
||||
|
||||
## Target Documents
|
||||
|
||||
| Document | Path | What matters |
|
||||
|----------|------|-------------|
|
||||
| README | `README.md` | Features table, roadmap, quickstart, "what is" accuracy, "works with" table |
|
||||
| SPEC | `doc/SPEC.md` | No false "not supported" claims, major model/schema accuracy |
|
||||
| PRODUCT | `doc/PRODUCT.md` | Core concepts, feature list, principles accuracy |
|
||||
|
||||
Out of scope: DEVELOPING.md, DATABASE.md, CLI.md, doc/plans/, skill files,
|
||||
release notes. These are dev-facing or ephemeral — lower risk of user-facing
|
||||
confusion.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Detect what changed
|
||||
|
||||
Find the last review cursor:
|
||||
|
||||
```bash
|
||||
# Read the last-reviewed commit SHA
|
||||
CURSOR_FILE=".doc-review-cursor"
|
||||
if [ -f "$CURSOR_FILE" ]; then
|
||||
LAST_SHA=$(cat "$CURSOR_FILE" | head -1)
|
||||
else
|
||||
# First run: look back 60 days
|
||||
LAST_SHA=$(git log --format="%H" --after="60 days ago" --reverse | head -1)
|
||||
fi
|
||||
```
|
||||
|
||||
Then gather commits since the cursor:
|
||||
|
||||
```bash
|
||||
git log "$LAST_SHA"..HEAD --oneline --no-merges
|
||||
```
|
||||
|
||||
### Step 2 — Classify changes
|
||||
|
||||
Scan commit messages and changed files. Categorize into:
|
||||
|
||||
- **Feature** — new capabilities (keywords: `feat`, `add`, `implement`, `support`)
|
||||
- **Breaking** — removed/renamed things (keywords: `remove`, `breaking`, `drop`, `rename`)
|
||||
- **Structural** — new directories, config changes, new adapters, new CLI commands
|
||||
|
||||
**Ignore:** refactors, test-only changes, CI config, dependency bumps, doc-only
|
||||
changes, style/formatting commits. These don't affect doc accuracy.
|
||||
|
||||
For borderline cases, check the actual diff — a commit titled "refactor: X"
|
||||
that adds a new public API is a feature.
|
||||
|
||||
### Step 3 — Build a change summary
|
||||
|
||||
Produce a concise list like:
|
||||
|
||||
```
|
||||
Since last review (<sha>, <date>):
|
||||
- FEATURE: Plugin system merged (runtime, SDK, CLI, slots, event bridge)
|
||||
- FEATURE: Project archiving added
|
||||
- BREAKING: Removed legacy webhook adapter
|
||||
- STRUCTURAL: New .agents/skills/ directory convention
|
||||
```
|
||||
|
||||
If there are no notable changes, skip to Step 7 (update cursor and exit).
|
||||
|
||||
### Step 4 — Audit each target doc
|
||||
|
||||
For each target document, read it fully and cross-reference against the change
|
||||
summary. Check for:
|
||||
|
||||
1. **False negatives** — major shipped features not mentioned at all
|
||||
2. **False positives** — features listed as "coming soon" / "roadmap" / "planned"
|
||||
/ "not supported" / "TBD" that already shipped
|
||||
3. **Quickstart accuracy** — install commands, prereqs, and startup instructions
|
||||
still correct (README only)
|
||||
4. **Feature table accuracy** — does the features section reflect current
|
||||
capabilities? (README only)
|
||||
5. **Works-with accuracy** — are supported adapters/integrations listed correctly?
|
||||
|
||||
Use `references/audit-checklist.md` as the structured checklist.
|
||||
Use `references/section-map.md` to know where to look for each feature area.
|
||||
|
||||
### Step 5 — Create branch and apply minimal edits
|
||||
|
||||
```bash
|
||||
# Create a branch for the doc updates
|
||||
BRANCH="docs/maintenance-$(date +%Y%m%d)"
|
||||
git checkout -b "$BRANCH"
|
||||
```
|
||||
|
||||
Apply **only** the edits needed to fix drift. Rules:
|
||||
|
||||
- **Minimal patches only.** Fix inaccuracies, don't rewrite sections.
|
||||
- **Preserve voice and style.** Match the existing tone of each document.
|
||||
- **No cosmetic changes.** Don't fix typos, reformat tables, or reorganize
|
||||
sections unless they're part of a factual fix.
|
||||
- **No new sections.** If a feature needs a whole new section, note it in the
|
||||
PR description as a follow-up — don't add it in a maintenance pass.
|
||||
- **Roadmap items:** Move shipped features out of Roadmap. Add a brief mention
|
||||
in the appropriate existing section if there isn't one already. Don't add
|
||||
long descriptions.
|
||||
|
||||
### Step 6 — Open a PR
|
||||
|
||||
Commit the changes and open a PR:
|
||||
|
||||
```bash
|
||||
git add README.md doc/SPEC.md doc/PRODUCT.md .doc-review-cursor
|
||||
git commit -m "docs: update documentation for accuracy
|
||||
|
||||
- [list each fix briefly]
|
||||
|
||||
Co-Authored-By: Paperclip <noreply@paperclip.ing>"
|
||||
|
||||
git push -u origin "$BRANCH"
|
||||
|
||||
gh pr create \
|
||||
--title "docs: periodic documentation accuracy update" \
|
||||
--body "$(cat <<'EOF'
|
||||
## Summary
|
||||
Automated doc maintenance pass. Fixes documentation drift detected since
|
||||
last review.
|
||||
|
||||
### Changes
|
||||
- [list each fix]
|
||||
|
||||
### Change summary (since last review)
|
||||
- [list notable code changes that triggered doc updates]
|
||||
|
||||
## Review notes
|
||||
- Only factual accuracy fixes — no style/cosmetic changes
|
||||
- Preserves existing voice and structure
|
||||
- Larger doc additions (new sections, tutorials) noted as follow-ups
|
||||
|
||||
🤖 Generated by doc-maintenance skill
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### Step 7 — Update the cursor
|
||||
|
||||
After a successful audit (whether or not edits were needed), update the cursor:
|
||||
|
||||
```bash
|
||||
git rev-parse HEAD > .doc-review-cursor
|
||||
```
|
||||
|
||||
If edits were made, this is already committed in the PR branch. If no edits
|
||||
were needed, commit the cursor update to the current branch.
|
||||
|
||||
## Change Classification Rules
|
||||
|
||||
| Signal | Category | Doc update needed? |
|
||||
|--------|----------|-------------------|
|
||||
| `feat:`, `add`, `implement`, `support` in message | Feature | Yes if user-facing |
|
||||
| `remove`, `drop`, `breaking`, `!:` in message | Breaking | Yes |
|
||||
| New top-level directory or config file | Structural | Maybe |
|
||||
| `fix:`, `bugfix` | Fix | No (unless it changes behavior described in docs) |
|
||||
| `refactor:`, `chore:`, `ci:`, `test:` | Maintenance | No |
|
||||
| `docs:` | Doc change | No (already handled) |
|
||||
| Dependency bumps only | Maintenance | No |
|
||||
|
||||
## Patch Style Guide
|
||||
|
||||
- Fix the fact, not the prose
|
||||
- If removing a roadmap item, don't leave a gap — remove the bullet cleanly
|
||||
- If adding a feature mention, match the format of surrounding entries
|
||||
(e.g. if features are in a table, add a table row)
|
||||
- Keep README changes especially minimal — it shouldn't churn often
|
||||
- For SPEC/PRODUCT, prefer updating existing statements over adding new ones
|
||||
(e.g. change "not supported in V1" to "supported via X" rather than adding
|
||||
a new section)
|
||||
|
||||
## Output
|
||||
|
||||
When the skill completes, report:
|
||||
|
||||
- How many commits were scanned
|
||||
- How many notable changes were found
|
||||
- How many doc edits were made (and to which files)
|
||||
- PR link (if edits were made)
|
||||
- Any follow-up items that need larger doc work
|
||||
85
.agents/skills/doc-maintenance/references/audit-checklist.md
Normal file
85
.agents/skills/doc-maintenance/references/audit-checklist.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Doc Maintenance Audit Checklist
|
||||
|
||||
Use this checklist when auditing each target document. For each item, compare
|
||||
against the change summary from git history.
|
||||
|
||||
## README.md
|
||||
|
||||
### Features table
|
||||
- [ ] Each feature card reflects a shipped capability
|
||||
- [ ] No feature cards for things that don't exist yet
|
||||
- [ ] No major shipped features missing from the table
|
||||
|
||||
### Roadmap
|
||||
- [ ] Nothing listed as "planned" or "coming soon" that already shipped
|
||||
- [ ] No removed/cancelled items still listed
|
||||
- [ ] Items reflect current priorities (cross-check with recent PRs)
|
||||
|
||||
### Quickstart
|
||||
- [ ] `npx paperclipai onboard` command is correct
|
||||
- [ ] Manual install steps are accurate (clone URL, commands)
|
||||
- [ ] Prerequisites (Node version, pnpm version) are current
|
||||
- [ ] Server URL and port are correct
|
||||
|
||||
### "What is Paperclip" section
|
||||
- [ ] High-level description is accurate
|
||||
- [ ] Step table (Define goal / Hire team / Approve and run) is correct
|
||||
|
||||
### "Works with" table
|
||||
- [ ] All supported adapters/runtimes are listed
|
||||
- [ ] No removed adapters still listed
|
||||
- [ ] Logos and labels match current adapter names
|
||||
|
||||
### "Paperclip is right for you if"
|
||||
- [ ] Use cases are still accurate
|
||||
- [ ] No claims about capabilities that don't exist
|
||||
|
||||
### "Why Paperclip is special"
|
||||
- [ ] Technical claims are accurate (atomic execution, governance, etc.)
|
||||
- [ ] No features listed that were removed or significantly changed
|
||||
|
||||
### FAQ
|
||||
- [ ] Answers are still correct
|
||||
- [ ] No references to removed features or outdated behavior
|
||||
|
||||
### Development section
|
||||
- [ ] Commands are accurate (`pnpm dev`, `pnpm build`, etc.)
|
||||
- [ ] Link to DEVELOPING.md is correct
|
||||
|
||||
## doc/SPEC.md
|
||||
|
||||
### Company Model
|
||||
- [ ] Fields match current schema
|
||||
- [ ] Governance model description is accurate
|
||||
|
||||
### Agent Model
|
||||
- [ ] Adapter types match what's actually supported
|
||||
- [ ] Agent configuration description is accurate
|
||||
- [ ] No features described as "not supported" or "not V1" that shipped
|
||||
|
||||
### Task Model
|
||||
- [ ] Task hierarchy description is accurate
|
||||
- [ ] Status values match current implementation
|
||||
|
||||
### Extensions / Plugins
|
||||
- [ ] If plugins are shipped, no "not in V1" or "future" language
|
||||
- [ ] Plugin model description matches implementation
|
||||
|
||||
### Open Questions
|
||||
- [ ] Resolved questions removed or updated
|
||||
- [ ] No "TBD" items that have been decided
|
||||
|
||||
## doc/PRODUCT.md
|
||||
|
||||
### Core Concepts
|
||||
- [ ] Company, Employees, Task Management descriptions accurate
|
||||
- [ ] Agent Execution modes described correctly
|
||||
- [ ] No missing major concepts
|
||||
|
||||
### Principles
|
||||
- [ ] Principles haven't been contradicted by shipped features
|
||||
- [ ] No principles referencing removed capabilities
|
||||
|
||||
### User Flow
|
||||
- [ ] Dream scenario still reflects actual onboarding
|
||||
- [ ] Steps are achievable with current features
|
||||
22
.agents/skills/doc-maintenance/references/section-map.md
Normal file
22
.agents/skills/doc-maintenance/references/section-map.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Section Map
|
||||
|
||||
Maps feature areas to specific document sections so the skill knows where to
|
||||
look when a feature ships or changes.
|
||||
|
||||
| Feature Area | README Section | SPEC Section | PRODUCT Section |
|
||||
|-------------|---------------|-------------|----------------|
|
||||
| Plugins / Extensions | Features table, Roadmap | Extensions, Agent Model | Core Concepts |
|
||||
| Adapters (new runtimes) | "Works with" table, FAQ | Agent Model, Agent Configuration | Employees & Agents, Agent Execution |
|
||||
| Governance / Approvals | Features table, "Why special" | Board Governance, Board Approval Gates | Principles |
|
||||
| Budget / Cost Control | Features table, "Why special" | Budget Delegation | Company (revenue & expenses) |
|
||||
| Task Management | Features table | Task Model | Task Management |
|
||||
| Org Chart / Hierarchy | Features table | Agent Model (reporting) | Employees & Agents |
|
||||
| Multi-Company | Features table, FAQ | Company Model | Company |
|
||||
| Heartbeats | Features table, FAQ | Agent Execution | Agent Execution |
|
||||
| CLI Commands | Development section | — | — |
|
||||
| Onboarding / Quickstart | Quickstart, FAQ | — | User Flow |
|
||||
| Skills / Skill Injection | "Why special" | — | — |
|
||||
| Company Templates | "Why special", Roadmap (ClipMart) | — | — |
|
||||
| Mobile / UI | Features table | — | — |
|
||||
| Project Archiving | — | — | — |
|
||||
| OpenClaw Integration | "Works with" table, FAQ | Agent Model | Agent Execution |
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: release-changelog
|
||||
description: >
|
||||
Generate the stable Paperclip release changelog at releases/v{version}.md by
|
||||
Generate the stable Paperclip release changelog at releases/vYYYY.MDD.P.md by
|
||||
reading commits, changesets, and merged PR context since the last stable tag.
|
||||
---
|
||||
|
||||
@@ -9,20 +9,33 @@ description: >
|
||||
|
||||
Generate the user-facing changelog for the **stable** Paperclip release.
|
||||
|
||||
## Versioning Model
|
||||
|
||||
Paperclip uses **calendar versioning (calver)**:
|
||||
|
||||
- Stable releases: `YYYY.MDD.P` (e.g. `2026.318.0`)
|
||||
- Canary releases: `YYYY.MDD.P-canary.N` (e.g. `2026.318.1-canary.0`)
|
||||
- Git tags: `vYYYY.MDD.P` for stable, `canary/vYYYY.MDD.P-canary.N` for canary
|
||||
|
||||
There are no major/minor/patch bumps. The stable version is derived from the
|
||||
intended release date (UTC) plus the next same-day stable patch slot.
|
||||
|
||||
Output:
|
||||
|
||||
- `releases/v{version}.md`
|
||||
- `releases/vYYYY.MDD.P.md`
|
||||
|
||||
Important rule:
|
||||
Important rules:
|
||||
|
||||
- even if there are canary releases such as `1.2.3-canary.0`, the changelog file stays `releases/v1.2.3.md`
|
||||
- even if there are canary releases such as `2026.318.1-canary.0`, the changelog file stays `releases/v2026.318.1.md`
|
||||
- do not derive versions from semver bump types
|
||||
- do not create canary changelog files
|
||||
|
||||
## Step 0 — Idempotency Check
|
||||
|
||||
Before generating anything, check whether the file already exists:
|
||||
|
||||
```bash
|
||||
ls releases/v{version}.md 2>/dev/null
|
||||
ls releases/vYYYY.MDD.P.md 2>/dev/null
|
||||
```
|
||||
|
||||
If it exists:
|
||||
@@ -41,13 +54,14 @@ 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:
|
||||
The stable version comes from one of:
|
||||
|
||||
- an explicit maintainer request
|
||||
- the chosen bump type applied to the last stable tag
|
||||
- `./scripts/release.sh stable --date YYYY-MM-DD --print-version`
|
||||
- the release plan already agreed in `doc/RELEASING.md`
|
||||
|
||||
Do not derive the changelog version from a canary tag or prerelease suffix.
|
||||
Do not derive major/minor/patch bumps from API intent — calver uses the date and same-day stable slot.
|
||||
|
||||
## Step 2 — Gather the Raw Inputs
|
||||
|
||||
@@ -73,7 +87,6 @@ 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:
|
||||
@@ -85,7 +98,8 @@ 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.
|
||||
If breaking changes are detected, flag them prominently — they must appear in the
|
||||
Breaking Changes section with an upgrade path.
|
||||
|
||||
## Step 4 — Categorize for Users
|
||||
|
||||
@@ -130,9 +144,9 @@ Rules:
|
||||
Template:
|
||||
|
||||
```markdown
|
||||
# v{version}
|
||||
# vYYYY.MDD.P
|
||||
|
||||
> Released: {YYYY-MM-DD}
|
||||
> Released: YYYY-MM-DD
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
247
.agents/skills/release/SKILL.md
Normal file
247
.agents/skills/release/SKILL.md
Normal file
@@ -0,0 +1,247 @@
|
||||
---
|
||||
name: release
|
||||
description: >
|
||||
Coordinate a full Paperclip release across engineering verification, npm,
|
||||
GitHub, smoke testing, and announcement follow-up. Use when leadership asks
|
||||
to ship a release, not merely to discuss versioning.
|
||||
---
|
||||
|
||||
# Release Coordination Skill
|
||||
|
||||
Run the full Paperclip maintainer release workflow, not just an npm publish.
|
||||
|
||||
This skill coordinates:
|
||||
|
||||
- stable changelog drafting via `release-changelog`
|
||||
- canary verification and publish status from `master`
|
||||
- Docker smoke testing via `scripts/docker-onboard-smoke.sh`
|
||||
- manual stable promotion from a chosen source ref
|
||||
- GitHub Release creation
|
||||
- website / announcement follow-up tasks
|
||||
|
||||
## Trigger
|
||||
|
||||
Use this skill when leadership asks for:
|
||||
|
||||
- "do a release"
|
||||
- "ship the release"
|
||||
- "promote this canary to stable"
|
||||
- "cut the stable release"
|
||||
|
||||
## Preconditions
|
||||
|
||||
Before proceeding, verify all of the following:
|
||||
|
||||
1. `.agents/skills/release-changelog/SKILL.md` exists and is usable.
|
||||
2. The repo working tree is clean, including untracked files.
|
||||
3. There is at least one canary or candidate commit since the last stable tag.
|
||||
4. The candidate SHA has passed the verification gate or is about to.
|
||||
5. If manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`.
|
||||
6. npm publish rights are available through GitHub trusted publishing, or through local npm auth for emergency/manual use.
|
||||
7. If running through Paperclip, you have issue context for status updates and follow-up task creation.
|
||||
|
||||
If any precondition fails, stop and report the blocker.
|
||||
|
||||
## Inputs
|
||||
|
||||
Collect these inputs up front:
|
||||
|
||||
- whether the target is a canary check or a stable promotion
|
||||
- the candidate `source_ref` for stable
|
||||
- whether the stable run is dry-run or live
|
||||
- release issue / company context for website and announcement follow-up
|
||||
|
||||
## Step 0 — Release Model
|
||||
|
||||
Paperclip now uses a commit-driven release model:
|
||||
|
||||
1. every push to `master` publishes a canary automatically
|
||||
2. canaries use `YYYY.MDD.P-canary.N`
|
||||
3. stable releases use `YYYY.MDD.P`
|
||||
4. the middle slot is `MDD`, where `M` is the UTC month and `DD` is the zero-padded UTC day
|
||||
5. the stable patch slot increments when more than one stable ships on the same UTC date
|
||||
6. stable releases are manually promoted from a chosen tested commit or canary source commit
|
||||
7. only stable releases get `releases/vYYYY.MDD.P.md`, git tag `vYYYY.MDD.P`, and a GitHub Release
|
||||
|
||||
Critical consequences:
|
||||
|
||||
- do not use release branches as the default path
|
||||
- do not derive major/minor/patch bumps
|
||||
- do not create canary changelog files
|
||||
- do not create canary GitHub Releases
|
||||
|
||||
## Step 1 — Choose the Candidate
|
||||
|
||||
For canary validation:
|
||||
|
||||
- inspect the latest successful canary run on `master`
|
||||
- record the canary version and source SHA
|
||||
|
||||
For stable promotion:
|
||||
|
||||
1. choose the tested source ref
|
||||
2. confirm it is the exact SHA you want to promote
|
||||
3. resolve the target stable version with `./scripts/release.sh stable --date YYYY-MM-DD --print-version`
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
git tag --list 'v*' --sort=-version:refname | head -1
|
||||
git log --oneline --no-merges
|
||||
npm view paperclipai@canary version
|
||||
```
|
||||
|
||||
## Step 2 — Draft the Stable Changelog
|
||||
|
||||
Stable changelog files live at:
|
||||
|
||||
- `releases/vYYYY.MDD.P.md`
|
||||
|
||||
Invoke `release-changelog` and generate or update the stable notes only.
|
||||
|
||||
Rules:
|
||||
|
||||
- review the draft with a human before publish
|
||||
- preserve manual edits if the file already exists
|
||||
- keep the filename stable-only
|
||||
- do not create a canary changelog file
|
||||
|
||||
## Step 3 — Verify the Candidate SHA
|
||||
|
||||
Run the standard gate:
|
||||
|
||||
```bash
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
|
||||
If the GitHub release workflow will run the publish, it can rerun this gate. Still report local status if you checked it.
|
||||
|
||||
For PRs that touch release logic, the repo also runs a canary release dry-run in CI. That is a release-specific guard, not a substitute for the standard gate.
|
||||
|
||||
## Step 4 — Validate the Canary
|
||||
|
||||
The normal canary path is automatic from `master` via:
|
||||
|
||||
- `.github/workflows/release.yml`
|
||||
|
||||
Confirm:
|
||||
|
||||
1. verification passed
|
||||
2. npm canary publish succeeded
|
||||
3. git tag `canary/vYYYY.MDD.P-canary.N` exists
|
||||
|
||||
Useful checks:
|
||||
|
||||
```bash
|
||||
npm view paperclipai@canary version
|
||||
git tag --list 'canary/v*' --sort=-version:refname | head -5
|
||||
```
|
||||
|
||||
## Step 5 — Smoke Test the Canary
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
Useful isolated variant:
|
||||
|
||||
```bash
|
||||
HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
1. install succeeds
|
||||
2. onboarding completes without crashes
|
||||
3. the server boots
|
||||
4. the UI loads
|
||||
5. basic company creation and dashboard load work
|
||||
|
||||
If smoke testing fails:
|
||||
|
||||
- stop the stable release
|
||||
- fix the issue on `master`
|
||||
- wait for the next automatic canary
|
||||
- rerun smoke testing
|
||||
|
||||
## Step 6 — Preview or Publish Stable
|
||||
|
||||
The normal stable path is manual `workflow_dispatch` on:
|
||||
|
||||
- `.github/workflows/release.yml`
|
||||
|
||||
Inputs:
|
||||
|
||||
- `source_ref`
|
||||
- `stable_date`
|
||||
- `dry_run`
|
||||
|
||||
Before live stable:
|
||||
|
||||
1. resolve the target stable version with `./scripts/release.sh stable --date YYYY-MM-DD --print-version`
|
||||
2. ensure `releases/vYYYY.MDD.P.md` exists on the source ref
|
||||
3. run the stable workflow in dry-run mode first when practical
|
||||
4. then run the real stable publish
|
||||
|
||||
The stable workflow:
|
||||
|
||||
- re-verifies the exact source ref
|
||||
- computes the next stable patch slot for the chosen UTC date
|
||||
- publishes `YYYY.MDD.P` under dist-tag `latest`
|
||||
- creates git tag `vYYYY.MDD.P`
|
||||
- creates or updates the GitHub Release from `releases/vYYYY.MDD.P.md`
|
||||
|
||||
Local emergency/manual commands:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh stable --dry-run
|
||||
./scripts/release.sh stable
|
||||
git push public-gh refs/tags/vYYYY.MDD.P
|
||||
./scripts/create-github-release.sh YYYY.MDD.P
|
||||
```
|
||||
|
||||
## Step 7 — Finish the Other Surfaces
|
||||
|
||||
Create or verify follow-up work for:
|
||||
|
||||
- website changelog publishing
|
||||
- launch post / social announcement
|
||||
- release summary in Paperclip issue context
|
||||
|
||||
These should reference the stable release, not the canary.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
If the canary is bad:
|
||||
|
||||
- publish another canary, do not ship stable
|
||||
|
||||
If stable npm publish succeeds but tag push or GitHub release creation fails:
|
||||
|
||||
- fix the git/GitHub issue immediately from the same release result
|
||||
- do not republish the same version
|
||||
|
||||
If `latest` is bad after stable publish:
|
||||
|
||||
```bash
|
||||
./scripts/rollback-latest.sh <last-good-version>
|
||||
```
|
||||
|
||||
Then fix forward with a new stable release.
|
||||
|
||||
## Output
|
||||
|
||||
When the skill completes, provide:
|
||||
|
||||
- candidate SHA and tested canary version, if relevant
|
||||
- stable version, if promoted
|
||||
- verification status
|
||||
- npm status
|
||||
- smoke-test status
|
||||
- git tag / GitHub Release status
|
||||
- website / announcement follow-up status
|
||||
- rollback recommendation if anything is still partially complete
|
||||
@@ -1,8 +0,0 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [["@paperclipai/*", "paperclipai"]],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "master",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["@paperclipai/ui"]
|
||||
}
|
||||
1
.claude/skills/company-creator
Symbolic link
1
.claude/skills/company-creator
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/company-creator
|
||||
17
.github/CODEOWNERS
vendored
Normal file
17
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Replace @cryppadotta if a different maintainer or team should own release infrastructure.
|
||||
|
||||
.github/** @cryppadotta @devinfoley
|
||||
scripts/release*.sh @cryppadotta @devinfoley
|
||||
scripts/release-*.mjs @cryppadotta @devinfoley
|
||||
scripts/create-github-release.sh @cryppadotta @devinfoley
|
||||
scripts/rollback-latest.sh @cryppadotta @devinfoley
|
||||
doc/RELEASING.md @cryppadotta @devinfoley
|
||||
doc/PUBLISHING.md @cryppadotta @devinfoley
|
||||
doc/RELEASE-AUTOMATION-SETUP.md @cryppadotta @devinfoley
|
||||
|
||||
# Package files — dependency changes require review
|
||||
# package.json matches recursively at all depths (covers root + all workspaces)
|
||||
package.json @cryppadotta @devinfoley
|
||||
pnpm-lock.yaml @cryppadotta @devinfoley
|
||||
pnpm-workspace.yaml @cryppadotta @devinfoley
|
||||
.npmrc @cryppadotta @devinfoley
|
||||
65
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
65
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
## Thinking Path
|
||||
|
||||
<!--
|
||||
Required. Trace your reasoning from the top of the project down to this
|
||||
specific change. Start with what Paperclip is, then narrow through the
|
||||
subsystem, the problem, and why this PR exists. Use blockquote style.
|
||||
Aim for 5–8 steps. See CONTRIBUTING.md for full examples.
|
||||
-->
|
||||
|
||||
> - Paperclip orchestrates AI agents for zero-human companies
|
||||
> - [Which subsystem or capability is involved]
|
||||
> - [What problem or gap exists]
|
||||
> - [Why it needs to be addressed]
|
||||
> - This pull request ...
|
||||
> - The benefit is ...
|
||||
|
||||
## What Changed
|
||||
|
||||
<!-- Bullet list of concrete changes. One bullet per logical unit. -->
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
<!--
|
||||
How can a reviewer confirm this works? Include test commands, manual
|
||||
steps, or both. For UI changes, include before/after screenshots.
|
||||
-->
|
||||
|
||||
-
|
||||
|
||||
## Risks
|
||||
|
||||
<!--
|
||||
What could go wrong? Mention migration safety, breaking changes,
|
||||
behavioral shifts, or "Low risk" if genuinely minor.
|
||||
-->
|
||||
|
||||
-
|
||||
|
||||
## Model Used
|
||||
|
||||
<!--
|
||||
Required. Specify which AI model was used to produce or assist with
|
||||
this change. Be as descriptive as possible — include:
|
||||
• Provider and model name (e.g., Claude, GPT, Gemini, Codex)
|
||||
• Exact model ID or version (e.g., claude-opus-4-6, gpt-4-turbo-2024-04-09)
|
||||
• Context window size if relevant (e.g., 1M context)
|
||||
• Reasoning/thinking mode if applicable (e.g., extended thinking, chain-of-thought)
|
||||
• Any other relevant capability details (e.g., tool use, code execution)
|
||||
If no AI model was used, write "None — human-authored".
|
||||
-->
|
||||
|
||||
-
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have included a thinking path that traces from project context to this change
|
||||
- [ ] I have specified the model used (with version and capability details)
|
||||
- [ ] I have run tests locally and they pass
|
||||
- [ ] I have added or updated tests where applicable
|
||||
- [ ] If this change affects the UI, I have included before/after screenshots
|
||||
- [ ] I have updated relevant documentation to reflect my changes
|
||||
- [ ] I have considered and documented any risks above
|
||||
- [ ] I will address all Greptile and reviewer comments before requesting merge
|
||||
55
.github/workflows/docker.yml
vendored
Normal file
55
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
49
.github/workflows/pr-policy.yml
vendored
49
.github/workflows/pr-policy.yml
vendored
@@ -1,49 +0,0 @@
|
||||
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
|
||||
|
||||
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: Block manual lockfile edits
|
||||
if: github.head_ref != 'chore/refresh-lockfile'
|
||||
run: |
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
||||
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
|
||||
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate dependency resolution when manifests change
|
||||
run: |
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
||||
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
|
||||
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
|
||||
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
||||
fi
|
||||
42
.github/workflows/pr-verify.yml
vendored
42
.github/workflows/pr-verify.yml
vendored
@@ -1,42 +0,0 @@
|
||||
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 --no-frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
186
.github/workflows/pr.yml
vendored
Normal file
186
.github/workflows/pr.yml
vendored
Normal file
@@ -0,0 +1,186 @@
|
||||
name: PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
policy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Block manual lockfile edits
|
||||
if: github.head_ref != 'chore/refresh-lockfile'
|
||||
run: |
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
||||
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then
|
||||
echo "Do not commit pnpm-lock.yaml in pull requests. CI owns lockfile updates."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Validate Dockerfile deps stage
|
||||
run: |
|
||||
missing=0
|
||||
|
||||
# Extract only the deps stage from the Dockerfile
|
||||
deps_stage="$(awk '/^FROM .* AS deps$/{found=1; next} found && /^FROM /{exit} found{print}' Dockerfile)"
|
||||
|
||||
if [ -z "$deps_stage" ]; then
|
||||
echo "::error::Could not extract deps stage from Dockerfile (expected 'FROM ... AS deps')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Derive workspace search roots from pnpm-workspace.yaml (exclude dev-only packages)
|
||||
search_roots="$(grep '^ *- ' pnpm-workspace.yaml | sed 's/^ *- //' | sed 's/\*$//' | grep -v 'examples' | grep -v 'create-paperclip-plugin' | tr '\n' ' ')"
|
||||
|
||||
if [ -z "$search_roots" ]; then
|
||||
echo "::error::Could not derive workspace roots from pnpm-workspace.yaml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check all workspace package.json files are copied in the deps stage
|
||||
for pkg in $(find $search_roots -maxdepth 2 -name package.json -not -path '*/examples/*' -not -path '*/create-paperclip-plugin/*' -not -path '*/node_modules/*' 2>/dev/null | sort -u); do
|
||||
dir="$(dirname "$pkg")"
|
||||
if ! echo "$deps_stage" | grep -q "^COPY ${dir}/package.json"; then
|
||||
echo "::error::Dockerfile deps stage missing: COPY ${pkg} ${dir}/"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check patches directory is copied if it exists
|
||||
if [ -d patches ] && ! echo "$deps_stage" | grep -q '^COPY patches/'; then
|
||||
echo "::error::Dockerfile deps stage missing: COPY patches/ patches/"
|
||||
missing=1
|
||||
fi
|
||||
|
||||
if [ "$missing" -eq 1 ]; then
|
||||
echo "Dockerfile deps stage is out of sync. Update it to include the missing files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate dependency resolution when manifests change
|
||||
run: |
|
||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
||||
manifest_pattern='(^|/)package\.json$|^pnpm-workspace\.yaml$|^\.npmrc$|^pnpmfile\.(cjs|js|mjs)$'
|
||||
if printf '%s\n' "$changed" | grep -Eq "$manifest_pattern"; then
|
||||
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
||||
fi
|
||||
|
||||
verify:
|
||||
needs: [policy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Release canary dry run
|
||||
run: |
|
||||
git checkout -B master HEAD
|
||||
git checkout -- pnpm-lock.yaml
|
||||
./scripts/release.sh canary --skip-verify --dry-run
|
||||
|
||||
e2e:
|
||||
needs: [policy]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Install Playwright
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Generate Paperclip config
|
||||
run: |
|
||||
mkdir -p ~/.paperclip/instances/default
|
||||
cat > ~/.paperclip/instances/default/config.json << 'CONF'
|
||||
{
|
||||
"$meta": { "version": 1, "updatedAt": "2026-01-01T00:00:00.000Z", "source": "onboard" },
|
||||
"database": { "mode": "embedded-postgres" },
|
||||
"logging": { "mode": "file" },
|
||||
"server": { "deploymentMode": "local_trusted", "host": "127.0.0.1", "port": 3100 },
|
||||
"auth": { "baseUrlMode": "auto" },
|
||||
"storage": { "provider": "local_disk" },
|
||||
"secrets": { "provider": "local_encrypted", "strictMode": false }
|
||||
}
|
||||
CONF
|
||||
|
||||
- name: Run e2e tests
|
||||
env:
|
||||
PAPERCLIP_E2E_SKIP_LLM: "true"
|
||||
run: pnpm run test:e2e
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
tests/e2e/playwright-report/
|
||||
tests/e2e/test-results/
|
||||
retention-days: 14
|
||||
16
.github/workflows/refresh-lockfile.yml
vendored
16
.github/workflows/refresh-lockfile.yml
vendored
@@ -51,11 +51,13 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Create or update pull request
|
||||
id: upsert-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if git diff --quiet -- pnpm-lock.yaml; then
|
||||
echo "Lockfile unchanged, nothing to do."
|
||||
echo "pr_created=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -79,3 +81,17 @@ jobs:
|
||||
else
|
||||
echo "PR #$existing already exists, branch updated via force push."
|
||||
fi
|
||||
echo "pr_created=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Enable auto-merge for lockfile PR
|
||||
if: steps.upsert-pr.outputs.pr_created == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
pr_url="$(gh pr list --head chore/refresh-lockfile --json url --jq '.[0].url')"
|
||||
if [ -z "$pr_url" ]; then
|
||||
echo "Error: lockfile PR was not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh pr merge --auto --squash --delete-branch "$pr_url"
|
||||
|
||||
118
.github/workflows/release-smoke.yml
vendored
Normal file
118
.github/workflows/release-smoke.yml
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
name: Release Smoke
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
paperclip_version:
|
||||
description: Published Paperclip dist-tag to test
|
||||
required: true
|
||||
default: canary
|
||||
type: choice
|
||||
options:
|
||||
- canary
|
||||
- latest
|
||||
host_port:
|
||||
description: Host port for the Docker smoke container
|
||||
required: false
|
||||
default: "3232"
|
||||
type: string
|
||||
artifact_name:
|
||||
description: Artifact name for uploaded diagnostics
|
||||
required: false
|
||||
default: release-smoke
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
paperclip_version:
|
||||
required: true
|
||||
type: string
|
||||
host_port:
|
||||
required: false
|
||||
default: "3232"
|
||||
type: string
|
||||
artifact_name:
|
||||
required: false
|
||||
default: release-smoke
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Launch Docker smoke harness
|
||||
run: |
|
||||
metadata_file="$RUNNER_TEMP/release-smoke.env"
|
||||
HOST_PORT="${{ inputs.host_port }}" \
|
||||
DATA_DIR="$RUNNER_TEMP/release-smoke-data" \
|
||||
PAPERCLIPAI_VERSION="${{ inputs.paperclip_version }}" \
|
||||
SMOKE_DETACH=true \
|
||||
SMOKE_METADATA_FILE="$metadata_file" \
|
||||
./scripts/docker-onboard-smoke.sh
|
||||
set -a
|
||||
source "$metadata_file"
|
||||
set +a
|
||||
{
|
||||
echo "SMOKE_BASE_URL=$SMOKE_BASE_URL"
|
||||
echo "SMOKE_ADMIN_EMAIL=$SMOKE_ADMIN_EMAIL"
|
||||
echo "SMOKE_ADMIN_PASSWORD=$SMOKE_ADMIN_PASSWORD"
|
||||
echo "SMOKE_CONTAINER_NAME=$SMOKE_CONTAINER_NAME"
|
||||
echo "SMOKE_DATA_DIR=$SMOKE_DATA_DIR"
|
||||
echo "SMOKE_IMAGE_NAME=$SMOKE_IMAGE_NAME"
|
||||
echo "SMOKE_PAPERCLIPAI_VERSION=$SMOKE_PAPERCLIPAI_VERSION"
|
||||
echo "SMOKE_METADATA_FILE=$metadata_file"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run release smoke Playwright suite
|
||||
env:
|
||||
PAPERCLIP_RELEASE_SMOKE_BASE_URL: ${{ env.SMOKE_BASE_URL }}
|
||||
PAPERCLIP_RELEASE_SMOKE_EMAIL: ${{ env.SMOKE_ADMIN_EMAIL }}
|
||||
PAPERCLIP_RELEASE_SMOKE_PASSWORD: ${{ env.SMOKE_ADMIN_PASSWORD }}
|
||||
run: pnpm run test:release-smoke
|
||||
|
||||
- name: Capture Docker logs
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then
|
||||
docker logs "$SMOKE_CONTAINER_NAME" >"$RUNNER_TEMP/docker-onboard-smoke.log" 2>&1 || true
|
||||
fi
|
||||
|
||||
- name: Upload diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ inputs.artifact_name }}
|
||||
path: |
|
||||
${{ runner.temp }}/docker-onboard-smoke.log
|
||||
${{ env.SMOKE_METADATA_FILE }}
|
||||
tests/release-smoke/playwright-report/
|
||||
tests/release-smoke/test-results/
|
||||
retention-days: 14
|
||||
|
||||
- name: Stop Docker smoke container
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then
|
||||
docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
205
.github/workflows/release.yml
vendored
205
.github/workflows/release.yml
vendored
@@ -1,38 +1,33 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Release channel
|
||||
source_ref:
|
||||
description: Commit SHA, branch, or tag to publish as stable
|
||||
required: true
|
||||
type: choice
|
||||
default: canary
|
||||
options:
|
||||
- canary
|
||||
- stable
|
||||
bump:
|
||||
description: Semantic version bump
|
||||
required: true
|
||||
type: choice
|
||||
default: patch
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
type: string
|
||||
default: master
|
||||
stable_date:
|
||||
description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable.
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Preview the release without publishing
|
||||
description: Preview the stable release without publishing
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
group: release-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
if: startsWith(github.ref, 'refs/heads/release/')
|
||||
verify_canary:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
@@ -56,7 +51,7 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
@@ -67,12 +62,12 @@ jobs:
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
publish:
|
||||
if: startsWith(github.ref, 'refs/heads/release/')
|
||||
needs: verify
|
||||
publish_canary:
|
||||
if: github.event_name == 'push'
|
||||
needs: verify_canary
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: npm-release
|
||||
environment: npm-canary
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
@@ -95,34 +90,168 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Restore tracked install-time changes
|
||||
run: git checkout -- pnpm-lock.yaml
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Run release script
|
||||
- name: Publish canary
|
||||
env:
|
||||
GITHUB_ACTIONS: "true"
|
||||
run: ./scripts/release.sh canary --skip-verify
|
||||
|
||||
- name: Push canary tag
|
||||
run: |
|
||||
tag="$(git tag --points-at HEAD | grep '^canary/v' | head -1)"
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Error: no canary tag points at HEAD after release." >&2
|
||||
exit 1
|
||||
fi
|
||||
git push origin "refs/tags/${tag}"
|
||||
|
||||
verify_stable:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.source_ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
preview_stable:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
|
||||
needs: verify_stable
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.source_ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Dry-run stable release
|
||||
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")
|
||||
args=(stable --skip-verify --dry-run)
|
||||
if [ -n "${{ inputs.stable_date }}" ]; then
|
||||
args+=(--date "${{ inputs.stable_date }}")
|
||||
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
|
||||
publish_stable:
|
||||
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run
|
||||
needs: verify_stable
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: npm-stable
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.source_ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Restore tracked install-time changes
|
||||
run: git checkout -- pnpm-lock.yaml
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Publish stable
|
||||
env:
|
||||
GITHUB_ACTIONS: "true"
|
||||
run: |
|
||||
args=(stable --skip-verify)
|
||||
if [ -n "${{ inputs.stable_date }}" ]; then
|
||||
args+=(--date "${{ inputs.stable_date }}")
|
||||
fi
|
||||
./scripts/release.sh "${args[@]}"
|
||||
|
||||
- name: Push stable tag
|
||||
run: |
|
||||
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Error: no stable tag points at HEAD after release." >&2
|
||||
exit 1
|
||||
fi
|
||||
git push origin "refs/tags/${tag}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: inputs.channel == 'stable' && !inputs.dry_run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PUBLISH_REMOTE: origin
|
||||
run: |
|
||||
version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')"
|
||||
if [ -z "$version" ]; then
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -31,13 +31,23 @@ server/src/**/*.js.map
|
||||
server/src/**/*.d.ts
|
||||
server/src/**/*.d.ts.map
|
||||
tmp/
|
||||
feedback-export-*
|
||||
|
||||
# Editor / tool temp files
|
||||
*.tmp
|
||||
.vscode/
|
||||
.claude/settings.local.json
|
||||
.paperclip-local/
|
||||
/.idea/
|
||||
/.agents/
|
||||
|
||||
# Doc maintenance cursor
|
||||
.doc-review-cursor
|
||||
|
||||
# Playwright
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
tests/e2e/playwright-report/
|
||||
tests/release-smoke/test-results/
|
||||
tests/release-smoke/playwright-report/
|
||||
.superset/
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -26,6 +26,9 @@ Before making changes, read in this order:
|
||||
- `ui/`: React + Vite board UI
|
||||
- `packages/db/`: Drizzle schema, migrations, DB clients
|
||||
- `packages/shared/`: shared types, constants, validators, API path constants
|
||||
- `packages/adapters/`: agent adapter implementations (Claude, Codex, Cursor, etc.)
|
||||
- `packages/adapter-utils/`: shared adapter utilities
|
||||
- `packages/plugins/`: plugin system packages
|
||||
- `doc/`: operational and product docs
|
||||
|
||||
## 4. Dev Setup (Auto DB)
|
||||
@@ -78,6 +81,9 @@ If you change schema/API behavior, update all impacted layers:
|
||||
4. Do not replace strategic docs wholesale unless asked.
|
||||
Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned.
|
||||
|
||||
5. Keep plan docs dated and centralized.
|
||||
New plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames.
|
||||
|
||||
## 6. Database Change Workflow
|
||||
|
||||
When changing data model:
|
||||
|
||||
@@ -7,15 +7,18 @@ 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
|
||||
- All tests pass and CI is green
|
||||
- Greptile score is 5/5 with all comments addressed
|
||||
- Use the [PR template](.github/PULL_REQUEST_TEMPLATE.md)
|
||||
|
||||
These almost always get merged quickly when they're clean.
|
||||
|
||||
### Path 2: Bigger or Impactful Changes
|
||||
|
||||
- **First** talk about it in Discord → #dev channel
|
||||
→ Describe what you're trying to solve
|
||||
→ Share rough ideas / approach
|
||||
@@ -24,18 +27,64 @@ These almost always get merged quickly when they're clean.
|
||||
- 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
|
||||
- All tests passing and CI green
|
||||
- Greptile score 5/5 with all comments addressed
|
||||
- [PR template](.github/PULL_REQUEST_TEMPLATE.md) fully filled out
|
||||
|
||||
PRs that follow this path are **much** more likely to be accepted, even when they're large.
|
||||
|
||||
## PR Requirements (all PRs)
|
||||
|
||||
### Use the PR Template
|
||||
|
||||
Every pull request **must** follow the PR template at [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md). If you create a PR via the GitHub API or other tooling that bypasses the template, copy its contents into your PR description manually. The template includes required sections: Thinking Path, What Changed, Verification, Risks, and a Checklist.
|
||||
|
||||
### Tests Must Pass
|
||||
|
||||
All tests must pass before a PR can be merged. Run them locally first and verify CI is green after pushing.
|
||||
|
||||
### Greptile Review
|
||||
|
||||
We use [Greptile](https://greptile.com) for automated code review. Your PR must achieve a **5/5 Greptile score** with **all Greptile comments addressed** before it can be merged. If Greptile leaves comments, fix or respond to each one and request a re-review.
|
||||
|
||||
## General Rules (both paths)
|
||||
|
||||
- Write clear commit messages
|
||||
- Keep PR title + description meaningful
|
||||
- One PR = one logical change (unless it's a small related group)
|
||||
- Run tests locally first
|
||||
- Be kind in discussions 😄
|
||||
|
||||
## Writing a Good PR message
|
||||
|
||||
Your PR description must follow the [PR template](.github/PULL_REQUEST_TEMPLATE.md). All sections are required. The "thinking path" at the top explains from the top of the project down to what you fixed. E.g.:
|
||||
|
||||
### Thinking Path Example 1:
|
||||
|
||||
> - Paperclip orchestrates ai-agents for zero-human companies
|
||||
> - There are many types of adapters for each LLM model provider
|
||||
> - But LLM's have a context limit and not all agents can automatically compact their context
|
||||
> - So we need to have an adapter-specific configuration for which adapters can and cannot automatically compact their context
|
||||
> - This pull request adds per-adapter configuration of compaction, either auto or paperclip managed
|
||||
> - That way we can get optimal performance from any adapter/provider in Paperclip
|
||||
|
||||
### Thinking Path Example 2:
|
||||
|
||||
> - Paperclip orchestrates ai-agents for zero-human companies
|
||||
> - But humans want to watch the agents and oversee their work
|
||||
> - Human users also operate in teams and so they need their own logins, profiles, views etc.
|
||||
> - So we have a multi-user system for humans
|
||||
> - But humans want to be able to update their own profile picture and avatar
|
||||
> - But the avatar upload form wasn't saving the avatar to the file storage system
|
||||
> - So this PR fixes the avatar upload form to use the file storage service
|
||||
> - The benefit is we don't have a one-off file storage for just one aspect of the system, which would cause confusion and extra configuration
|
||||
|
||||
Then have the rest of your normal PR message after the Thinking Path.
|
||||
|
||||
This should include details about what you did, why you did it, why it matters & the benefits, how we can verify it works, and any risks.
|
||||
|
||||
Please include screenshots if possible if you have a visible change. (use something like the [agent-browser skill](https://github.com/vercel-labs/agent-browser/blob/main/skills/agent-browser/SKILL.md) or similar to take screenshots). Ideally, you include before and after screenshots.
|
||||
|
||||
Questions? Just ask in #dev — we're happy to help.
|
||||
|
||||
Happy hacking!
|
||||
|
||||
37
Dockerfile
37
Dockerfile
@@ -1,8 +1,23 @@
|
||||
FROM node:lts-trixie-slim AS base
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN corepack enable
|
||||
&& apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 \
|
||||
&& mkdir -p -m 755 /etc/apt/keyrings \
|
||||
&& wget -nv -O/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
&& echo "20e0125d6f6e077a9ad46f03371bc26d90b04939fb95170f5a1905099cc6bcc0 /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \
|
||||
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& mkdir -p -m 755 /etc/apt/sources.list.d \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gh \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable
|
||||
|
||||
# Modify the existing node user/group to have the specified UID/GID to match host user
|
||||
RUN usermod -u $USER_UID --non-unique node \
|
||||
&& groupmod -g $USER_GID --non-unique node \
|
||||
&& usermod -g $USER_GID -d /paperclip node
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
@@ -16,9 +31,12 @@ 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/
|
||||
COPY packages/plugins/sdk/package.json packages/plugins/sdk/
|
||||
COPY patches/ patches/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -27,16 +45,22 @@ WORKDIR /app
|
||||
COPY --from=deps /app /app
|
||||
COPY . .
|
||||
RUN pnpm --filter @paperclipai/ui build
|
||||
RUN pnpm --filter @paperclipai/plugin-sdk build
|
||||
RUN pnpm --filter @paperclipai/server build
|
||||
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
|
||||
|
||||
FROM base AS production
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
WORKDIR /app
|
||||
COPY --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
|
||||
|
||||
COPY scripts/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOME=/paperclip \
|
||||
HOST=0.0.0.0 \
|
||||
@@ -44,12 +68,15 @@ ENV NODE_ENV=production \
|
||||
SERVE_UI=true \
|
||||
PAPERCLIP_HOME=/paperclip \
|
||||
PAPERCLIP_INSTANCE_ID=default \
|
||||
USER_UID=${USER_UID} \
|
||||
USER_GID=${USER_GID} \
|
||||
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
|
||||
PAPERCLIP_DEPLOYMENT_MODE=authenticated \
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE=private
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE=private \
|
||||
OPENCODE_ALLOW_ALL_MODELS=true
|
||||
|
||||
VOLUME ["/paperclip"]
|
||||
EXPOSE 3100
|
||||
|
||||
USER node
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "--import", "./server/node_modules/tsx/dist/loader.mjs", "server/dist/index.js"]
|
||||
|
||||
42
README.md
42
README.md
@@ -177,6 +177,8 @@ Open source. Self-hosted. No Paperclip account required.
|
||||
npx paperclipai onboard --yes
|
||||
```
|
||||
|
||||
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
@@ -234,22 +236,44 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- ⚪ Get OpenClaw onboarding easier
|
||||
- ⚪ Get cloud agents working e.g. Cursor / e2b agents
|
||||
- ⚪ ClipMart - buy and sell entire agent companies
|
||||
- ⚪ Easy agent configurations / easier to understand
|
||||
- ⚪ Better support for harness engineering
|
||||
- ⚪ Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
|
||||
- ⚪ Better docs
|
||||
- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc)
|
||||
- ✅ Get OpenClaw / claw-style agent employees
|
||||
- ✅ companies.sh - import and export entire organizations
|
||||
- ✅ Easy AGENTS.md configurations
|
||||
- ✅ Skills Manager
|
||||
- ✅ Scheduled Routines
|
||||
- ✅ Better Budgeting
|
||||
- ⚪ Artifacts & Deployments
|
||||
- ⚪ CEO Chat
|
||||
- ⚪ MAXIMIZER MODE
|
||||
- ⚪ Multiple Human Users
|
||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
||||
- ⚪ Cloud deployments
|
||||
- ⚪ Desktop App
|
||||
|
||||
<br/>
|
||||
|
||||
## Community & Plugins
|
||||
|
||||
Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip)
|
||||
|
||||
## Telemetry
|
||||
|
||||
Paperclip collects anonymous usage telemetry to help us understand how the product is used and improve it. No personal information, issue content, prompts, file paths, or secrets are ever collected. Private repository references are hashed with a per-install salt before being sent.
|
||||
|
||||
Telemetry is **enabled by default** and can be disabled with any of the following:
|
||||
|
||||
| Method | How |
|
||||
|---|---|
|
||||
| Environment variable | `PAPERCLIP_TELEMETRY_DISABLED=1` |
|
||||
| Standard convention | `DO_NOT_TRACK=1` |
|
||||
| CI environments | Automatically disabled when `CI=true` |
|
||||
| Config file | Set `telemetry.enabled: false` in your Paperclip config |
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
|
||||
|
||||
<!-- TODO: add CONTRIBUTING.md -->
|
||||
|
||||
<br/>
|
||||
|
||||
## Community
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# paperclipai
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
- @paperclipai/adapter-claude-local@0.3.1
|
||||
- @paperclipai/adapter-codex-local@0.3.1
|
||||
- @paperclipai/adapter-cursor-local@0.3.1
|
||||
- @paperclipai/adapter-gemini-local@0.3.1
|
||||
- @paperclipai/adapter-openclaw-gateway@0.3.1
|
||||
- @paperclipai/adapter-opencode-local@0.3.1
|
||||
- @paperclipai/adapter-pi-local@0.3.1
|
||||
- @paperclipai/db@0.3.1
|
||||
- @paperclipai/shared@0.3.1
|
||||
- @paperclipai/server@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
292
cli/README.md
Normal file
292
cli/README.md
Normal file
@@ -0,0 +1,292 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/header.png" alt="Paperclip — runs your business" width="720" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#quickstart"><strong>Quickstart</strong></a> ·
|
||||
<a href="https://paperclip.ing/docs"><strong>Docs</strong></a> ·
|
||||
<a href="https://github.com/paperclipai/paperclip"><strong>GitHub</strong></a> ·
|
||||
<a href="https://discord.gg/m4HZY7xNG3"><strong>Discord</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/paperclipai/paperclip/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License" /></a>
|
||||
<a href="https://github.com/paperclipai/paperclip/stargazers"><img src="https://img.shields.io/github/stars/paperclipai/paperclip?style=flat" alt="Stars" /></a>
|
||||
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/badge/discord-join%20chat-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<video src="https://github.com/user-attachments/assets/773bdfb2-6d1e-4e30-8c5f-3487d5b70c8f" width="600" controls></video>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
## What is Paperclip?
|
||||
|
||||
# Open-source orchestration for zero-human companies
|
||||
|
||||
**If OpenClaw is an _employee_, Paperclip is the _company_**
|
||||
|
||||
Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track your agents' work and costs from one dashboard.
|
||||
|
||||
It looks like a task manager — but under the hood it has org charts, budgets, governance, goal alignment, and agent coordination.
|
||||
|
||||
**Manage business goals, not pull requests.**
|
||||
|
||||
| | Step | Example |
|
||||
| ------ | --------------- | ------------------------------------------------------------------ |
|
||||
| **01** | Define the goal | _"Build the #1 AI note-taking app to $1M MRR."_ |
|
||||
| **02** | Hire the team | CEO, CTO, engineers, designers, marketers — any bot, any provider. |
|
||||
| **03** | Approve and run | Review strategy. Set budgets. Hit go. Monitor from the dashboard. |
|
||||
|
||||
<br/>
|
||||
|
||||
> **COMING SOON: Clipmart** — Download and run entire companies with one click. Browse pre-built company templates — full org structures, agent configs, and skills — and import them into your Paperclip instance in seconds.
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><strong>Works<br/>with</strong></td>
|
||||
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/openclaw.svg" width="32" alt="OpenClaw" /><br/><sub>OpenClaw</sub></td>
|
||||
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/claude.svg" width="32" alt="Claude" /><br/><sub>Claude Code</sub></td>
|
||||
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/codex.svg" width="32" alt="Codex" /><br/><sub>Codex</sub></td>
|
||||
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/cursor.svg" width="32" alt="Cursor" /><br/><sub>Cursor</sub></td>
|
||||
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/bash.svg" width="32" alt="Bash" /><br/><sub>Bash</sub></td>
|
||||
<td align="center"><img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/logos/http.svg" width="32" alt="HTTP" /><br/><sub>HTTP</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<em>If it can receive a heartbeat, it's hired.</em>
|
||||
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
## Paperclip is right for you if
|
||||
|
||||
- ✅ You want to build **autonomous AI companies**
|
||||
- ✅ You **coordinate many different agents** (OpenClaw, Codex, Claude, Cursor) toward a common goal
|
||||
- ✅ You have **20 simultaneous Claude Code terminals** open and lose track of what everyone is doing
|
||||
- ✅ You want agents running **autonomously 24/7**, but still want to audit work and chime in when needed
|
||||
- ✅ You want to **monitor costs** and enforce budgets
|
||||
- ✅ You want a process for managing agents that **feels like using a task manager**
|
||||
- ✅ You want to manage your autonomous businesses **from your phone**
|
||||
|
||||
<br/>
|
||||
|
||||
## Features
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%">
|
||||
<h3>🔌 Bring Your Own Agent</h3>
|
||||
Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired.
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
<h3>🎯 Goal Alignment</h3>
|
||||
Every task traces back to the company mission. Agents know <em>what</em> to do and <em>why</em>.
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
<h3>💓 Heartbeats</h3>
|
||||
Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>💰 Cost Control</h3>
|
||||
Monthly budgets per agent. When they hit the limit, they stop. No runaway costs.
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>🏢 Multi-Company</h3>
|
||||
One deployment, many companies. Complete data isolation. One control plane for your portfolio.
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>🎫 Ticket System</h3>
|
||||
Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>🛡️ Governance</h3>
|
||||
You're the board. Approve hires, override strategy, pause or terminate any agent — at any time.
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>📊 Org Chart</h3>
|
||||
Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description.
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>📱 Mobile Ready</h3>
|
||||
Monitor and manage your autonomous businesses from anywhere.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br/>
|
||||
|
||||
## Problems Paperclip solves
|
||||
|
||||
| Without Paperclip | With Paperclip |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ❌ You have 20 Claude Code tabs open and can't track which one does what. On reboot you lose everything. | ✅ Tasks are ticket-based, conversations are threaded, sessions persist across reboots. |
|
||||
| ❌ You manually gather context from several places to remind your bot what you're actually doing. | ✅ Context flows from the task up through the project and company goals — your agent always knows what to do and why. |
|
||||
| ❌ Folders of agent configs are disorganized and you're re-inventing task management, communication, and coordination between agents. | ✅ Paperclip gives you org charts, ticketing, delegation, and governance out of the box — so you run a company, not a pile of scripts. |
|
||||
| ❌ Runaway loops waste hundreds of dollars of tokens and max your quota before you even know what happened. | ✅ Cost tracking surfaces token budgets and throttles agents when they're out. Management prioritizes with budgets. |
|
||||
| ❌ You have recurring jobs (customer support, social, reports) and have to remember to manually kick them off. | ✅ Heartbeats handle regular work on a schedule. Management supervises. |
|
||||
| ❌ You have an idea, you have to find your repo, fire up Claude Code, keep a tab open, and babysit it. | ✅ Add a task in Paperclip. Your coding agent works on it until it's done. Management reviews their work. |
|
||||
|
||||
<br/>
|
||||
|
||||
## Why Paperclip is special
|
||||
|
||||
Paperclip handles the hard orchestration details correctly.
|
||||
|
||||
| | |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| **Atomic execution.** | Task checkout and budget enforcement are atomic, so no double-work and no runaway spend. |
|
||||
| **Persistent agent state.** | Agents resume the same task context across heartbeats instead of restarting from scratch. |
|
||||
| **Runtime skill injection.** | Agents can learn Paperclip workflows and project context at runtime, without retraining. |
|
||||
| **Governance with rollback.** | Approval gates are enforced, config changes are revisioned, and bad changes can be rolled back safely. |
|
||||
| **Goal-aware execution.** | Tasks carry full goal ancestry so agents consistently see the "why," not just a title. |
|
||||
| **Portable company templates.** | Export/import orgs, agents, and skills with secret scrubbing and collision handling. |
|
||||
| **True multi-company isolation.** | Every entity is company-scoped, so one deployment can run many companies with separate data and audit trails. |
|
||||
|
||||
<br/>
|
||||
|
||||
## What Paperclip is not
|
||||
|
||||
| | |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Not a chatbot.** | Agents have jobs, not chat windows. |
|
||||
| **Not an agent framework.** | We don't tell you how to build agents. We tell you how to run a company made of them. |
|
||||
| **Not a workflow builder.** | No drag-and-drop pipelines. Paperclip models companies — with org charts, goals, budgets, and governance. |
|
||||
| **Not a prompt manager.** | Agents bring their own prompts, models, and runtimes. Paperclip manages the organization they work in. |
|
||||
| **Not a single-agent tool.** | This is for teams. If you have one agent, you probably don't need Paperclip. If you have twenty — you definitely do. |
|
||||
| **Not a code review tool.** | Paperclip orchestrates work, not pull requests. Bring your own review process. |
|
||||
|
||||
<br/>
|
||||
|
||||
## Quickstart
|
||||
|
||||
Open source. Self-hosted. No Paperclip account required.
|
||||
|
||||
```bash
|
||||
npx paperclipai onboard --yes
|
||||
```
|
||||
|
||||
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/paperclipai/paperclip.git
|
||||
cd paperclip
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required.
|
||||
|
||||
> **Requirements:** Node.js 20+, pnpm 9.15+
|
||||
|
||||
<br/>
|
||||
|
||||
## FAQ
|
||||
|
||||
**What does a typical setup look like?**
|
||||
Locally, a single Node.js process manages an embedded Postgres and local file storage. For production, point it at your own Postgres and deploy however you like. Configure projects, agents, and goals — the agents take care of the rest.
|
||||
|
||||
If you're a solo-entreprenuer you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it.
|
||||
|
||||
**Can I run multiple companies?**
|
||||
Yes. A single deployment can run an unlimited number of companies with complete data isolation.
|
||||
|
||||
**How is Paperclip different from agents like OpenClaw or Claude Code?**
|
||||
Paperclip _uses_ those agents. It orchestrates them into a company — with org charts, budgets, goals, governance, and accountability.
|
||||
|
||||
**Why should I use Paperclip instead of just pointing my OpenClaw to Asana or Trello?**
|
||||
Agent orchestration has subtleties in how you coordinate who has work checked out, how to maintain sessions, monitoring costs, establishing governance - Paperclip does this for you.
|
||||
|
||||
(Bring-your-own-ticket-system is on the Roadmap)
|
||||
|
||||
**Do agents run continuously?**
|
||||
By default, agents run on scheduled heartbeats and event-based triggers (task assignment, @-mentions). You can also hook in continuous agents like OpenClaw. You bring your agent and Paperclip coordinates.
|
||||
|
||||
<br/>
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm dev # Full dev (API + UI, watch mode)
|
||||
pnpm dev:once # Full dev without file watching
|
||||
pnpm dev:server # Server only
|
||||
pnpm build # Build all
|
||||
pnpm typecheck # Type checking
|
||||
pnpm test:run # Run tests
|
||||
pnpm db:generate # Generate DB migration
|
||||
pnpm db:migrate # Apply migrations
|
||||
```
|
||||
|
||||
See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide.
|
||||
|
||||
<br/>
|
||||
|
||||
## Roadmap
|
||||
|
||||
- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc)
|
||||
- ✅ Get OpenClaw / claw-style agent employees
|
||||
- ✅ companies.sh - import and export entire organizations
|
||||
- ✅ Easy AGENTS.md configurations
|
||||
- ✅ Skills Manager
|
||||
- ✅ Scheduled Routines
|
||||
- ✅ Better Budgeting
|
||||
- ⚪ Artifacts & Deployments
|
||||
- ⚪ CEO Chat
|
||||
- ⚪ MAXIMIZER MODE
|
||||
- ⚪ Multiple Human Users
|
||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
||||
- ⚪ Cloud deployments
|
||||
- ⚪ Desktop App
|
||||
|
||||
<br/>
|
||||
|
||||
## Community & Plugins
|
||||
|
||||
Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip)
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions. See the [contributing guide](https://github.com/paperclipai/paperclip/blob/master/CONTRIBUTING.md) for details.
|
||||
|
||||
<br/>
|
||||
|
||||
## Community
|
||||
|
||||
- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community
|
||||
- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests
|
||||
- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC
|
||||
|
||||
<br/>
|
||||
|
||||
## License
|
||||
|
||||
MIT © 2026 Paperclip
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left)
|
||||
|
||||
<br/>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/paperclipai/paperclip/master/doc/assets/footer.jpg" alt="" width="720" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>Open source under MIT. Built for people who want to run companies, not babysit agents.</sub>
|
||||
</p>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperclipai",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -16,10 +16,13 @@
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip.git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "cli"
|
||||
},
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
@@ -37,6 +40,7 @@
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "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:*",
|
||||
|
||||
@@ -4,7 +4,9 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureAgentJwtSecret,
|
||||
mergePaperclipEnvEntries,
|
||||
readAgentJwtSecretFromEnv,
|
||||
readPaperclipEnvEntries,
|
||||
resolveAgentJwtEnvFile,
|
||||
} from "../config/env.js";
|
||||
import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js";
|
||||
@@ -58,4 +60,20 @@ describe("agent jwt env helpers", () => {
|
||||
const result = agentJwtSecretCheck(configPath);
|
||||
expect(result.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("quotes hash-prefixed env values so dotenv round-trips them", () => {
|
||||
const configPath = tempConfigPath();
|
||||
const envPath = resolveAgentJwtEnvFile(configPath);
|
||||
|
||||
mergePaperclipEnvEntries(
|
||||
{
|
||||
PAPERCLIP_WORKTREE_COLOR: "#439edb",
|
||||
},
|
||||
envPath,
|
||||
);
|
||||
|
||||
const contents = fs.readFileSync(envPath, "utf-8");
|
||||
expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"');
|
||||
expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,9 @@ function writeBaseConfig(configPath: string) {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: { baseDir: "/tmp/paperclip-storage" },
|
||||
|
||||
16
cli/src/__tests__/auth-command-registration.test.ts
Normal file
16
cli/src/__tests__/auth-command-registration.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Command } from "commander";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { registerClientAuthCommands } from "../commands/client/auth.js";
|
||||
|
||||
describe("registerClientAuthCommands", () => {
|
||||
it("registers auth commands without duplicate company-id flags", () => {
|
||||
const program = new Command();
|
||||
const auth = program.command("auth");
|
||||
|
||||
expect(() => registerClientAuthCommands(auth)).not.toThrow();
|
||||
|
||||
const login = auth.commands.find((command) => command.name() === "login");
|
||||
expect(login).toBeDefined();
|
||||
expect(login?.options.filter((option) => option.long === "--company-id")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
53
cli/src/__tests__/board-auth.test.ts
Normal file
53
cli/src/__tests__/board-auth.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getStoredBoardCredential,
|
||||
readBoardAuthStore,
|
||||
removeStoredBoardCredential,
|
||||
setStoredBoardCredential,
|
||||
} from "../client/board-auth.js";
|
||||
|
||||
function createTempAuthPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-auth-"));
|
||||
return path.join(dir, "auth.json");
|
||||
}
|
||||
|
||||
describe("board auth store", () => {
|
||||
it("returns an empty store when the file does not exist", () => {
|
||||
const authPath = createTempAuthPath();
|
||||
expect(readBoardAuthStore(authPath)).toEqual({
|
||||
version: 1,
|
||||
credentials: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("stores and retrieves credentials by normalized api base", () => {
|
||||
const authPath = createTempAuthPath();
|
||||
setStoredBoardCredential({
|
||||
apiBase: "http://localhost:3100/",
|
||||
token: "token-123",
|
||||
userId: "user-1",
|
||||
storePath: authPath,
|
||||
});
|
||||
|
||||
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toMatchObject({
|
||||
apiBase: "http://localhost:3100",
|
||||
token: "token-123",
|
||||
userId: "user-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes stored credentials", () => {
|
||||
const authPath = createTempAuthPath();
|
||||
setStoredBoardCredential({
|
||||
apiBase: "http://localhost:3100",
|
||||
token: "token-123",
|
||||
storePath: authPath,
|
||||
});
|
||||
|
||||
expect(removeStoredBoardCredential("http://localhost:3100", authPath)).toBe(true);
|
||||
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,20 @@ function makeCompany(overrides: Partial<Company>): Company {
|
||||
name: "Alpha",
|
||||
description: null,
|
||||
status: "active",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
issuePrefix: "ALP",
|
||||
issueCounter: 1,
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: null,
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
|
||||
502
cli/src/__tests__/company-import-export-e2e.test.ts
Normal file
502
cli/src/__tests__/company-import-export-e2e.test.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { createStoredZipArchive } from "./helpers/zip.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
type ServerProcess = ReturnType<typeof spawn>;
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to allocate test port")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres company import/export e2e tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) {
|
||||
const config = {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "doctor",
|
||||
},
|
||||
database: {
|
||||
mode: "postgres",
|
||||
connectionString,
|
||||
embeddedPostgresDataDir: path.join(tempRoot, "embedded-db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: false,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(tempRoot, "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(tempRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
allowedHostnames: [],
|
||||
serveUi: false,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(tempRoot, "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(tempRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function createServerEnv(configPath: string, port: number, connectionString: string) {
|
||||
const env = { ...process.env };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith("PAPERCLIP_")) {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
delete env.DATABASE_URL;
|
||||
delete env.PORT;
|
||||
delete env.HOST;
|
||||
delete env.SERVE_UI;
|
||||
delete env.HEARTBEAT_SCHEDULER_ENABLED;
|
||||
|
||||
env.PAPERCLIP_CONFIG = configPath;
|
||||
env.DATABASE_URL = connectionString;
|
||||
env.HOST = "127.0.0.1";
|
||||
env.PORT = String(port);
|
||||
env.SERVE_UI = "false";
|
||||
env.PAPERCLIP_DB_BACKUP_ENABLED = "false";
|
||||
env.HEARTBEAT_SCHEDULER_ENABLED = "false";
|
||||
env.PAPERCLIP_MIGRATION_AUTO_APPLY = "true";
|
||||
env.PAPERCLIP_UI_DEV_MIDDLEWARE = "false";
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function createCliEnv() {
|
||||
const env = { ...process.env };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith("PAPERCLIP_")) {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
delete env.DATABASE_URL;
|
||||
delete env.PORT;
|
||||
delete env.HOST;
|
||||
delete env.SERVE_UI;
|
||||
delete env.PAPERCLIP_DB_BACKUP_ENABLED;
|
||||
delete env.HEARTBEAT_SCHEDULER_ENABLED;
|
||||
delete env.PAPERCLIP_MIGRATION_AUTO_APPLY;
|
||||
delete env.PAPERCLIP_UI_DEV_MIDDLEWARE;
|
||||
return env;
|
||||
}
|
||||
|
||||
function collectTextFiles(root: string, current: string, files: Record<string, string>) {
|
||||
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||
const absolutePath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectTextFiles(root, absolutePath, files);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
|
||||
files[relativePath] = readFileSync(absolutePath, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServerProcess(child: ServerProcess | null) {
|
||||
if (!child || child.exitCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => {
|
||||
child.once("exit", () => resolve());
|
||||
setTimeout(() => {
|
||||
if (child.exitCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
async function api<T>(baseUrl: string, pathname: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${baseUrl}${pathname}`, init);
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed ${res.status} ${pathname}: ${text}`);
|
||||
}
|
||||
return text ? JSON.parse(text) as T : (null as T);
|
||||
}
|
||||
|
||||
async function runCliJson<T>(args: string[], opts: { apiBase: string; configPath: string }) {
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const result = await execFileAsync(
|
||||
"pnpm",
|
||||
["--silent", "paperclipai", ...args, "--api-base", opts.apiBase, "--config", opts.configPath, "--json"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: createCliEnv(),
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const stdout = result.stdout.trim();
|
||||
const jsonStart = stdout.search(/[\[{]/);
|
||||
if (jsonStart === -1) {
|
||||
throw new Error(`CLI did not emit JSON.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
}
|
||||
return JSON.parse(stdout.slice(jsonStart)) as T;
|
||||
}
|
||||
|
||||
async function waitForServer(
|
||||
apiBase: string,
|
||||
child: ServerProcess,
|
||||
output: { stdout: string[]; stderr: string[] },
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 30_000) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(
|
||||
`paperclipai run exited before healthcheck succeeded.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/health`);
|
||||
if (res.ok) return;
|
||||
} catch {
|
||||
// Server is still starting.
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for ${apiBase}/api/health.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("paperclipai company import/export e2e", () => {
|
||||
let tempRoot = "";
|
||||
let configPath = "";
|
||||
let exportDir = "";
|
||||
let apiBase = "";
|
||||
let serverProcess: ServerProcess | null = null;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-"));
|
||||
configPath = path.join(tempRoot, "config", "config.json");
|
||||
exportDir = path.join(tempRoot, "exported-company");
|
||||
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-");
|
||||
|
||||
const port = await getAvailablePort();
|
||||
writeTestConfig(configPath, tempRoot, port, tempDb.connectionString);
|
||||
apiBase = `http://127.0.0.1:${port}`;
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const output = { stdout: [] as string[], stderr: [] as string[] };
|
||||
const child = spawn(
|
||||
"pnpm",
|
||||
["paperclipai", "run", "--config", configPath],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: createServerEnv(configPath, port, tempDb.connectionString),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
serverProcess = child;
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
output.stdout.push(String(chunk));
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
output.stderr.push(String(chunk));
|
||||
});
|
||||
|
||||
await waitForServer(apiBase, child, output);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await stopServerProcess(serverProcess);
|
||||
await tempDb?.cleanup();
|
||||
if (tempRoot) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("exports a company package and imports it into new and existing companies", async () => {
|
||||
expect(serverProcess).not.toBeNull();
|
||||
|
||||
const sourceCompany = await api<{ id: string; name: string; issuePrefix: string }>(apiBase, "/api/companies", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: `CLI Export Source ${Date.now()}` }),
|
||||
});
|
||||
|
||||
const sourceAgent = await api<{ id: string; name: string }>(
|
||||
apiBase,
|
||||
`/api/companies/${sourceCompany.id}/agents`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Export Engineer",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
promptTemplate: "You verify company portability.",
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const sourceProject = await api<{ id: string; name: string }>(
|
||||
apiBase,
|
||||
`/api/companies/${sourceCompany.id}/projects`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Portability Verification",
|
||||
status: "in_progress",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const largeIssueDescription = `Round-trip the company package through the CLI.\n\n${"portable-data ".repeat(12_000)}`;
|
||||
|
||||
const sourceIssue = await api<{ id: string; title: string; identifier: string }>(
|
||||
apiBase,
|
||||
`/api/companies/${sourceCompany.id}/issues`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: "Validate company import/export",
|
||||
description: largeIssueDescription,
|
||||
status: "todo",
|
||||
projectId: sourceProject.id,
|
||||
assigneeAgentId: sourceAgent.id,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const exportResult = await runCliJson<{
|
||||
ok: boolean;
|
||||
out: string;
|
||||
filesWritten: number;
|
||||
}>(
|
||||
[
|
||||
"company",
|
||||
"export",
|
||||
sourceCompany.id,
|
||||
"--out",
|
||||
exportDir,
|
||||
"--include",
|
||||
"company,agents,projects,issues",
|
||||
],
|
||||
{ apiBase, configPath },
|
||||
);
|
||||
|
||||
expect(exportResult.ok).toBe(true);
|
||||
expect(exportResult.filesWritten).toBeGreaterThan(0);
|
||||
expect(readFileSync(path.join(exportDir, "COMPANY.md"), "utf8")).toContain(sourceCompany.name);
|
||||
expect(readFileSync(path.join(exportDir, ".paperclip.yaml"), "utf8")).toContain('schema: "paperclip/v1"');
|
||||
|
||||
const importedNew = await runCliJson<{
|
||||
company: { id: string; name: string; action: string };
|
||||
agents: Array<{ id: string | null; action: string; name: string }>;
|
||||
}>(
|
||||
[
|
||||
"company",
|
||||
"import",
|
||||
exportDir,
|
||||
"--target",
|
||||
"new",
|
||||
"--new-company-name",
|
||||
`Imported ${sourceCompany.name}`,
|
||||
"--include",
|
||||
"company,agents,projects,issues",
|
||||
"--yes",
|
||||
],
|
||||
{ apiBase, configPath },
|
||||
);
|
||||
|
||||
expect(importedNew.company.action).toBe("created");
|
||||
expect(importedNew.agents).toHaveLength(1);
|
||||
expect(importedNew.agents[0]?.action).toBe("created");
|
||||
|
||||
const importedAgents = await api<Array<{ id: string; name: string }>>(
|
||||
apiBase,
|
||||
`/api/companies/${importedNew.company.id}/agents`,
|
||||
);
|
||||
const importedProjects = await api<Array<{ id: string; name: string }>>(
|
||||
apiBase,
|
||||
`/api/companies/${importedNew.company.id}/projects`,
|
||||
);
|
||||
const importedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
|
||||
apiBase,
|
||||
`/api/companies/${importedNew.company.id}/issues`,
|
||||
);
|
||||
|
||||
expect(importedAgents.map((agent) => agent.name)).toContain(sourceAgent.name);
|
||||
expect(importedProjects.map((project) => project.name)).toContain(sourceProject.name);
|
||||
expect(importedIssues.map((issue) => issue.title)).toContain(sourceIssue.title);
|
||||
|
||||
const previewExisting = await runCliJson<{
|
||||
errors: string[];
|
||||
plan: {
|
||||
companyAction: string;
|
||||
agentPlans: Array<{ action: string }>;
|
||||
projectPlans: Array<{ action: string }>;
|
||||
issuePlans: Array<{ action: string }>;
|
||||
};
|
||||
}>(
|
||||
[
|
||||
"company",
|
||||
"import",
|
||||
exportDir,
|
||||
"--target",
|
||||
"existing",
|
||||
"--company-id",
|
||||
importedNew.company.id,
|
||||
"--include",
|
||||
"company,agents,projects,issues",
|
||||
"--collision",
|
||||
"rename",
|
||||
"--dry-run",
|
||||
],
|
||||
{ apiBase, configPath },
|
||||
);
|
||||
|
||||
expect(previewExisting.errors).toEqual([]);
|
||||
expect(previewExisting.plan.companyAction).toBe("none");
|
||||
expect(previewExisting.plan.agentPlans.some((plan) => plan.action === "create")).toBe(true);
|
||||
expect(previewExisting.plan.projectPlans.some((plan) => plan.action === "create")).toBe(true);
|
||||
expect(previewExisting.plan.issuePlans.some((plan) => plan.action === "create")).toBe(true);
|
||||
|
||||
const importedExisting = await runCliJson<{
|
||||
company: { id: string; action: string };
|
||||
agents: Array<{ id: string | null; action: string; name: string }>;
|
||||
}>(
|
||||
[
|
||||
"company",
|
||||
"import",
|
||||
exportDir,
|
||||
"--target",
|
||||
"existing",
|
||||
"--company-id",
|
||||
importedNew.company.id,
|
||||
"--include",
|
||||
"company,agents,projects,issues",
|
||||
"--collision",
|
||||
"rename",
|
||||
"--yes",
|
||||
],
|
||||
{ apiBase, configPath },
|
||||
);
|
||||
|
||||
expect(importedExisting.company.action).toBe("unchanged");
|
||||
expect(importedExisting.agents.some((agent) => agent.action === "created")).toBe(true);
|
||||
|
||||
const twiceImportedAgents = await api<Array<{ id: string; name: string }>>(
|
||||
apiBase,
|
||||
`/api/companies/${importedNew.company.id}/agents`,
|
||||
);
|
||||
const twiceImportedProjects = await api<Array<{ id: string; name: string }>>(
|
||||
apiBase,
|
||||
`/api/companies/${importedNew.company.id}/projects`,
|
||||
);
|
||||
const twiceImportedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
|
||||
apiBase,
|
||||
`/api/companies/${importedNew.company.id}/issues`,
|
||||
);
|
||||
|
||||
expect(twiceImportedAgents).toHaveLength(2);
|
||||
expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2);
|
||||
expect(twiceImportedProjects).toHaveLength(2);
|
||||
expect(twiceImportedIssues).toHaveLength(2);
|
||||
|
||||
const zipPath = path.join(tempRoot, "exported-company.zip");
|
||||
const portableFiles: Record<string, string> = {};
|
||||
collectTextFiles(exportDir, exportDir, portableFiles);
|
||||
writeFileSync(zipPath, createStoredZipArchive(portableFiles, "paperclip-demo"));
|
||||
|
||||
const importedFromZip = await runCliJson<{
|
||||
company: { id: string; name: string; action: string };
|
||||
agents: Array<{ id: string | null; action: string; name: string }>;
|
||||
}>(
|
||||
[
|
||||
"company",
|
||||
"import",
|
||||
zipPath,
|
||||
"--target",
|
||||
"new",
|
||||
"--new-company-name",
|
||||
`Zip Imported ${sourceCompany.name}`,
|
||||
"--include",
|
||||
"company,agents,projects,issues",
|
||||
"--yes",
|
||||
],
|
||||
{ apiBase, configPath },
|
||||
);
|
||||
|
||||
expect(importedFromZip.company.action).toBe("created");
|
||||
expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true);
|
||||
}, 60_000);
|
||||
});
|
||||
74
cli/src/__tests__/company-import-url.test.ts
Normal file
74
cli/src/__tests__/company-import-url.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isGithubShorthand,
|
||||
looksLikeRepoUrl,
|
||||
isHttpUrl,
|
||||
normalizeGithubImportSource,
|
||||
} from "../commands/client/company.js";
|
||||
|
||||
describe("isHttpUrl", () => {
|
||||
it("matches http URLs", () => {
|
||||
expect(isHttpUrl("http://example.com/foo")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches https URLs", () => {
|
||||
expect(isHttpUrl("https://example.com/foo")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects local paths", () => {
|
||||
expect(isHttpUrl("/tmp/my-company")).toBe(false);
|
||||
expect(isHttpUrl("./relative")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeRepoUrl", () => {
|
||||
it("matches GitHub URLs", () => {
|
||||
expect(looksLikeRepoUrl("https://github.com/org/repo")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects URLs without owner/repo path", () => {
|
||||
expect(looksLikeRepoUrl("https://example.com/foo")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects local paths", () => {
|
||||
expect(looksLikeRepoUrl("/tmp/my-company")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGithubShorthand", () => {
|
||||
it("matches owner/repo/path shorthands", () => {
|
||||
expect(isGithubShorthand("paperclipai/companies/gstack")).toBe(true);
|
||||
expect(isGithubShorthand("paperclipai/companies")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects local-looking paths", () => {
|
||||
expect(isGithubShorthand("./exports/acme")).toBe(false);
|
||||
expect(isGithubShorthand("/tmp/acme")).toBe(false);
|
||||
expect(isGithubShorthand("C:\\temp\\acme")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeGithubImportSource", () => {
|
||||
it("normalizes shorthand imports to canonical GitHub sources", () => {
|
||||
expect(normalizeGithubImportSource("paperclipai/companies/gstack")).toBe(
|
||||
"https://github.com/paperclipai/companies?ref=main&path=gstack",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies --ref to shorthand imports", () => {
|
||||
expect(normalizeGithubImportSource("paperclipai/companies/gstack", "feature/demo")).toBe(
|
||||
"https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies --ref to existing GitHub tree URLs without losing the package path", () => {
|
||||
expect(
|
||||
normalizeGithubImportSource(
|
||||
"https://github.com/paperclipai/companies/tree/main/gstack",
|
||||
"release/2026-03-23",
|
||||
),
|
||||
).toBe(
|
||||
"https://github.com/paperclipai/companies?ref=release%2F2026-03-23&path=gstack",
|
||||
);
|
||||
});
|
||||
});
|
||||
44
cli/src/__tests__/company-import-zip.test.ts
Normal file
44
cli/src/__tests__/company-import-zip.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveInlineSourceFromPath } from "../commands/client/company.js";
|
||||
import { createStoredZipArchive } from "./helpers/zip.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveInlineSourceFromPath", () => {
|
||||
it("imports portable files from a zip archive instead of scanning the parent directory", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-company-import-zip-"));
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
const archivePath = path.join(tempDir, "paperclip-demo.zip");
|
||||
const archive = createStoredZipArchive(
|
||||
{
|
||||
"COMPANY.md": "# Company\n",
|
||||
".paperclip.yaml": "schema: paperclip/v1\n",
|
||||
"agents/ceo/AGENT.md": "# CEO\n",
|
||||
"notes/todo.txt": "ignore me\n",
|
||||
},
|
||||
"paperclip-demo",
|
||||
);
|
||||
await writeFile(archivePath, archive);
|
||||
|
||||
const resolved = await resolveInlineSourceFromPath(archivePath);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
rootPath: "paperclip-demo",
|
||||
files: {
|
||||
"COMPANY.md": "# Company\n",
|
||||
".paperclip.yaml": "schema: paperclip/v1\n",
|
||||
"agents/ceo/AGENT.md": "# CEO\n",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
595
cli/src/__tests__/company.test.ts
Normal file
595
cli/src/__tests__/company.test.ts
Normal file
@@ -0,0 +1,595 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared";
|
||||
import {
|
||||
buildCompanyDashboardUrl,
|
||||
buildDefaultImportAdapterOverrides,
|
||||
buildDefaultImportSelectionState,
|
||||
buildImportSelectionCatalog,
|
||||
buildSelectedFilesFromImportSelection,
|
||||
renderCompanyImportPreview,
|
||||
renderCompanyImportResult,
|
||||
resolveCompanyImportApplyConfirmationMode,
|
||||
resolveCompanyImportApiPath,
|
||||
} from "../commands/client/company.js";
|
||||
|
||||
describe("resolveCompanyImportApiPath", () => {
|
||||
it("uses company-scoped preview route for existing-company dry runs", () => {
|
||||
expect(
|
||||
resolveCompanyImportApiPath({
|
||||
dryRun: true,
|
||||
targetMode: "existing_company",
|
||||
companyId: "company-123",
|
||||
}),
|
||||
).toBe("/api/companies/company-123/imports/preview");
|
||||
});
|
||||
|
||||
it("uses company-scoped apply route for existing-company imports", () => {
|
||||
expect(
|
||||
resolveCompanyImportApiPath({
|
||||
dryRun: false,
|
||||
targetMode: "existing_company",
|
||||
companyId: "company-123",
|
||||
}),
|
||||
).toBe("/api/companies/company-123/imports/apply");
|
||||
});
|
||||
|
||||
it("keeps global routes for new-company imports", () => {
|
||||
expect(
|
||||
resolveCompanyImportApiPath({
|
||||
dryRun: true,
|
||||
targetMode: "new_company",
|
||||
}),
|
||||
).toBe("/api/companies/import/preview");
|
||||
|
||||
expect(
|
||||
resolveCompanyImportApiPath({
|
||||
dryRun: false,
|
||||
targetMode: "new_company",
|
||||
}),
|
||||
).toBe("/api/companies/import");
|
||||
});
|
||||
|
||||
it("throws when an existing-company import is missing a company id", () => {
|
||||
expect(() =>
|
||||
resolveCompanyImportApiPath({
|
||||
dryRun: true,
|
||||
targetMode: "existing_company",
|
||||
companyId: " ",
|
||||
})
|
||||
).toThrow(/require a companyId/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCompanyImportApplyConfirmationMode", () => {
|
||||
it("skips confirmation when --yes is set", () => {
|
||||
expect(
|
||||
resolveCompanyImportApplyConfirmationMode({
|
||||
yes: true,
|
||||
interactive: false,
|
||||
json: false,
|
||||
}),
|
||||
).toBe("skip");
|
||||
});
|
||||
|
||||
it("prompts in interactive text mode when --yes is not set", () => {
|
||||
expect(
|
||||
resolveCompanyImportApplyConfirmationMode({
|
||||
yes: false,
|
||||
interactive: true,
|
||||
json: false,
|
||||
}),
|
||||
).toBe("prompt");
|
||||
});
|
||||
|
||||
it("requires --yes for non-interactive apply", () => {
|
||||
expect(() =>
|
||||
resolveCompanyImportApplyConfirmationMode({
|
||||
yes: false,
|
||||
interactive: false,
|
||||
json: false,
|
||||
})
|
||||
).toThrow(/non-interactive terminal requires --yes/i);
|
||||
});
|
||||
|
||||
it("requires --yes for json apply", () => {
|
||||
expect(() =>
|
||||
resolveCompanyImportApplyConfirmationMode({
|
||||
yes: false,
|
||||
interactive: false,
|
||||
json: true,
|
||||
})
|
||||
).toThrow(/with --json requires --yes/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCompanyDashboardUrl", () => {
|
||||
it("preserves the configured base path when building a dashboard URL", () => {
|
||||
expect(buildCompanyDashboardUrl("https://paperclip.example/app/", "PAP")).toBe(
|
||||
"https://paperclip.example/app/PAP/dashboard",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderCompanyImportPreview", () => {
|
||||
it("summarizes the preview with counts, selection info, and truncated examples", () => {
|
||||
const preview: CompanyPortabilityPreviewResult = {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: true,
|
||||
skills: true,
|
||||
},
|
||||
targetCompanyId: "company-123",
|
||||
targetCompanyName: "Imported Co",
|
||||
collisionStrategy: "rename",
|
||||
selectedAgentSlugs: ["ceo", "cto", "eng-1", "eng-2", "eng-3", "eng-4", "eng-5"],
|
||||
plan: {
|
||||
companyAction: "update",
|
||||
agentPlans: [
|
||||
{ slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null },
|
||||
{ slug: "cto", action: "update", plannedName: "CTO", existingAgentId: "agent-2", reason: "replace strategy" },
|
||||
{ slug: "eng-1", action: "skip", plannedName: "Engineer 1", existingAgentId: "agent-3", reason: "skip strategy" },
|
||||
{ slug: "eng-2", action: "create", plannedName: "Engineer 2", existingAgentId: null, reason: null },
|
||||
{ slug: "eng-3", action: "create", plannedName: "Engineer 3", existingAgentId: null, reason: null },
|
||||
{ slug: "eng-4", action: "create", plannedName: "Engineer 4", existingAgentId: null, reason: null },
|
||||
{ slug: "eng-5", action: "create", plannedName: "Engineer 5", existingAgentId: null, reason: null },
|
||||
],
|
||||
projectPlans: [
|
||||
{ slug: "alpha", action: "create", plannedName: "Alpha", existingProjectId: null, reason: null },
|
||||
],
|
||||
issuePlans: [
|
||||
{ slug: "kickoff", action: "create", plannedTitle: "Kickoff", reason: null },
|
||||
],
|
||||
},
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-03-23T17:00:00.000Z",
|
||||
source: {
|
||||
companyId: "company-src",
|
||||
companyName: "Source Co",
|
||||
},
|
||||
includes: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: true,
|
||||
skills: true,
|
||||
},
|
||||
company: {
|
||||
path: "COMPANY.md",
|
||||
name: "Source Co",
|
||||
description: null,
|
||||
brandColor: null,
|
||||
logoPath: null,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
},
|
||||
sidebar: {
|
||||
agents: ["ceo"],
|
||||
projects: ["alpha"],
|
||||
},
|
||||
agents: [
|
||||
{
|
||||
slug: "ceo",
|
||||
name: "CEO",
|
||||
path: "agents/ceo/AGENT.md",
|
||||
skills: [],
|
||||
role: "ceo",
|
||||
title: null,
|
||||
icon: null,
|
||||
capabilities: null,
|
||||
reportsToSlug: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
budgetMonthlyCents: 0,
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
key: "skill-a",
|
||||
slug: "skill-a",
|
||||
name: "Skill A",
|
||||
path: "skills/skill-a/SKILL.md",
|
||||
description: null,
|
||||
sourceType: "inline",
|
||||
sourceLocator: null,
|
||||
sourceRef: null,
|
||||
trustLevel: null,
|
||||
compatibility: null,
|
||||
metadata: null,
|
||||
fileInventory: [],
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
slug: "alpha",
|
||||
name: "Alpha",
|
||||
path: "projects/alpha/PROJECT.md",
|
||||
description: null,
|
||||
ownerAgentSlug: null,
|
||||
leadAgentSlug: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
status: null,
|
||||
executionWorkspacePolicy: null,
|
||||
workspaces: [],
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
issues: [
|
||||
{
|
||||
slug: "kickoff",
|
||||
identifier: null,
|
||||
title: "Kickoff",
|
||||
path: "projects/alpha/issues/kickoff/TASK.md",
|
||||
projectSlug: "alpha",
|
||||
projectWorkspaceKey: null,
|
||||
assigneeAgentSlug: "ceo",
|
||||
description: null,
|
||||
recurring: false,
|
||||
routine: null,
|
||||
legacyRecurrence: null,
|
||||
status: null,
|
||||
priority: null,
|
||||
labelIds: [],
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
envInputs: [
|
||||
{
|
||||
key: "OPENAI_API_KEY",
|
||||
description: null,
|
||||
agentSlug: "ceo",
|
||||
kind: "secret",
|
||||
requirement: "required",
|
||||
defaultValue: null,
|
||||
portability: "portable",
|
||||
},
|
||||
],
|
||||
},
|
||||
files: {
|
||||
"COMPANY.md": "# Source Co",
|
||||
},
|
||||
envInputs: [
|
||||
{
|
||||
key: "OPENAI_API_KEY",
|
||||
description: null,
|
||||
agentSlug: "ceo",
|
||||
kind: "secret",
|
||||
requirement: "required",
|
||||
defaultValue: null,
|
||||
portability: "portable",
|
||||
},
|
||||
],
|
||||
warnings: ["One warning"],
|
||||
errors: ["One error"],
|
||||
};
|
||||
|
||||
const rendered = renderCompanyImportPreview(preview, {
|
||||
sourceLabel: "GitHub: https://github.com/paperclipai/companies/demo",
|
||||
targetLabel: "Imported Co (company-123)",
|
||||
infoMessages: ["Using claude-local adapter"],
|
||||
});
|
||||
|
||||
expect(rendered).toContain("Include");
|
||||
expect(rendered).toContain("company, projects, tasks, agents, skills");
|
||||
expect(rendered).toContain("7 agents total");
|
||||
expect(rendered).toContain("1 project total");
|
||||
expect(rendered).toContain("1 task total");
|
||||
expect(rendered).toContain("skills: 1 skill packaged");
|
||||
expect(rendered).toContain("+1 more");
|
||||
expect(rendered).toContain("Using claude-local adapter");
|
||||
expect(rendered).toContain("Warnings");
|
||||
expect(rendered).toContain("Errors");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderCompanyImportResult", () => {
|
||||
it("summarizes import results with created, updated, and skipped counts", () => {
|
||||
const rendered = renderCompanyImportResult(
|
||||
{
|
||||
company: {
|
||||
id: "company-123",
|
||||
name: "Imported Co",
|
||||
action: "updated",
|
||||
},
|
||||
agents: [
|
||||
{ slug: "ceo", id: "agent-1", action: "created", name: "CEO", reason: null },
|
||||
{ slug: "cto", id: "agent-2", action: "updated", name: "CTO", reason: "replace strategy" },
|
||||
{ slug: "ops", id: null, action: "skipped", name: "Ops", reason: "skip strategy" },
|
||||
],
|
||||
projects: [
|
||||
{ slug: "app", id: "project-1", action: "created", name: "App", reason: null },
|
||||
{ slug: "ops", id: "project-2", action: "updated", name: "Operations", reason: "replace strategy" },
|
||||
{ slug: "archive", id: null, action: "skipped", name: "Archive", reason: "skip strategy" },
|
||||
],
|
||||
envInputs: [],
|
||||
warnings: ["Review API keys"],
|
||||
},
|
||||
{
|
||||
targetLabel: "Imported Co (company-123)",
|
||||
companyUrl: "https://paperclip.example/PAP/dashboard",
|
||||
infoMessages: ["Using claude-local adapter"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered).toContain("Company");
|
||||
expect(rendered).toContain("https://paperclip.example/PAP/dashboard");
|
||||
expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)");
|
||||
expect(rendered).toContain("3 projects total (1 created, 1 updated, 1 skipped)");
|
||||
expect(rendered).toContain("Agent results");
|
||||
expect(rendered).toContain("Project results");
|
||||
expect(rendered).toContain("Using claude-local adapter");
|
||||
expect(rendered).toContain("Review API keys");
|
||||
});
|
||||
});
|
||||
|
||||
describe("import selection catalog", () => {
|
||||
it("defaults to everything and keeps project selection separate from task selection", () => {
|
||||
const preview: CompanyPortabilityPreviewResult = {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: true,
|
||||
skills: true,
|
||||
},
|
||||
targetCompanyId: "company-123",
|
||||
targetCompanyName: "Imported Co",
|
||||
collisionStrategy: "rename",
|
||||
selectedAgentSlugs: ["ceo"],
|
||||
plan: {
|
||||
companyAction: "create",
|
||||
agentPlans: [],
|
||||
projectPlans: [],
|
||||
issuePlans: [],
|
||||
},
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-03-23T18:00:00.000Z",
|
||||
source: {
|
||||
companyId: "company-src",
|
||||
companyName: "Source Co",
|
||||
},
|
||||
includes: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: true,
|
||||
skills: true,
|
||||
},
|
||||
company: {
|
||||
path: "COMPANY.md",
|
||||
name: "Source Co",
|
||||
description: null,
|
||||
brandColor: null,
|
||||
logoPath: "images/company-logo.png",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
},
|
||||
sidebar: {
|
||||
agents: ["ceo"],
|
||||
projects: ["alpha"],
|
||||
},
|
||||
agents: [
|
||||
{
|
||||
slug: "ceo",
|
||||
name: "CEO",
|
||||
path: "agents/ceo/AGENT.md",
|
||||
skills: [],
|
||||
role: "ceo",
|
||||
title: null,
|
||||
icon: null,
|
||||
capabilities: null,
|
||||
reportsToSlug: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
budgetMonthlyCents: 0,
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
key: "skill-a",
|
||||
slug: "skill-a",
|
||||
name: "Skill A",
|
||||
path: "skills/skill-a/SKILL.md",
|
||||
description: null,
|
||||
sourceType: "inline",
|
||||
sourceLocator: null,
|
||||
sourceRef: null,
|
||||
trustLevel: null,
|
||||
compatibility: null,
|
||||
metadata: null,
|
||||
fileInventory: [{ path: "skills/skill-a/helper.md", kind: "doc" }],
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
slug: "alpha",
|
||||
name: "Alpha",
|
||||
path: "projects/alpha/PROJECT.md",
|
||||
description: null,
|
||||
ownerAgentSlug: null,
|
||||
leadAgentSlug: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
status: null,
|
||||
executionWorkspacePolicy: null,
|
||||
workspaces: [],
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
issues: [
|
||||
{
|
||||
slug: "kickoff",
|
||||
identifier: null,
|
||||
title: "Kickoff",
|
||||
path: "projects/alpha/issues/kickoff/TASK.md",
|
||||
projectSlug: "alpha",
|
||||
projectWorkspaceKey: null,
|
||||
assigneeAgentSlug: "ceo",
|
||||
description: null,
|
||||
recurring: false,
|
||||
routine: null,
|
||||
legacyRecurrence: null,
|
||||
status: null,
|
||||
priority: null,
|
||||
labelIds: [],
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
envInputs: [],
|
||||
},
|
||||
files: {
|
||||
"COMPANY.md": "# Source Co",
|
||||
"README.md": "# Readme",
|
||||
".paperclip.yaml": "schema: paperclip/v1\n",
|
||||
"images/company-logo.png": {
|
||||
encoding: "base64",
|
||||
data: "",
|
||||
contentType: "image/png",
|
||||
},
|
||||
"projects/alpha/PROJECT.md": "# Alpha",
|
||||
"projects/alpha/notes.md": "project notes",
|
||||
"projects/alpha/issues/kickoff/TASK.md": "# Kickoff",
|
||||
"projects/alpha/issues/kickoff/details.md": "task details",
|
||||
"agents/ceo/AGENT.md": "# CEO",
|
||||
"agents/ceo/prompt.md": "prompt",
|
||||
"skills/skill-a/SKILL.md": "# Skill A",
|
||||
"skills/skill-a/helper.md": "helper",
|
||||
},
|
||||
envInputs: [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const catalog = buildImportSelectionCatalog(preview);
|
||||
const state = buildDefaultImportSelectionState(catalog);
|
||||
|
||||
expect(state.company).toBe(true);
|
||||
expect(state.projects.has("alpha")).toBe(true);
|
||||
expect(state.issues.has("kickoff")).toBe(true);
|
||||
expect(state.agents.has("ceo")).toBe(true);
|
||||
expect(state.skills.has("skill-a")).toBe(true);
|
||||
|
||||
state.company = false;
|
||||
state.issues.clear();
|
||||
state.agents.clear();
|
||||
state.skills.clear();
|
||||
|
||||
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
|
||||
|
||||
expect(selectedFiles).toContain(".paperclip.yaml");
|
||||
expect(selectedFiles).toContain("projects/alpha/PROJECT.md");
|
||||
expect(selectedFiles).toContain("projects/alpha/notes.md");
|
||||
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/TASK.md");
|
||||
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/details.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default adapter overrides", () => {
|
||||
it("maps process-only imported agents to claude_local", () => {
|
||||
const preview: CompanyPortabilityPreviewResult = {
|
||||
include: {
|
||||
company: false,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
skills: false,
|
||||
},
|
||||
targetCompanyId: null,
|
||||
targetCompanyName: null,
|
||||
collisionStrategy: "rename",
|
||||
selectedAgentSlugs: ["legacy-agent", "explicit-agent"],
|
||||
plan: {
|
||||
companyAction: "none",
|
||||
agentPlans: [],
|
||||
projectPlans: [],
|
||||
issuePlans: [],
|
||||
},
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-03-23T18:20:00.000Z",
|
||||
source: null,
|
||||
includes: {
|
||||
company: false,
|
||||
agents: true,
|
||||
projects: false,
|
||||
issues: false,
|
||||
skills: false,
|
||||
},
|
||||
company: null,
|
||||
sidebar: null,
|
||||
agents: [
|
||||
{
|
||||
slug: "legacy-agent",
|
||||
name: "Legacy Agent",
|
||||
path: "agents/legacy-agent/AGENT.md",
|
||||
skills: [],
|
||||
role: "agent",
|
||||
title: null,
|
||||
icon: null,
|
||||
capabilities: null,
|
||||
reportsToSlug: null,
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
budgetMonthlyCents: 0,
|
||||
metadata: null,
|
||||
},
|
||||
{
|
||||
slug: "explicit-agent",
|
||||
name: "Explicit Agent",
|
||||
path: "agents/explicit-agent/AGENT.md",
|
||||
skills: [],
|
||||
role: "agent",
|
||||
title: null,
|
||||
icon: null,
|
||||
capabilities: null,
|
||||
reportsToSlug: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
budgetMonthlyCents: 0,
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
projects: [],
|
||||
issues: [],
|
||||
envInputs: [],
|
||||
},
|
||||
files: {},
|
||||
envInputs: [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
expect(buildDefaultImportAdapterOverrides(preview)).toEqual({
|
||||
"legacy-agent": {
|
||||
adapterType: "claude_local",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,9 @@ function createTempConfig(): string {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
|
||||
177
cli/src/__tests__/feedback.test.ts
Normal file
177
cli/src/__tests__/feedback.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { Command } from "commander";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FeedbackTrace } from "@paperclipai/shared";
|
||||
import { readZipArchive } from "../commands/client/zip.js";
|
||||
import {
|
||||
buildFeedbackTraceQuery,
|
||||
registerFeedbackCommands,
|
||||
renderFeedbackReport,
|
||||
summarizeFeedbackTraces,
|
||||
writeFeedbackExportBundle,
|
||||
} from "../commands/client/feedback.js";
|
||||
|
||||
function makeTrace(overrides: Partial<FeedbackTrace> = {}): FeedbackTrace {
|
||||
return {
|
||||
id: "trace-12345678",
|
||||
companyId: "company-123",
|
||||
feedbackVoteId: "vote-12345678",
|
||||
issueId: "issue-123",
|
||||
projectId: "project-123",
|
||||
issueIdentifier: "PAP-123",
|
||||
issueTitle: "Fix the feedback command",
|
||||
authorUserId: "user-123",
|
||||
targetType: "issue_comment",
|
||||
targetId: "comment-123",
|
||||
vote: "down",
|
||||
status: "pending",
|
||||
destination: "paperclip_labs_feedback_v1",
|
||||
exportId: null,
|
||||
consentVersion: "feedback-data-sharing-v1",
|
||||
schemaVersion: "1",
|
||||
bundleVersion: "1",
|
||||
payloadVersion: "1",
|
||||
payloadDigest: null,
|
||||
payloadSnapshot: {
|
||||
vote: {
|
||||
value: "down",
|
||||
reason: "Needed more detail",
|
||||
},
|
||||
},
|
||||
targetSummary: {
|
||||
label: "Comment",
|
||||
excerpt: "The first answer was too vague.",
|
||||
authorAgentId: "agent-123",
|
||||
authorUserId: null,
|
||||
createdAt: new Date("2026-03-31T12:00:00.000Z"),
|
||||
documentKey: null,
|
||||
documentTitle: null,
|
||||
revisionNumber: null,
|
||||
},
|
||||
redactionSummary: null,
|
||||
attemptCount: 0,
|
||||
lastAttemptedAt: null,
|
||||
exportedAt: null,
|
||||
failureReason: null,
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-03-31T12:02:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerFeedbackCommands", () => {
|
||||
it("registers the top-level feedback commands", () => {
|
||||
const program = new Command();
|
||||
|
||||
expect(() => registerFeedbackCommands(program)).not.toThrow();
|
||||
|
||||
const feedback = program.commands.find((command) => command.name() === "feedback");
|
||||
expect(feedback).toBeDefined();
|
||||
expect(feedback?.commands.map((command) => command.name())).toEqual(["report", "export"]);
|
||||
expect(feedback?.commands[0]?.options.filter((option) => option.long === "--company-id")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeedbackTraceQuery", () => {
|
||||
it("encodes all supported filters", () => {
|
||||
expect(
|
||||
buildFeedbackTraceQuery({
|
||||
targetType: "issue_comment",
|
||||
vote: "down",
|
||||
status: "pending",
|
||||
projectId: "project-123",
|
||||
issueId: "issue-123",
|
||||
from: "2026-03-31T00:00:00.000Z",
|
||||
to: "2026-03-31T23:59:59.999Z",
|
||||
sharedOnly: true,
|
||||
}),
|
||||
).toBe(
|
||||
"?targetType=issue_comment&vote=down&status=pending&projectId=project-123&issueId=issue-123&from=2026-03-31T00%3A00%3A00.000Z&to=2026-03-31T23%3A59%3A59.999Z&sharedOnly=true&includePayload=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderFeedbackReport", () => {
|
||||
it("includes summary counts and the optional reason", () => {
|
||||
const traces = [
|
||||
makeTrace(),
|
||||
makeTrace({
|
||||
id: "trace-87654321",
|
||||
feedbackVoteId: "vote-87654321",
|
||||
vote: "up",
|
||||
status: "local_only",
|
||||
payloadSnapshot: {
|
||||
vote: {
|
||||
value: "up",
|
||||
reason: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const report = renderFeedbackReport({
|
||||
apiBase: "http://127.0.0.1:3100",
|
||||
companyId: "company-123",
|
||||
traces,
|
||||
summary: summarizeFeedbackTraces(traces),
|
||||
includePayloads: false,
|
||||
});
|
||||
|
||||
expect(report).toContain("Paperclip Feedback Report");
|
||||
expect(report).toContain("thumbs up");
|
||||
expect(report).toContain("thumbs down");
|
||||
expect(report).toContain("Needed more detail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeFeedbackExportBundle", () => {
|
||||
it("writes votes, traces, a manifest, and a zip archive", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-feedback-export-"));
|
||||
const outputDir = path.join(tempDir, "feedback-export");
|
||||
const traces = [
|
||||
makeTrace(),
|
||||
makeTrace({
|
||||
id: "trace-abcdef12",
|
||||
feedbackVoteId: "vote-abcdef12",
|
||||
issueIdentifier: "PAP-124",
|
||||
issueId: "issue-124",
|
||||
vote: "up",
|
||||
status: "local_only",
|
||||
payloadSnapshot: {
|
||||
vote: {
|
||||
value: "up",
|
||||
reason: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const exported = await writeFeedbackExportBundle({
|
||||
apiBase: "http://127.0.0.1:3100",
|
||||
companyId: "company-123",
|
||||
traces,
|
||||
outputDir,
|
||||
});
|
||||
|
||||
expect(exported.manifest.summary.total).toBe(2);
|
||||
expect(exported.manifest.summary.withReason).toBe(1);
|
||||
|
||||
const manifest = JSON.parse(await readFile(path.join(outputDir, "index.json"), "utf8")) as {
|
||||
files: { votes: string[]; traces: string[]; zip: string };
|
||||
};
|
||||
expect(manifest.files.votes).toHaveLength(2);
|
||||
expect(manifest.files.traces).toHaveLength(2);
|
||||
|
||||
const archive = await readFile(exported.zipPath);
|
||||
const zip = await readZipArchive(archive);
|
||||
expect(Object.keys(zip.files)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"index.json",
|
||||
`votes/${manifest.files.votes[0]}`,
|
||||
`traces/${manifest.files.traces[0]}`,
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
6
cli/src/__tests__/helpers/embedded-postgres.ts
Normal file
6
cli/src/__tests__/helpers/embedded-postgres.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
type EmbeddedPostgresTestDatabase,
|
||||
type EmbeddedPostgresTestSupport,
|
||||
} from "@paperclipai/db";
|
||||
87
cli/src/__tests__/helpers/zip.ts
Normal file
87
cli/src/__tests__/helpers/zip.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
function writeUint16(target: Uint8Array, offset: number, value: number) {
|
||||
target[offset] = value & 0xff;
|
||||
target[offset + 1] = (value >>> 8) & 0xff;
|
||||
}
|
||||
|
||||
function writeUint32(target: Uint8Array, offset: number, value: number) {
|
||||
target[offset] = value & 0xff;
|
||||
target[offset + 1] = (value >>> 8) & 0xff;
|
||||
target[offset + 2] = (value >>> 16) & 0xff;
|
||||
target[offset + 3] = (value >>> 24) & 0xff;
|
||||
}
|
||||
|
||||
function crc32(bytes: Uint8Array) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
export function createStoredZipArchive(files: Record<string, string>, rootPath: string) {
|
||||
const encoder = new TextEncoder();
|
||||
const localChunks: Uint8Array[] = [];
|
||||
const centralChunks: Uint8Array[] = [];
|
||||
let localOffset = 0;
|
||||
let entryCount = 0;
|
||||
|
||||
for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
|
||||
const body = encoder.encode(content);
|
||||
const checksum = crc32(body);
|
||||
|
||||
const localHeader = new Uint8Array(30 + fileName.length);
|
||||
writeUint32(localHeader, 0, 0x04034b50);
|
||||
writeUint16(localHeader, 4, 20);
|
||||
writeUint16(localHeader, 6, 0x0800);
|
||||
writeUint16(localHeader, 8, 0);
|
||||
writeUint32(localHeader, 14, checksum);
|
||||
writeUint32(localHeader, 18, body.length);
|
||||
writeUint32(localHeader, 22, body.length);
|
||||
writeUint16(localHeader, 26, fileName.length);
|
||||
localHeader.set(fileName, 30);
|
||||
|
||||
const centralHeader = new Uint8Array(46 + fileName.length);
|
||||
writeUint32(centralHeader, 0, 0x02014b50);
|
||||
writeUint16(centralHeader, 4, 20);
|
||||
writeUint16(centralHeader, 6, 20);
|
||||
writeUint16(centralHeader, 8, 0x0800);
|
||||
writeUint16(centralHeader, 10, 0);
|
||||
writeUint32(centralHeader, 16, checksum);
|
||||
writeUint32(centralHeader, 20, body.length);
|
||||
writeUint32(centralHeader, 24, body.length);
|
||||
writeUint16(centralHeader, 28, fileName.length);
|
||||
writeUint32(centralHeader, 42, localOffset);
|
||||
centralHeader.set(fileName, 46);
|
||||
|
||||
localChunks.push(localHeader, body);
|
||||
centralChunks.push(centralHeader);
|
||||
localOffset += localHeader.length + body.length;
|
||||
entryCount += 1;
|
||||
}
|
||||
|
||||
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const archive = new Uint8Array(
|
||||
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
|
||||
);
|
||||
let offset = 0;
|
||||
for (const chunk of localChunks) {
|
||||
archive.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
const centralDirectoryOffset = offset;
|
||||
for (const chunk of centralChunks) {
|
||||
archive.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
writeUint32(archive, offset, 0x06054b50);
|
||||
writeUint16(archive, offset + 8, entryCount);
|
||||
writeUint16(archive, offset + 10, entryCount);
|
||||
writeUint32(archive, offset + 12, centralDirectoryLength);
|
||||
writeUint32(archive, offset + 16, centralDirectoryOffset);
|
||||
|
||||
return archive;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiRequestError, PaperclipApiClient } from "../client/http.js";
|
||||
import { ApiConnectionError, ApiRequestError, PaperclipApiClient } from "../client/http.js";
|
||||
|
||||
describe("PaperclipApiClient", () => {
|
||||
afterEach(() => {
|
||||
@@ -58,4 +58,49 @@ describe("PaperclipApiClient", () => {
|
||||
details: { issueId: "1" },
|
||||
} satisfies Partial<ApiRequestError>);
|
||||
});
|
||||
|
||||
it("throws ApiConnectionError with recovery guidance when fetch fails", async () => {
|
||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError("fetch failed"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
|
||||
|
||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toBeInstanceOf(ApiConnectionError);
|
||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toMatchObject({
|
||||
url: "http://localhost:3100/api/companies/import/preview",
|
||||
method: "POST",
|
||||
causeMessage: "fetch failed",
|
||||
} satisfies Partial<ApiConnectionError>);
|
||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
||||
/Could not reach the Paperclip API\./,
|
||||
);
|
||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
||||
/curl http:\/\/localhost:3100\/api\/health/,
|
||||
);
|
||||
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
|
||||
/pnpm dev|pnpm paperclipai run/,
|
||||
);
|
||||
});
|
||||
|
||||
it("retries once after interactive auth recovery", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Board access required" }), { status: 403 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const recoverAuth = vi.fn().mockResolvedValue("board-token-123");
|
||||
const client = new PaperclipApiClient({
|
||||
apiBase: "http://localhost:3100",
|
||||
recoverAuth,
|
||||
});
|
||||
|
||||
const result = await client.post<{ ok: boolean }>("/api/test", { hello: "world" });
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(recoverAuth).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const retryHeaders = fetchMock.mock.calls[1]?.[1]?.headers as Record<string, string>;
|
||||
expect(retryHeaders.authorization).toBe("Bearer board-token-123");
|
||||
});
|
||||
});
|
||||
|
||||
108
cli/src/__tests__/onboard.test.ts
Normal file
108
cli/src/__tests__/onboard.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { onboard } from "../commands/onboard.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function createExistingConfigFixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-"));
|
||||
const runtimeRoot = path.join(root, "runtime");
|
||||
const configPath = path.join(root, ".paperclip", "config.json");
|
||||
const config: PaperclipConfig = {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-29T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(runtimeRoot, "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(runtimeRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(runtimeRoot, "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
||||
|
||||
return { configPath, configText: fs.readFileSync(configPath, "utf8") };
|
||||
}
|
||||
|
||||
describe("onboard", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it("preserves an existing config when rerun without flags", async () => {
|
||||
const fixture = createExistingConfigFixture();
|
||||
|
||||
await onboard({ config: fixture.configPath });
|
||||
|
||||
expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText);
|
||||
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
|
||||
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves an existing config when rerun with --yes", async () => {
|
||||
const fixture = createExistingConfigFixture();
|
||||
|
||||
await onboard({ config: fixture.configPath, yes: true, invokedByRun: true });
|
||||
|
||||
expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText);
|
||||
expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false);
|
||||
expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true);
|
||||
});
|
||||
});
|
||||
249
cli/src/__tests__/routines.test.ts
Normal file
249
cli/src/__tests__/routines.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
projects,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { disableAllRoutinesInConfig } from "../commands/routines.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres routines CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeTestConfig(configPath: string, tempRoot: string, connectionString: string) {
|
||||
const config = {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: "doctor" as const,
|
||||
},
|
||||
database: {
|
||||
mode: "postgres" as const,
|
||||
connectionString,
|
||||
embeddedPostgresDataDir: path.join(tempRoot, "embedded-db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: false,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(tempRoot, "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file" as const,
|
||||
logDir: path.join(tempRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted" as const,
|
||||
exposure: "private" as const,
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: false,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto" as const,
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk" as const,
|
||||
localDisk: {
|
||||
baseDir: path.join(tempRoot, "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted" as const,
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(tempRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("disableAllRoutinesInConfig", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let tempRoot = "";
|
||||
let configPath = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-cli-db-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-cli-config-"));
|
||||
configPath = path.join(tempRoot, "config.json");
|
||||
writeTestConfig(configPath, tempRoot, tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(routines);
|
||||
await db.delete(projects);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
if (tempRoot) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("pauses only non-archived routines for the selected company", async () => {
|
||||
const companyId = randomUUID();
|
||||
const otherCompanyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const otherProjectId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const otherAgentId = randomUUID();
|
||||
const activeRoutineId = randomUUID();
|
||||
const pausedRoutineId = randomUUID();
|
||||
const archivedRoutineId = randomUUID();
|
||||
const otherCompanyRoutineId = randomUUID();
|
||||
|
||||
await db.insert(companies).values([
|
||||
{
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
{
|
||||
id: otherCompanyId,
|
||||
name: "Other company",
|
||||
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
id: otherAgentId,
|
||||
companyId: otherCompanyId,
|
||||
name: "Other coder",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(projects).values([
|
||||
{
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Project",
|
||||
status: "in_progress",
|
||||
},
|
||||
{
|
||||
id: otherProjectId,
|
||||
companyId: otherCompanyId,
|
||||
name: "Other project",
|
||||
status: "in_progress",
|
||||
},
|
||||
]);
|
||||
|
||||
await db.insert(routines).values([
|
||||
{
|
||||
id: activeRoutineId,
|
||||
companyId,
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
title: "Active routine",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: pausedRoutineId,
|
||||
companyId,
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
title: "Paused routine",
|
||||
status: "paused",
|
||||
},
|
||||
{
|
||||
id: archivedRoutineId,
|
||||
companyId,
|
||||
projectId,
|
||||
assigneeAgentId: agentId,
|
||||
title: "Archived routine",
|
||||
status: "archived",
|
||||
},
|
||||
{
|
||||
id: otherCompanyRoutineId,
|
||||
companyId: otherCompanyId,
|
||||
projectId: otherProjectId,
|
||||
assigneeAgentId: otherAgentId,
|
||||
title: "Other company routine",
|
||||
status: "active",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await disableAllRoutinesInConfig({
|
||||
config: configPath,
|
||||
companyId,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
companyId,
|
||||
totalRoutines: 3,
|
||||
pausedCount: 1,
|
||||
alreadyPausedCount: 1,
|
||||
archivedCount: 1,
|
||||
});
|
||||
|
||||
const companyRoutines = await db
|
||||
.select({
|
||||
id: routines.id,
|
||||
status: routines.status,
|
||||
})
|
||||
.from(routines)
|
||||
.where(eq(routines.companyId, companyId));
|
||||
const statusById = new Map(companyRoutines.map((routine) => [routine.id, routine.status]));
|
||||
|
||||
expect(statusById.get(activeRoutineId)).toBe("paused");
|
||||
expect(statusById.get(pausedRoutineId)).toBe("paused");
|
||||
expect(statusById.get(archivedRoutineId)).toBe("archived");
|
||||
|
||||
const otherCompanyRoutine = await db
|
||||
.select({
|
||||
status: routines.status,
|
||||
})
|
||||
.from(routines)
|
||||
.where(eq(routines.id, otherCompanyRoutineId));
|
||||
expect(otherCompanyRoutine[0]?.status).toBe("active");
|
||||
});
|
||||
});
|
||||
117
cli/src/__tests__/telemetry.test.ts
Normal file
117
cli/src/__tests__/telemetry.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const CI_ENV_VARS = ["CI", "CONTINUOUS_INTEGRATION", "BUILD_NUMBER", "GITHUB_ACTIONS", "GITLAB_CI"];
|
||||
|
||||
function makeConfigPath(root: string, enabled: boolean): string {
|
||||
const configPath = path.join(root, ".paperclip", "config.json");
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-31T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(root, "runtime", "db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(root, "runtime", "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(root, "runtime", "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(root, "runtime", "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(root, "runtime", "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
}, null, 2));
|
||||
return configPath;
|
||||
}
|
||||
|
||||
describe("cli telemetry", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
for (const key of CI_ENV_VARS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true })));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("respects telemetry.enabled=false from the config file", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-telemetry-"));
|
||||
const configPath = makeConfigPath(root, false);
|
||||
process.env.PAPERCLIP_HOME = path.join(root, "home");
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "telemetry-test";
|
||||
|
||||
const { initTelemetryFromConfigFile } = await import("../telemetry.js");
|
||||
const client = initTelemetryFromConfigFile(configPath);
|
||||
|
||||
expect(client).toBeNull();
|
||||
expect(fs.existsSync(path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("creates telemetry state only after the first event is tracked", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-telemetry-"));
|
||||
process.env.PAPERCLIP_HOME = path.join(root, "home");
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "telemetry-test";
|
||||
|
||||
const { initTelemetry, flushTelemetry } = await import("../telemetry.js");
|
||||
const client = initTelemetry({ enabled: true });
|
||||
const statePath = path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json");
|
||||
|
||||
expect(client).not.toBeNull();
|
||||
expect(fs.existsSync(statePath)).toBe(false);
|
||||
|
||||
client!.track("install.started", { setupMode: "quickstart" });
|
||||
|
||||
expect(fs.existsSync(statePath)).toBe(true);
|
||||
|
||||
await flushTelemetry();
|
||||
});
|
||||
});
|
||||
492
cli/src/__tests__/worktree-merge-history.test.ts
Normal file
492
cli/src/__tests__/worktree-merge-history.test.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildWorktreeMergePlan, parseWorktreeMergeScopes } from "../commands/worktree-merge-history-lib.js";
|
||||
|
||||
function makeIssue(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: "goal-1",
|
||||
parentId: null,
|
||||
title: "Issue",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "local-board",
|
||||
issueNumber: 1,
|
||||
identifier: "PAP-1",
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeComment(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "comment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "local-board",
|
||||
body: "hello",
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeIssueDocument(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "issue-document-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
documentId: "document-1",
|
||||
key: "plan",
|
||||
linkCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
linkUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
latestBody: "# Plan",
|
||||
latestRevisionId: "revision-1",
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "local-board",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "local-board",
|
||||
documentCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
documentUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeDocumentRevision(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "revision-1",
|
||||
companyId: "company-1",
|
||||
documentId: "document-1",
|
||||
revisionNumber: 1,
|
||||
body: "# Plan",
|
||||
changeSummary: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "local-board",
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeAttachment(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "attachment-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
issueCommentId: null,
|
||||
assetId: "asset-1",
|
||||
provider: "local_disk",
|
||||
objectKey: "company-1/issues/issue-1/2026/03/20/asset.png",
|
||||
contentType: "image/png",
|
||||
byteSize: 12,
|
||||
sha256: "deadbeef",
|
||||
originalFilename: "asset.png",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "local-board",
|
||||
assetCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
assetUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
attachmentCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
attachmentUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeProject(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
goalId: null,
|
||||
name: "Project",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#22c55e",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
archivedAt: null,
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeProjectWorkspace(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "workspace-1",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
name: "Workspace",
|
||||
sourceType: "local_path",
|
||||
cwd: "/tmp/project",
|
||||
repoUrl: "https://github.com/example/project.git",
|
||||
repoRef: "main",
|
||||
defaultRef: "main",
|
||||
visibility: "default",
|
||||
setupCommand: null,
|
||||
cleanupCommand: null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: null,
|
||||
metadata: null,
|
||||
isPrimary: true,
|
||||
createdAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("worktree merge history planner", () => {
|
||||
it("parses default scopes", () => {
|
||||
expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]);
|
||||
expect(parseWorktreeMergeScopes("issues")).toEqual(["issues"]);
|
||||
});
|
||||
|
||||
it("dedupes nested worktree issues by preserved source uuid", () => {
|
||||
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10", title: "Shared" });
|
||||
const branchOneIssue = makeIssue({
|
||||
id: "issue-b",
|
||||
identifier: "PAP-22",
|
||||
title: "Branch one issue",
|
||||
createdAt: new Date("2026-03-20T01:00:00.000Z"),
|
||||
});
|
||||
const branchTwoIssue = makeIssue({
|
||||
id: "issue-c",
|
||||
identifier: "PAP-23",
|
||||
title: "Branch two issue",
|
||||
createdAt: new Date("2026-03-20T02:00:00.000Z"),
|
||||
});
|
||||
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 500,
|
||||
scopes: ["issues", "comments"],
|
||||
sourceIssues: [sharedIssue, branchOneIssue, branchTwoIssue],
|
||||
targetIssues: [sharedIssue, branchOneIssue],
|
||||
sourceComments: [],
|
||||
targetComments: [],
|
||||
targetAgents: [],
|
||||
targetProjects: [],
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [{ id: "goal-1" }] as any,
|
||||
});
|
||||
|
||||
expect(plan.counts.issuesToInsert).toBe(1);
|
||||
expect(plan.issuePlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual(["issue-c"]);
|
||||
expect(plan.issuePlans.find((item) => item.source.id === "issue-c" && item.action === "insert")).toMatchObject({
|
||||
previewIdentifier: "PAP-501",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears missing references and coerces in_progress without an assignee", () => {
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 10,
|
||||
scopes: ["issues"],
|
||||
sourceIssues: [
|
||||
makeIssue({
|
||||
id: "issue-x",
|
||||
identifier: "PAP-99",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "agent-missing",
|
||||
projectId: "project-missing",
|
||||
projectWorkspaceId: "workspace-missing",
|
||||
goalId: "goal-missing",
|
||||
}),
|
||||
],
|
||||
targetIssues: [],
|
||||
sourceComments: [],
|
||||
targetComments: [],
|
||||
targetAgents: [],
|
||||
targetProjects: [],
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [],
|
||||
});
|
||||
|
||||
const insert = plan.issuePlans[0] as any;
|
||||
expect(insert.targetStatus).toBe("todo");
|
||||
expect(insert.targetAssigneeAgentId).toBeNull();
|
||||
expect(insert.targetProjectId).toBeNull();
|
||||
expect(insert.targetProjectWorkspaceId).toBeNull();
|
||||
expect(insert.targetGoalId).toBeNull();
|
||||
expect(insert.adjustments).toEqual([
|
||||
"clear_assignee_agent",
|
||||
"clear_project",
|
||||
"clear_project_workspace",
|
||||
"clear_goal",
|
||||
"coerce_in_progress_to_todo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies an explicit project mapping override instead of clearing the project", () => {
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 10,
|
||||
scopes: ["issues"],
|
||||
sourceIssues: [
|
||||
makeIssue({
|
||||
id: "issue-project-map",
|
||||
identifier: "PAP-77",
|
||||
projectId: "source-project-1",
|
||||
projectWorkspaceId: "source-workspace-1",
|
||||
}),
|
||||
],
|
||||
targetIssues: [],
|
||||
sourceComments: [],
|
||||
targetComments: [],
|
||||
targetAgents: [],
|
||||
targetProjects: [{ id: "target-project-1", name: "Mapped project", status: "in_progress" }] as any,
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [{ id: "goal-1" }] as any,
|
||||
projectIdOverrides: {
|
||||
"source-project-1": "target-project-1",
|
||||
},
|
||||
});
|
||||
|
||||
const insert = plan.issuePlans[0] as any;
|
||||
expect(insert.targetProjectId).toBe("target-project-1");
|
||||
expect(insert.projectResolution).toBe("mapped");
|
||||
expect(insert.mappedProjectName).toBe("Mapped project");
|
||||
expect(insert.targetProjectWorkspaceId).toBeNull();
|
||||
expect(insert.adjustments).toEqual(["clear_project_workspace"]);
|
||||
});
|
||||
|
||||
it("plans selected project imports and preserves project workspace links", () => {
|
||||
const sourceProject = makeProject({
|
||||
id: "source-project-1",
|
||||
name: "Paperclip Evals",
|
||||
goalId: "goal-1",
|
||||
});
|
||||
const sourceWorkspace = makeProjectWorkspace({
|
||||
id: "source-workspace-1",
|
||||
projectId: "source-project-1",
|
||||
cwd: "/Users/dotta/paperclip-evals",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip-evals.git",
|
||||
});
|
||||
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 10,
|
||||
scopes: ["issues"],
|
||||
sourceIssues: [
|
||||
makeIssue({
|
||||
id: "issue-project-import",
|
||||
identifier: "PAP-88",
|
||||
projectId: "source-project-1",
|
||||
projectWorkspaceId: "source-workspace-1",
|
||||
}),
|
||||
],
|
||||
targetIssues: [],
|
||||
sourceComments: [],
|
||||
targetComments: [],
|
||||
sourceProjects: [sourceProject],
|
||||
sourceProjectWorkspaces: [sourceWorkspace],
|
||||
targetAgents: [],
|
||||
targetProjects: [],
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [{ id: "goal-1" }] as any,
|
||||
importProjectIds: ["source-project-1"],
|
||||
});
|
||||
|
||||
expect(plan.counts.projectsToImport).toBe(1);
|
||||
expect(plan.projectImports[0]).toMatchObject({
|
||||
source: { id: "source-project-1", name: "Paperclip Evals" },
|
||||
targetGoalId: "goal-1",
|
||||
workspaces: [{ id: "source-workspace-1" }],
|
||||
});
|
||||
|
||||
const insert = plan.issuePlans[0] as any;
|
||||
expect(insert.targetProjectId).toBe("source-project-1");
|
||||
expect(insert.targetProjectWorkspaceId).toBe("source-workspace-1");
|
||||
expect(insert.projectResolution).toBe("imported");
|
||||
expect(insert.mappedProjectName).toBe("Paperclip Evals");
|
||||
expect(insert.adjustments).toEqual([]);
|
||||
});
|
||||
|
||||
it("imports comments onto shared or newly imported issues while skipping existing comments", () => {
|
||||
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
|
||||
const newIssue = makeIssue({
|
||||
id: "issue-b",
|
||||
identifier: "PAP-11",
|
||||
createdAt: new Date("2026-03-20T01:00:00.000Z"),
|
||||
});
|
||||
const existingComment = makeComment({ id: "comment-existing", issueId: "issue-a" });
|
||||
const sharedIssueComment = makeComment({ id: "comment-shared", issueId: "issue-a" });
|
||||
const newIssueComment = makeComment({
|
||||
id: "comment-new-issue",
|
||||
issueId: "issue-b",
|
||||
authorAgentId: "missing-agent",
|
||||
createdAt: new Date("2026-03-20T01:05:00.000Z"),
|
||||
});
|
||||
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 10,
|
||||
scopes: ["issues", "comments"],
|
||||
sourceIssues: [sharedIssue, newIssue],
|
||||
targetIssues: [sharedIssue],
|
||||
sourceComments: [existingComment, sharedIssueComment, newIssueComment],
|
||||
targetComments: [existingComment],
|
||||
targetAgents: [],
|
||||
targetProjects: [],
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [{ id: "goal-1" }] as any,
|
||||
});
|
||||
|
||||
expect(plan.counts.commentsToInsert).toBe(2);
|
||||
expect(plan.counts.commentsExisting).toBe(1);
|
||||
expect(plan.commentPlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual([
|
||||
"comment-shared",
|
||||
"comment-new-issue",
|
||||
]);
|
||||
expect(plan.adjustments.clear_author_agent).toBe(1);
|
||||
});
|
||||
|
||||
it("merges document revisions onto an existing shared document and renumbers conflicts", () => {
|
||||
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
|
||||
const sourceDocument = makeIssueDocument({
|
||||
issueId: "issue-a",
|
||||
documentId: "document-a",
|
||||
latestBody: "# Branch plan",
|
||||
latestRevisionId: "revision-branch-2",
|
||||
latestRevisionNumber: 2,
|
||||
documentUpdatedAt: new Date("2026-03-20T02:00:00.000Z"),
|
||||
linkUpdatedAt: new Date("2026-03-20T02:00:00.000Z"),
|
||||
});
|
||||
const targetDocument = makeIssueDocument({
|
||||
issueId: "issue-a",
|
||||
documentId: "document-a",
|
||||
latestBody: "# Main plan",
|
||||
latestRevisionId: "revision-main-2",
|
||||
latestRevisionNumber: 2,
|
||||
documentUpdatedAt: new Date("2026-03-20T01:00:00.000Z"),
|
||||
linkUpdatedAt: new Date("2026-03-20T01:00:00.000Z"),
|
||||
});
|
||||
const sourceRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" });
|
||||
const sourceRevisionTwo = makeDocumentRevision({
|
||||
documentId: "document-a",
|
||||
id: "revision-branch-2",
|
||||
revisionNumber: 2,
|
||||
body: "# Branch plan",
|
||||
createdAt: new Date("2026-03-20T02:00:00.000Z"),
|
||||
});
|
||||
const targetRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" });
|
||||
const targetRevisionTwo = makeDocumentRevision({
|
||||
documentId: "document-a",
|
||||
id: "revision-main-2",
|
||||
revisionNumber: 2,
|
||||
body: "# Main plan",
|
||||
createdAt: new Date("2026-03-20T01:00:00.000Z"),
|
||||
});
|
||||
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 10,
|
||||
scopes: ["issues", "comments"],
|
||||
sourceIssues: [sharedIssue],
|
||||
targetIssues: [sharedIssue],
|
||||
sourceComments: [],
|
||||
targetComments: [],
|
||||
sourceDocuments: [sourceDocument],
|
||||
targetDocuments: [targetDocument],
|
||||
sourceDocumentRevisions: [sourceRevisionOne, sourceRevisionTwo],
|
||||
targetDocumentRevisions: [targetRevisionOne, targetRevisionTwo],
|
||||
sourceAttachments: [],
|
||||
targetAttachments: [],
|
||||
targetAgents: [],
|
||||
targetProjects: [],
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [{ id: "goal-1" }] as any,
|
||||
});
|
||||
|
||||
expect(plan.counts.documentsToMerge).toBe(1);
|
||||
expect(plan.counts.documentRevisionsToInsert).toBe(1);
|
||||
expect(plan.documentPlans[0]).toMatchObject({
|
||||
action: "merge_existing",
|
||||
latestRevisionId: "revision-branch-2",
|
||||
latestRevisionNumber: 3,
|
||||
});
|
||||
const mergePlan = plan.documentPlans[0] as any;
|
||||
expect(mergePlan.revisionsToInsert).toHaveLength(1);
|
||||
expect(mergePlan.revisionsToInsert[0]).toMatchObject({
|
||||
source: { id: "revision-branch-2" },
|
||||
targetRevisionNumber: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("imports attachments while clearing missing comment and author references", () => {
|
||||
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
|
||||
const attachment = makeAttachment({
|
||||
issueId: "issue-a",
|
||||
issueCommentId: "comment-missing",
|
||||
createdByAgentId: "agent-missing",
|
||||
});
|
||||
|
||||
const plan = buildWorktreeMergePlan({
|
||||
companyId: "company-1",
|
||||
companyName: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
previewIssueCounterStart: 10,
|
||||
scopes: ["issues"],
|
||||
sourceIssues: [sharedIssue],
|
||||
targetIssues: [sharedIssue],
|
||||
sourceComments: [],
|
||||
targetComments: [],
|
||||
sourceDocuments: [],
|
||||
targetDocuments: [],
|
||||
sourceDocumentRevisions: [],
|
||||
targetDocumentRevisions: [],
|
||||
sourceAttachments: [attachment],
|
||||
targetAttachments: [],
|
||||
targetAgents: [],
|
||||
targetProjects: [],
|
||||
targetProjectWorkspaces: [],
|
||||
targetGoals: [{ id: "goal-1" }] as any,
|
||||
});
|
||||
|
||||
expect(plan.counts.attachmentsToInsert).toBe(1);
|
||||
expect(plan.adjustments.clear_attachment_agent).toBe(1);
|
||||
expect(plan.attachmentPlans[0]).toMatchObject({
|
||||
action: "insert",
|
||||
targetIssueCommentId: null,
|
||||
targetCreatedByAgentId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,19 +2,23 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
copyGitHooksToWorktreeGitDir,
|
||||
copySeededSecretsKey,
|
||||
readSourceAttachmentBody,
|
||||
rebindWorkspaceCwd,
|
||||
resolveSourceConfigPath,
|
||||
resolveGitWorktreeAddArgs,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeInitCommand,
|
||||
worktreeMakeCommand,
|
||||
} from "../commands/worktree.js";
|
||||
import {
|
||||
buildWorktreeConfig,
|
||||
buildWorktreeEnvEntries,
|
||||
formatShellExports,
|
||||
generateWorktreeColor,
|
||||
resolveWorktreeSeedPlan,
|
||||
resolveWorktreeLocalPaths,
|
||||
rewriteLocalUrlPort,
|
||||
@@ -22,6 +26,20 @@ import {
|
||||
} 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: {
|
||||
@@ -57,6 +75,9 @@ function buildSourceConfig(): PaperclipConfig {
|
||||
publicBaseUrl: "http://127.0.0.1:3100",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
@@ -166,13 +187,59 @@ describe("worktree helpers", () => {
|
||||
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
|
||||
);
|
||||
|
||||
const env = buildWorktreeEnvEntries(paths);
|
||||
const env = buildWorktreeEnvEntries(paths, {
|
||||
name: "feature-worktree-support",
|
||||
color: "#3abf7a",
|
||||
});
|
||||
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
|
||||
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
|
||||
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
|
||||
expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support");
|
||||
expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a");
|
||||
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
||||
});
|
||||
|
||||
it("falls back across storage roots before skipping a missing attachment object", async () => {
|
||||
const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
const expected = Buffer.from("image-bytes");
|
||||
await expect(
|
||||
readSourceAttachmentBody(
|
||||
[
|
||||
{
|
||||
getObject: vi.fn().mockRejectedValue(missingErr),
|
||||
},
|
||||
{
|
||||
getObject: vi.fn().mockResolvedValue(expected),
|
||||
},
|
||||
],
|
||||
"company-1",
|
||||
"company-1/issues/issue-1/missing.png",
|
||||
),
|
||||
).resolves.toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null when an attachment object is missing from every lookup storage", async () => {
|
||||
const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
await expect(
|
||||
readSourceAttachmentBody(
|
||||
[
|
||||
{
|
||||
getObject: vi.fn().mockRejectedValue(missingErr),
|
||||
},
|
||||
{
|
||||
getObject: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { status: 404 })),
|
||||
},
|
||||
],
|
||||
"company-1",
|
||||
"company-1/issues/issue-1/missing.png",
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("generates vivid worktree colors as hex", () => {
|
||||
expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
|
||||
const minimal = resolveWorktreeSeedPlan("minimal");
|
||||
const full = resolveWorktreeSeedPlan("full");
|
||||
@@ -189,7 +256,11 @@ describe("worktree helpers", () => {
|
||||
|
||||
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");
|
||||
@@ -208,6 +279,16 @@ describe("worktree helpers", () => {
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -233,6 +314,173 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("persists the current agent jwt secret into the worktree env file", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const originalCwd = process.cwd();
|
||||
const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret";
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await worktreeInitCommand({
|
||||
seed: false,
|
||||
fromConfig: path.join(tempRoot, "missing", "config.json"),
|
||||
home: path.join(tempRoot, ".paperclip-worktrees"),
|
||||
});
|
||||
|
||||
const envPath = path.join(repoRoot, ".paperclip", ".env");
|
||||
const envContents = fs.readFileSync(envPath, "utf8");
|
||||
expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
|
||||
expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo");
|
||||
expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalJwtSecret === undefined) {
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
} else {
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("avoids ports already claimed by sibling worktree instance configs", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const siblingInstanceRoot = path.join(homeDir, "instances", "existing-worktree");
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
fs.mkdirSync(siblingInstanceRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(siblingInstanceRoot, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
...buildSourceConfig(),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"),
|
||||
embeddedPostgresPort: 54330,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(siblingInstanceRoot, "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(siblingInstanceRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "authenticated",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3101,
|
||||
allowedHostnames: ["localhost"],
|
||||
serveUi: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(siblingInstanceRoot, "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(siblingInstanceRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
process.chdir(repoRoot);
|
||||
await worktreeInitCommand({
|
||||
seed: false,
|
||||
fromConfig: path.join(tempRoot, "missing", "config.json"),
|
||||
home: homeDir,
|
||||
});
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8"));
|
||||
expect(config.server.port).toBeGreaterThan(3101);
|
||||
expect(config.database.embeddedPostgresPort).not.toBe(54330);
|
||||
expect(config.database.embeddedPostgresPort).not.toBe(config.server.port);
|
||||
expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults the seed source config to the current repo-local Paperclip config", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const localConfigPath = path.join(repoRoot, ".paperclip", "config.json");
|
||||
const originalCwd = process.cwd();
|
||||
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(localConfigPath), { recursive: true });
|
||||
fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
process.chdir(repoRoot);
|
||||
|
||||
expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath));
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalPaperclipConfig === undefined) {
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
} else {
|
||||
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the source config path across worktree:make cwd changes", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-"));
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
const targetRoot = path.join(tempRoot, "target");
|
||||
const originalCwd = process.cwd();
|
||||
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
process.chdir(targetRoot);
|
||||
|
||||
expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe(
|
||||
path.resolve(sourceConfigPath),
|
||||
);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalPaperclipConfig === undefined) {
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
} else {
|
||||
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rebinds same-repo workspace paths onto the current worktree root", () => {
|
||||
expect(
|
||||
rebindWorkspaceCwd({
|
||||
@@ -315,7 +563,7 @@ describe("worktree helpers", () => {
|
||||
const fakeHome = path.join(tempRoot, "home");
|
||||
const worktreePath = path.join(fakeHome, "paperclip-make-test");
|
||||
const originalCwd = process.cwd();
|
||||
const originalHome = process.env.HOME;
|
||||
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
@@ -327,7 +575,6 @@ describe("worktree helpers", () => {
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||
|
||||
process.env.HOME = fakeHome;
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await worktreeMakeCommand("paperclip-make-test", {
|
||||
@@ -340,12 +587,8 @@ describe("worktree helpers", () => {
|
||||
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
homedirSpy.mockRestore();
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
||||
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||
import { 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";
|
||||
@@ -33,6 +34,11 @@ const cursorLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printCursorStreamEvent,
|
||||
};
|
||||
|
||||
const geminiLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "gemini_local",
|
||||
formatStdoutEvent: printGeminiStreamEvent,
|
||||
};
|
||||
|
||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||
@@ -45,6 +51,7 @@ const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
openCodeLocalCLIAdapter,
|
||||
piLocalCLIAdapter,
|
||||
cursorLocalCLIAdapter,
|
||||
geminiLocalCLIAdapter,
|
||||
openclawGatewayCLIAdapter,
|
||||
processCLIAdapter,
|
||||
httpCLIAdapter,
|
||||
|
||||
282
cli/src/client/board-auth.ts
Normal file
282
cli/src/client/board-auth.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import pc from "picocolors";
|
||||
import { buildCliCommandLabel } from "./command-label.js";
|
||||
import { resolveDefaultCliAuthPath } from "../config/home.js";
|
||||
|
||||
type RequestedAccess = "board" | "instance_admin_required";
|
||||
|
||||
interface BoardAuthCredential {
|
||||
apiBase: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userId?: string | null;
|
||||
}
|
||||
|
||||
interface BoardAuthStore {
|
||||
version: 1;
|
||||
credentials: Record<string, BoardAuthCredential>;
|
||||
}
|
||||
|
||||
interface CreateChallengeResponse {
|
||||
id: string;
|
||||
token: string;
|
||||
boardApiToken: string;
|
||||
approvalPath: string;
|
||||
approvalUrl: string | null;
|
||||
pollPath: string;
|
||||
expiresAt: string;
|
||||
suggestedPollIntervalMs: number;
|
||||
}
|
||||
|
||||
interface ChallengeStatusResponse {
|
||||
id: string;
|
||||
status: "pending" | "approved" | "cancelled" | "expired";
|
||||
command: string;
|
||||
clientName: string | null;
|
||||
requestedAccess: RequestedAccess;
|
||||
requestedCompanyId: string | null;
|
||||
requestedCompanyName: string | null;
|
||||
approvedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
expiresAt: string;
|
||||
approvedByUser: { id: string; name: string; email: string } | null;
|
||||
}
|
||||
|
||||
function defaultBoardAuthStore(): BoardAuthStore {
|
||||
return {
|
||||
version: 1,
|
||||
credentials: {},
|
||||
};
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function normalizeApiBase(apiBase: string): string {
|
||||
return apiBase.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function resolveBoardAuthStorePath(overridePath?: string): string {
|
||||
if (overridePath?.trim()) return path.resolve(overridePath.trim());
|
||||
if (process.env.PAPERCLIP_AUTH_STORE?.trim()) return path.resolve(process.env.PAPERCLIP_AUTH_STORE.trim());
|
||||
return resolveDefaultCliAuthPath();
|
||||
}
|
||||
|
||||
export function readBoardAuthStore(storePath?: string): BoardAuthStore {
|
||||
const filePath = resolveBoardAuthStorePath(storePath);
|
||||
if (!fs.existsSync(filePath)) return defaultBoardAuthStore();
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<BoardAuthStore> | null;
|
||||
const credentials = raw?.credentials && typeof raw.credentials === "object" ? raw.credentials : {};
|
||||
const normalized: Record<string, BoardAuthCredential> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (typeof value !== "object" || value === null) continue;
|
||||
const record = value as unknown as Record<string, unknown>;
|
||||
const apiBase = toStringOrNull(record.apiBase);
|
||||
const token = toStringOrNull(record.token);
|
||||
const createdAt = toStringOrNull(record.createdAt);
|
||||
const updatedAt = toStringOrNull(record.updatedAt);
|
||||
if (!apiBase || !token || !createdAt || !updatedAt) continue;
|
||||
normalized[normalizeApiBase(key)] = {
|
||||
apiBase,
|
||||
token,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
userId: toStringOrNull(record.userId),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
credentials: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeBoardAuthStore(store: BoardAuthStore, storePath?: string): void {
|
||||
const filePath = resolveBoardAuthStorePath(storePath);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function getStoredBoardCredential(apiBase: string, storePath?: string): BoardAuthCredential | null {
|
||||
const store = readBoardAuthStore(storePath);
|
||||
return store.credentials[normalizeApiBase(apiBase)] ?? null;
|
||||
}
|
||||
|
||||
export function setStoredBoardCredential(input: {
|
||||
apiBase: string;
|
||||
token: string;
|
||||
userId?: string | null;
|
||||
storePath?: string;
|
||||
}): BoardAuthCredential {
|
||||
const normalizedApiBase = normalizeApiBase(input.apiBase);
|
||||
const store = readBoardAuthStore(input.storePath);
|
||||
const now = new Date().toISOString();
|
||||
const existing = store.credentials[normalizedApiBase];
|
||||
const credential: BoardAuthCredential = {
|
||||
apiBase: normalizedApiBase,
|
||||
token: input.token.trim(),
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
userId: input.userId ?? existing?.userId ?? null,
|
||||
};
|
||||
store.credentials[normalizedApiBase] = credential;
|
||||
writeBoardAuthStore(store, input.storePath);
|
||||
return credential;
|
||||
}
|
||||
|
||||
export function removeStoredBoardCredential(apiBase: string, storePath?: string): boolean {
|
||||
const normalizedApiBase = normalizeApiBase(apiBase);
|
||||
const store = readBoardAuthStore(storePath);
|
||||
if (!store.credentials[normalizedApiBase]) return false;
|
||||
delete store.credentials[normalizedApiBase];
|
||||
writeBoardAuthStore(store, storePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers ?? undefined);
|
||||
if (init?.body !== undefined && !headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
if (!headers.has("accept")) {
|
||||
headers.set("accept", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
const message =
|
||||
body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string"
|
||||
? (body as { error: string }).error
|
||||
: `Request failed: ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function openUrl(url: string): boolean {
|
||||
const platform = process.platform;
|
||||
try {
|
||||
if (platform === "darwin") {
|
||||
const child = spawn("open", [url], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
return true;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
return true;
|
||||
}
|
||||
const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginBoardCli(params: {
|
||||
apiBase: string;
|
||||
requestedAccess: RequestedAccess;
|
||||
requestedCompanyId?: string | null;
|
||||
clientName?: string | null;
|
||||
command?: string;
|
||||
storePath?: string;
|
||||
print?: boolean;
|
||||
}): Promise<{ token: string; approvalUrl: string; userId?: string | null }> {
|
||||
const apiBase = normalizeApiBase(params.apiBase);
|
||||
const createUrl = `${apiBase}/api/cli-auth/challenges`;
|
||||
const command = params.command?.trim() || buildCliCommandLabel();
|
||||
|
||||
const challenge = await requestJson<CreateChallengeResponse>(createUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
command,
|
||||
clientName: params.clientName?.trim() || "paperclipai cli",
|
||||
requestedAccess: params.requestedAccess,
|
||||
requestedCompanyId: params.requestedCompanyId?.trim() || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const approvalUrl = challenge.approvalUrl ?? `${apiBase}${challenge.approvalPath}`;
|
||||
if (params.print !== false) {
|
||||
console.error(pc.bold("Board authentication required"));
|
||||
console.error(`Open this URL in your browser to approve CLI access:\n${approvalUrl}`);
|
||||
}
|
||||
|
||||
const opened = openUrl(approvalUrl);
|
||||
if (params.print !== false && opened) {
|
||||
console.error(pc.dim("Opened the approval page in your browser."));
|
||||
}
|
||||
|
||||
const expiresAtMs = Date.parse(challenge.expiresAt);
|
||||
const pollMs = Math.max(500, challenge.suggestedPollIntervalMs || 1000);
|
||||
|
||||
while (Number.isFinite(expiresAtMs) ? Date.now() < expiresAtMs : true) {
|
||||
const status = await requestJson<ChallengeStatusResponse>(
|
||||
`${apiBase}/api${challenge.pollPath}?token=${encodeURIComponent(challenge.token)}`,
|
||||
);
|
||||
|
||||
if (status.status === "approved") {
|
||||
const me = await requestJson<{ userId: string; user?: { id: string } | null }>(
|
||||
`${apiBase}/api/cli-auth/me`,
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${challenge.boardApiToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
setStoredBoardCredential({
|
||||
apiBase,
|
||||
token: challenge.boardApiToken,
|
||||
userId: me.userId ?? me.user?.id ?? null,
|
||||
storePath: params.storePath,
|
||||
});
|
||||
return {
|
||||
token: challenge.boardApiToken,
|
||||
approvalUrl,
|
||||
userId: me.userId ?? me.user?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.status === "cancelled") {
|
||||
throw new Error("CLI auth challenge was cancelled.");
|
||||
}
|
||||
if (status.status === "expired") {
|
||||
throw new Error("CLI auth challenge expired before approval.");
|
||||
}
|
||||
|
||||
await sleep(pollMs);
|
||||
}
|
||||
|
||||
throw new Error("CLI auth challenge expired before approval.");
|
||||
}
|
||||
|
||||
export async function revokeStoredBoardCredential(params: {
|
||||
apiBase: string;
|
||||
token: string;
|
||||
}): Promise<void> {
|
||||
const apiBase = normalizeApiBase(params.apiBase);
|
||||
await requestJson<{ revoked: boolean }>(`${apiBase}/api/cli-auth/revoke-current`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${params.token}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
4
cli/src/client/command-label.ts
Normal file
4
cli/src/client/command-label.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function buildCliCommandLabel(): string {
|
||||
const args = process.argv.slice(2);
|
||||
return args.length > 0 ? `paperclipai ${args.join(" ")}` : "paperclipai";
|
||||
}
|
||||
@@ -13,25 +13,54 @@ export class ApiRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiConnectionError extends Error {
|
||||
url: string;
|
||||
method: string;
|
||||
causeMessage?: string;
|
||||
|
||||
constructor(input: {
|
||||
apiBase: string;
|
||||
path: string;
|
||||
method: string;
|
||||
cause?: unknown;
|
||||
}) {
|
||||
const url = buildUrl(input.apiBase, input.path);
|
||||
const causeMessage = formatConnectionCause(input.cause);
|
||||
super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage }));
|
||||
this.url = url;
|
||||
this.method = input.method;
|
||||
this.causeMessage = causeMessage;
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
ignoreNotFound?: boolean;
|
||||
}
|
||||
|
||||
interface RecoverAuthInput {
|
||||
path: string;
|
||||
method: string;
|
||||
error: ApiRequestError;
|
||||
}
|
||||
|
||||
interface ApiClientOptions {
|
||||
apiBase: string;
|
||||
apiKey?: string;
|
||||
runId?: string;
|
||||
recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export class PaperclipApiClient {
|
||||
readonly apiBase: string;
|
||||
readonly apiKey?: string;
|
||||
apiKey?: string;
|
||||
readonly runId?: string;
|
||||
readonly recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
|
||||
|
||||
constructor(opts: ApiClientOptions) {
|
||||
this.apiBase = opts.apiBase.replace(/\/+$/, "");
|
||||
this.apiKey = opts.apiKey?.trim() || undefined;
|
||||
this.runId = opts.runId?.trim() || undefined;
|
||||
this.recoverAuth = opts.recoverAuth;
|
||||
}
|
||||
|
||||
get<T>(path: string, opts?: RequestOptions): Promise<T | null> {
|
||||
@@ -56,8 +85,18 @@ export class PaperclipApiClient {
|
||||
return this.request<T>(path, { method: "DELETE" }, opts);
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init: RequestInit, opts?: RequestOptions): Promise<T | null> {
|
||||
setApiKey(apiKey: string | undefined) {
|
||||
this.apiKey = apiKey?.trim() || undefined;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
opts?: RequestOptions,
|
||||
hasRetriedAuth = false,
|
||||
): Promise<T | null> {
|
||||
const url = buildUrl(this.apiBase, path);
|
||||
const method = String(init.method ?? "GET").toUpperCase();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json",
|
||||
@@ -76,17 +115,39 @@ export class PaperclipApiClient {
|
||||
headers["x-paperclip-run-id"] = this.runId;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ApiConnectionError({
|
||||
apiBase: this.apiBase,
|
||||
path,
|
||||
method,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts?.ignoreNotFound && response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw await toApiError(response);
|
||||
const apiError = await toApiError(response);
|
||||
if (!hasRetriedAuth && this.recoverAuth) {
|
||||
const recoveredToken = await this.recoverAuth({
|
||||
path,
|
||||
method,
|
||||
error: apiError,
|
||||
});
|
||||
if (recoveredToken) {
|
||||
this.setApiKey(recoveredToken);
|
||||
return this.request<T>(path, init, opts, true);
|
||||
}
|
||||
}
|
||||
throw apiError;
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
@@ -136,6 +197,50 @@ async function toApiError(response: Response): Promise<ApiRequestError> {
|
||||
return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed);
|
||||
}
|
||||
|
||||
function buildConnectionErrorMessage(input: {
|
||||
apiBase: string;
|
||||
url: string;
|
||||
method: string;
|
||||
causeMessage?: string;
|
||||
}): string {
|
||||
const healthUrl = buildHealthCheckUrl(input.url);
|
||||
const lines = [
|
||||
"Could not reach the Paperclip API.",
|
||||
"",
|
||||
`Request: ${input.method} ${input.url}`,
|
||||
];
|
||||
if (input.causeMessage) {
|
||||
lines.push(`Cause: ${input.causeMessage}`);
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
"This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
|
||||
"",
|
||||
"Try:",
|
||||
"- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
|
||||
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
|
||||
`- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildHealthCheckUrl(requestUrl: string): string {
|
||||
const url = new URL(requestUrl);
|
||||
url.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/api(?:\/.*)?$/, "")}/api/health`;
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function formatConnectionCause(error: unknown): string | undefined {
|
||||
if (!error) return undefined;
|
||||
if (error instanceof Error) {
|
||||
return error.message.trim() || error.name;
|
||||
}
|
||||
const message = String(error).trim();
|
||||
return message || undefined;
|
||||
}
|
||||
|
||||
function toStringRecord(headers: HeadersInit | undefined): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
if (Array.isArray(headers)) {
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Command } from "commander";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import {
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
resolvePaperclipSkillsDir,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -34,15 +38,12 @@ interface SkillsInstallSummary {
|
||||
tool: "codex" | "claude";
|
||||
target: string;
|
||||
linked: string[];
|
||||
removed: string[];
|
||||
skipped: string[];
|
||||
failed: Array<{ name: string; error: string }>;
|
||||
}
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
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();
|
||||
@@ -56,14 +57,6 @@ function claudeSkillsHome(): string {
|
||||
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,
|
||||
@@ -73,20 +66,65 @@ async function installSkillsForTarget(
|
||||
tool,
|
||||
target: targetSkillsDir,
|
||||
linked: [],
|
||||
removed: [],
|
||||
skipped: [],
|
||||
failed: [],
|
||||
};
|
||||
|
||||
await fs.mkdir(targetSkillsDir, { recursive: true });
|
||||
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
|
||||
summary.removed = await removeMaintainerOnlySkillSymlinks(
|
||||
targetSkillsDir,
|
||||
entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name),
|
||||
);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(sourceSkillsDir, entry.name);
|
||||
const target = path.join(targetSkillsDir, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) {
|
||||
summary.skipped.push(entry.name);
|
||||
continue;
|
||||
if (existing.isSymbolicLink()) {
|
||||
let linkedPath: string | null = null;
|
||||
try {
|
||||
linkedPath = await fs.readlink(target);
|
||||
} catch (err) {
|
||||
await fs.unlink(target);
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
summary.linked.push(entry.name);
|
||||
continue;
|
||||
} catch (linkErr) {
|
||||
summary.failed.push({
|
||||
name: entry.name,
|
||||
error:
|
||||
err instanceof Error && linkErr instanceof Error
|
||||
? `${err.message}; then ${linkErr.message}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: `Failed to recover broken symlink: ${String(err)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedLinkedPath = path.isAbsolute(linkedPath)
|
||||
? linkedPath
|
||||
: path.resolve(path.dirname(target), linkedPath);
|
||||
const linkedTargetExists = await fs
|
||||
.stat(resolvedLinkedPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!linkedTargetExists) {
|
||||
await fs.unlink(target);
|
||||
} else {
|
||||
summary.skipped.push(entry.name);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
summary.skipped.push(entry.name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -210,7 +248,7 @@ export function registerAgentCommands(program: Command): void {
|
||||
|
||||
const installSummaries: SkillsInstallSummary[] = [];
|
||||
if (opts.installSkills !== false) {
|
||||
const skillsDir = await resolvePaperclipSkillsDir();
|
||||
const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [path.resolve(process.cwd(), "skills")]);
|
||||
if (!skillsDir) {
|
||||
throw new Error(
|
||||
"Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.",
|
||||
@@ -258,7 +296,7 @@ export function registerAgentCommands(program: Command): void {
|
||||
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}`,
|
||||
`${summary.tool}: linked=${summary.linked.length} removed=${summary.removed.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
|
||||
);
|
||||
for (const failed of summary.failed) {
|
||||
console.log(` failed ${failed.name}: ${failed.error}`);
|
||||
|
||||
113
cli/src/commands/client/auth.ts
Normal file
113
cli/src/commands/client/auth.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
getStoredBoardCredential,
|
||||
loginBoardCli,
|
||||
removeStoredBoardCredential,
|
||||
revokeStoredBoardCredential,
|
||||
} from "../../client/board-auth.js";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
interface AuthLoginOptions extends BaseClientOptions {
|
||||
instanceAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface AuthLogoutOptions extends BaseClientOptions {}
|
||||
interface AuthWhoamiOptions extends BaseClientOptions {}
|
||||
|
||||
export function registerClientAuthCommands(auth: Command): void {
|
||||
addCommonClientOptions(
|
||||
auth
|
||||
.command("login")
|
||||
.description("Authenticate the CLI for board-user access")
|
||||
.option("--instance-admin", "Request instance-admin approval instead of plain board access", false)
|
||||
.action(async (opts: AuthLoginOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const login = await loginBoardCli({
|
||||
apiBase: ctx.api.apiBase,
|
||||
requestedAccess: opts.instanceAdmin ? "instance_admin_required" : "board",
|
||||
requestedCompanyId: ctx.companyId ?? null,
|
||||
command: "paperclipai auth login",
|
||||
});
|
||||
printOutput(
|
||||
{
|
||||
ok: true,
|
||||
apiBase: ctx.api.apiBase,
|
||||
userId: login.userId ?? null,
|
||||
approvalUrl: login.approvalUrl,
|
||||
},
|
||||
{ json: ctx.json },
|
||||
);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: true },
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
auth
|
||||
.command("logout")
|
||||
.description("Remove the stored board-user credential for this API base")
|
||||
.action(async (opts: AuthLogoutOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const credential = getStoredBoardCredential(ctx.api.apiBase);
|
||||
if (!credential) {
|
||||
printOutput({ ok: true, apiBase: ctx.api.apiBase, revoked: false, removedLocalCredential: false }, { json: ctx.json });
|
||||
return;
|
||||
}
|
||||
let revoked = false;
|
||||
try {
|
||||
await revokeStoredBoardCredential({
|
||||
apiBase: ctx.api.apiBase,
|
||||
token: credential.token,
|
||||
});
|
||||
revoked = true;
|
||||
} catch {
|
||||
// Remove the local credential even if the server-side revoke fails.
|
||||
}
|
||||
const removedLocalCredential = removeStoredBoardCredential(ctx.api.apiBase);
|
||||
printOutput(
|
||||
{
|
||||
ok: true,
|
||||
apiBase: ctx.api.apiBase,
|
||||
revoked,
|
||||
removedLocalCredential,
|
||||
},
|
||||
{ json: ctx.json },
|
||||
);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
auth
|
||||
.command("whoami")
|
||||
.description("Show the current board-user identity for this API base")
|
||||
.action(async (opts: AuthWhoamiOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const me = await ctx.api.get<{
|
||||
user: { id: string; name: string; email: string } | null;
|
||||
userId: string;
|
||||
isInstanceAdmin: boolean;
|
||||
companyIds: string[];
|
||||
source: string;
|
||||
keyId: string | null;
|
||||
}>("/api/cli-auth/me");
|
||||
printOutput(me, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import pc from "picocolors";
|
||||
import type { Command } from "commander";
|
||||
import { getStoredBoardCredential, loginBoardCli } from "../../client/board-auth.js";
|
||||
import { buildCliCommandLabel } from "../../client/command-label.js";
|
||||
import { readConfig } from "../../config/store.js";
|
||||
import { readContext, resolveProfile, type ClientContextProfile } from "../../client/context.js";
|
||||
import { ApiRequestError, PaperclipApiClient } from "../../client/http.js";
|
||||
@@ -53,10 +55,12 @@ export function resolveCommandContext(
|
||||
profile.apiBase ||
|
||||
inferApiBaseFromConfig(options.config);
|
||||
|
||||
const apiKey =
|
||||
const explicitApiKey =
|
||||
options.apiKey?.trim() ||
|
||||
process.env.PAPERCLIP_API_KEY?.trim() ||
|
||||
readKeyFromProfileEnv(profile);
|
||||
const storedBoardCredential = explicitApiKey ? null : getStoredBoardCredential(apiBase);
|
||||
const apiKey = explicitApiKey || storedBoardCredential?.token;
|
||||
|
||||
const companyId =
|
||||
options.companyId?.trim() ||
|
||||
@@ -69,7 +73,27 @@ export function resolveCommandContext(
|
||||
);
|
||||
}
|
||||
|
||||
const api = new PaperclipApiClient({ apiBase, apiKey });
|
||||
const api = new PaperclipApiClient({
|
||||
apiBase,
|
||||
apiKey,
|
||||
recoverAuth: explicitApiKey || !canAttemptInteractiveBoardAuth()
|
||||
? undefined
|
||||
: async ({ error }) => {
|
||||
const requestedAccess = error.message.includes("Instance admin required")
|
||||
? "instance_admin_required"
|
||||
: "board";
|
||||
if (!shouldRecoverBoardAuth(error)) {
|
||||
return null;
|
||||
}
|
||||
const login = await loginBoardCli({
|
||||
apiBase,
|
||||
requestedAccess,
|
||||
requestedCompanyId: companyId ?? null,
|
||||
command: buildCliCommandLabel(),
|
||||
});
|
||||
return login.token;
|
||||
},
|
||||
});
|
||||
return {
|
||||
api,
|
||||
companyId,
|
||||
@@ -79,6 +103,16 @@ export function resolveCommandContext(
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRecoverBoardAuth(error: ApiRequestError): boolean {
|
||||
if (error.status === 401) return true;
|
||||
if (error.status !== 403) return false;
|
||||
return error.message.includes("Board access required") || error.message.includes("Instance admin required");
|
||||
}
|
||||
|
||||
function canAttemptInteractiveBoardAuth(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
}
|
||||
|
||||
export function printOutput(data: unknown, opts: { json?: boolean; label?: string } = {}): void {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
645
cli/src/commands/client/feedback.ts
Normal file
645
cli/src/commands/client/feedback.ts
Normal file
@@ -0,0 +1,645 @@
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import pc from "picocolors";
|
||||
import { Command } from "commander";
|
||||
import type { Company, FeedbackTrace, FeedbackTraceBundle } from "@paperclipai/shared";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
type ResolvedClientContext,
|
||||
} from "./common.js";
|
||||
|
||||
interface FeedbackFilterOptions extends BaseClientOptions {
|
||||
targetType?: string;
|
||||
vote?: string;
|
||||
status?: string;
|
||||
projectId?: string;
|
||||
issueId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sharedOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface FeedbackTraceQueryOptions {
|
||||
targetType?: string;
|
||||
vote?: string;
|
||||
status?: string;
|
||||
projectId?: string;
|
||||
issueId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sharedOnly?: boolean;
|
||||
}
|
||||
|
||||
interface FeedbackReportOptions extends FeedbackFilterOptions {
|
||||
payloads?: boolean;
|
||||
}
|
||||
|
||||
interface FeedbackExportOptions extends FeedbackFilterOptions {
|
||||
out?: string;
|
||||
}
|
||||
|
||||
interface FeedbackSummary {
|
||||
total: number;
|
||||
thumbsUp: number;
|
||||
thumbsDown: number;
|
||||
withReason: number;
|
||||
statuses: Record<string, number>;
|
||||
}
|
||||
|
||||
interface FeedbackExportManifest {
|
||||
exportedAt: string;
|
||||
serverUrl: string;
|
||||
companyId: string;
|
||||
summary: FeedbackSummary & {
|
||||
uniqueIssues: number;
|
||||
issues: string[];
|
||||
};
|
||||
files: {
|
||||
votes: string[];
|
||||
traces: string[];
|
||||
fullTraces: string[];
|
||||
zip: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface FeedbackExportResult {
|
||||
outputDir: string;
|
||||
zipPath: string;
|
||||
manifest: FeedbackExportManifest;
|
||||
}
|
||||
|
||||
export function registerFeedbackCommands(program: Command): void {
|
||||
const feedback = program.command("feedback").description("Inspect and export local feedback traces");
|
||||
|
||||
addCommonClientOptions(
|
||||
feedback
|
||||
.command("report")
|
||||
.description("Render a terminal report for company feedback traces")
|
||||
.option("-C, --company-id <id>", "Company ID (overrides context default)")
|
||||
.option("--target-type <type>", "Filter by target type")
|
||||
.option("--vote <vote>", "Filter by vote value")
|
||||
.option("--status <status>", "Filter by trace status")
|
||||
.option("--project-id <id>", "Filter by project ID")
|
||||
.option("--issue-id <id>", "Filter by issue ID")
|
||||
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
|
||||
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
|
||||
.option("--shared-only", "Only include traces eligible for sharing/export")
|
||||
.option("--payloads", "Include raw payload dumps in the terminal report", false)
|
||||
.action(async (opts: FeedbackReportOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const companyId = await resolveFeedbackCompanyId(ctx, opts.companyId);
|
||||
const traces = await fetchCompanyFeedbackTraces(ctx, companyId, opts);
|
||||
const summary = summarizeFeedbackTraces(traces);
|
||||
if (ctx.json) {
|
||||
printOutput(
|
||||
{
|
||||
apiBase: ctx.api.apiBase,
|
||||
companyId,
|
||||
summary,
|
||||
traces,
|
||||
},
|
||||
{ json: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(renderFeedbackReport({
|
||||
apiBase: ctx.api.apiBase,
|
||||
companyId,
|
||||
traces,
|
||||
summary,
|
||||
includePayloads: Boolean(opts.payloads),
|
||||
}));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
feedback
|
||||
.command("export")
|
||||
.description("Export feedback votes and raw trace bundles into a folder plus zip archive")
|
||||
.option("-C, --company-id <id>", "Company ID (overrides context default)")
|
||||
.option("--target-type <type>", "Filter by target type")
|
||||
.option("--vote <vote>", "Filter by vote value")
|
||||
.option("--status <status>", "Filter by trace status")
|
||||
.option("--project-id <id>", "Filter by project ID")
|
||||
.option("--issue-id <id>", "Filter by issue ID")
|
||||
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
|
||||
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
|
||||
.option("--shared-only", "Only include traces eligible for sharing/export")
|
||||
.option("--out <path>", "Output directory (default: ./feedback-export-<timestamp>)")
|
||||
.action(async (opts: FeedbackExportOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const companyId = await resolveFeedbackCompanyId(ctx, opts.companyId);
|
||||
const traces = await fetchCompanyFeedbackTraces(ctx, companyId, opts);
|
||||
const outputDir = path.resolve(opts.out?.trim() || defaultFeedbackExportDirName());
|
||||
const exported = await writeFeedbackExportBundle({
|
||||
apiBase: ctx.api.apiBase,
|
||||
companyId,
|
||||
traces,
|
||||
outputDir,
|
||||
traceBundleFetcher: (trace) => fetchFeedbackTraceBundle(ctx, trace.id),
|
||||
});
|
||||
if (ctx.json) {
|
||||
printOutput(
|
||||
{
|
||||
companyId,
|
||||
outputDir: exported.outputDir,
|
||||
zipPath: exported.zipPath,
|
||||
summary: exported.manifest.summary,
|
||||
},
|
||||
{ json: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(renderFeedbackExportSummary(exported));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveFeedbackCompanyId(
|
||||
ctx: ResolvedClientContext,
|
||||
explicitCompanyId?: string,
|
||||
): Promise<string> {
|
||||
const direct = explicitCompanyId?.trim() || ctx.companyId?.trim();
|
||||
if (direct) return direct;
|
||||
const companies = (await ctx.api.get<Company[]>("/api/companies")) ?? [];
|
||||
const companyId = companies[0]?.id?.trim();
|
||||
if (!companyId) {
|
||||
throw new Error(
|
||||
"Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or configure a CLI context default.",
|
||||
);
|
||||
}
|
||||
return companyId;
|
||||
}
|
||||
|
||||
export function buildFeedbackTraceQuery(opts: FeedbackTraceQueryOptions, includePayload = true): string {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.targetType) params.set("targetType", opts.targetType);
|
||||
if (opts.vote) params.set("vote", opts.vote);
|
||||
if (opts.status) params.set("status", opts.status);
|
||||
if (opts.projectId) params.set("projectId", opts.projectId);
|
||||
if (opts.issueId) params.set("issueId", opts.issueId);
|
||||
if (opts.from) params.set("from", opts.from);
|
||||
if (opts.to) params.set("to", opts.to);
|
||||
if (opts.sharedOnly) params.set("sharedOnly", "true");
|
||||
if (includePayload) params.set("includePayload", "true");
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
|
||||
export function normalizeFeedbackTraceExportFormat(value: string | undefined): "json" | "ndjson" {
|
||||
if (!value || value === "ndjson") return "ndjson";
|
||||
if (value === "json") return "json";
|
||||
throw new Error(`Unsupported export format: ${value}`);
|
||||
}
|
||||
|
||||
export function serializeFeedbackTraces(traces: FeedbackTrace[], format: string | undefined): string {
|
||||
if (normalizeFeedbackTraceExportFormat(format) === "json") {
|
||||
return JSON.stringify(traces, null, 2);
|
||||
}
|
||||
return traces.map((trace) => JSON.stringify(trace)).join("\n");
|
||||
}
|
||||
|
||||
export async function fetchCompanyFeedbackTraces(
|
||||
ctx: ResolvedClientContext,
|
||||
companyId: string,
|
||||
opts: FeedbackFilterOptions,
|
||||
): Promise<FeedbackTrace[]> {
|
||||
return (
|
||||
(await ctx.api.get<FeedbackTrace[]>(
|
||||
`/api/companies/${companyId}/feedback-traces${buildFeedbackTraceQuery(opts, true)}`,
|
||||
)) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchFeedbackTraceBundle(
|
||||
ctx: ResolvedClientContext,
|
||||
traceId: string,
|
||||
): Promise<FeedbackTraceBundle> {
|
||||
const bundle = await ctx.api.get<FeedbackTraceBundle>(`/api/feedback-traces/${traceId}/bundle`);
|
||||
if (!bundle) {
|
||||
throw new Error(`Feedback trace bundle ${traceId} not found`);
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
export function summarizeFeedbackTraces(traces: FeedbackTrace[]): FeedbackSummary {
|
||||
const statuses: Record<string, number> = {};
|
||||
let thumbsUp = 0;
|
||||
let thumbsDown = 0;
|
||||
let withReason = 0;
|
||||
|
||||
for (const trace of traces) {
|
||||
if (trace.vote === "up") thumbsUp += 1;
|
||||
if (trace.vote === "down") thumbsDown += 1;
|
||||
if (readFeedbackReason(trace)) withReason += 1;
|
||||
statuses[trace.status] = (statuses[trace.status] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
total: traces.length,
|
||||
thumbsUp,
|
||||
thumbsDown,
|
||||
withReason,
|
||||
statuses,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderFeedbackReport(input: {
|
||||
apiBase: string;
|
||||
companyId: string;
|
||||
traces: FeedbackTrace[];
|
||||
summary: FeedbackSummary;
|
||||
includePayloads: boolean;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("");
|
||||
lines.push(pc.bold(pc.magenta("Paperclip Feedback Report")));
|
||||
lines.push(pc.dim(new Date().toISOString()));
|
||||
lines.push(horizontalRule());
|
||||
lines.push(`${pc.dim("Server:")} ${input.apiBase}`);
|
||||
lines.push(`${pc.dim("Company:")} ${input.companyId}`);
|
||||
lines.push("");
|
||||
|
||||
if (input.traces.length === 0) {
|
||||
lines.push(pc.yellow("[!!] No feedback traces found."));
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
lines.push(pc.bold(pc.cyan("Summary")));
|
||||
lines.push(horizontalRule());
|
||||
lines.push(` ${pc.green(pc.bold(String(input.summary.thumbsUp)))} thumbs up`);
|
||||
lines.push(` ${pc.red(pc.bold(String(input.summary.thumbsDown)))} thumbs down`);
|
||||
lines.push(` ${pc.yellow(pc.bold(String(input.summary.withReason)))} downvotes with a reason`);
|
||||
lines.push(` ${pc.bold(String(input.summary.total))} total traces`);
|
||||
lines.push("");
|
||||
lines.push(pc.dim("Export status:"));
|
||||
for (const status of ["pending", "sent", "local_only", "failed"]) {
|
||||
lines.push(` ${padRight(status, 10)} ${input.summary.statuses[status] ?? 0}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(pc.bold(pc.cyan("Trace Details")));
|
||||
lines.push(horizontalRule());
|
||||
|
||||
for (const trace of input.traces) {
|
||||
const voteColor = trace.vote === "up" ? pc.green : pc.red;
|
||||
const voteIcon = trace.vote === "up" ? "^" : "v";
|
||||
const issueRef = trace.issueIdentifier ?? trace.issueId;
|
||||
const label = trace.targetSummary.label?.trim() || trace.targetType;
|
||||
const excerpt = compactText(trace.targetSummary.excerpt);
|
||||
const reason = readFeedbackReason(trace);
|
||||
lines.push(
|
||||
` ${voteColor(voteIcon)} ${pc.bold(issueRef)} ${pc.dim(compactText(trace.issueTitle, 64))}`,
|
||||
);
|
||||
lines.push(
|
||||
` ${pc.dim("Trace:")} ${trace.id.slice(0, 8)} ${pc.dim("Status:")} ${trace.status} ${pc.dim("Date:")} ${formatTimestamp(trace.createdAt)}`,
|
||||
);
|
||||
lines.push(` ${pc.dim("Target:")} ${label}`);
|
||||
if (excerpt) {
|
||||
lines.push(` ${pc.dim("Excerpt:")} ${excerpt}`);
|
||||
}
|
||||
if (reason) {
|
||||
lines.push(` ${pc.yellow(pc.bold("Reason:"))} ${pc.yellow(reason)}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (input.includePayloads) {
|
||||
lines.push(pc.bold(pc.cyan("Raw Payloads")));
|
||||
lines.push(horizontalRule());
|
||||
for (const trace of input.traces) {
|
||||
if (!trace.payloadSnapshot) continue;
|
||||
const issueRef = trace.issueIdentifier ?? trace.issueId;
|
||||
lines.push(` ${pc.bold(`${issueRef} (${trace.id.slice(0, 8)})`)}`);
|
||||
const body = JSON.stringify(trace.payloadSnapshot, null, 2)?.split("\n") ?? [];
|
||||
for (const line of body) {
|
||||
lines.push(` ${pc.dim(line)}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(horizontalRule());
|
||||
lines.push(pc.dim(`Report complete. ${input.traces.length} trace(s) displayed.`));
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function writeFeedbackExportBundle(input: {
|
||||
apiBase: string;
|
||||
companyId: string;
|
||||
traces: FeedbackTrace[];
|
||||
outputDir: string;
|
||||
traceBundleFetcher?: (trace: FeedbackTrace) => Promise<FeedbackTraceBundle>;
|
||||
}): Promise<FeedbackExportResult> {
|
||||
await ensureEmptyOutputDirectory(input.outputDir);
|
||||
await mkdir(path.join(input.outputDir, "votes"), { recursive: true });
|
||||
await mkdir(path.join(input.outputDir, "traces"), { recursive: true });
|
||||
await mkdir(path.join(input.outputDir, "full-traces"), { recursive: true });
|
||||
|
||||
const summary = summarizeFeedbackTraces(input.traces);
|
||||
const voteFiles: string[] = [];
|
||||
const traceFiles: string[] = [];
|
||||
const fullTraceDirs: string[] = [];
|
||||
const fullTraceFiles: string[] = [];
|
||||
const issueSet = new Set<string>();
|
||||
|
||||
for (const trace of input.traces) {
|
||||
const issueRef = sanitizeFileSegment(trace.issueIdentifier ?? trace.issueId);
|
||||
const voteRecord = buildFeedbackVoteRecord(trace);
|
||||
const voteFileName = `${issueRef}-${trace.feedbackVoteId.slice(0, 8)}.json`;
|
||||
const traceFileName = `${issueRef}-${trace.id.slice(0, 8)}.json`;
|
||||
voteFiles.push(voteFileName);
|
||||
traceFiles.push(traceFileName);
|
||||
issueSet.add(trace.issueIdentifier ?? trace.issueId);
|
||||
await writeFile(
|
||||
path.join(input.outputDir, "votes", voteFileName),
|
||||
`${JSON.stringify(voteRecord, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(input.outputDir, "traces", traceFileName),
|
||||
`${JSON.stringify(trace, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
if (input.traceBundleFetcher) {
|
||||
const bundle = await input.traceBundleFetcher(trace);
|
||||
const bundleDirName = `${issueRef}-${trace.id.slice(0, 8)}`;
|
||||
const bundleDir = path.join(input.outputDir, "full-traces", bundleDirName);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
fullTraceDirs.push(bundleDirName);
|
||||
await writeFile(
|
||||
path.join(bundleDir, "bundle.json"),
|
||||
`${JSON.stringify(bundle, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fullTraceFiles.push(path.posix.join("full-traces", bundleDirName, "bundle.json"));
|
||||
for (const file of bundle.files) {
|
||||
const targetPath = path.join(bundleDir, file.path);
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, file.contents, "utf8");
|
||||
fullTraceFiles.push(path.posix.join("full-traces", bundleDirName, file.path.replace(/\\/g, "/")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const zipPath = `${input.outputDir}.zip`;
|
||||
const manifest: FeedbackExportManifest = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
serverUrl: input.apiBase,
|
||||
companyId: input.companyId,
|
||||
summary: {
|
||||
...summary,
|
||||
uniqueIssues: issueSet.size,
|
||||
issues: Array.from(issueSet).sort((left, right) => left.localeCompare(right)),
|
||||
},
|
||||
files: {
|
||||
votes: voteFiles.slice().sort((left, right) => left.localeCompare(right)),
|
||||
traces: traceFiles.slice().sort((left, right) => left.localeCompare(right)),
|
||||
fullTraces: fullTraceDirs.slice().sort((left, right) => left.localeCompare(right)),
|
||||
zip: path.basename(zipPath),
|
||||
},
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
path.join(input.outputDir, "index.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const archiveFiles = await collectJsonFilesForArchive(input.outputDir, [
|
||||
"index.json",
|
||||
...manifest.files.votes.map((file) => path.posix.join("votes", file)),
|
||||
...manifest.files.traces.map((file) => path.posix.join("traces", file)),
|
||||
...fullTraceFiles,
|
||||
]);
|
||||
await writeFile(zipPath, createStoredZipArchive(archiveFiles, path.basename(input.outputDir)));
|
||||
|
||||
return {
|
||||
outputDir: input.outputDir,
|
||||
zipPath,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderFeedbackExportSummary(exported: FeedbackExportResult): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("");
|
||||
lines.push(pc.bold(pc.magenta("Paperclip Feedback Export")));
|
||||
lines.push(pc.dim(exported.manifest.exportedAt));
|
||||
lines.push(horizontalRule());
|
||||
lines.push(`${pc.dim("Company:")} ${exported.manifest.companyId}`);
|
||||
lines.push(`${pc.dim("Output:")} ${exported.outputDir}`);
|
||||
lines.push(`${pc.dim("Archive:")} ${exported.zipPath}`);
|
||||
lines.push("");
|
||||
lines.push(pc.bold("Export Summary"));
|
||||
lines.push(horizontalRule());
|
||||
lines.push(` ${pc.green(pc.bold(String(exported.manifest.summary.thumbsUp)))} thumbs up`);
|
||||
lines.push(` ${pc.red(pc.bold(String(exported.manifest.summary.thumbsDown)))} thumbs down`);
|
||||
lines.push(` ${pc.yellow(pc.bold(String(exported.manifest.summary.withReason)))} with reason`);
|
||||
lines.push(` ${pc.bold(String(exported.manifest.summary.uniqueIssues))} unique issues`);
|
||||
lines.push("");
|
||||
lines.push(pc.dim("Files:"));
|
||||
lines.push(` ${path.join(exported.outputDir, "index.json")}`);
|
||||
lines.push(` ${path.join(exported.outputDir, "votes")} (${exported.manifest.files.votes.length} files)`);
|
||||
lines.push(` ${path.join(exported.outputDir, "traces")} (${exported.manifest.files.traces.length} files)`);
|
||||
lines.push(` ${path.join(exported.outputDir, "full-traces")} (${exported.manifest.files.fullTraces.length} bundles)`);
|
||||
lines.push(` ${exported.zipPath}`);
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function readFeedbackReason(trace: FeedbackTrace): string | null {
|
||||
const payload = asRecord(trace.payloadSnapshot);
|
||||
const vote = asRecord(payload?.vote);
|
||||
const reason = vote?.reason;
|
||||
return typeof reason === "string" && reason.trim() ? reason.trim() : null;
|
||||
}
|
||||
|
||||
function buildFeedbackVoteRecord(trace: FeedbackTrace) {
|
||||
return {
|
||||
voteId: trace.feedbackVoteId,
|
||||
traceId: trace.id,
|
||||
issueId: trace.issueId,
|
||||
issueIdentifier: trace.issueIdentifier,
|
||||
issueTitle: trace.issueTitle,
|
||||
vote: trace.vote,
|
||||
targetType: trace.targetType,
|
||||
targetId: trace.targetId,
|
||||
targetSummary: trace.targetSummary,
|
||||
status: trace.status,
|
||||
consentVersion: trace.consentVersion,
|
||||
createdAt: trace.createdAt,
|
||||
updatedAt: trace.updatedAt,
|
||||
reason: readFeedbackReason(trace),
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function compactText(value: string | null | undefined, maxLength = 88): string | null {
|
||||
if (!value) return null;
|
||||
const compact = value.replace(/\s+/g, " ").trim();
|
||||
if (!compact) return null;
|
||||
if (compact.length <= maxLength) return compact;
|
||||
return `${compact.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: unknown): string {
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 19).replace("T", " ");
|
||||
if (typeof value === "string") return value.slice(0, 19).replace("T", " ");
|
||||
return "-";
|
||||
}
|
||||
|
||||
function horizontalRule(): string {
|
||||
return pc.dim("-".repeat(72));
|
||||
}
|
||||
|
||||
function padRight(value: string, width: number): string {
|
||||
return `${value}${" ".repeat(Math.max(0, width - value.length))}`;
|
||||
}
|
||||
|
||||
function defaultFeedbackExportDirName(): string {
|
||||
const iso = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||
return `feedback-export-${iso}`;
|
||||
}
|
||||
|
||||
async function ensureEmptyOutputDirectory(outputDir: string): Promise<void> {
|
||||
try {
|
||||
const info = await stat(outputDir);
|
||||
if (!info.isDirectory()) {
|
||||
throw new Error(`Output path already exists and is not a directory: ${outputDir}`);
|
||||
}
|
||||
const entries = await readdir(outputDir);
|
||||
if (entries.length > 0) {
|
||||
throw new Error(`Output directory already exists and is not empty: ${outputDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (/ENOENT/.test(message)) {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectJsonFilesForArchive(
|
||||
outputDir: string,
|
||||
relativePaths: string[],
|
||||
): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
for (const relativePath of relativePaths) {
|
||||
const normalized = relativePath.replace(/\\/g, "/");
|
||||
files[normalized] = await readFile(path.join(outputDir, normalized), "utf8");
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function sanitizeFileSegment(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "feedback";
|
||||
}
|
||||
|
||||
function writeUint16(target: Uint8Array, offset: number, value: number) {
|
||||
target[offset] = value & 0xff;
|
||||
target[offset + 1] = (value >>> 8) & 0xff;
|
||||
}
|
||||
|
||||
function writeUint32(target: Uint8Array, offset: number, value: number) {
|
||||
target[offset] = value & 0xff;
|
||||
target[offset + 1] = (value >>> 8) & 0xff;
|
||||
target[offset + 2] = (value >>> 16) & 0xff;
|
||||
target[offset + 3] = (value >>> 24) & 0xff;
|
||||
}
|
||||
|
||||
function crc32(bytes: Uint8Array) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function createStoredZipArchive(files: Record<string, string>, rootPath: string): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
const localChunks: Uint8Array[] = [];
|
||||
const centralChunks: Uint8Array[] = [];
|
||||
let localOffset = 0;
|
||||
let entryCount = 0;
|
||||
|
||||
for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
|
||||
const body = encoder.encode(content);
|
||||
const checksum = crc32(body);
|
||||
|
||||
const localHeader = new Uint8Array(30 + fileName.length);
|
||||
writeUint32(localHeader, 0, 0x04034b50);
|
||||
writeUint16(localHeader, 4, 20);
|
||||
writeUint16(localHeader, 6, 0x0800);
|
||||
writeUint16(localHeader, 8, 0);
|
||||
writeUint32(localHeader, 14, checksum);
|
||||
writeUint32(localHeader, 18, body.length);
|
||||
writeUint32(localHeader, 22, body.length);
|
||||
writeUint16(localHeader, 26, fileName.length);
|
||||
localHeader.set(fileName, 30);
|
||||
|
||||
const centralHeader = new Uint8Array(46 + fileName.length);
|
||||
writeUint32(centralHeader, 0, 0x02014b50);
|
||||
writeUint16(centralHeader, 4, 20);
|
||||
writeUint16(centralHeader, 6, 20);
|
||||
writeUint16(centralHeader, 8, 0x0800);
|
||||
writeUint16(centralHeader, 10, 0);
|
||||
writeUint32(centralHeader, 16, checksum);
|
||||
writeUint32(centralHeader, 20, body.length);
|
||||
writeUint32(centralHeader, 24, body.length);
|
||||
writeUint16(centralHeader, 28, fileName.length);
|
||||
writeUint32(centralHeader, 42, localOffset);
|
||||
centralHeader.set(fileName, 46);
|
||||
|
||||
localChunks.push(localHeader, body);
|
||||
centralChunks.push(centralHeader);
|
||||
localOffset += localHeader.length + body.length;
|
||||
entryCount += 1;
|
||||
}
|
||||
|
||||
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const archive = new Uint8Array(
|
||||
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
|
||||
);
|
||||
let offset = 0;
|
||||
for (const chunk of localChunks) {
|
||||
archive.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
const centralDirectoryOffset = offset;
|
||||
for (const chunk of centralChunks) {
|
||||
archive.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
writeUint32(archive, offset, 0x06054b50);
|
||||
writeUint16(archive, offset + 8, entryCount);
|
||||
writeUint16(archive, offset + 10, entryCount);
|
||||
writeUint32(archive, offset + 12, centralDirectoryLength);
|
||||
writeUint32(archive, offset + 16, centralDirectoryOffset);
|
||||
return archive;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Command } from "commander";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import {
|
||||
addIssueCommentSchema,
|
||||
checkoutIssueSchema,
|
||||
createIssueSchema,
|
||||
type FeedbackTrace,
|
||||
updateIssueSchema,
|
||||
type Issue,
|
||||
type IssueComment,
|
||||
@@ -15,6 +17,11 @@ import {
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
import {
|
||||
buildFeedbackTraceQuery,
|
||||
normalizeFeedbackTraceExportFormat,
|
||||
serializeFeedbackTraces,
|
||||
} from "./feedback.js";
|
||||
|
||||
interface IssueBaseOptions extends BaseClientOptions {
|
||||
status?: string;
|
||||
@@ -61,6 +68,18 @@ interface IssueCheckoutOptions extends BaseClientOptions {
|
||||
expectedStatuses?: string;
|
||||
}
|
||||
|
||||
interface IssueFeedbackOptions extends BaseClientOptions {
|
||||
targetType?: string;
|
||||
vote?: string;
|
||||
status?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sharedOnly?: boolean;
|
||||
includePayload?: boolean;
|
||||
out?: string;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
export function registerIssueCommands(program: Command): void {
|
||||
const issue = program.command("issue").description("Issue operations");
|
||||
|
||||
@@ -237,6 +256,85 @@ export function registerIssueCommands(program: Command): void {
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("feedback:list")
|
||||
.description("List feedback traces for an issue")
|
||||
.argument("<issueId>", "Issue ID")
|
||||
.option("--target-type <type>", "Filter by target type")
|
||||
.option("--vote <vote>", "Filter by vote value")
|
||||
.option("--status <status>", "Filter by trace status")
|
||||
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
|
||||
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
|
||||
.option("--shared-only", "Only include traces eligible for sharing/export")
|
||||
.option("--include-payload", "Include stored payload snapshots in the response")
|
||||
.action(async (issueId: string, opts: IssueFeedbackOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const traces = (await ctx.api.get<FeedbackTrace[]>(
|
||||
`/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts)}`,
|
||||
)) ?? [];
|
||||
if (ctx.json) {
|
||||
printOutput(traces, { json: true });
|
||||
return;
|
||||
}
|
||||
printOutput(
|
||||
traces.map((trace) => ({
|
||||
id: trace.id,
|
||||
issue: trace.issueIdentifier ?? trace.issueId,
|
||||
vote: trace.vote,
|
||||
status: trace.status,
|
||||
targetType: trace.targetType,
|
||||
target: trace.targetSummary.label,
|
||||
})),
|
||||
{ json: false },
|
||||
);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("feedback:export")
|
||||
.description("Export feedback traces for an issue")
|
||||
.argument("<issueId>", "Issue ID")
|
||||
.option("--target-type <type>", "Filter by target type")
|
||||
.option("--vote <vote>", "Filter by vote value")
|
||||
.option("--status <status>", "Filter by trace status")
|
||||
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
|
||||
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
|
||||
.option("--shared-only", "Only include traces eligible for sharing/export")
|
||||
.option("--include-payload", "Include stored payload snapshots in the export")
|
||||
.option("--out <path>", "Write export to a file path instead of stdout")
|
||||
.option("--format <format>", "Export format: json or ndjson", "ndjson")
|
||||
.action(async (issueId: string, opts: IssueFeedbackOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const traces = (await ctx.api.get<FeedbackTrace[]>(
|
||||
`/api/issues/${issueId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`,
|
||||
)) ?? [];
|
||||
const serialized = serializeFeedbackTraces(traces, opts.format);
|
||||
if (opts.out?.trim()) {
|
||||
await writeFile(opts.out, serialized, "utf8");
|
||||
if (ctx.json) {
|
||||
printOutput(
|
||||
{ out: opts.out, count: traces.length, format: normalizeFeedbackTraceExportFormat(opts.format) },
|
||||
{ json: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(`Wrote ${traces.length} feedback trace(s) to ${opts.out}`);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(`${serialized}${serialized.endsWith("\n") ? "" : "\n"}`);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
issue
|
||||
.command("checkout")
|
||||
|
||||
374
cli/src/commands/client/plugin.ts
Normal file
374
cli/src/commands/client/plugin.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types mirroring server-side shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PluginRecord {
|
||||
id: string;
|
||||
pluginKey: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
status: string;
|
||||
displayName?: string;
|
||||
lastError?: string | null;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PluginListOptions extends BaseClientOptions {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface PluginInstallOptions extends BaseClientOptions {
|
||||
local?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface PluginUninstallOptions extends BaseClientOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a local path argument to an absolute path so the server can find the
|
||||
* plugin on disk regardless of where the user ran the CLI.
|
||||
*/
|
||||
function resolvePackageArg(packageArg: string, isLocal: boolean): string {
|
||||
if (!isLocal) return packageArg;
|
||||
// Already absolute
|
||||
if (path.isAbsolute(packageArg)) return packageArg;
|
||||
// Expand leading ~ to home directory
|
||||
if (packageArg.startsWith("~")) {
|
||||
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
|
||||
}
|
||||
return path.resolve(process.cwd(), packageArg);
|
||||
}
|
||||
|
||||
function formatPlugin(p: PluginRecord): string {
|
||||
const statusColor =
|
||||
p.status === "ready"
|
||||
? pc.green(p.status)
|
||||
: p.status === "error"
|
||||
? pc.red(p.status)
|
||||
: p.status === "disabled"
|
||||
? pc.dim(p.status)
|
||||
: pc.yellow(p.status);
|
||||
|
||||
const parts = [
|
||||
`key=${pc.bold(p.pluginKey)}`,
|
||||
`status=${statusColor}`,
|
||||
`version=${p.version}`,
|
||||
`id=${pc.dim(p.id)}`,
|
||||
];
|
||||
|
||||
if (p.lastError) {
|
||||
parts.push(`error=${pc.red(p.lastError.slice(0, 80))}`);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function registerPluginCommands(program: Command): void {
|
||||
const plugin = program.command("plugin").description("Plugin lifecycle management");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin list
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("list")
|
||||
.description("List installed plugins")
|
||||
.option("--status <status>", "Filter by status (ready, error, disabled, installed, upgrade_pending)")
|
||||
.action(async (opts: PluginListOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const qs = opts.status ? `?status=${encodeURIComponent(opts.status)}` : "";
|
||||
const plugins = await ctx.api.get<PluginRecord[]>(`/api/plugins${qs}`);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(plugins, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = plugins ?? [];
|
||||
if (rows.length === 0) {
|
||||
console.log(pc.dim("No plugins installed."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const p of rows) {
|
||||
console.log(formatPlugin(p));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin install <package-or-path>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("install <package>")
|
||||
.description(
|
||||
"Install a plugin from a local path or npm package.\n" +
|
||||
" Examples:\n" +
|
||||
" paperclipai plugin install ./my-plugin # local path\n" +
|
||||
" paperclipai plugin install @acme/plugin-linear # npm package\n" +
|
||||
" paperclipai plugin install @acme/plugin-linear@1.2 # pinned version",
|
||||
)
|
||||
.option("-l, --local", "Treat <package> as a local filesystem path", false)
|
||||
.option("--version <version>", "Specific npm version to install (npm packages only)")
|
||||
.action(async (packageArg: string, opts: PluginInstallOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
|
||||
// Auto-detect local paths: starts with . or / or ~ or is an absolute path
|
||||
const isLocal =
|
||||
opts.local ||
|
||||
packageArg.startsWith("./") ||
|
||||
packageArg.startsWith("../") ||
|
||||
packageArg.startsWith("/") ||
|
||||
packageArg.startsWith("~");
|
||||
|
||||
const resolvedPackage = resolvePackageArg(packageArg, isLocal);
|
||||
|
||||
if (!ctx.json) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
isLocal
|
||||
? `Installing plugin from local path: ${resolvedPackage}`
|
||||
: `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", {
|
||||
packageName: resolvedPackage,
|
||||
version: opts.version,
|
||||
isLocalPath: isLocal,
|
||||
});
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(installedPlugin, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!installedPlugin) {
|
||||
console.log(pc.dim("Install returned no plugin record."));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
pc.green(
|
||||
`✓ Installed ${pc.bold(installedPlugin.pluginKey)} v${installedPlugin.version} (${installedPlugin.status})`,
|
||||
),
|
||||
);
|
||||
|
||||
if (installedPlugin.lastError) {
|
||||
console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin uninstall <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("uninstall <pluginKey>")
|
||||
.description(
|
||||
"Uninstall a plugin by its plugin key or database ID.\n" +
|
||||
" Use --force to hard-purge all state and config.",
|
||||
)
|
||||
.option("--force", "Purge all plugin state and config (hard delete)", false)
|
||||
.action(async (pluginKey: string, opts: PluginUninstallOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const purge = opts.force === true;
|
||||
const qs = purge ? "?purge=true" : "";
|
||||
|
||||
if (!ctx.json) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
purge
|
||||
? `Uninstalling and purging plugin: ${pluginKey}`
|
||||
: `Uninstalling plugin: ${pluginKey}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await ctx.api.delete<PluginRecord | null>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}${qs}`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.green(`✓ Uninstalled ${pc.bold(pluginKey)}${purge ? " (purged)" : ""}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin enable <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("enable <pluginKey>")
|
||||
.description("Enable a disabled or errored plugin")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.post<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}/enable`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.green(`✓ Enabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin disable <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("disable <pluginKey>")
|
||||
.description("Disable a running plugin without uninstalling it")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.post<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}/disable`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.dim(`Disabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin inspect <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("inspect <pluginKey>")
|
||||
.description("Show full details for an installed plugin")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.get<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
console.log(pc.red(`Plugin not found: ${pluginKey}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(formatPlugin(result));
|
||||
if (result.lastError) {
|
||||
console.log(`\n${pc.red("Last error:")}\n${result.lastError}`);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin examples
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("examples")
|
||||
.description("List bundled example plugins available for local install")
|
||||
.action(async (opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const examples = await ctx.api.get<
|
||||
Array<{
|
||||
packageName: string;
|
||||
pluginKey: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
localPath: string;
|
||||
tag: string;
|
||||
}>
|
||||
>("/api/plugins/examples");
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(examples, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = examples ?? [];
|
||||
if (rows.length === 0) {
|
||||
console.log(pc.dim("No bundled examples available."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ex of rows) {
|
||||
console.log(
|
||||
`${pc.bold(ex.displayName)} ${pc.dim(ex.pluginKey)}\n` +
|
||||
` ${ex.description}\n` +
|
||||
` ${pc.cyan(`paperclipai plugin install ${ex.localPath}`)}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
129
cli/src/commands/client/zip.ts
Normal file
129
cli/src/commands/client/zip.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
import path from "node:path";
|
||||
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export const binaryContentTypeByExtension: Record<string, string> = {
|
||||
".gif": "image/gif",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
function normalizeArchivePath(pathValue: string) {
|
||||
return pathValue
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function readUint16(source: Uint8Array, offset: number) {
|
||||
return source[offset]! | (source[offset + 1]! << 8);
|
||||
}
|
||||
|
||||
function readUint32(source: Uint8Array, offset: number) {
|
||||
return (
|
||||
source[offset]! |
|
||||
(source[offset + 1]! << 8) |
|
||||
(source[offset + 2]! << 16) |
|
||||
(source[offset + 3]! << 24)
|
||||
) >>> 0;
|
||||
}
|
||||
|
||||
function sharedArchiveRoot(paths: string[]) {
|
||||
if (paths.length === 0) return null;
|
||||
const firstSegments = paths
|
||||
.map((entry) => normalizeArchivePath(entry).split("/").filter(Boolean))
|
||||
.filter((parts) => parts.length > 0);
|
||||
if (firstSegments.length === 0) return null;
|
||||
const candidate = firstSegments[0]![0]!;
|
||||
return firstSegments.every((parts) => parts.length > 1 && parts[0] === candidate)
|
||||
? candidate
|
||||
: null;
|
||||
}
|
||||
|
||||
function bytesToPortableFileEntry(pathValue: string, bytes: Uint8Array): CompanyPortabilityFileEntry {
|
||||
const contentType = binaryContentTypeByExtension[path.extname(pathValue).toLowerCase()];
|
||||
if (!contentType) return textDecoder.decode(bytes);
|
||||
return {
|
||||
encoding: "base64",
|
||||
data: Buffer.from(bytes).toString("base64"),
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
|
||||
async function inflateZipEntry(compressionMethod: number, bytes: Uint8Array) {
|
||||
if (compressionMethod === 0) return bytes;
|
||||
if (compressionMethod !== 8) {
|
||||
throw new Error("Unsupported zip archive: only STORE and DEFLATE entries are supported.");
|
||||
}
|
||||
return new Uint8Array(inflateRawSync(bytes));
|
||||
}
|
||||
|
||||
export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise<{
|
||||
rootPath: string | null;
|
||||
files: Record<string, CompanyPortabilityFileEntry>;
|
||||
}> {
|
||||
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
|
||||
const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset + 4 <= bytes.length) {
|
||||
const signature = readUint32(bytes, offset);
|
||||
if (signature === 0x02014b50 || signature === 0x06054b50) break;
|
||||
if (signature !== 0x04034b50) {
|
||||
throw new Error("Invalid zip archive: unsupported local file header.");
|
||||
}
|
||||
|
||||
if (offset + 30 > bytes.length) {
|
||||
throw new Error("Invalid zip archive: truncated local file header.");
|
||||
}
|
||||
|
||||
const generalPurposeFlag = readUint16(bytes, offset + 6);
|
||||
const compressionMethod = readUint16(bytes, offset + 8);
|
||||
const compressedSize = readUint32(bytes, offset + 18);
|
||||
const fileNameLength = readUint16(bytes, offset + 26);
|
||||
const extraFieldLength = readUint16(bytes, offset + 28);
|
||||
|
||||
if ((generalPurposeFlag & 0x0008) !== 0) {
|
||||
throw new Error("Unsupported zip archive: data descriptors are not supported.");
|
||||
}
|
||||
|
||||
const nameOffset = offset + 30;
|
||||
const bodyOffset = nameOffset + fileNameLength + extraFieldLength;
|
||||
const bodyEnd = bodyOffset + compressedSize;
|
||||
if (bodyEnd > bytes.length) {
|
||||
throw new Error("Invalid zip archive: truncated file contents.");
|
||||
}
|
||||
|
||||
const rawArchivePath = textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength));
|
||||
const archivePath = normalizeArchivePath(rawArchivePath);
|
||||
const isDirectoryEntry = /\/$/.test(rawArchivePath.replace(/\\/g, "/"));
|
||||
if (archivePath && !isDirectoryEntry) {
|
||||
const entryBytes = await inflateZipEntry(compressionMethod, bytes.slice(bodyOffset, bodyEnd));
|
||||
entries.push({
|
||||
path: archivePath,
|
||||
body: bytesToPortableFileEntry(archivePath, entryBytes),
|
||||
});
|
||||
}
|
||||
|
||||
offset = bodyEnd;
|
||||
}
|
||||
|
||||
const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path));
|
||||
const files: Record<string, CompanyPortabilityFileEntry> = {};
|
||||
for (const entry of entries) {
|
||||
const normalizedPath =
|
||||
rootPath && entry.path.startsWith(`${rootPath}/`)
|
||||
? entry.path.slice(rootPath.length + 1)
|
||||
: entry.path;
|
||||
if (!normalizedPath) continue;
|
||||
files[normalizedPath] = entry.body;
|
||||
}
|
||||
|
||||
return { rootPath, files };
|
||||
}
|
||||
@@ -63,6 +63,9 @@ function defaultConfig(): PaperclipConfig {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
};
|
||||
|
||||
@@ -33,6 +33,11 @@ import {
|
||||
} from "../config/home.js";
|
||||
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
import {
|
||||
getTelemetryClient,
|
||||
trackInstallStarted,
|
||||
trackInstallCompleted,
|
||||
} from "../telemetry.js";
|
||||
|
||||
type SetupMode = "quickstart" | "advanced";
|
||||
|
||||
@@ -244,11 +249,12 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
),
|
||||
);
|
||||
|
||||
let existingConfig: PaperclipConfig | null = null;
|
||||
if (configExists(opts.config)) {
|
||||
p.log.message(pc.dim(`${configPath} exists, updating config`));
|
||||
p.log.message(pc.dim(`${configPath} exists`));
|
||||
|
||||
try {
|
||||
readConfig(opts.config);
|
||||
existingConfig = readConfig(opts.config);
|
||||
} catch (err) {
|
||||
p.log.message(
|
||||
pc.yellow(
|
||||
@@ -258,6 +264,76 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (existingConfig) {
|
||||
p.log.message(
|
||||
pc.dim("Existing Paperclip install detected; keeping the current configuration unchanged."),
|
||||
);
|
||||
p.log.message(pc.dim(`Use ${pc.cyan("paperclipai configure")} if you want to change settings.`));
|
||||
|
||||
const jwtSecret = ensureAgentJwtSecret(configPath);
|
||||
const envFilePath = resolveAgentJwtEnvFile(configPath);
|
||||
if (jwtSecret.created) {
|
||||
p.log.success(`Created ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
|
||||
} else if (process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()) {
|
||||
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from environment`);
|
||||
} else {
|
||||
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
|
||||
}
|
||||
|
||||
const keyResult = ensureLocalSecretsKeyFile(existingConfig, configPath);
|
||||
if (keyResult.status === "created") {
|
||||
p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`);
|
||||
} else if (keyResult.status === "existing") {
|
||||
p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`));
|
||||
}
|
||||
|
||||
p.note(
|
||||
[
|
||||
"Existing config preserved",
|
||||
`Database: ${existingConfig.database.mode}`,
|
||||
existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured",
|
||||
`Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`,
|
||||
`Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${existingConfig.server.host}:${existingConfig.server.port}`,
|
||||
`Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`,
|
||||
`Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`,
|
||||
`Storage: ${existingConfig.storage.provider}`,
|
||||
`Secrets: ${existingConfig.secrets.provider} (strict mode ${existingConfig.secrets.strictMode ? "on" : "off"})`,
|
||||
"Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured",
|
||||
].join("\n"),
|
||||
"Configuration ready",
|
||||
);
|
||||
|
||||
p.note(
|
||||
[
|
||||
`Run: ${pc.cyan("paperclipai run")}`,
|
||||
`Reconfigure later: ${pc.cyan("paperclipai configure")}`,
|
||||
`Diagnose setup: ${pc.cyan("paperclipai doctor")}`,
|
||||
].join("\n"),
|
||||
"Next commands",
|
||||
);
|
||||
|
||||
let shouldRunNow = opts.run === true || opts.yes === true;
|
||||
if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) {
|
||||
const answer = await p.confirm({
|
||||
message: "Start Paperclip now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!p.isCancel(answer)) {
|
||||
shouldRunNow = answer;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRunNow && !opts.invokedByRun) {
|
||||
process.env.PAPERCLIP_OPEN_ON_LISTEN = "true";
|
||||
const { runCommand } = await import("./run.js");
|
||||
await runCommand({ config: configPath, repair: true, yes: true });
|
||||
return;
|
||||
}
|
||||
|
||||
p.outro("Existing Paperclip setup is ready.");
|
||||
return;
|
||||
}
|
||||
|
||||
let setupMode: SetupMode = "quickstart";
|
||||
if (opts.yes) {
|
||||
p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults."));
|
||||
@@ -285,6 +361,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
setupMode = setupModeChoice as SetupMode;
|
||||
}
|
||||
|
||||
const tc = getTelemetryClient();
|
||||
if (tc) trackInstallStarted(tc);
|
||||
|
||||
let llm: PaperclipConfig["llm"] | undefined;
|
||||
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
|
||||
let {
|
||||
@@ -417,6 +496,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
logging,
|
||||
server,
|
||||
auth,
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage,
|
||||
secrets,
|
||||
};
|
||||
@@ -430,6 +512,10 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
|
||||
writeConfig(config, opts.config);
|
||||
|
||||
if (tc) trackInstallCompleted(tc, {
|
||||
adapterType: server.deploymentMode,
|
||||
});
|
||||
|
||||
p.note(
|
||||
[
|
||||
`Database: ${database.mode}`,
|
||||
|
||||
352
cli/src/commands/routines.ts
Normal file
352
cli/src/commands/routines.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
createDb,
|
||||
createEmbeddedPostgresLogBuffer,
|
||||
ensurePostgresDatabase,
|
||||
formatEmbeddedPostgresError,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||
|
||||
type RoutinesDisableAllOptions = {
|
||||
config?: string;
|
||||
dataDir?: string;
|
||||
companyId?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type DisableAllRoutinesResult = {
|
||||
companyId: string;
|
||||
totalRoutines: number;
|
||||
pausedCount: number;
|
||||
alreadyPausedCount: number;
|
||||
archivedCount: number;
|
||||
};
|
||||
|
||||
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;
|
||||
initdbFlags?: string[];
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
type EmbeddedPostgresHandle = {
|
||||
port: number;
|
||||
startedByThisProcess: boolean;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
type ClosableDb = ReturnType<typeof createDb> & {
|
||||
$client?: {
|
||||
end?: (options?: { timeout?: number }) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
function nonEmpty(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
async function isPortAvailable(port: number): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findAvailablePort(preferredPort: number): Promise<number> {
|
||||
let port = Math.max(1, Math.trunc(preferredPort));
|
||||
while (!(await isPortAvailable(port))) {
|
||||
port += 1;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function readPidFilePort(postmasterPidFile: string): number | null {
|
||||
if (!fs.existsSync(postmasterPidFile)) return null;
|
||||
try {
|
||||
const lines = fs.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 (!fs.existsSync(postmasterPidFile)) return null;
|
||||
try {
|
||||
const pid = Number(fs.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 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 logBuffer = createEmbeddedPostgresLogBuffer();
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: logBuffer.append,
|
||||
onError: logBuffer.append,
|
||||
});
|
||||
|
||||
if (!fs.existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
||||
try {
|
||||
await instance.initialise();
|
||||
} catch (error) {
|
||||
throw formatEmbeddedPostgresError(error, {
|
||||
fallbackMessage: `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${port}`,
|
||||
recentLogs: logBuffer.getRecentLogs(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(postmasterPidFile)) {
|
||||
fs.rmSync(postmasterPidFile, { force: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await instance.start();
|
||||
} catch (error) {
|
||||
throw formatEmbeddedPostgresError(error, {
|
||||
fallbackMessage: `Failed to start embedded PostgreSQL on port ${port}`,
|
||||
recentLogs: logBuffer.getRecentLogs(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
startedByThisProcess: true,
|
||||
stop: async () => {
|
||||
await instance.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function closeDb(db: ClosableDb): Promise<void> {
|
||||
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function openConfiguredDb(configPath: string): Promise<{
|
||||
db: ClosableDb;
|
||||
stop: () => Promise<void>;
|
||||
}> {
|
||||
const config = readConfig(configPath);
|
||||
if (!config) {
|
||||
throw new Error(`Config not found at ${configPath}.`);
|
||||
}
|
||||
|
||||
let embeddedHandle: EmbeddedPostgresHandle | null = null;
|
||||
try {
|
||||
if (config.database.mode === "embedded-postgres") {
|
||||
embeddedHandle = await ensureEmbeddedPostgres(
|
||||
config.database.embeddedPostgresDataDir,
|
||||
config.database.embeddedPostgresPort,
|
||||
);
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
const db = createDb(connectionString) as ClosableDb;
|
||||
return {
|
||||
db,
|
||||
stop: async () => {
|
||||
await closeDb(db);
|
||||
if (embeddedHandle?.startedByThisProcess) {
|
||||
await embeddedHandle.stop().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const connectionString = nonEmpty(config.database.connectionString);
|
||||
if (!connectionString) {
|
||||
throw new Error(`Config at ${configPath} does not define a database connection string.`);
|
||||
}
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
const db = createDb(connectionString) as ClosableDb;
|
||||
return {
|
||||
db,
|
||||
stop: async () => {
|
||||
await closeDb(db);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (embeddedHandle?.startedByThisProcess) {
|
||||
await embeddedHandle.stop().catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableAllRoutinesInConfig(
|
||||
options: Pick<RoutinesDisableAllOptions, "config" | "companyId">,
|
||||
): Promise<DisableAllRoutinesResult> {
|
||||
const configPath = resolveConfigPath(options.config);
|
||||
loadPaperclipEnvFile(configPath);
|
||||
const companyId =
|
||||
nonEmpty(options.companyId)
|
||||
?? nonEmpty(process.env.PAPERCLIP_COMPANY_ID)
|
||||
?? null;
|
||||
if (!companyId) {
|
||||
throw new Error("Company ID is required. Pass --company-id or set PAPERCLIP_COMPANY_ID.");
|
||||
}
|
||||
|
||||
const config = readConfig(configPath);
|
||||
if (!config) {
|
||||
throw new Error(`Config not found at ${configPath}.`);
|
||||
}
|
||||
|
||||
let embeddedHandle: EmbeddedPostgresHandle | null = null;
|
||||
let db: ClosableDb | null = null;
|
||||
try {
|
||||
if (config.database.mode === "embedded-postgres") {
|
||||
embeddedHandle = await ensureEmbeddedPostgres(
|
||||
config.database.embeddedPostgresDataDir,
|
||||
config.database.embeddedPostgresPort,
|
||||
);
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${embeddedHandle.port}/paperclip`;
|
||||
await applyPendingMigrations(connectionString);
|
||||
db = createDb(connectionString) as ClosableDb;
|
||||
} else {
|
||||
const connectionString = nonEmpty(config.database.connectionString);
|
||||
if (!connectionString) {
|
||||
throw new Error(`Config at ${configPath} does not define a database connection string.`);
|
||||
}
|
||||
await applyPendingMigrations(connectionString);
|
||||
db = createDb(connectionString) as ClosableDb;
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({
|
||||
id: routines.id,
|
||||
status: routines.status,
|
||||
})
|
||||
.from(routines)
|
||||
.where(eq(routines.companyId, companyId));
|
||||
|
||||
const alreadyPausedCount = existing.filter((routine) => routine.status === "paused").length;
|
||||
const archivedCount = existing.filter((routine) => routine.status === "archived").length;
|
||||
const idsToPause = existing
|
||||
.filter((routine) => routine.status !== "paused" && routine.status !== "archived")
|
||||
.map((routine) => routine.id);
|
||||
|
||||
if (idsToPause.length > 0) {
|
||||
await db
|
||||
.update(routines)
|
||||
.set({
|
||||
status: "paused",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(inArray(routines.id, idsToPause));
|
||||
}
|
||||
|
||||
return {
|
||||
companyId,
|
||||
totalRoutines: existing.length,
|
||||
pausedCount: idsToPause.length,
|
||||
alreadyPausedCount,
|
||||
archivedCount,
|
||||
};
|
||||
} finally {
|
||||
if (db) {
|
||||
await closeDb(db);
|
||||
}
|
||||
if (embeddedHandle?.startedByThisProcess) {
|
||||
await embeddedHandle.stop().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableAllRoutinesCommand(options: RoutinesDisableAllOptions): Promise<void> {
|
||||
const result = await disableAllRoutinesInConfig(options);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.totalRoutines === 0) {
|
||||
console.log(pc.dim(`No routines found for company ${result.companyId}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Paused ${result.pausedCount} routine(s) for company ${result.companyId} ` +
|
||||
`(${result.alreadyPausedCount} already paused, ${result.archivedCount} archived).`,
|
||||
);
|
||||
}
|
||||
|
||||
export function registerRoutineCommands(program: Command): void {
|
||||
const routinesCommand = program.command("routines").description("Local routine maintenance commands");
|
||||
|
||||
routinesCommand
|
||||
.command("disable-all")
|
||||
.description("Pause all non-archived routines in the configured local instance for one company")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
|
||||
.option("-C, --company-id <id>", "Company ID")
|
||||
.option("--json", "Output raw JSON")
|
||||
.action(async (opts: RoutinesDisableAllOptions) => {
|
||||
try {
|
||||
await disableAllRoutinesCommand(opts);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(pc.red(message));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { expandHomePrefix } from "../config/home.js";
|
||||
@@ -44,6 +45,11 @@ export type WorktreeLocalPaths = {
|
||||
storageDir: string;
|
||||
};
|
||||
|
||||
export type WorktreeUiBranding = {
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode {
|
||||
return (WORKTREE_SEED_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -87,6 +93,51 @@ export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string)
|
||||
return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd));
|
||||
}
|
||||
|
||||
function hslComponentToHex(n: number): string {
|
||||
return Math.round(Math.max(0, Math.min(255, n)))
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
}
|
||||
|
||||
function hslToHex(hue: number, saturation: number, lightness: number): string {
|
||||
const s = Math.max(0, Math.min(100, saturation)) / 100;
|
||||
const l = Math.max(0, Math.min(100, lightness)) / 100;
|
||||
const c = (1 - Math.abs((2 * l) - 1)) * s;
|
||||
const h = ((hue % 360) + 360) % 360;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - (c / 2);
|
||||
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
|
||||
if (h < 60) {
|
||||
r = c;
|
||||
g = x;
|
||||
} else if (h < 120) {
|
||||
r = x;
|
||||
g = c;
|
||||
} else if (h < 180) {
|
||||
g = c;
|
||||
b = x;
|
||||
} else if (h < 240) {
|
||||
g = x;
|
||||
b = c;
|
||||
} else if (h < 300) {
|
||||
r = x;
|
||||
b = c;
|
||||
} else {
|
||||
r = c;
|
||||
b = x;
|
||||
}
|
||||
|
||||
return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`;
|
||||
}
|
||||
|
||||
export function generateWorktreeColor(): string {
|
||||
return hslToHex(randomInt(0, 360), 68, 56);
|
||||
}
|
||||
|
||||
export function resolveWorktreeLocalPaths(opts: {
|
||||
cwd: string;
|
||||
homeDir?: string;
|
||||
@@ -173,6 +224,9 @@ export function buildWorktreeConfig(input: {
|
||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
disableSignUp: source?.auth.disableSignUp ?? false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: source?.telemetry?.enabled ?? true,
|
||||
},
|
||||
storage: {
|
||||
provider: source?.storage.provider ?? "local_disk",
|
||||
localDisk: {
|
||||
@@ -196,13 +250,18 @@ export function buildWorktreeConfig(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record<string, string> {
|
||||
export function buildWorktreeEnvEntries(
|
||||
paths: WorktreeLocalPaths,
|
||||
branding?: WorktreeUiBranding,
|
||||
): 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",
|
||||
...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}),
|
||||
...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
764
cli/src/commands/worktree-merge-history-lib.ts
Normal file
764
cli/src/commands/worktree-merge-history-lib.ts
Normal file
@@ -0,0 +1,764 @@
|
||||
import {
|
||||
agents,
|
||||
assets,
|
||||
documentRevisions,
|
||||
goals,
|
||||
issueAttachments,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
type IssueRow = typeof issues.$inferSelect;
|
||||
type CommentRow = typeof issueComments.$inferSelect;
|
||||
type AgentRow = typeof agents.$inferSelect;
|
||||
type ProjectRow = typeof projects.$inferSelect;
|
||||
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
|
||||
type GoalRow = typeof goals.$inferSelect;
|
||||
type IssueDocumentLinkRow = typeof issueDocuments.$inferSelect;
|
||||
type DocumentRevisionTableRow = typeof documentRevisions.$inferSelect;
|
||||
type IssueAttachmentTableRow = typeof issueAttachments.$inferSelect;
|
||||
type AssetRow = typeof assets.$inferSelect;
|
||||
|
||||
export const WORKTREE_MERGE_SCOPES = ["issues", "comments"] as const;
|
||||
export type WorktreeMergeScope = (typeof WORKTREE_MERGE_SCOPES)[number];
|
||||
|
||||
export type ImportAdjustment =
|
||||
| "clear_assignee_agent"
|
||||
| "clear_project"
|
||||
| "clear_project_workspace"
|
||||
| "clear_goal"
|
||||
| "clear_author_agent"
|
||||
| "coerce_in_progress_to_todo"
|
||||
| "clear_document_agent"
|
||||
| "clear_document_revision_agent"
|
||||
| "clear_attachment_agent";
|
||||
|
||||
export type IssueMergeAction = "skip_existing" | "insert";
|
||||
export type CommentMergeAction = "skip_existing" | "skip_missing_parent" | "insert";
|
||||
|
||||
export type PlannedIssueInsert = {
|
||||
source: IssueRow;
|
||||
action: "insert";
|
||||
previewIssueNumber: number;
|
||||
previewIdentifier: string;
|
||||
targetStatus: string;
|
||||
targetAssigneeAgentId: string | null;
|
||||
targetCreatedByAgentId: string | null;
|
||||
targetProjectId: string | null;
|
||||
targetProjectWorkspaceId: string | null;
|
||||
targetGoalId: string | null;
|
||||
projectResolution: "preserved" | "cleared" | "mapped" | "imported";
|
||||
mappedProjectName: string | null;
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedIssueSkip = {
|
||||
source: IssueRow;
|
||||
action: "skip_existing";
|
||||
driftKeys: string[];
|
||||
};
|
||||
|
||||
export type PlannedCommentInsert = {
|
||||
source: CommentRow;
|
||||
action: "insert";
|
||||
targetAuthorAgentId: string | null;
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedCommentSkip = {
|
||||
source: CommentRow;
|
||||
action: "skip_existing" | "skip_missing_parent";
|
||||
};
|
||||
|
||||
export type IssueDocumentRow = {
|
||||
id: IssueDocumentLinkRow["id"];
|
||||
companyId: IssueDocumentLinkRow["companyId"];
|
||||
issueId: IssueDocumentLinkRow["issueId"];
|
||||
documentId: IssueDocumentLinkRow["documentId"];
|
||||
key: IssueDocumentLinkRow["key"];
|
||||
linkCreatedAt: IssueDocumentLinkRow["createdAt"];
|
||||
linkUpdatedAt: IssueDocumentLinkRow["updatedAt"];
|
||||
title: string | null;
|
||||
format: string;
|
||||
latestBody: string;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
updatedByAgentId: string | null;
|
||||
updatedByUserId: string | null;
|
||||
documentCreatedAt: Date;
|
||||
documentUpdatedAt: Date;
|
||||
};
|
||||
|
||||
export type DocumentRevisionRow = {
|
||||
id: DocumentRevisionTableRow["id"];
|
||||
companyId: DocumentRevisionTableRow["companyId"];
|
||||
documentId: DocumentRevisionTableRow["documentId"];
|
||||
revisionNumber: DocumentRevisionTableRow["revisionNumber"];
|
||||
body: DocumentRevisionTableRow["body"];
|
||||
changeSummary: DocumentRevisionTableRow["changeSummary"];
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type IssueAttachmentRow = {
|
||||
id: IssueAttachmentTableRow["id"];
|
||||
companyId: IssueAttachmentTableRow["companyId"];
|
||||
issueId: IssueAttachmentTableRow["issueId"];
|
||||
issueCommentId: IssueAttachmentTableRow["issueCommentId"];
|
||||
assetId: IssueAttachmentTableRow["assetId"];
|
||||
provider: AssetRow["provider"];
|
||||
objectKey: AssetRow["objectKey"];
|
||||
contentType: AssetRow["contentType"];
|
||||
byteSize: AssetRow["byteSize"];
|
||||
sha256: AssetRow["sha256"];
|
||||
originalFilename: AssetRow["originalFilename"];
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
assetCreatedAt: Date;
|
||||
assetUpdatedAt: Date;
|
||||
attachmentCreatedAt: Date;
|
||||
attachmentUpdatedAt: Date;
|
||||
};
|
||||
|
||||
export type PlannedDocumentRevisionInsert = {
|
||||
source: DocumentRevisionRow;
|
||||
targetRevisionNumber: number;
|
||||
targetCreatedByAgentId: string | null;
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedIssueDocumentInsert = {
|
||||
source: IssueDocumentRow;
|
||||
action: "insert";
|
||||
targetCreatedByAgentId: string | null;
|
||||
targetUpdatedByAgentId: string | null;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
revisionsToInsert: PlannedDocumentRevisionInsert[];
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedIssueDocumentMerge = {
|
||||
source: IssueDocumentRow;
|
||||
action: "merge_existing";
|
||||
targetCreatedByAgentId: string | null;
|
||||
targetUpdatedByAgentId: string | null;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
revisionsToInsert: PlannedDocumentRevisionInsert[];
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedIssueDocumentSkip = {
|
||||
source: IssueDocumentRow;
|
||||
action: "skip_existing" | "skip_missing_parent" | "skip_conflicting_key";
|
||||
};
|
||||
|
||||
export type PlannedAttachmentInsert = {
|
||||
source: IssueAttachmentRow;
|
||||
action: "insert";
|
||||
targetIssueCommentId: string | null;
|
||||
targetCreatedByAgentId: string | null;
|
||||
adjustments: ImportAdjustment[];
|
||||
};
|
||||
|
||||
export type PlannedAttachmentSkip = {
|
||||
source: IssueAttachmentRow;
|
||||
action: "skip_existing" | "skip_missing_parent";
|
||||
};
|
||||
|
||||
export type PlannedProjectImport = {
|
||||
source: ProjectRow;
|
||||
targetLeadAgentId: string | null;
|
||||
targetGoalId: string | null;
|
||||
workspaces: ProjectWorkspaceRow[];
|
||||
};
|
||||
|
||||
export type WorktreeMergePlan = {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
issuePrefix: string;
|
||||
previewIssueCounterStart: number;
|
||||
scopes: WorktreeMergeScope[];
|
||||
projectImports: PlannedProjectImport[];
|
||||
issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip>;
|
||||
commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>;
|
||||
documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip>;
|
||||
attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip>;
|
||||
counts: {
|
||||
projectsToImport: number;
|
||||
issuesToInsert: number;
|
||||
issuesExisting: number;
|
||||
issueDrift: number;
|
||||
commentsToInsert: number;
|
||||
commentsExisting: number;
|
||||
commentsMissingParent: number;
|
||||
documentsToInsert: number;
|
||||
documentsToMerge: number;
|
||||
documentsExisting: number;
|
||||
documentsConflictingKey: number;
|
||||
documentsMissingParent: number;
|
||||
documentRevisionsToInsert: number;
|
||||
attachmentsToInsert: number;
|
||||
attachmentsExisting: number;
|
||||
attachmentsMissingParent: number;
|
||||
};
|
||||
adjustments: Record<ImportAdjustment, number>;
|
||||
};
|
||||
|
||||
function compareIssueCoreFields(source: IssueRow, target: IssueRow): string[] {
|
||||
const driftKeys: string[] = [];
|
||||
if (source.title !== target.title) driftKeys.push("title");
|
||||
if ((source.description ?? null) !== (target.description ?? null)) driftKeys.push("description");
|
||||
if (source.status !== target.status) driftKeys.push("status");
|
||||
if (source.priority !== target.priority) driftKeys.push("priority");
|
||||
if ((source.parentId ?? null) !== (target.parentId ?? null)) driftKeys.push("parentId");
|
||||
if ((source.projectId ?? null) !== (target.projectId ?? null)) driftKeys.push("projectId");
|
||||
if ((source.projectWorkspaceId ?? null) !== (target.projectWorkspaceId ?? null)) driftKeys.push("projectWorkspaceId");
|
||||
if ((source.goalId ?? null) !== (target.goalId ?? null)) driftKeys.push("goalId");
|
||||
if ((source.assigneeAgentId ?? null) !== (target.assigneeAgentId ?? null)) driftKeys.push("assigneeAgentId");
|
||||
if ((source.assigneeUserId ?? null) !== (target.assigneeUserId ?? null)) driftKeys.push("assigneeUserId");
|
||||
return driftKeys;
|
||||
}
|
||||
|
||||
function incrementAdjustment(
|
||||
counts: Record<ImportAdjustment, number>,
|
||||
adjustment: ImportAdjustment,
|
||||
): void {
|
||||
counts[adjustment] += 1;
|
||||
}
|
||||
|
||||
function groupBy<T>(rows: T[], keyFor: (row: T) => string): Map<string, T[]> {
|
||||
const out = new Map<string, T[]>();
|
||||
for (const row of rows) {
|
||||
const key = keyFor(row);
|
||||
const existing = out.get(key);
|
||||
if (existing) {
|
||||
existing.push(row);
|
||||
} else {
|
||||
out.set(key, [row]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sameDate(left: Date, right: Date): boolean {
|
||||
return left.getTime() === right.getTime();
|
||||
}
|
||||
|
||||
function sortDocumentRows(rows: IssueDocumentRow[]): IssueDocumentRow[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const createdDelta = left.documentCreatedAt.getTime() - right.documentCreatedAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
const linkDelta = left.linkCreatedAt.getTime() - right.linkCreatedAt.getTime();
|
||||
if (linkDelta !== 0) return linkDelta;
|
||||
return left.documentId.localeCompare(right.documentId);
|
||||
});
|
||||
}
|
||||
|
||||
function sortDocumentRevisions(rows: DocumentRevisionRow[]): DocumentRevisionRow[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const revisionDelta = left.revisionNumber - right.revisionNumber;
|
||||
if (revisionDelta !== 0) return revisionDelta;
|
||||
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
function sortAttachments(rows: IssueAttachmentRow[]): IssueAttachmentRow[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const createdDelta = left.attachmentCreatedAt.getTime() - right.attachmentCreatedAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
function sortIssuesForImport(sourceIssues: IssueRow[]): IssueRow[] {
|
||||
const byId = new Map(sourceIssues.map((issue) => [issue.id, issue]));
|
||||
const memoDepth = new Map<string, number>();
|
||||
|
||||
const depthFor = (issue: IssueRow, stack = new Set<string>()): number => {
|
||||
const memoized = memoDepth.get(issue.id);
|
||||
if (memoized !== undefined) return memoized;
|
||||
if (!issue.parentId) {
|
||||
memoDepth.set(issue.id, 0);
|
||||
return 0;
|
||||
}
|
||||
if (stack.has(issue.id)) {
|
||||
memoDepth.set(issue.id, 0);
|
||||
return 0;
|
||||
}
|
||||
const parent = byId.get(issue.parentId);
|
||||
if (!parent) {
|
||||
memoDepth.set(issue.id, 0);
|
||||
return 0;
|
||||
}
|
||||
stack.add(issue.id);
|
||||
const depth = depthFor(parent, stack) + 1;
|
||||
stack.delete(issue.id);
|
||||
memoDepth.set(issue.id, depth);
|
||||
return depth;
|
||||
};
|
||||
|
||||
return [...sourceIssues].sort((left, right) => {
|
||||
const depthDelta = depthFor(left) - depthFor(right);
|
||||
if (depthDelta !== 0) return depthDelta;
|
||||
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseWorktreeMergeScopes(rawValue: string | undefined): WorktreeMergeScope[] {
|
||||
if (!rawValue || rawValue.trim().length === 0) {
|
||||
return ["issues", "comments"];
|
||||
}
|
||||
|
||||
const parsed = rawValue
|
||||
.split(",")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value): value is WorktreeMergeScope =>
|
||||
(WORKTREE_MERGE_SCOPES as readonly string[]).includes(value),
|
||||
);
|
||||
|
||||
if (parsed.length === 0) {
|
||||
throw new Error(
|
||||
`Invalid scope "${rawValue}". Expected a comma-separated list of: ${WORKTREE_MERGE_SCOPES.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return [...new Set(parsed)];
|
||||
}
|
||||
|
||||
export function buildWorktreeMergePlan(input: {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
issuePrefix: string;
|
||||
previewIssueCounterStart: number;
|
||||
scopes: WorktreeMergeScope[];
|
||||
sourceIssues: IssueRow[];
|
||||
targetIssues: IssueRow[];
|
||||
sourceComments: CommentRow[];
|
||||
targetComments: CommentRow[];
|
||||
sourceProjects?: ProjectRow[];
|
||||
sourceProjectWorkspaces?: ProjectWorkspaceRow[];
|
||||
sourceDocuments?: IssueDocumentRow[];
|
||||
targetDocuments?: IssueDocumentRow[];
|
||||
sourceDocumentRevisions?: DocumentRevisionRow[];
|
||||
targetDocumentRevisions?: DocumentRevisionRow[];
|
||||
sourceAttachments?: IssueAttachmentRow[];
|
||||
targetAttachments?: IssueAttachmentRow[];
|
||||
targetAgents: AgentRow[];
|
||||
targetProjects: ProjectRow[];
|
||||
targetProjectWorkspaces: ProjectWorkspaceRow[];
|
||||
targetGoals: GoalRow[];
|
||||
importProjectIds?: Iterable<string>;
|
||||
projectIdOverrides?: Record<string, string | null | undefined>;
|
||||
}): WorktreeMergePlan {
|
||||
const targetIssuesById = new Map(input.targetIssues.map((issue) => [issue.id, issue]));
|
||||
const targetCommentIds = new Set(input.targetComments.map((comment) => comment.id));
|
||||
const targetAgentIds = new Set(input.targetAgents.map((agent) => agent.id));
|
||||
const targetProjectIds = new Set(input.targetProjects.map((project) => project.id));
|
||||
const targetProjectsById = new Map(input.targetProjects.map((project) => [project.id, project]));
|
||||
const targetProjectWorkspaceIds = new Set(input.targetProjectWorkspaces.map((workspace) => workspace.id));
|
||||
const targetGoalIds = new Set(input.targetGoals.map((goal) => goal.id));
|
||||
const sourceProjectsById = new Map((input.sourceProjects ?? []).map((project) => [project.id, project]));
|
||||
const sourceProjectWorkspaces = input.sourceProjectWorkspaces ?? [];
|
||||
const sourceProjectWorkspacesByProjectId = groupBy(sourceProjectWorkspaces, (workspace) => workspace.projectId);
|
||||
const importProjectIds = new Set(input.importProjectIds ?? []);
|
||||
const scopes = new Set(input.scopes);
|
||||
|
||||
const adjustmentCounts: Record<ImportAdjustment, number> = {
|
||||
clear_assignee_agent: 0,
|
||||
clear_project: 0,
|
||||
clear_project_workspace: 0,
|
||||
clear_goal: 0,
|
||||
clear_author_agent: 0,
|
||||
coerce_in_progress_to_todo: 0,
|
||||
clear_document_agent: 0,
|
||||
clear_document_revision_agent: 0,
|
||||
clear_attachment_agent: 0,
|
||||
};
|
||||
|
||||
const projectImports: PlannedProjectImport[] = [];
|
||||
for (const projectId of importProjectIds) {
|
||||
if (targetProjectIds.has(projectId)) continue;
|
||||
const sourceProject = sourceProjectsById.get(projectId);
|
||||
if (!sourceProject) continue;
|
||||
projectImports.push({
|
||||
source: sourceProject,
|
||||
targetLeadAgentId:
|
||||
sourceProject.leadAgentId && targetAgentIds.has(sourceProject.leadAgentId)
|
||||
? sourceProject.leadAgentId
|
||||
: null,
|
||||
targetGoalId:
|
||||
sourceProject.goalId && targetGoalIds.has(sourceProject.goalId)
|
||||
? sourceProject.goalId
|
||||
: null,
|
||||
workspaces: [...(sourceProjectWorkspacesByProjectId.get(projectId) ?? [])].sort((left, right) => {
|
||||
const primaryDelta = Number(right.isPrimary) - Number(left.isPrimary);
|
||||
if (primaryDelta !== 0) return primaryDelta;
|
||||
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
}),
|
||||
});
|
||||
}
|
||||
const importedProjectWorkspaceIds = new Set(
|
||||
projectImports.flatMap((project) => project.workspaces.map((workspace) => workspace.id)),
|
||||
);
|
||||
|
||||
const issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip> = [];
|
||||
let nextPreviewIssueNumber = input.previewIssueCounterStart;
|
||||
for (const issue of sortIssuesForImport(input.sourceIssues)) {
|
||||
const existing = targetIssuesById.get(issue.id);
|
||||
if (existing) {
|
||||
issuePlans.push({
|
||||
source: issue,
|
||||
action: "skip_existing",
|
||||
driftKeys: compareIssueCoreFields(issue, existing),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
nextPreviewIssueNumber += 1;
|
||||
const adjustments: ImportAdjustment[] = [];
|
||||
const targetAssigneeAgentId =
|
||||
issue.assigneeAgentId && targetAgentIds.has(issue.assigneeAgentId) ? issue.assigneeAgentId : null;
|
||||
if (issue.assigneeAgentId && !targetAssigneeAgentId) {
|
||||
adjustments.push("clear_assignee_agent");
|
||||
incrementAdjustment(adjustmentCounts, "clear_assignee_agent");
|
||||
}
|
||||
|
||||
const targetCreatedByAgentId =
|
||||
issue.createdByAgentId && targetAgentIds.has(issue.createdByAgentId) ? issue.createdByAgentId : null;
|
||||
|
||||
let targetProjectId =
|
||||
issue.projectId && targetProjectIds.has(issue.projectId) ? issue.projectId : null;
|
||||
let projectResolution: PlannedIssueInsert["projectResolution"] = targetProjectId ? "preserved" : "cleared";
|
||||
let mappedProjectName: string | null = null;
|
||||
const overrideProjectId =
|
||||
issue.projectId && input.projectIdOverrides
|
||||
? input.projectIdOverrides[issue.projectId] ?? null
|
||||
: null;
|
||||
if (!targetProjectId && overrideProjectId && targetProjectIds.has(overrideProjectId)) {
|
||||
targetProjectId = overrideProjectId;
|
||||
projectResolution = "mapped";
|
||||
mappedProjectName = targetProjectsById.get(overrideProjectId)?.name ?? null;
|
||||
}
|
||||
if (!targetProjectId && issue.projectId && importProjectIds.has(issue.projectId)) {
|
||||
const sourceProject = sourceProjectsById.get(issue.projectId);
|
||||
if (sourceProject) {
|
||||
targetProjectId = sourceProject.id;
|
||||
projectResolution = "imported";
|
||||
mappedProjectName = sourceProject.name;
|
||||
}
|
||||
}
|
||||
if (issue.projectId && !targetProjectId) {
|
||||
adjustments.push("clear_project");
|
||||
incrementAdjustment(adjustmentCounts, "clear_project");
|
||||
}
|
||||
|
||||
const targetProjectWorkspaceId =
|
||||
targetProjectId
|
||||
&& targetProjectId === issue.projectId
|
||||
&& issue.projectWorkspaceId
|
||||
&& (targetProjectWorkspaceIds.has(issue.projectWorkspaceId)
|
||||
|| importedProjectWorkspaceIds.has(issue.projectWorkspaceId))
|
||||
? issue.projectWorkspaceId
|
||||
: null;
|
||||
if (issue.projectWorkspaceId && !targetProjectWorkspaceId) {
|
||||
adjustments.push("clear_project_workspace");
|
||||
incrementAdjustment(adjustmentCounts, "clear_project_workspace");
|
||||
}
|
||||
|
||||
const targetGoalId =
|
||||
issue.goalId && targetGoalIds.has(issue.goalId) ? issue.goalId : null;
|
||||
if (issue.goalId && !targetGoalId) {
|
||||
adjustments.push("clear_goal");
|
||||
incrementAdjustment(adjustmentCounts, "clear_goal");
|
||||
}
|
||||
|
||||
let targetStatus = issue.status;
|
||||
if (
|
||||
targetStatus === "in_progress"
|
||||
&& !targetAssigneeAgentId
|
||||
&& !(issue.assigneeUserId && issue.assigneeUserId.trim().length > 0)
|
||||
) {
|
||||
targetStatus = "todo";
|
||||
adjustments.push("coerce_in_progress_to_todo");
|
||||
incrementAdjustment(adjustmentCounts, "coerce_in_progress_to_todo");
|
||||
}
|
||||
|
||||
issuePlans.push({
|
||||
source: issue,
|
||||
action: "insert",
|
||||
previewIssueNumber: nextPreviewIssueNumber,
|
||||
previewIdentifier: `${input.issuePrefix}-${nextPreviewIssueNumber}`,
|
||||
targetStatus,
|
||||
targetAssigneeAgentId,
|
||||
targetCreatedByAgentId,
|
||||
targetProjectId,
|
||||
targetProjectWorkspaceId,
|
||||
targetGoalId,
|
||||
projectResolution,
|
||||
mappedProjectName,
|
||||
adjustments,
|
||||
});
|
||||
}
|
||||
|
||||
const issueIdsAvailableAfterImport = new Set<string>([
|
||||
...input.targetIssues.map((issue) => issue.id),
|
||||
...issuePlans.filter((plan): plan is PlannedIssueInsert => plan.action === "insert").map((plan) => plan.source.id),
|
||||
]);
|
||||
|
||||
const commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip> = [];
|
||||
if (scopes.has("comments")) {
|
||||
const sortedComments = [...input.sourceComments].sort((left, right) => {
|
||||
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
|
||||
if (createdDelta !== 0) return createdDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
|
||||
for (const comment of sortedComments) {
|
||||
if (targetCommentIds.has(comment.id)) {
|
||||
commentPlans.push({ source: comment, action: "skip_existing" });
|
||||
continue;
|
||||
}
|
||||
if (!issueIdsAvailableAfterImport.has(comment.issueId)) {
|
||||
commentPlans.push({ source: comment, action: "skip_missing_parent" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const adjustments: ImportAdjustment[] = [];
|
||||
const targetAuthorAgentId =
|
||||
comment.authorAgentId && targetAgentIds.has(comment.authorAgentId) ? comment.authorAgentId : null;
|
||||
if (comment.authorAgentId && !targetAuthorAgentId) {
|
||||
adjustments.push("clear_author_agent");
|
||||
incrementAdjustment(adjustmentCounts, "clear_author_agent");
|
||||
}
|
||||
|
||||
commentPlans.push({
|
||||
source: comment,
|
||||
action: "insert",
|
||||
targetAuthorAgentId,
|
||||
adjustments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sourceDocuments = input.sourceDocuments ?? [];
|
||||
const targetDocuments = input.targetDocuments ?? [];
|
||||
const sourceDocumentRevisions = input.sourceDocumentRevisions ?? [];
|
||||
const targetDocumentRevisions = input.targetDocumentRevisions ?? [];
|
||||
|
||||
const targetDocumentsById = new Map(targetDocuments.map((document) => [document.documentId, document]));
|
||||
const targetDocumentsByIssueKey = new Map(targetDocuments.map((document) => [`${document.issueId}:${document.key}`, document]));
|
||||
const sourceRevisionsByDocumentId = groupBy(sourceDocumentRevisions, (revision) => revision.documentId);
|
||||
const targetRevisionsByDocumentId = groupBy(targetDocumentRevisions, (revision) => revision.documentId);
|
||||
const commentIdsAvailableAfterImport = new Set<string>([
|
||||
...input.targetComments.map((comment) => comment.id),
|
||||
...commentPlans.filter((plan): plan is PlannedCommentInsert => plan.action === "insert").map((plan) => plan.source.id),
|
||||
]);
|
||||
|
||||
const documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip> = [];
|
||||
for (const document of sortDocumentRows(sourceDocuments)) {
|
||||
if (!issueIdsAvailableAfterImport.has(document.issueId)) {
|
||||
documentPlans.push({ source: document, action: "skip_missing_parent" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingDocument = targetDocumentsById.get(document.documentId);
|
||||
const conflictingIssueKeyDocument = targetDocumentsByIssueKey.get(`${document.issueId}:${document.key}`);
|
||||
if (!existingDocument && conflictingIssueKeyDocument && conflictingIssueKeyDocument.documentId !== document.documentId) {
|
||||
documentPlans.push({ source: document, action: "skip_conflicting_key" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const adjustments: ImportAdjustment[] = [];
|
||||
const targetCreatedByAgentId =
|
||||
document.createdByAgentId && targetAgentIds.has(document.createdByAgentId) ? document.createdByAgentId : null;
|
||||
const targetUpdatedByAgentId =
|
||||
document.updatedByAgentId && targetAgentIds.has(document.updatedByAgentId) ? document.updatedByAgentId : null;
|
||||
if (
|
||||
(document.createdByAgentId && !targetCreatedByAgentId)
|
||||
|| (document.updatedByAgentId && !targetUpdatedByAgentId)
|
||||
) {
|
||||
adjustments.push("clear_document_agent");
|
||||
incrementAdjustment(adjustmentCounts, "clear_document_agent");
|
||||
}
|
||||
|
||||
const sourceRevisions = sortDocumentRevisions(sourceRevisionsByDocumentId.get(document.documentId) ?? []);
|
||||
const targetRevisions = sortDocumentRevisions(targetRevisionsByDocumentId.get(document.documentId) ?? []);
|
||||
const existingRevisionIds = new Set(targetRevisions.map((revision) => revision.id));
|
||||
const usedRevisionNumbers = new Set(targetRevisions.map((revision) => revision.revisionNumber));
|
||||
let nextRevisionNumber = targetRevisions.reduce(
|
||||
(maxValue, revision) => Math.max(maxValue, revision.revisionNumber),
|
||||
0,
|
||||
) + 1;
|
||||
|
||||
const targetRevisionNumberById = new Map<string, number>(
|
||||
targetRevisions.map((revision) => [revision.id, revision.revisionNumber]),
|
||||
);
|
||||
const revisionsToInsert: PlannedDocumentRevisionInsert[] = [];
|
||||
|
||||
for (const revision of sourceRevisions) {
|
||||
if (existingRevisionIds.has(revision.id)) continue;
|
||||
let targetRevisionNumber = revision.revisionNumber;
|
||||
if (usedRevisionNumbers.has(targetRevisionNumber)) {
|
||||
while (usedRevisionNumbers.has(nextRevisionNumber)) {
|
||||
nextRevisionNumber += 1;
|
||||
}
|
||||
targetRevisionNumber = nextRevisionNumber;
|
||||
nextRevisionNumber += 1;
|
||||
}
|
||||
usedRevisionNumbers.add(targetRevisionNumber);
|
||||
targetRevisionNumberById.set(revision.id, targetRevisionNumber);
|
||||
|
||||
const revisionAdjustments: ImportAdjustment[] = [];
|
||||
const targetCreatedByAgentId =
|
||||
revision.createdByAgentId && targetAgentIds.has(revision.createdByAgentId) ? revision.createdByAgentId : null;
|
||||
if (revision.createdByAgentId && !targetCreatedByAgentId) {
|
||||
revisionAdjustments.push("clear_document_revision_agent");
|
||||
incrementAdjustment(adjustmentCounts, "clear_document_revision_agent");
|
||||
}
|
||||
|
||||
revisionsToInsert.push({
|
||||
source: revision,
|
||||
targetRevisionNumber,
|
||||
targetCreatedByAgentId,
|
||||
adjustments: revisionAdjustments,
|
||||
});
|
||||
}
|
||||
|
||||
const latestRevisionId = document.latestRevisionId ?? existingDocument?.latestRevisionId ?? null;
|
||||
const latestRevisionNumber =
|
||||
(latestRevisionId ? targetRevisionNumberById.get(latestRevisionId) : undefined)
|
||||
?? document.latestRevisionNumber
|
||||
?? existingDocument?.latestRevisionNumber
|
||||
?? 0;
|
||||
|
||||
if (!existingDocument) {
|
||||
documentPlans.push({
|
||||
source: document,
|
||||
action: "insert",
|
||||
targetCreatedByAgentId,
|
||||
targetUpdatedByAgentId,
|
||||
latestRevisionId,
|
||||
latestRevisionNumber,
|
||||
revisionsToInsert,
|
||||
adjustments,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const documentAlreadyMatches =
|
||||
existingDocument.key === document.key
|
||||
&& existingDocument.title === document.title
|
||||
&& existingDocument.format === document.format
|
||||
&& existingDocument.latestBody === document.latestBody
|
||||
&& (existingDocument.latestRevisionId ?? null) === latestRevisionId
|
||||
&& existingDocument.latestRevisionNumber === latestRevisionNumber
|
||||
&& (existingDocument.updatedByAgentId ?? null) === targetUpdatedByAgentId
|
||||
&& (existingDocument.updatedByUserId ?? null) === (document.updatedByUserId ?? null)
|
||||
&& sameDate(existingDocument.documentUpdatedAt, document.documentUpdatedAt)
|
||||
&& sameDate(existingDocument.linkUpdatedAt, document.linkUpdatedAt)
|
||||
&& revisionsToInsert.length === 0;
|
||||
|
||||
if (documentAlreadyMatches) {
|
||||
documentPlans.push({ source: document, action: "skip_existing" });
|
||||
continue;
|
||||
}
|
||||
|
||||
documentPlans.push({
|
||||
source: document,
|
||||
action: "merge_existing",
|
||||
targetCreatedByAgentId,
|
||||
targetUpdatedByAgentId,
|
||||
latestRevisionId,
|
||||
latestRevisionNumber,
|
||||
revisionsToInsert,
|
||||
adjustments,
|
||||
});
|
||||
}
|
||||
|
||||
const sourceAttachments = input.sourceAttachments ?? [];
|
||||
const targetAttachmentIds = new Set((input.targetAttachments ?? []).map((attachment) => attachment.id));
|
||||
const attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip> = [];
|
||||
for (const attachment of sortAttachments(sourceAttachments)) {
|
||||
if (targetAttachmentIds.has(attachment.id)) {
|
||||
attachmentPlans.push({ source: attachment, action: "skip_existing" });
|
||||
continue;
|
||||
}
|
||||
if (!issueIdsAvailableAfterImport.has(attachment.issueId)) {
|
||||
attachmentPlans.push({ source: attachment, action: "skip_missing_parent" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const adjustments: ImportAdjustment[] = [];
|
||||
const targetCreatedByAgentId =
|
||||
attachment.createdByAgentId && targetAgentIds.has(attachment.createdByAgentId)
|
||||
? attachment.createdByAgentId
|
||||
: null;
|
||||
if (attachment.createdByAgentId && !targetCreatedByAgentId) {
|
||||
adjustments.push("clear_attachment_agent");
|
||||
incrementAdjustment(adjustmentCounts, "clear_attachment_agent");
|
||||
}
|
||||
|
||||
attachmentPlans.push({
|
||||
source: attachment,
|
||||
action: "insert",
|
||||
targetIssueCommentId:
|
||||
attachment.issueCommentId && commentIdsAvailableAfterImport.has(attachment.issueCommentId)
|
||||
? attachment.issueCommentId
|
||||
: null,
|
||||
targetCreatedByAgentId,
|
||||
adjustments,
|
||||
});
|
||||
}
|
||||
|
||||
const counts = {
|
||||
projectsToImport: projectImports.length,
|
||||
issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length,
|
||||
issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length,
|
||||
issueDrift: issuePlans.filter((plan) => plan.action === "skip_existing" && plan.driftKeys.length > 0).length,
|
||||
commentsToInsert: commentPlans.filter((plan) => plan.action === "insert").length,
|
||||
commentsExisting: commentPlans.filter((plan) => plan.action === "skip_existing").length,
|
||||
commentsMissingParent: commentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
|
||||
documentsToInsert: documentPlans.filter((plan) => plan.action === "insert").length,
|
||||
documentsToMerge: documentPlans.filter((plan) => plan.action === "merge_existing").length,
|
||||
documentsExisting: documentPlans.filter((plan) => plan.action === "skip_existing").length,
|
||||
documentsConflictingKey: documentPlans.filter((plan) => plan.action === "skip_conflicting_key").length,
|
||||
documentsMissingParent: documentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
|
||||
documentRevisionsToInsert: documentPlans.reduce(
|
||||
(sum, plan) =>
|
||||
sum + (plan.action === "insert" || plan.action === "merge_existing" ? plan.revisionsToInsert.length : 0),
|
||||
0,
|
||||
),
|
||||
attachmentsToInsert: attachmentPlans.filter((plan) => plan.action === "insert").length,
|
||||
attachmentsExisting: attachmentPlans.filter((plan) => plan.action === "skip_existing").length,
|
||||
attachmentsMissingParent: attachmentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
|
||||
};
|
||||
|
||||
return {
|
||||
companyId: input.companyId,
|
||||
companyName: input.companyName,
|
||||
issuePrefix: input.issuePrefix,
|
||||
previewIssueCounterStart: input.previewIssueCounterStart,
|
||||
scopes: input.scopes,
|
||||
projectImports,
|
||||
issuePlans,
|
||||
commentPlans,
|
||||
documentPlans,
|
||||
attachmentPlans,
|
||||
counts,
|
||||
adjustments: adjustmentCounts,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,11 +22,18 @@ function parseEnvFile(contents: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatEnvValue(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function renderEnvFile(entries: Record<string, string>) {
|
||||
const lines = [
|
||||
"# Paperclip environment variables",
|
||||
"# Generated by Paperclip CLI commands",
|
||||
...Object.entries(entries).map(([key, value]) => `${key}=${value}`),
|
||||
...Object.entries(entries).map(([key, value]) => `${key}=${formatEnvValue(value)}`),
|
||||
"",
|
||||
];
|
||||
return lines.join("\n");
|
||||
|
||||
@@ -33,6 +33,10 @@ export function resolveDefaultContextPath(): string {
|
||||
return path.resolve(resolvePaperclipHomeDir(), "context.json");
|
||||
}
|
||||
|
||||
export function resolveDefaultCliAuthPath(): string {
|
||||
return path.resolve(resolvePaperclipHomeDir(), "auth.json");
|
||||
}
|
||||
|
||||
export function resolveDefaultEmbeddedPostgresDir(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "db");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
loggingConfigSchema,
|
||||
serverConfigSchema,
|
||||
authConfigSchema,
|
||||
telemetryConfigSchema,
|
||||
storageConfigSchema,
|
||||
storageLocalDiskConfigSchema,
|
||||
storageS3ConfigSchema,
|
||||
@@ -19,10 +20,11 @@ export {
|
||||
type LoggingConfig,
|
||||
type ServerConfig,
|
||||
type AuthConfig,
|
||||
type TelemetryConfig,
|
||||
type StorageConfig,
|
||||
type StorageLocalDiskConfig,
|
||||
type StorageS3Config,
|
||||
type SecretsConfig,
|
||||
type SecretsLocalEncryptedConfig,
|
||||
type ConfigMeta,
|
||||
} from "@paperclipai/shared";
|
||||
} from "../../../packages/shared/src/config-schema.js";
|
||||
|
||||
@@ -15,9 +15,15 @@ import { registerAgentCommands } from "./commands/client/agent.js";
|
||||
import { registerApprovalCommands } from "./commands/client/approval.js";
|
||||
import { registerActivityCommands } from "./commands/client/activity.js";
|
||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { registerRoutineCommands } from "./commands/routines.js";
|
||||
import { registerFeedbackCommands } from "./commands/client/feedback.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||
import { registerClientAuthCommands } from "./commands/client/auth.js";
|
||||
import { cliVersion } from "./version.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
@@ -26,7 +32,7 @@ const DATA_DIR_OPTION_HELP =
|
||||
program
|
||||
.name("paperclipai")
|
||||
.description("Paperclip CLI — setup, diagnose, and configure your instance")
|
||||
.version("0.2.7");
|
||||
.version(cliVersion);
|
||||
|
||||
program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
|
||||
@@ -36,6 +42,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
hasContextOption: optionNames.has("context"),
|
||||
});
|
||||
loadPaperclipEnvFile(options.config);
|
||||
initTelemetryFromConfigFile(options.config);
|
||||
});
|
||||
|
||||
program
|
||||
@@ -135,7 +142,10 @@ registerAgentCommands(program);
|
||||
registerApprovalCommands(program);
|
||||
registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerRoutineCommands(program);
|
||||
registerFeedbackCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerPluginCommands(program);
|
||||
|
||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||
|
||||
@@ -149,7 +159,22 @@ auth
|
||||
.option("--base-url <url>", "Public base URL used to print invite link")
|
||||
.action(bootstrapCeoInvite);
|
||||
|
||||
program.parseAsync().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
registerClientAuthCommands(auth);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let failed = false;
|
||||
try {
|
||||
await program.parseAsync();
|
||||
} catch (err) {
|
||||
failed = true;
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
await flushTelemetry();
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
|
||||
@@ -162,4 +162,3 @@ export async function promptServer(opts?: {
|
||||
auth,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
49
cli/src/telemetry.ts
Normal file
49
cli/src/telemetry.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
TelemetryClient,
|
||||
resolveTelemetryConfig,
|
||||
loadOrCreateState,
|
||||
trackInstallStarted,
|
||||
trackInstallCompleted,
|
||||
trackCompanyImported,
|
||||
} from "../../packages/shared/src/telemetry/index.js";
|
||||
import { resolvePaperclipInstanceRoot } from "./config/home.js";
|
||||
import { readConfig } from "./config/store.js";
|
||||
import { cliVersion } from "./version.js";
|
||||
|
||||
let client: TelemetryClient | null = null;
|
||||
|
||||
export function initTelemetry(fileConfig?: { enabled?: boolean }): TelemetryClient | null {
|
||||
if (client) return client;
|
||||
|
||||
const config = resolveTelemetryConfig(fileConfig);
|
||||
if (!config.enabled) return null;
|
||||
|
||||
const stateDir = path.join(resolvePaperclipInstanceRoot(), "telemetry");
|
||||
client = new TelemetryClient(config, () => loadOrCreateState(stateDir, cliVersion), cliVersion);
|
||||
return client;
|
||||
}
|
||||
|
||||
export function initTelemetryFromConfigFile(configPath?: string): TelemetryClient | null {
|
||||
try {
|
||||
return initTelemetry(readConfig(configPath)?.telemetry);
|
||||
} catch {
|
||||
return initTelemetry();
|
||||
}
|
||||
}
|
||||
|
||||
export function getTelemetryClient(): TelemetryClient | null {
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function flushTelemetry(): Promise<void> {
|
||||
if (client) {
|
||||
await client.flush();
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
trackInstallStarted,
|
||||
trackInstallCompleted,
|
||||
trackCompanyImported,
|
||||
};
|
||||
10
cli/src/version.ts
Normal file
10
cli/src/version.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
type PackageJson = {
|
||||
version?: string;
|
||||
};
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require("../package.json") as PackageJson;
|
||||
|
||||
export const cliVersion = pkg.version ?? "0.0.0";
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "../packages/shared/src"]
|
||||
}
|
||||
|
||||
115
doc/AGENTCOMPANIES_SPEC_INVENTORY.md
Normal file
115
doc/AGENTCOMPANIES_SPEC_INVENTORY.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Agent Companies Spec Inventory
|
||||
|
||||
This document indexes every part of the Paperclip codebase that touches the [Agent Companies Specification](docs/companies/companies-spec.md) (`agentcompanies/v1-draft`).
|
||||
|
||||
Use it when you need to:
|
||||
|
||||
1. **Update the spec** — know which implementation code must change in lockstep.
|
||||
2. **Change code that involves the spec** — find all related files quickly.
|
||||
3. **Keep things aligned** — audit whether implementation matches the spec.
|
||||
|
||||
---
|
||||
|
||||
## 1. Specification & Design Documents
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `docs/companies/companies-spec.md` | **Normative spec** — defines the markdown-first package format (COMPANY.md, TEAM.md, AGENTS.md, PROJECT.md, TASK.md, SKILL.md), reserved files, frontmatter schemas, and vendor extension conventions (`.paperclip.yaml`). |
|
||||
| `doc/plans/2026-03-13-company-import-export-v2.md` | Implementation plan for the markdown-first package model cutover — phases, API changes, UI plan, and rollout strategy. |
|
||||
| `doc/SPEC-implementation.md` | V1 implementation contract; references the portability system and `.paperclip.yaml` sidecar format. |
|
||||
| `docs/specs/cliphub-plan.md` | Earlier blueprint bundle plan; partially superseded by the markdown-first spec (noted in the v2 plan). |
|
||||
| `doc/plans/2026-02-16-module-system.md` | Module system plan; JSON-only company template sections superseded by the markdown-first model. |
|
||||
| `doc/plans/2026-03-14-skills-ui-product-plan.md` | Skills UI plan; references portable skill files and `.paperclip.yaml`. |
|
||||
| `doc/plans/2026-03-14-adapter-skill-sync-rollout.md` | Adapter skill sync rollout; companion to the v2 import/export plan. |
|
||||
|
||||
## 2. Shared Types & Validators
|
||||
|
||||
These define the contract between server, CLI, and UI.
|
||||
|
||||
| File | What it defines |
|
||||
|---|---|
|
||||
| `packages/shared/src/types/company-portability.ts` | TypeScript interfaces: `CompanyPortabilityManifest`, `CompanyPortabilityFileEntry`, `CompanyPortabilityEnvInput`, export/import/preview request and result types, manifest entry types for agents, skills, projects, issues, recurring routines, companies. |
|
||||
| `packages/shared/src/validators/company-portability.ts` | Zod schemas for all portability request/response shapes — used by both server routes and CLI. |
|
||||
| `packages/shared/src/types/index.ts` | Re-exports portability types. |
|
||||
| `packages/shared/src/validators/index.ts` | Re-exports portability validators. |
|
||||
|
||||
## 3. Server — Services
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `server/src/services/company-portability.ts` | **Core portability service.** Export (manifest generation, markdown file emission, `.paperclip.yaml` sidecars), import (graph resolution, collision handling, entity creation), preview (planned-action summary). Handles skill key derivation, recurring task <-> routine mapping, legacy recurrence migration, and package README generation. References `agentcompanies/v1` version string. |
|
||||
| `server/src/services/routines.ts` | Paperclip routine runtime service. Portability now exports routines as recurring `TASK.md` entries and imports recurring tasks back through this service. |
|
||||
| `server/src/services/company-export-readme.ts` | Generates `README.md` and Mermaid org-chart for exported company packages. |
|
||||
| `server/src/services/index.ts` | Re-exports `companyPortabilityService`. |
|
||||
|
||||
## 4. Server — Routes
|
||||
|
||||
| File | Endpoints |
|
||||
|---|---|
|
||||
| `server/src/routes/companies.ts` | `POST /api/companies/:companyId/export` — legacy export bundle<br>`POST /api/companies/:companyId/exports/preview` — export preview<br>`POST /api/companies/:companyId/exports` — export package<br>`POST /api/companies/import/preview` — import preview<br>`POST /api/companies/import` — perform import |
|
||||
|
||||
Route registration lives in `server/src/app.ts` via `companyRoutes(db, storage)`.
|
||||
|
||||
## 5. Server — Tests
|
||||
|
||||
| File | Coverage |
|
||||
|---|---|
|
||||
| `server/src/__tests__/company-portability.test.ts` | Unit tests for the portability service (export, import, preview, manifest shape, `agentcompanies/v1` version). |
|
||||
| `server/src/__tests__/company-portability-routes.test.ts` | Integration tests for the portability HTTP endpoints. |
|
||||
|
||||
## 6. CLI
|
||||
|
||||
| File | Commands |
|
||||
|---|---|
|
||||
| `cli/src/commands/client/company.ts` | `company export` — exports a company package to disk (flags: `--out`, `--include`, `--projects`, `--issues`, `--projectIssues`).<br>`company import <fromPathOrUrl>` — imports a company package from a file or folder (flags: positional source path/URL or GitHub shorthand, `--include`, `--target`, `--companyId`, `--newCompanyName`, `--agents`, `--collision`, `--ref`, `--dryRun`).<br>Reads/writes portable file entries and handles `.paperclip.yaml` filtering. |
|
||||
|
||||
## 7. UI — Pages
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `ui/src/pages/CompanyExport.tsx` | Export UI: preview, manifest display, file tree visualization, ZIP archive creation and download. Filters `.paperclip.yaml` based on selection. Shows manifest and README in editor. |
|
||||
| `ui/src/pages/CompanyImport.tsx` | Import UI: source input (upload/folder/GitHub URL/generic URL), ZIP reading, preview pane with dependency tree, entity selection checkboxes, trust/licensing warnings, secrets requirements, collision strategy, adapter config. |
|
||||
|
||||
## 8. UI — Components
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `ui/src/components/PackageFileTree.tsx` | Reusable file tree component for both import and export. Builds tree from `CompanyPortabilityFileEntry` items, parses frontmatter, shows action indicators (create/update/skip), and maps frontmatter field labels. |
|
||||
|
||||
## 9. UI — Libraries
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `ui/src/lib/portable-files.ts` | Helpers for portable file entries: `getPortableFileText`, `getPortableFileDataUrl`, `getPortableFileContentType`, `isPortableImageFile`. |
|
||||
| `ui/src/lib/zip.ts` | ZIP archive creation (`createZipArchive`) and reading (`readZipArchive`) — implements ZIP format from scratch for company packages. CRC32, DOS date/time encoding. |
|
||||
| `ui/src/lib/zip.test.ts` | Tests for ZIP utilities; exercises round-trip with portability file entries and `.paperclip.yaml` content. |
|
||||
|
||||
## 10. UI — API Client
|
||||
|
||||
| File | Functions |
|
||||
|---|---|
|
||||
| `ui/src/api/companies.ts` | `companiesApi.exportBundle`, `companiesApi.exportPreview`, `companiesApi.exportPackage`, `companiesApi.importPreview`, `companiesApi.importBundle` — typed fetch wrappers for the portability endpoints. |
|
||||
|
||||
## 11. Skills & Agent Instructions
|
||||
|
||||
| File | Relevance |
|
||||
|---|---|
|
||||
| `skills/paperclip/references/company-skills.md` | Reference doc for company skill library workflow — install, inspect, update, assign. Skill packages are a subset of the agent companies spec. |
|
||||
| `server/src/services/company-skills.ts` | Company skill management service — handles SKILL.md-based imports and company-level skill library. |
|
||||
| `server/src/services/agent-instructions.ts` | Agent instructions service — resolves AGENTS.md paths for agent instruction loading. |
|
||||
|
||||
## 12. Quick Cross-Reference by Spec Concept
|
||||
|
||||
| Spec concept | Primary implementation files |
|
||||
|---|---|
|
||||
| `COMPANY.md` frontmatter & body | `company-portability.ts` (export emitter + import parser) |
|
||||
| `AGENTS.md` frontmatter & body | `company-portability.ts`, `agent-instructions.ts` |
|
||||
| `PROJECT.md` frontmatter & body | `company-portability.ts` |
|
||||
| `TASK.md` frontmatter & body | `company-portability.ts` |
|
||||
| `SKILL.md` packages | `company-portability.ts`, `company-skills.ts` |
|
||||
| `.paperclip.yaml` vendor sidecar | `company-portability.ts`, `routines.ts`, `CompanyExport.tsx`, `company.ts` (CLI) |
|
||||
| `manifest.json` | `company-portability.ts` (generation), shared types (schema) |
|
||||
| ZIP package format | `zip.ts` (UI), `company.ts` (CLI file I/O) |
|
||||
| Collision resolution | `company-portability.ts` (server), `CompanyImport.tsx` (UI) |
|
||||
| Env/secrets declarations | shared types (`CompanyPortabilityEnvInput`), `CompanyImport.tsx` (UI) |
|
||||
| README + org chart | `company-export-readme.ts` |
|
||||
@@ -39,6 +39,19 @@ This starts:
|
||||
|
||||
`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.
|
||||
|
||||
`pnpm dev:once` auto-applies pending local migrations by default before starting the dev server.
|
||||
|
||||
`pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate.
|
||||
|
||||
Inspect or stop the current repo's managed dev runner:
|
||||
|
||||
```sh
|
||||
pnpm dev:list
|
||||
pnpm dev:stop
|
||||
```
|
||||
|
||||
`pnpm dev:once` now tracks backend-relevant file changes and pending migrations. When the current boot is stale, the board UI shows a `Restart required` banner. You can also enable guarded auto-restart in `Instance Settings > Experimental`, which waits for queued/running local agent runs to finish before restarting the dev server.
|
||||
|
||||
Tailscale/private-auth dev mode:
|
||||
|
||||
```sh
|
||||
@@ -84,11 +97,15 @@ docker run --name paperclip \
|
||||
Or use Compose:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.quickstart.yml up --build
|
||||
docker compose -f docker/docker-compose.quickstart.yml up --build
|
||||
```
|
||||
|
||||
See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) and persistence details.
|
||||
|
||||
## Docker For Untrusted PR Review
|
||||
|
||||
For a separate review-oriented container that keeps `codex`/`claude` login state in Docker volumes and checks out PRs into an isolated scratch workspace, see `doc/UNTRUSTED-PR-REVIEW.md`.
|
||||
|
||||
## Database in Dev (Auto-Handled)
|
||||
|
||||
For local development, leave `DATABASE_URL` unset.
|
||||
@@ -124,6 +141,12 @@ 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.
|
||||
|
||||
For `codex_local`, Paperclip also manages a per-company Codex home under the instance root and seeds it from the shared Codex login/config home (`$CODEX_HOME` or `~/.codex`):
|
||||
|
||||
- `~/.paperclip/instances/default/companies/<company-id>/codex-home`
|
||||
|
||||
If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs fail at execution time with a clear adapter error. Quota polling uses a short-lived `codex app-server` subprocess: when `codex` cannot be spawned, that provider reports `ok: false` in aggregated quota results and the API server keeps running (it must not exit on a missing binary).
|
||||
|
||||
## Worktree-local Instances
|
||||
|
||||
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.
|
||||
@@ -142,7 +165,7 @@ This command:
|
||||
- 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
|
||||
- by default seeds the isolated DB in `minimal` mode from the current effective Paperclip instance/config (repo-local worktree config when present, otherwise the default instance) via a logical SQL snapshot
|
||||
|
||||
Seed modes:
|
||||
|
||||
@@ -152,7 +175,15 @@ Seed modes:
|
||||
|
||||
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.
|
||||
Provisioned git worktrees also pause all seeded routines in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development.
|
||||
|
||||
That repo-local env also sets:
|
||||
|
||||
- `PAPERCLIP_IN_WORKTREE=true`
|
||||
- `PAPERCLIP_WORKTREE_NAME=<worktree-name>`
|
||||
- `PAPERCLIP_WORKTREE_COLOR=<hex-color>`
|
||||
|
||||
The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon.
|
||||
|
||||
Print shell exports explicitly when needed:
|
||||
|
||||
@@ -162,17 +193,84 @@ paperclipai worktree env
|
||||
eval "$(paperclipai worktree env)"
|
||||
```
|
||||
|
||||
Useful options:
|
||||
### 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 minimal
|
||||
paperclipai worktree init --seed-mode full
|
||||
paperclipai worktree init --from-instance default
|
||||
paperclipai worktree init --from-data-dir ~/.paperclip
|
||||
paperclipai worktree init --force
|
||||
```
|
||||
|
||||
Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install:
|
||||
|
||||
```sh
|
||||
cd ~/.paperclip/worktrees/PAP-884-ai-commits-component
|
||||
pnpm paperclipai worktree init --force --seed-mode minimal \
|
||||
--name PAP-884-ai-commits-component \
|
||||
--from-config ~/.paperclip/instances/default/config.json
|
||||
```
|
||||
|
||||
That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`, and preserves the git worktree contents themselves.
|
||||
|
||||
**`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
|
||||
|
||||
134
doc/DOCKER.md
134
doc/DOCKER.md
@@ -2,6 +2,28 @@
|
||||
|
||||
Run Paperclip in Docker without installing Node or pnpm locally.
|
||||
|
||||
All commands below assume you are in the **project root** (the directory containing `package.json`), not inside `docker/`.
|
||||
|
||||
## Building the image
|
||||
|
||||
```sh
|
||||
docker build -t paperclip-local .
|
||||
```
|
||||
|
||||
The Dockerfile installs common agent tools (`git`, `gh`, `curl`, `wget`, `ripgrep`, `python3`) and the Claude, Codex, and OpenCode CLIs.
|
||||
|
||||
Build arguments:
|
||||
|
||||
| Arg | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `USER_UID` | `1000` | UID for the container `node` user (match your host UID to avoid permission issues on bind mounts) |
|
||||
| `USER_GID` | `1000` | GID for the container `node` group |
|
||||
|
||||
```sh
|
||||
docker build -t paperclip-local \
|
||||
--build-arg USER_UID=$(id -u) --build-arg USER_GID=$(id -g) .
|
||||
```
|
||||
|
||||
## One-liner (build + run)
|
||||
|
||||
```sh
|
||||
@@ -10,6 +32,7 @@ docker run --name paperclip \
|
||||
-p 3100:3100 \
|
||||
-e HOST=0.0.0.0 \
|
||||
-e PAPERCLIP_HOME=/paperclip \
|
||||
-e BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
|
||||
-v "$(pwd)/data/docker-paperclip:/paperclip" \
|
||||
paperclip-local
|
||||
```
|
||||
@@ -25,10 +48,15 @@ Data persistence:
|
||||
|
||||
All persisted under your bind mount (`./data/docker-paperclip` in the example above).
|
||||
|
||||
## Compose Quickstart
|
||||
## Docker Compose
|
||||
|
||||
### Quickstart (embedded SQLite)
|
||||
|
||||
Single container, no external database. Data persists via a bind mount.
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.quickstart.yml up --build
|
||||
BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
|
||||
docker compose -f docker/docker-compose.quickstart.yml up --build
|
||||
```
|
||||
|
||||
Defaults:
|
||||
@@ -39,11 +67,36 @@ Defaults:
|
||||
Optional overrides:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=./data/pc docker compose -f docker-compose.quickstart.yml up --build
|
||||
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=../data/pc \
|
||||
docker compose -f docker/docker-compose.quickstart.yml up --build
|
||||
```
|
||||
|
||||
**Note:** `PAPERCLIP_DATA_DIR` is resolved relative to the compose file (`docker/`), so `../data/pc` maps to `data/pc` in the project root.
|
||||
|
||||
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.
|
||||
|
||||
Pass `OPENAI_API_KEY` and/or `ANTHROPIC_API_KEY` to enable local adapter runs.
|
||||
|
||||
### Full stack (with PostgreSQL)
|
||||
|
||||
Paperclip server + PostgreSQL 17. The database is health-checked before the server starts.
|
||||
|
||||
```sh
|
||||
BETTER_AUTH_SECRET=$(openssl rand -hex 32) \
|
||||
docker compose -f docker/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
PostgreSQL data persists in a named Docker volume (`pgdata`). Paperclip data persists in `paperclip-data`.
|
||||
|
||||
### Untrusted PR review
|
||||
|
||||
Isolated container for reviewing untrusted pull requests with Codex or Claude, without exposing your host machine. See `doc/UNTRUSTED-PR-REVIEW.md` for the full workflow.
|
||||
|
||||
```sh
|
||||
docker compose -f docker/docker-compose.untrusted-review.yml build
|
||||
docker compose -f docker/docker-compose.untrusted-review.yml run --rm --service-ports review
|
||||
```
|
||||
|
||||
## Authenticated Compose (Single Public URL)
|
||||
|
||||
For authenticated deployments, set one canonical public URL and let Paperclip derive auth/callback defaults:
|
||||
@@ -93,6 +146,72 @@ Notes:
|
||||
- Without API keys, the app still runs normally.
|
||||
- Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.
|
||||
|
||||
## Podman Quadlet (systemd)
|
||||
|
||||
The `docker/quadlet/` directory contains unit files to run Paperclip + PostgreSQL as systemd services via Podman Quadlet.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `docker/quadlet/paperclip.pod` | Pod definition — groups containers into a shared network namespace |
|
||||
| `docker/quadlet/paperclip.container` | Paperclip server — joins the pod, connects to Postgres at `127.0.0.1` |
|
||||
| `docker/quadlet/paperclip-db.container` | PostgreSQL 17 — joins the pod, health-checked |
|
||||
|
||||
### Setup
|
||||
|
||||
1. Build the image (see above).
|
||||
|
||||
2. Copy quadlet files to your systemd directory:
|
||||
|
||||
```sh
|
||||
# Rootless (recommended)
|
||||
cp docker/quadlet/*.pod docker/quadlet/*.container \
|
||||
~/.config/containers/systemd/
|
||||
|
||||
# Or rootful
|
||||
sudo cp docker/quadlet/*.pod docker/quadlet/*.container \
|
||||
/etc/containers/systemd/
|
||||
```
|
||||
|
||||
3. Create a secrets env file (keep out of version control):
|
||||
|
||||
```sh
|
||||
cat > ~/.config/containers/systemd/paperclip.env <<EOL
|
||||
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
|
||||
POSTGRES_USER=paperclip
|
||||
POSTGRES_PASSWORD=paperclip
|
||||
POSTGRES_DB=paperclip
|
||||
DATABASE_URL=postgres://paperclip:paperclip@127.0.0.1:5432/paperclip
|
||||
# OPENAI_API_KEY=sk-...
|
||||
# ANTHROPIC_API_KEY=sk-...
|
||||
EOL
|
||||
```
|
||||
|
||||
4. Create the data directory and start:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.local/share/paperclip
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user start paperclip-pod
|
||||
```
|
||||
|
||||
### Quadlet management
|
||||
|
||||
```sh
|
||||
journalctl --user -u paperclip -f # App logs
|
||||
journalctl --user -u paperclip-db -f # DB logs
|
||||
systemctl --user status paperclip-pod # Pod status
|
||||
systemctl --user restart paperclip-pod # Restart all
|
||||
systemctl --user stop paperclip-pod # Stop all
|
||||
```
|
||||
|
||||
### Quadlet notes
|
||||
|
||||
- **First boot**: Unlike Docker Compose's `condition: service_healthy`, Quadlet's `After=` only waits for the DB unit to *start*, not for PostgreSQL to be ready. On a cold first boot you may see one or two restart attempts in `journalctl --user -u paperclip` while PostgreSQL initialises — this is expected and resolves automatically via `Restart=on-failure`.
|
||||
- Containers in a pod share `localhost`, so Paperclip reaches Postgres at `127.0.0.1:5432`.
|
||||
- PostgreSQL data persists in the `paperclip-pgdata` named volume.
|
||||
- Paperclip data persists at `~/.local/share/paperclip`.
|
||||
- For rootful quadlet deployment, remove `%h` prefixes and use absolute paths.
|
||||
|
||||
## Onboard Smoke Test (Ubuntu + npm only)
|
||||
|
||||
Use this when you want to mimic a fresh machine that only has Ubuntu + npm and verify:
|
||||
@@ -114,6 +233,7 @@ Useful overrides:
|
||||
```sh
|
||||
HOST_PORT=3200 PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
|
||||
PAPERCLIP_DEPLOYMENT_MODE=authenticated PAPERCLIP_DEPLOYMENT_EXPOSURE=private ./scripts/docker-onboard-smoke.sh
|
||||
SMOKE_DETACH=true SMOKE_METADATA_FILE=/tmp/paperclip-smoke.env PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
Notes:
|
||||
@@ -125,4 +245,10 @@ Notes:
|
||||
- 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`.
|
||||
- Set `SMOKE_DETACH=true` to leave the container running for automation and optionally write shell-ready metadata to `SMOKE_METADATA_FILE`.
|
||||
- The image definition is in `docker/Dockerfile.onboard-smoke`.
|
||||
|
||||
## General Notes
|
||||
|
||||
- The `docker-entrypoint.sh` adjusts the container `node` user UID/GID at startup to match the values passed via `USER_UID`/`USER_GID`, avoiding permission issues on bind-mounted volumes.
|
||||
- Paperclip data persists via Docker volumes/bind mounts (compose) or at `~/.local/share/paperclip` (quadlet).
|
||||
|
||||
@@ -94,3 +94,53 @@ Canonical mode design and command expectations live in `doc/DEPLOYMENT-MODES.md`
|
||||
## Further Detail
|
||||
|
||||
See [SPEC.md](./SPEC.md) for the full technical specification and [TASKS.md](./TASKS.md) for the task management data model.
|
||||
|
||||
---
|
||||
|
||||
Paperclip’s core identity is a **control plane for autonomous AI companies**, centered on **companies, org charts, goals, issues/comments, heartbeats, budgets, approvals, and board governance**. The public docs are also explicit about the current boundaries: **tasks/comments are the built-in communication model**, Paperclip is **not a chatbot**, and it is **not a code review tool**. The roadmap already points toward **easier onboarding, cloud agents, easier agent configuration, plugins, better docs, and ClipMart/ClipHub-style reusable companies/templates**.
|
||||
|
||||
## What Paperclip should do vs. not do
|
||||
|
||||
**Do**
|
||||
|
||||
- Stay **board-level and company-level**. Users should manage goals, orgs, budgets, approvals, and outputs.
|
||||
- Make the first five minutes feel magical: install, answer a few questions, see a CEO do something real.
|
||||
- Keep work anchored to **issues/comments/projects/goals**, even if the surface feels conversational.
|
||||
- Treat **agency / internal team / startup** as the same underlying abstraction with different templates and labels.
|
||||
- Make outputs first-class: files, docs, reports, previews, links, screenshots.
|
||||
- Provide **hooks into engineering workflows**: worktrees, preview servers, PR links, external review tools.
|
||||
- Use **plugins** for edge cases like rich chat, knowledge bases, doc editors, custom tracing.
|
||||
|
||||
**Do not**
|
||||
|
||||
- Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable.
|
||||
- Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review.
|
||||
- Do not build enterprise-grade RBAC first. The current V1 spec still treats multi-board governance and fine-grained human permissions as out of scope, so the first multi-user version should be coarse and company-scoped.
|
||||
- Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath.
|
||||
- Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real.
|
||||
|
||||
## Specific design goals
|
||||
|
||||
1. **Time-to-first-success under 5 minutes**
|
||||
A fresh user should go from install to “my CEO completed a first task” in one sitting.
|
||||
|
||||
2. **Board-level abstraction always wins**
|
||||
The default UI should answer: what is the company doing, who is doing it, why does it matter, what did it cost, and what needs my approval.
|
||||
|
||||
3. **Conversation stays attached to work objects**
|
||||
“Chat with CEO” should still resolve to strategy threads, decisions, tasks, or approvals.
|
||||
|
||||
4. **Progressive disclosure**
|
||||
Top layer: human-readable summary. Middle layer: checklist/steps/artifacts. Bottom layer: raw logs/tool calls/transcript.
|
||||
|
||||
5. **Output-first**
|
||||
Work is not done until the user can see the result: file, document, preview link, screenshot, plan, or PR.
|
||||
|
||||
6. **Local-first, cloud-ready**
|
||||
The mental model should not change between local solo use and shared/private or public/cloud deployment.
|
||||
|
||||
7. **Safe autonomy**
|
||||
Auto mode is allowed; hidden token burn is not.
|
||||
|
||||
8. **Thin core, rich edges**
|
||||
Put optional chat, knowledge, and special surfaces into plugins/extensions rather than bloating the control plane.
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
# Publishing to npm
|
||||
|
||||
Low-level reference for how Paperclip packages are built for npm.
|
||||
Low-level reference for how Paperclip packages are prepared and published to npm.
|
||||
|
||||
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.
|
||||
For the maintainer workflow, use [doc/RELEASING.md](RELEASING.md). This document focuses on packaging internals.
|
||||
|
||||
## Current Release Entry Points
|
||||
|
||||
Use these scripts instead of older one-off publish commands:
|
||||
Use these scripts:
|
||||
|
||||
- [`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
|
||||
- [`scripts/release.sh`](../scripts/release.sh) for canary and stable publish flows
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after pushing a stable tag
|
||||
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest`
|
||||
- [`scripts/build-npm.sh`](../scripts/build-npm.sh) for the CLI packaging build
|
||||
|
||||
Paperclip no longer uses release branches or Changesets for publishing.
|
||||
|
||||
## Why the CLI needs special packaging
|
||||
|
||||
@@ -23,7 +24,7 @@ The CLI package, `paperclipai`, imports code from workspace packages such as:
|
||||
- `@paperclipai/shared`
|
||||
- adapter packages under `packages/adapters/`
|
||||
|
||||
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.
|
||||
Those workspace references are valid in development but not in a publishable npm package. The release flow rewrites versions temporarily, then builds a publishable CLI bundle.
|
||||
|
||||
## `build-npm.sh`
|
||||
|
||||
@@ -33,89 +34,158 @@ Run:
|
||||
./scripts/build-npm.sh
|
||||
```
|
||||
|
||||
This script does six things:
|
||||
This script:
|
||||
|
||||
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
|
||||
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 metadata
|
||||
|
||||
`build-npm.sh` is used by the release script so that npm users install a real package rather than unresolved workspace dependencies.
|
||||
After the release script exits, the dev manifest and temporary files are restored automatically.
|
||||
|
||||
## Publishable CLI layout
|
||||
## Package discovery and versioning
|
||||
|
||||
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:
|
||||
Public packages are discovered from:
|
||||
|
||||
- `packages/`
|
||||
- `server/`
|
||||
- `ui/`
|
||||
- `cli/`
|
||||
|
||||
`ui/` remains ignored for npm publishing because it is private.
|
||||
The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which:
|
||||
|
||||
This matters because all public packages are versioned and published together as one release unit.
|
||||
- finds all public packages
|
||||
- sorts them topologically by internal dependencies
|
||||
- rewrites each package version to the target release version
|
||||
- rewrites internal `workspace:*` dependency references to the exact target version
|
||||
- updates the CLI's displayed version string
|
||||
|
||||
## Canary packaging model
|
||||
Those rewrites are temporary. The working tree is restored after publish or dry-run.
|
||||
|
||||
Canaries are published as semver prereleases such as:
|
||||
## `@paperclipai/ui` packaging
|
||||
|
||||
- `1.2.3-canary.0`
|
||||
- `1.2.3-canary.1`
|
||||
The UI package publishes prebuilt static assets, not the source workspace.
|
||||
|
||||
They are published under the npm dist-tag `canary`.
|
||||
The `ui` package uses [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs) during `prepack` to swap in a lean publish manifest that:
|
||||
|
||||
This means:
|
||||
- keeps the release-managed `name` and `version`
|
||||
- publishes only `dist/`
|
||||
- omits the source-only dependency graph from downstream installs
|
||||
|
||||
- `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`
|
||||
After packing or publishing, `postpack` restores the development manifest automatically.
|
||||
|
||||
## Stable packaging model
|
||||
### Manual first publish for `@paperclipai/ui`
|
||||
|
||||
Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`.
|
||||
If you need to publish only the UI package once by hand, use the real package name:
|
||||
|
||||
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.
|
||||
- `@paperclipai/ui`
|
||||
|
||||
Recommended flow from the repo root:
|
||||
|
||||
```bash
|
||||
# optional sanity check: this 404s until the first publish exists
|
||||
npm view @paperclipai/ui version
|
||||
|
||||
# make sure the dist payload is fresh
|
||||
pnpm --filter @paperclipai/ui build
|
||||
|
||||
# confirm your local npm auth before the real publish
|
||||
npm whoami
|
||||
|
||||
# safe preview of the exact publish payload
|
||||
cd ui
|
||||
pnpm publish --dry-run --no-git-checks --access public
|
||||
|
||||
# real publish
|
||||
pnpm publish --no-git-checks --access public
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Publish from `ui/`, not the repo root.
|
||||
- `prepack` automatically rewrites `ui/package.json` to the lean publish manifest, and `postpack` restores the dev manifest after the command finishes.
|
||||
- If `npm view @paperclipai/ui version` already returns the same version that is in [`ui/package.json`](../ui/package.json), do not republish. Bump the version or use the normal repo-wide release flow in [`scripts/release.sh`](../scripts/release.sh).
|
||||
|
||||
If the first real publish returns npm `E404`, check npm-side prerequisites before retrying:
|
||||
|
||||
- `npm whoami` must succeed first. An expired or missing npm login will block the publish.
|
||||
- For an organization-scoped package like `@paperclipai/ui`, the `paperclipai` npm organization must exist and the publisher must be a member with permission to publish to that scope.
|
||||
- The initial publish must include `--access public` for a public scoped package.
|
||||
- npm also requires either account 2FA for publishing or a granular token that is allowed to bypass 2FA.
|
||||
|
||||
## Version formats
|
||||
|
||||
Paperclip uses calendar versions:
|
||||
|
||||
- stable: `YYYY.MDD.P`
|
||||
- canary: `YYYY.MDD.P-canary.N`
|
||||
|
||||
Examples:
|
||||
|
||||
- stable: `2026.318.0`
|
||||
- canary: `2026.318.1-canary.2`
|
||||
|
||||
## Publish model
|
||||
|
||||
### Canary
|
||||
|
||||
Canaries publish under the npm dist-tag `canary`.
|
||||
|
||||
Example:
|
||||
|
||||
- `paperclipai@2026.318.1-canary.2`
|
||||
|
||||
This keeps the default install path unchanged while allowing explicit installs with:
|
||||
|
||||
```bash
|
||||
npx paperclipai@canary onboard
|
||||
```
|
||||
|
||||
### Stable
|
||||
|
||||
Stable publishes use the npm dist-tag `latest`.
|
||||
|
||||
Example:
|
||||
|
||||
- `paperclipai@2026.318.0`
|
||||
|
||||
Stable publishes do not create a release commit. Instead:
|
||||
|
||||
- package versions are rewritten temporarily
|
||||
- packages are published from the chosen source commit
|
||||
- git tag `vYYYY.MDD.P` points at that original commit
|
||||
|
||||
## Trusted publishing
|
||||
|
||||
The intended CI model is npm trusted publishing through GitHub OIDC.
|
||||
|
||||
That means:
|
||||
|
||||
- no long-lived `NPM_TOKEN` in repository secrets
|
||||
- GitHub Actions obtains short-lived publish credentials
|
||||
- trusted publisher rules are configured per workflow file
|
||||
|
||||
See [doc/RELEASE-AUTOMATION-SETUP.md](RELEASE-AUTOMATION-SETUP.md) for the GitHub/npm setup steps.
|
||||
|
||||
## Rollback model
|
||||
|
||||
Rollback does not unpublish packages.
|
||||
Rollback does not unpublish anything.
|
||||
|
||||
Instead, the maintainer should move the `latest` dist-tag back to the previous good stable version with:
|
||||
It repoints the `latest` dist-tag to a prior stable version:
|
||||
|
||||
```bash
|
||||
./scripts/rollback-latest.sh <stable-version>
|
||||
./scripts/rollback-latest.sh 2026.318.0
|
||||
```
|
||||
|
||||
That keeps history intact while restoring the default install path quickly.
|
||||
|
||||
## Notes for CI
|
||||
|
||||
The repo includes a manual GitHub Actions release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
|
||||
|
||||
Recommended CI release setup:
|
||||
|
||||
- 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
|
||||
This is the fastest way to restore the default install path if a stable release is bad.
|
||||
|
||||
## Related Files
|
||||
|
||||
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
|
||||
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
|
||||
- [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs)
|
||||
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
|
||||
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
|
||||
- [`doc/RELEASING.md`](RELEASING.md)
|
||||
|
||||
282
doc/RELEASE-AUTOMATION-SETUP.md
Normal file
282
doc/RELEASE-AUTOMATION-SETUP.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# Release Automation Setup
|
||||
|
||||
This document covers the GitHub and npm setup required for the current Paperclip release model:
|
||||
|
||||
- automatic canaries from `master`
|
||||
- manual stable promotion from a chosen source ref
|
||||
- npm trusted publishing via GitHub OIDC
|
||||
- protected release infrastructure in a public repository
|
||||
|
||||
Repo-side files that depend on this setup:
|
||||
|
||||
- `.github/workflows/release.yml`
|
||||
- `.github/CODEOWNERS`
|
||||
|
||||
Note:
|
||||
|
||||
- the release workflows intentionally use `pnpm install --no-frozen-lockfile`
|
||||
- this matches the repo's current policy where `pnpm-lock.yaml` is refreshed by GitHub automation after manifest changes land on `master`
|
||||
- the publish jobs then restore `pnpm-lock.yaml` before running `scripts/release.sh`, so the release script still sees a clean worktree
|
||||
|
||||
## 1. Merge the Repo Changes First
|
||||
|
||||
Before touching GitHub or npm settings, merge the release automation code so the referenced workflow filenames already exist on the default branch.
|
||||
|
||||
Required files:
|
||||
|
||||
- `.github/workflows/release.yml`
|
||||
- `.github/CODEOWNERS`
|
||||
|
||||
## 2. Configure npm Trusted Publishing
|
||||
|
||||
Do this for every public package that Paperclip publishes.
|
||||
|
||||
At minimum that includes:
|
||||
|
||||
- `paperclipai`
|
||||
- `@paperclipai/server`
|
||||
- `@paperclipai/ui`
|
||||
- public packages under `packages/`
|
||||
|
||||
### 2.1. In npm, open each package settings page
|
||||
|
||||
For each package:
|
||||
|
||||
1. open npm as an owner of the package
|
||||
2. go to the package settings / publishing access area
|
||||
3. add a trusted publisher for the GitHub repository `paperclipai/paperclip`
|
||||
|
||||
### 2.2. Add one trusted publisher entry per package
|
||||
|
||||
npm currently allows one trusted publisher configuration per package.
|
||||
|
||||
Configure:
|
||||
|
||||
- workflow: `.github/workflows/release.yml`
|
||||
|
||||
Repository:
|
||||
|
||||
- `paperclipai/paperclip`
|
||||
|
||||
Environment name:
|
||||
|
||||
- leave the npm trusted-publisher environment field blank
|
||||
|
||||
Why:
|
||||
|
||||
- the single `release.yml` workflow handles both canary and stable publishing
|
||||
- GitHub environments `npm-canary` and `npm-stable` still enforce different approval rules on the GitHub side
|
||||
|
||||
### 2.3. Verify trusted publishing before removing old auth
|
||||
|
||||
After the workflows are live:
|
||||
|
||||
1. run a canary publish
|
||||
2. confirm npm publish succeeds without any `NPM_TOKEN`
|
||||
3. run a stable dry-run
|
||||
4. run one real stable publish
|
||||
|
||||
Only after that should you remove old token-based access.
|
||||
|
||||
## 3. Remove Legacy npm Tokens
|
||||
|
||||
After trusted publishing works:
|
||||
|
||||
1. revoke any repository or organization `NPM_TOKEN` secrets used for publish
|
||||
2. revoke any personal automation token that used to publish Paperclip
|
||||
3. if npm offers a package-level setting to restrict publishing to trusted publishers, enable it
|
||||
|
||||
Goal:
|
||||
|
||||
- no long-lived npm publishing token should remain in GitHub Actions
|
||||
|
||||
## 4. Create GitHub Environments
|
||||
|
||||
Create two environments in the GitHub repository:
|
||||
|
||||
- `npm-canary`
|
||||
- `npm-stable`
|
||||
|
||||
Path:
|
||||
|
||||
1. GitHub repository
|
||||
2. `Settings`
|
||||
3. `Environments`
|
||||
4. `New environment`
|
||||
|
||||
## 5. Configure `npm-canary`
|
||||
|
||||
Recommended settings for `npm-canary`:
|
||||
|
||||
- environment name: `npm-canary`
|
||||
- required reviewers: none
|
||||
- wait timer: none
|
||||
- deployment branches and tags:
|
||||
- selected branches only
|
||||
- allow `master`
|
||||
|
||||
Reasoning:
|
||||
|
||||
- every push to `master` should be able to publish a canary automatically
|
||||
- no human approval should be required for canaries
|
||||
|
||||
## 6. Configure `npm-stable`
|
||||
|
||||
Recommended settings for `npm-stable`:
|
||||
|
||||
- environment name: `npm-stable`
|
||||
- required reviewers: at least one maintainer other than the person triggering the workflow when possible
|
||||
- prevent self-review: enabled
|
||||
- admin bypass: disabled if your team can tolerate it
|
||||
- wait timer: optional
|
||||
- deployment branches and tags:
|
||||
- selected branches only
|
||||
- allow `master`
|
||||
|
||||
Reasoning:
|
||||
|
||||
- stable publishes should require an explicit human approval gate
|
||||
- the workflow is manual, but the environment should still be the real control point
|
||||
|
||||
## 7. Protect `master`
|
||||
|
||||
Open the branch protection settings for `master`.
|
||||
|
||||
Recommended rules:
|
||||
|
||||
1. require pull requests before merging
|
||||
2. require status checks to pass before merging
|
||||
3. require review from code owners
|
||||
4. dismiss stale approvals when new commits are pushed
|
||||
5. restrict who can push directly to `master`
|
||||
|
||||
At minimum, make sure workflow and release script changes cannot land without review.
|
||||
|
||||
## 8. Enforce CODEOWNERS Review
|
||||
|
||||
This repo now includes `.github/CODEOWNERS`, but GitHub only enforces it if branch protection requires code owner reviews.
|
||||
|
||||
In branch protection for `master`, enable:
|
||||
|
||||
- `Require review from Code Owners`
|
||||
|
||||
Then verify the owner entries are correct for your actual maintainer set.
|
||||
|
||||
Current file:
|
||||
|
||||
- `.github/CODEOWNERS`
|
||||
|
||||
If `@cryppadotta` is not the right reviewer identity in the public repo, change it before enabling enforcement.
|
||||
|
||||
## 9. Protect Release Infrastructure Specifically
|
||||
|
||||
These files should always trigger code owner review:
|
||||
|
||||
- `.github/workflows/release.yml`
|
||||
- `scripts/release.sh`
|
||||
- `scripts/release-lib.sh`
|
||||
- `scripts/release-package-map.mjs`
|
||||
- `scripts/create-github-release.sh`
|
||||
- `scripts/rollback-latest.sh`
|
||||
- `doc/RELEASING.md`
|
||||
- `doc/PUBLISHING.md`
|
||||
|
||||
If you want stronger controls, add a repository ruleset that explicitly blocks direct pushes to:
|
||||
|
||||
- `.github/workflows/**`
|
||||
- `scripts/release*`
|
||||
|
||||
## 10. Do Not Store a Claude Token in GitHub Actions
|
||||
|
||||
Do not add a personal Claude or Anthropic token for automatic changelog generation.
|
||||
|
||||
Recommended policy:
|
||||
|
||||
- stable changelog generation happens locally from a trusted maintainer machine
|
||||
- canaries never generate changelogs
|
||||
|
||||
This keeps LLM spending intentional and avoids a high-value token sitting in Actions.
|
||||
|
||||
## 11. Verify the Canary Workflow
|
||||
|
||||
After setup:
|
||||
|
||||
1. merge a harmless commit to `master`
|
||||
2. open the `Release` workflow run triggered by that push
|
||||
3. confirm it passes verification
|
||||
4. confirm publish succeeds under the `npm-canary` environment
|
||||
5. confirm npm now shows a new `canary` release
|
||||
6. confirm a git tag named `canary/vYYYY.MDD.P-canary.N` was pushed
|
||||
|
||||
Install-path check:
|
||||
|
||||
```bash
|
||||
npx paperclipai@canary onboard
|
||||
```
|
||||
|
||||
## 12. Verify the Stable Workflow
|
||||
|
||||
After at least one good canary exists:
|
||||
|
||||
1. resolve the target stable version with `./scripts/release.sh stable --date YYYY-MM-DD --print-version`
|
||||
2. prepare `releases/vYYYY.MDD.P.md` on the source commit you want to promote
|
||||
3. open `Actions` -> `Release`
|
||||
4. run it with:
|
||||
- `source_ref`: the tested commit SHA or canary tag source commit
|
||||
- `stable_date`: leave blank or set the intended UTC date like `2026-03-18`
|
||||
do not enter a version like `2026.318.0`; the workflow computes that from the date
|
||||
- `dry_run`: `true`
|
||||
5. confirm the dry-run succeeds
|
||||
6. rerun with `dry_run: false`
|
||||
7. approve the `npm-stable` environment when prompted
|
||||
8. confirm npm `latest` points to the new stable version
|
||||
9. confirm git tag `vYYYY.MDD.P` exists
|
||||
10. confirm the GitHub Release was created
|
||||
|
||||
Implementation note:
|
||||
|
||||
- the GitHub Actions stable workflow calls `create-github-release.sh` with `PUBLISH_REMOTE=origin`
|
||||
- local maintainer usage can still pass `PUBLISH_REMOTE=public-gh` explicitly when needed
|
||||
|
||||
## 13. Suggested Maintainer Policy
|
||||
|
||||
Use this policy going forward:
|
||||
|
||||
- canaries are automatic and cheap
|
||||
- stables are manual and approved
|
||||
- only stables get public notes and announcements
|
||||
- release notes are committed before stable publish
|
||||
- rollback uses `npm dist-tag`, not unpublish
|
||||
|
||||
## 14. Troubleshooting
|
||||
|
||||
### Trusted publishing fails with an auth error
|
||||
|
||||
Check:
|
||||
|
||||
1. the workflow filename on GitHub exactly matches the filename configured in npm
|
||||
2. the package has the trusted publisher entry for the correct repository
|
||||
3. the job has `id-token: write`
|
||||
4. the job is running from the expected repository, not a fork
|
||||
|
||||
### Stable workflow runs but never asks for approval
|
||||
|
||||
Check:
|
||||
|
||||
1. the `publish` job uses environment `npm-stable`
|
||||
2. the environment actually has required reviewers configured
|
||||
3. the workflow is running in the canonical repository, not a fork
|
||||
|
||||
### CODEOWNERS does not trigger
|
||||
|
||||
Check:
|
||||
|
||||
1. `.github/CODEOWNERS` is on the default branch
|
||||
2. branch protection on `master` requires code owner review
|
||||
3. the owner identities in the file are valid reviewers with repository access
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [doc/RELEASING.md](RELEASING.md)
|
||||
- [doc/PUBLISHING.md](PUBLISHING.md)
|
||||
- [doc/plans/2026-03-17-release-automation-and-versioning.md](plans/2026-03-17-release-automation-and-versioning.md)
|
||||
477
doc/RELEASING.md
477
doc/RELEASING.md
@@ -1,220 +1,174 @@
|
||||
# Releasing Paperclip
|
||||
|
||||
Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface.
|
||||
Maintainer runbook for shipping Paperclip across npm, GitHub, and the website-facing changelog surface.
|
||||
|
||||
The release model is branch-driven:
|
||||
The release model is now commit-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
|
||||
1. Every push to `master` publishes a canary automatically.
|
||||
2. Stable releases are manually promoted from a chosen tested commit or canary tag.
|
||||
3. Stable release notes live in `releases/vYYYY.MDD.P.md`.
|
||||
4. Only stable releases get GitHub Releases.
|
||||
|
||||
## Versioning Model
|
||||
|
||||
Paperclip uses calendar versions that still fit semver syntax:
|
||||
|
||||
- stable: `YYYY.MDD.P`
|
||||
- canary: `YYYY.MDD.P-canary.N`
|
||||
|
||||
Examples:
|
||||
|
||||
- first stable on March 18, 2026: `2026.318.0`
|
||||
- second stable on March 18, 2026: `2026.318.1`
|
||||
- fourth canary for the `2026.318.1` line: `2026.318.1-canary.3`
|
||||
|
||||
Important constraints:
|
||||
|
||||
- the middle numeric slot is `MDD`, where `M` is the UTC month and `DD` is the zero-padded UTC day
|
||||
- use `2026.303.0` for March 3, not `2026.33.0`
|
||||
- do not use leading zeroes such as `2026.0318.0`
|
||||
- do not use four numeric segments such as `2026.3.18.1`
|
||||
- the semver-safe canary form is `2026.318.0-canary.1`
|
||||
|
||||
## Release Surfaces
|
||||
|
||||
Every release has four separate surfaces:
|
||||
Every stable 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.
|
||||
A stable release is done only when all four surfaces are handled.
|
||||
|
||||
Canaries only cover the first two surfaces plus an internal traceability tag.
|
||||
|
||||
## 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.
|
||||
- canaries publish from `master`
|
||||
- stables publish from an explicitly chosen source ref
|
||||
- tags point at the original source commit, not a generated release commit
|
||||
- stable notes are always `releases/vYYYY.MDD.P.md`
|
||||
- canaries never create GitHub Releases
|
||||
- canaries never require changelog generation
|
||||
|
||||
## TL;DR
|
||||
|
||||
### 1. Start the release train
|
||||
### Canary
|
||||
|
||||
Use this to compute the next version, create or resume the branch, create or resume a dedicated worktree, and push the branch to GitHub.
|
||||
Every push to `master` runs the canary path inside [`.github/workflows/release.yml`](../.github/workflows/release.yml).
|
||||
|
||||
```bash
|
||||
./scripts/release-start.sh patch
|
||||
```
|
||||
It:
|
||||
|
||||
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 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
|
||||
```
|
||||
- verifies the pushed commit
|
||||
- computes the canary version for the current UTC date
|
||||
- publishes under npm dist-tag `canary`
|
||||
- creates a git tag `canary/vYYYY.MDD.P-canary.N`
|
||||
|
||||
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>
|
||||
npx paperclipai@canary onboard --data-dir "$(mktemp -d /tmp/paperclip-canary.XXXXXX)"
|
||||
```
|
||||
|
||||
The preflight script now checks all of the following before it runs the verification gate:
|
||||
### Stable
|
||||
|
||||
- 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
|
||||
Use [`.github/workflows/release.yml`](../.github/workflows/release.yml) from the Actions tab with the manual `workflow_dispatch` inputs.
|
||||
|
||||
Then it runs:
|
||||
[Run the action here](https://github.com/paperclipai/paperclip/actions/workflows/release.yml)
|
||||
|
||||
Inputs:
|
||||
|
||||
- `source_ref`
|
||||
- commit SHA, branch, or tag
|
||||
- `stable_date`
|
||||
- optional UTC date override in `YYYY-MM-DD`
|
||||
- enter a date like `2026-03-18`, not a version like `2026.318.0`
|
||||
- `dry_run`
|
||||
- preview only when true
|
||||
|
||||
Before running stable:
|
||||
|
||||
1. pick the canary commit or tag you trust
|
||||
2. resolve the target stable version with `./scripts/release.sh stable --date "$(date +%F)" --print-version`
|
||||
3. create or update `releases/vYYYY.MDD.P.md` on that source ref
|
||||
4. run the stable workflow from that source ref
|
||||
|
||||
Example:
|
||||
|
||||
- `source_ref`: `master`
|
||||
- `stable_date`: `2026-03-18`
|
||||
- resulting stable version: `2026.318.0`
|
||||
|
||||
The workflow:
|
||||
|
||||
- re-verifies the exact source ref
|
||||
- computes the next stable patch slot for the chosen UTC date
|
||||
- publishes `YYYY.MDD.P` under npm dist-tag `latest`
|
||||
- creates git tag `vYYYY.MDD.P`
|
||||
- creates or updates the GitHub Release from `releases/vYYYY.MDD.P.md`
|
||||
|
||||
## Local Commands
|
||||
|
||||
### Preview a canary locally
|
||||
|
||||
```bash
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
./scripts/release.sh canary --dry-run
|
||||
```
|
||||
|
||||
### 4. Publish one or more canaries
|
||||
|
||||
Run:
|
||||
### Preview a stable locally
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <patch|minor|major> --canary --dry-run
|
||||
./scripts/release.sh <patch|minor|major> --canary
|
||||
./scripts/release.sh stable --dry-run
|
||||
```
|
||||
|
||||
Result:
|
||||
### Publish a stable locally
|
||||
|
||||
- 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
|
||||
This is mainly for emergency/manual use. The normal path is the GitHub workflow.
|
||||
|
||||
Guardrails:
|
||||
```bash
|
||||
./scripts/release.sh stable
|
||||
git push public-gh refs/tags/vYYYY.MDD.P
|
||||
PUBLISH_REMOTE=public-gh ./scripts/create-github-release.sh YYYY.MDD.P
|
||||
```
|
||||
|
||||
- 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
|
||||
## Stable Changelog Workflow
|
||||
|
||||
Concrete example:
|
||||
Stable changelog files live at:
|
||||
|
||||
- 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
|
||||
- `releases/vYYYY.MDD.P.md`
|
||||
|
||||
### 5. Smoke test the canary
|
||||
Canaries do not get changelog files.
|
||||
|
||||
Run the actual install path in Docker:
|
||||
Recommended local generation flow:
|
||||
|
||||
```bash
|
||||
VERSION="$(./scripts/release.sh stable --date 2026-03-18 --print-version)"
|
||||
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."
|
||||
```
|
||||
|
||||
The repo intentionally does not run this through GitHub Actions because:
|
||||
|
||||
- canaries are too frequent
|
||||
- stable notes are the only public narrative surface that needs LLM help
|
||||
- maintainer LLM tokens should not live in Actions
|
||||
|
||||
## Smoke Testing
|
||||
|
||||
For a canary:
|
||||
|
||||
```bash
|
||||
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
For the current stable:
|
||||
|
||||
```bash
|
||||
PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
Useful isolated variants:
|
||||
|
||||
```bash
|
||||
@@ -222,201 +176,76 @@ HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary .
|
||||
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:
|
||||
Automated browser smoke is also available:
|
||||
|
||||
```bash
|
||||
./scripts/clean-onboard-ref.sh
|
||||
PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh
|
||||
./scripts/clean-onboard-ref.sh HEAD
|
||||
gh workflow run release-smoke.yml -f paperclip_version=canary
|
||||
gh workflow run release-smoke.yml -f paperclip_version=latest
|
||||
```
|
||||
|
||||
Minimum checks:
|
||||
|
||||
- `npx paperclipai@canary onboard` installs
|
||||
- onboarding completes without crashes
|
||||
- the server boots
|
||||
- the UI loads
|
||||
- basic company creation and dashboard load work
|
||||
- authenticated login works with the smoke credentials
|
||||
- the browser lands in onboarding on a fresh instance
|
||||
- company creation succeeds
|
||||
- the first CEO agent is created
|
||||
- the first CEO heartbeat run is triggered
|
||||
|
||||
If smoke testing fails:
|
||||
## Rollback
|
||||
|
||||
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
|
||||
Rollback does not unpublish versions.
|
||||
|
||||
### 6. Publish stable from the same release branch
|
||||
|
||||
Once the branch head is vetted, run:
|
||||
It only moves the `latest` dist-tag back to a previous stable:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <patch|minor|major> --dry-run
|
||||
./scripts/release.sh <patch|minor|major>
|
||||
./scripts/rollback-latest.sh 2026.318.0 --dry-run
|
||||
./scripts/rollback-latest.sh 2026.318.0
|
||||
```
|
||||
|
||||
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
|
||||
Then fix forward with a new stable patch slot or release date.
|
||||
|
||||
## Failure Playbooks
|
||||
|
||||
### If the canary publishes but the smoke test fails
|
||||
### If the canary publishes but smoke testing fails
|
||||
|
||||
Do not publish stable.
|
||||
Do not run stable.
|
||||
|
||||
Instead:
|
||||
|
||||
1. fix the issue on `release/X.Y.Z`
|
||||
2. publish another canary
|
||||
3. rerun smoke testing
|
||||
1. fix the issue on `master`
|
||||
2. merge the fix
|
||||
3. wait for the next automatic canary
|
||||
4. rerun smoke testing
|
||||
|
||||
### If stable npm publish succeeds but push or GitHub release creation fails
|
||||
### If stable npm publish succeeds but tag 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
|
||||
1. push the missing tag
|
||||
2. rerun `PUBLISH_REMOTE=public-gh ./scripts/create-github-release.sh YYYY.MDD.P`
|
||||
3. verify the GitHub Release notes point at `releases/vYYYY.MDD.P.md`
|
||||
|
||||
Do not republish the same version.
|
||||
|
||||
### If `latest` is broken after stable publish
|
||||
|
||||
Preview:
|
||||
Roll back the dist-tag:
|
||||
|
||||
```bash
|
||||
./scripts/rollback-latest.sh X.Y.Z --dry-run
|
||||
./scripts/rollback-latest.sh YYYY.MDD.P
|
||||
```
|
||||
|
||||
Roll back:
|
||||
Then fix forward with a new stable release.
|
||||
|
||||
```bash
|
||||
./scripts/rollback-latest.sh X.Y.Z
|
||||
```
|
||||
## Related Files
|
||||
|
||||
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
|
||||
- [skills/release/SKILL.md](../skills/release/SKILL.md) — agent release coordination workflow
|
||||
- [skills/release-changelog/SKILL.md](../skills/release-changelog/SKILL.md) — stable changelog drafting workflow
|
||||
- [`scripts/release.sh`](../scripts/release.sh)
|
||||
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh)
|
||||
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh)
|
||||
- [`doc/PUBLISHING.md`](PUBLISHING.md)
|
||||
- [`doc/RELEASE-AUTOMATION-SETUP.md`](RELEASE-AUTOMATION-SETUP.md)
|
||||
|
||||
@@ -330,6 +330,34 @@ Operational policy:
|
||||
- `asset_id` uuid fk not null
|
||||
- `issue_comment_id` uuid fk null
|
||||
|
||||
## 7.15 `documents` + `document_revisions` + `issue_documents`
|
||||
|
||||
- `documents` stores editable text-first documents:
|
||||
- `id` uuid pk
|
||||
- `company_id` uuid fk not null
|
||||
- `title` text null
|
||||
- `format` text not null (`markdown`)
|
||||
- `latest_body` text not null
|
||||
- `latest_revision_id` uuid null
|
||||
- `latest_revision_number` int not null
|
||||
- `created_by_agent_id` uuid fk null
|
||||
- `created_by_user_id` uuid/text fk null
|
||||
- `updated_by_agent_id` uuid fk null
|
||||
- `updated_by_user_id` uuid/text fk null
|
||||
- `document_revisions` stores append-only history:
|
||||
- `id` uuid pk
|
||||
- `company_id` uuid fk not null
|
||||
- `document_id` uuid fk not null
|
||||
- `revision_number` int not null
|
||||
- `body` text not null
|
||||
- `change_summary` text null
|
||||
- `issue_documents` links documents to issues with a stable workflow key:
|
||||
- `id` uuid pk
|
||||
- `company_id` uuid fk not null
|
||||
- `issue_id` uuid fk not null
|
||||
- `document_id` uuid fk not null
|
||||
- `key` text not null (`plan`, `design`, `notes`, etc.)
|
||||
|
||||
## 8. State Machines
|
||||
|
||||
## 8.1 Agent Status
|
||||
@@ -413,6 +441,7 @@ All endpoints are under `/api` and return JSON.
|
||||
- `POST /companies`
|
||||
- `GET /companies/:companyId`
|
||||
- `PATCH /companies/:companyId`
|
||||
- `PATCH /companies/:companyId/branding`
|
||||
- `POST /companies/:companyId/archive`
|
||||
|
||||
## 10.2 Goals
|
||||
@@ -441,6 +470,11 @@ All endpoints are under `/api` and return JSON.
|
||||
- `POST /companies/:companyId/issues`
|
||||
- `GET /issues/:issueId`
|
||||
- `PATCH /issues/:issueId`
|
||||
- `GET /issues/:issueId/documents`
|
||||
- `GET /issues/:issueId/documents/:key`
|
||||
- `PUT /issues/:issueId/documents/:key`
|
||||
- `GET /issues/:issueId/documents/:key/revisions`
|
||||
- `DELETE /issues/:issueId/documents/:key`
|
||||
- `POST /issues/:issueId/checkout`
|
||||
- `POST /issues/:issueId/release`
|
||||
- `POST /issues/:issueId/comments`
|
||||
@@ -810,20 +844,31 @@ V1 is complete only when all criteria are true:
|
||||
|
||||
V1 supports company import/export using a portable package contract:
|
||||
|
||||
- exactly one JSON entrypoint: `paperclip.manifest.json`
|
||||
- all other package files are markdown with frontmatter
|
||||
- agent convention:
|
||||
- `agents/<slug>/AGENTS.md` (required for V1 export/import)
|
||||
- `agents/<slug>/HEARTBEAT.md` (optional, import accepted)
|
||||
- `agents/<slug>/*.md` (optional, import accepted)
|
||||
- markdown-first package rooted at `COMPANY.md`
|
||||
- implicit folder discovery by convention
|
||||
- `.paperclip.yaml` sidecar for Paperclip-specific fidelity
|
||||
- canonical base package is vendor-neutral and aligned with `docs/companies/companies-spec.md`
|
||||
- common conventions:
|
||||
- `agents/<slug>/AGENTS.md`
|
||||
- `teams/<slug>/TEAM.md`
|
||||
- `projects/<slug>/PROJECT.md`
|
||||
- `projects/<slug>/tasks/<slug>/TASK.md`
|
||||
- `tasks/<slug>/TASK.md`
|
||||
- `skills/<slug>/SKILL.md`
|
||||
|
||||
Export/import behavior in V1:
|
||||
|
||||
- export includes company metadata and/or agents based on selection
|
||||
- export strips environment-specific paths (`cwd`, local instruction file paths)
|
||||
- export never includes secret values; secret requirements are reported
|
||||
- export emits a clean vendor-neutral markdown package plus `.paperclip.yaml`
|
||||
- projects and starter tasks are opt-in export content rather than default package content
|
||||
- recurring `TASK.md` entries use `recurring: true` in the base package and Paperclip routine fidelity in `.paperclip.yaml`
|
||||
- Paperclip imports recurring task packages as routines instead of downgrading them to one-time issues
|
||||
- export strips environment-specific paths (`cwd`, local instruction file paths, inline prompt duplication) while preserving portable project repo/workspace metadata such as `repoUrl`, refs, and workspace-policy references keyed in `.paperclip.yaml`
|
||||
- export never includes secret values; env inputs are reported as portable declarations instead
|
||||
- import supports target modes:
|
||||
- create a new company
|
||||
- import into an existing company
|
||||
- import recreates exported project workspaces and remaps portable workspace keys back to target-local workspace ids
|
||||
- import forces imported agent timer heartbeats off so packages never start scheduled runs implicitly
|
||||
- import supports collision strategies: `rename`, `skip`, `replace`
|
||||
- import supports preview (dry-run) before apply
|
||||
- GitHub imports warn on unpinned refs instead of blocking
|
||||
|
||||
32
doc/SPEC.md
32
doc/SPEC.md
@@ -186,14 +186,21 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an
|
||||
|
||||
### Execution Adapters
|
||||
|
||||
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters:
|
||||
Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Built-in adapters include:
|
||||
|
||||
| Adapter | Mechanism | Example |
|
||||
| --------- | ----------------------- | --------------------------------------------- |
|
||||
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
||||
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
||||
| Adapter | Mechanism | Example |
|
||||
| ---------------- | -------------------------- | -------------------------------------------------- |
|
||||
| `process` | Execute a child process | `python run_agent.py --agent-id {id}` |
|
||||
| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` |
|
||||
| `claude_local` | Local Claude Code process | Claude Code heartbeat worker |
|
||||
| `codex_local` | Local Codex process | Codex CLI heartbeat worker |
|
||||
| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker |
|
||||
| `pi_local` | Local Pi process | Pi CLI heartbeat worker |
|
||||
| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker |
|
||||
| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway |
|
||||
| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker |
|
||||
|
||||
The `process` and `http` adapters ship as defaults. Additional adapters can be added via the plugin system (see Plugin / Extension Architecture).
|
||||
The `process` and `http` adapters ship as generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture).
|
||||
|
||||
### Adapter Interface
|
||||
|
||||
@@ -373,7 +380,7 @@ Flow:
|
||||
| Layer | Technology |
|
||||
| -------- | ------------------------------------------------------------ |
|
||||
| Frontend | React + Vite |
|
||||
| Backend | TypeScript + Hono (REST API, not tRPC — need non-TS clients) |
|
||||
| Backend | TypeScript + Express (REST API, not tRPC — need non-TS clients) |
|
||||
| Database | PostgreSQL (see [doc/DATABASE.md](./doc/DATABASE.md) for details — PGlite embedded for dev, Docker or hosted Supabase for production) |
|
||||
| Auth | [Better Auth](https://www.better-auth.com/) |
|
||||
|
||||
@@ -403,7 +410,7 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization
|
||||
|
||||
### Work Artifacts
|
||||
|
||||
Paperclip does **not** manage work artifacts (code repos, file systems, deployments, documents). That's entirely the agent's domain. Paperclip tracks tasks and costs. Where and how work gets done is outside scope.
|
||||
Paperclip manages task-linked work artifacts: issue documents (rich-text plans, specs, notes attached to issues) and file attachments. Agents read and write these through the API as part of normal task execution. Full delivery infrastructure (code repos, deployments, production runtime) remains the agent's domain — Paperclip orchestrates the work, not the build pipeline.
|
||||
|
||||
### Open Questions
|
||||
|
||||
@@ -429,7 +436,7 @@ The core Paperclip system must be extensible. Features like knowledge bases, ext
|
||||
- **Agent Adapter plugins** — new Adapter types can be registered via the plugin system
|
||||
- Plugin-registrable UI components (future)
|
||||
|
||||
This isn't a V1 deliverable (we're not building a plugin framework upfront), but the architecture should not paint us into a corner. Keep boundaries clean so extensions are possible.
|
||||
The plugin framework has shipped. Plugins can register new adapter types, hook into lifecycle events, and contribute UI components (e.g. global toolbar buttons). A plugin SDK and CLI commands (`paperclipai plugin`) are available for authoring and installing plugins.
|
||||
|
||||
---
|
||||
|
||||
@@ -473,15 +480,14 @@ Each is a distinct page/route:
|
||||
- [ ] **Default agent** — basic Claude Code/Codex loop with Paperclip skill
|
||||
- [ ] **Default CEO** — strategic planning, delegation, board communication
|
||||
- [ ] **Paperclip skill (SKILL.md)** — teaches agents to interact with the API
|
||||
- [ ] **REST API** — full API for agent interaction (Hono)
|
||||
- [ ] **REST API** — full API for agent interaction (Express)
|
||||
- [ ] **Web UI** — React/Vite: org chart, task board, dashboard, cost views
|
||||
- [ ] **Agent auth** — connection string generation with URL + key + instructions
|
||||
- [ ] **One-command dev setup** — embedded PGlite, everything local
|
||||
- [ ] **Multiple Adapter types** (HTTP Adapter, OpenClaw Adapter)
|
||||
- [ ] **Multiple Adapter types** (HTTP, OpenClaw gateway, and local coding adapters)
|
||||
|
||||
### Not V1
|
||||
|
||||
- Template export/import
|
||||
- Knowledge base - a future plugin
|
||||
- Advanced governance models (hiring budgets, multi-member boards)
|
||||
- Revenue/expense tracking beyond token costs - a future plugin
|
||||
@@ -506,7 +512,7 @@ Things Paperclip explicitly does **not** do:
|
||||
- **Not a SaaS** — single-tenant, self-hosted
|
||||
- **Not opinionated about Agent implementation** — any language, any framework, any runtime
|
||||
- **Not automatically self-healing** — surfaces problems, doesn't silently fix them
|
||||
- **Does not manage work artifacts** — no repo management, no deployment, no file systems
|
||||
- **Does not manage delivery infrastructure** — no repo management, no deployment, no file systems (but does manage task-linked documents and attachments)
|
||||
- **Does not auto-reassign work** — stale tasks are surfaced, not silently redistributed
|
||||
- **Does not track external revenue/expenses** — that's a future plugin. Token/LLM cost budgeting is core.
|
||||
|
||||
|
||||
135
doc/UNTRUSTED-PR-REVIEW.md
Normal file
135
doc/UNTRUSTED-PR-REVIEW.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Untrusted PR Review In Docker
|
||||
|
||||
Use this workflow when you want Codex or Claude to inspect a pull request that you do not want touching your host machine directly.
|
||||
|
||||
This is intentionally separate from the normal Paperclip dev image.
|
||||
|
||||
## What this container isolates
|
||||
|
||||
- `codex` auth/session state in a Docker volume, not your host `~/.codex`
|
||||
- `claude` auth/session state in a Docker volume, not your host `~/.claude`
|
||||
- `gh` auth state in the same container-local home volume
|
||||
- review clones, worktrees, dependency installs, and local databases in a writable scratch volume under `/work`
|
||||
|
||||
By default this workflow does **not** mount your host repo checkout, your host home directory, or your SSH agent.
|
||||
|
||||
## Files
|
||||
|
||||
- `docker/untrusted-review/Dockerfile`
|
||||
- `docker/docker-compose.untrusted-review.yml`
|
||||
- `review-checkout-pr` inside the container
|
||||
|
||||
## Build and start a shell
|
||||
|
||||
```sh
|
||||
docker compose -f docker/docker-compose.untrusted-review.yml build
|
||||
docker compose -f docker/docker-compose.untrusted-review.yml run --rm --service-ports review
|
||||
```
|
||||
|
||||
That opens an interactive shell in the review container with:
|
||||
|
||||
- Node + Corepack/pnpm
|
||||
- `codex`
|
||||
- `claude`
|
||||
- `gh`
|
||||
- `git`, `rg`, `fd`, `jq`
|
||||
|
||||
## First-time login inside the container
|
||||
|
||||
Run these once. The resulting login state persists in the `review-home` Docker volume.
|
||||
|
||||
```sh
|
||||
gh auth login
|
||||
codex login
|
||||
claude login
|
||||
```
|
||||
|
||||
If you prefer API-key auth instead of CLI login, pass keys through Compose env:
|
||||
|
||||
```sh
|
||||
OPENAI_API_KEY=... ANTHROPIC_API_KEY=... docker compose -f docker/docker-compose.untrusted-review.yml run --rm review
|
||||
```
|
||||
|
||||
## Check out a PR safely
|
||||
|
||||
Inside the container:
|
||||
|
||||
```sh
|
||||
review-checkout-pr paperclipai/paperclip 432
|
||||
cd /work/checkouts/paperclipai-paperclip/pr-432
|
||||
```
|
||||
|
||||
What this does:
|
||||
|
||||
1. Creates or reuses a repo clone under `/work/repos/...`
|
||||
2. Fetches `pull/<pr>/head` from GitHub
|
||||
3. Creates a detached git worktree under `/work/checkouts/...`
|
||||
|
||||
The checkout lives entirely inside the container volume.
|
||||
|
||||
## Ask Codex or Claude to review it
|
||||
|
||||
Inside the PR checkout:
|
||||
|
||||
```sh
|
||||
codex
|
||||
```
|
||||
|
||||
Then give it a prompt like:
|
||||
|
||||
```text
|
||||
Review this PR as hostile input. Focus on security issues, data exfiltration paths, sandbox escapes, dangerous install/runtime scripts, auth changes, and subtle behavioral regressions. Do not modify files. Produce findings ordered by severity with file references.
|
||||
```
|
||||
|
||||
Or with Claude:
|
||||
|
||||
```sh
|
||||
claude
|
||||
```
|
||||
|
||||
## Preview the Paperclip app from the PR
|
||||
|
||||
Only do this when you intentionally want to execute the PR's code inside the container.
|
||||
|
||||
Inside the PR checkout:
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
HOST=0.0.0.0 pnpm dev
|
||||
```
|
||||
|
||||
Open from the host:
|
||||
|
||||
- `http://localhost:3100`
|
||||
|
||||
The Compose file also exposes Vite's default port:
|
||||
|
||||
- `http://localhost:5173`
|
||||
|
||||
Notes:
|
||||
|
||||
- `pnpm install` can run untrusted lifecycle scripts from the PR. That is why this happens inside the isolated container instead of on your host.
|
||||
- If you only want static inspection, do not run install/dev commands.
|
||||
- Paperclip's embedded PostgreSQL and local storage stay inside the container home volume via `PAPERCLIP_HOME=/home/reviewer/.paperclip-review`.
|
||||
|
||||
## Reset state
|
||||
|
||||
Remove the review container volumes when you want a clean environment:
|
||||
|
||||
```sh
|
||||
docker compose -f docker/docker-compose.untrusted-review.yml down -v
|
||||
```
|
||||
|
||||
That deletes:
|
||||
|
||||
- Codex/Claude/GitHub login state stored in `review-home`
|
||||
- cloned repos, worktrees, installs, and scratch data stored in `review-work`
|
||||
|
||||
## Security limits
|
||||
|
||||
This is a useful isolation boundary, but it is still Docker, not a full VM.
|
||||
|
||||
- A reviewed PR can still access the container's network unless you disable it.
|
||||
- Any secrets you pass into the container are available to code you execute inside it.
|
||||
- Do not mount your host repo, host home, `.ssh`, or Docker socket unless you are intentionally weakening the boundary.
|
||||
- If you need a stronger boundary than this, use a disposable VM instead of Docker.
|
||||
172
doc/memory-landscape.md
Normal file
172
doc/memory-landscape.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Memory Landscape
|
||||
|
||||
Date: 2026-03-17
|
||||
|
||||
This document summarizes the memory systems referenced in task `PAP-530` and extracts the design patterns that matter for Paperclip.
|
||||
|
||||
## What Paperclip Needs From This Survey
|
||||
|
||||
Paperclip is not trying to become a single opinionated memory engine. The more useful target is a control-plane memory surface that:
|
||||
|
||||
- stays company-scoped
|
||||
- lets each company choose a default memory provider
|
||||
- lets specific agents override that default
|
||||
- keeps provenance back to Paperclip runs, issues, comments, and documents
|
||||
- records memory-related cost and latency the same way the rest of the control plane records work
|
||||
- works with plugin-provided providers, not only built-ins
|
||||
|
||||
The question is not "which memory project wins?" The question is "what is the smallest Paperclip contract that can sit above several very different memory systems without flattening away the useful differences?"
|
||||
|
||||
## Quick Grouping
|
||||
|
||||
### Hosted memory APIs
|
||||
|
||||
- `mem0`
|
||||
- `supermemory`
|
||||
- `Memori`
|
||||
|
||||
These optimize for a simple application integration story: send conversation/content plus an identity, then query for relevant memory or user context later.
|
||||
|
||||
### Agent-centric memory frameworks / memory OSes
|
||||
|
||||
- `MemOS`
|
||||
- `memU`
|
||||
- `EverMemOS`
|
||||
- `OpenViking`
|
||||
|
||||
These treat memory as an agent runtime subsystem, not only as a search index. They usually add task memory, profiles, filesystem-style organization, async ingestion, or skill/resource management.
|
||||
|
||||
### Local-first memory stores / indexes
|
||||
|
||||
- `nuggets`
|
||||
- `memsearch`
|
||||
|
||||
These emphasize local persistence, inspectability, and low operational overhead. They are useful because Paperclip is local-first today and needs at least one zero-config path.
|
||||
|
||||
## Per-Project Notes
|
||||
|
||||
| Project | Shape | Notable API / model | Strong fit for Paperclip | Main mismatch |
|
||||
|---|---|---|---|---|
|
||||
| [nuggets](https://github.com/NeoVertex1/nuggets) | local memory engine + messaging gateway | topic-scoped HRR memory with `remember`, `recall`, `forget`, fact promotion into `MEMORY.md` | good example of lightweight local memory and automatic promotion | very specific architecture; not a general multi-tenant service |
|
||||
| [mem0](https://github.com/mem0ai/mem0) | hosted + OSS SDK | `add`, `search`, `getAll`, `get`, `update`, `delete`, `deleteAll`; entity partitioning via `user_id`, `agent_id`, `run_id`, `app_id` | closest to a clean provider API with identities and metadata filters | provider owns extraction heavily; Paperclip should not assume every backend behaves like mem0 |
|
||||
| [MemOS](https://github.com/MemTensor/MemOS) | memory OS / framework | unified add-retrieve-edit-delete, memory cubes, multimodal memory, tool memory, async scheduler, feedback/correction | strong source for optional capabilities beyond plain search | much broader than the minimal contract Paperclip should standardize first |
|
||||
| [supermemory](https://github.com/supermemoryai/supermemory) | hosted memory + context API | `add`, `profile`, `search.memories`, `search.documents`, document upload, settings; automatic profile building and forgetting | strong example of "context bundle" rather than raw search results | heavily productized around its own ontology and hosted flow |
|
||||
| [memU](https://github.com/NevaMind-AI/memU) | proactive agent memory framework | file-system metaphor, proactive loop, intent prediction, always-on companion model | good source for when memory should trigger agent behavior, not just retrieval | proactive assistant framing is broader than Paperclip's task-centric control plane |
|
||||
| [Memori](https://github.com/MemoriLabs/Memori) | hosted memory fabric + SDK wrappers | registers against LLM SDKs, attribution via `entity_id` + `process_id`, sessions, cloud + BYODB | strong example of automatic capture around model clients | wrapper-centric design does not map 1:1 to Paperclip's run / issue / comment lifecycle |
|
||||
| [EverMemOS](https://github.com/EverMind-AI/EverMemOS) | conversational long-term memory system | MemCell extraction, structured narratives, user profiles, hybrid retrieval / reranking | useful model for provenance-rich structured memories and evolving profiles | focused on conversational memory rather than generalized control-plane events |
|
||||
| [memsearch](https://github.com/zilliztech/memsearch) | markdown-first local memory index | markdown as source of truth, `index`, `search`, `watch`, transcript parsing, plugin hooks | excellent baseline for a local built-in provider and inspectable provenance | intentionally simple; no hosted service semantics or rich correction workflow |
|
||||
| [OpenViking](https://github.com/volcengine/OpenViking) | context database | filesystem-style organization of memories/resources/skills, tiered loading, visualized retrieval trajectories | strong source for browse/inspect UX and context provenance | treats "context database" as a larger product surface than Paperclip should own |
|
||||
|
||||
## Common Primitives Across The Landscape
|
||||
|
||||
Even though the systems disagree on architecture, they converge on a few primitives:
|
||||
|
||||
- `ingest`: add memory from text, messages, documents, or transcripts
|
||||
- `query`: search or retrieve memory given a task, question, or scope
|
||||
- `scope`: partition memory by user, agent, project, process, or session
|
||||
- `provenance`: carry enough metadata to explain where a memory came from
|
||||
- `maintenance`: update, forget, dedupe, compact, or correct memories over time
|
||||
- `context assembly`: turn raw memories into a prompt-ready bundle for the agent
|
||||
|
||||
If Paperclip does not expose these, it will not adapt well to the systems above.
|
||||
|
||||
## Where The Systems Differ
|
||||
|
||||
These differences are exactly why Paperclip needs a layered contract instead of a single hard-coded engine.
|
||||
|
||||
### 1. Who owns extraction?
|
||||
|
||||
- `mem0`, `supermemory`, and `Memori` expect the provider to infer memories from conversations.
|
||||
- `memsearch` expects the host to decide what markdown to write, then indexes it.
|
||||
- `MemOS`, `memU`, `EverMemOS`, and `OpenViking` sit somewhere in between and often expose richer memory construction pipelines.
|
||||
|
||||
Paperclip should support both:
|
||||
|
||||
- provider-managed extraction
|
||||
- Paperclip-managed extraction with provider-managed storage / retrieval
|
||||
|
||||
### 2. What is the source of truth?
|
||||
|
||||
- `memsearch` and `nuggets` make the source inspectable on disk.
|
||||
- hosted APIs often make the provider store canonical.
|
||||
- filesystem-style systems like `OpenViking` and `memU` treat hierarchy itself as part of the memory model.
|
||||
|
||||
Paperclip should not require a single storage shape. It should require normalized references back to Paperclip entities.
|
||||
|
||||
### 3. Is memory just search, or also profile and planning state?
|
||||
|
||||
- `mem0` and `memsearch` center search and CRUD.
|
||||
- `supermemory` adds user profiles as a first-class output.
|
||||
- `MemOS`, `memU`, `EverMemOS`, and `OpenViking` expand into tool traces, task memory, resources, and skills.
|
||||
|
||||
Paperclip should make plain search the minimum contract and richer outputs optional capabilities.
|
||||
|
||||
### 4. Is memory synchronous or asynchronous?
|
||||
|
||||
- local tools often work synchronously in-process.
|
||||
- larger systems add schedulers, background indexing, compaction, or sync jobs.
|
||||
|
||||
Paperclip needs both direct request/response operations and background maintenance hooks.
|
||||
|
||||
## Paperclip-Specific Takeaways
|
||||
|
||||
### Paperclip should own these concerns
|
||||
|
||||
- binding a provider to a company and optionally overriding it per agent
|
||||
- mapping Paperclip entities into provider scopes
|
||||
- provenance back to issue comments, documents, runs, and activity
|
||||
- cost / token / latency reporting for memory work
|
||||
- browse and inspect surfaces in the Paperclip UI
|
||||
- governance on destructive operations
|
||||
|
||||
### Providers should own these concerns
|
||||
|
||||
- extraction heuristics
|
||||
- embedding / indexing strategy
|
||||
- ranking and reranking
|
||||
- profile synthesis
|
||||
- contradiction resolution and forgetting logic
|
||||
- storage engine details
|
||||
|
||||
### The control-plane contract should stay small
|
||||
|
||||
Paperclip does not need to standardize every feature from every provider. It needs:
|
||||
|
||||
- a required portable core
|
||||
- optional capability flags for richer providers
|
||||
- a way to record provider-native ids and metadata without pretending all providers are equivalent internally
|
||||
|
||||
## Recommended Direction
|
||||
|
||||
Paperclip should adopt a two-layer memory model:
|
||||
|
||||
1. `Memory binding + control plane layer`
|
||||
Paperclip decides which provider key is in effect for a company, agent, or project, and it logs every memory operation with provenance and usage.
|
||||
|
||||
2. `Provider adapter layer`
|
||||
A built-in or plugin-supplied adapter turns Paperclip memory requests into provider-specific calls.
|
||||
|
||||
The portable core should cover:
|
||||
|
||||
- ingest / write
|
||||
- search / recall
|
||||
- browse / inspect
|
||||
- get by provider record handle
|
||||
- forget / correction
|
||||
- usage reporting
|
||||
|
||||
Optional capabilities can cover:
|
||||
|
||||
- profile synthesis
|
||||
- async ingestion
|
||||
- multimodal content
|
||||
- tool / resource / skill memory
|
||||
- provider-native graph browsing
|
||||
|
||||
That is enough to support:
|
||||
|
||||
- a local markdown-first baseline similar to `memsearch`
|
||||
- hosted services similar to `mem0`, `supermemory`, or `Memori`
|
||||
- richer agent-memory systems like `MemOS` or `OpenViking`
|
||||
|
||||
without forcing Paperclip itself to become a monolithic memory engine.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Paperclip Module System
|
||||
|
||||
> Supersession note: the company-template/package-format direction in this document is no longer current. For the current markdown-first company import/export plan, see `doc/plans/2026-03-13-company-import-export-v2.md` and `docs/companies/companies-spec.md`.
|
||||
|
||||
## Overview
|
||||
|
||||
Paperclip's module system lets you extend the control plane with new capabilities — revenue tracking, observability, notifications, dashboards — without forking core. Modules are self-contained packages that register routes, UI pages, database tables, and lifecycle hooks.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user