mirror of
https://github.com/paperclipai/paperclip
synced 2026-05-07 07:32:02 +02:00
Compare commits
374 Commits
@paperclip
...
feature/wo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b754752164 | ||
|
|
98b0e2fca1 | ||
|
|
d7278199b6 | ||
|
|
99f6fafa1d | ||
|
|
d2949d0554 | ||
|
|
e19bfe110d | ||
|
|
fb195d2d64 | ||
|
|
a53e7eb780 | ||
|
|
22761167c2 | ||
|
|
84ef17bf85 | ||
|
|
23dec980e2 | ||
|
|
03c37f8dea | ||
|
|
8360b2e3e3 | ||
|
|
3ec96fdb73 | ||
|
|
eecb780dd7 | ||
|
|
632079ae3b | ||
|
|
7d8d6a5caf | ||
|
|
948080fee9 | ||
|
|
8d53800c19 | ||
|
|
422f57b160 | ||
|
|
31c947bf7f | ||
|
|
f5bf743745 | ||
|
|
0a8b96cdb3 | ||
|
|
a47ea343ba | ||
|
|
0781b7a15c | ||
|
|
30ee59c324 | ||
|
|
aa2b11d528 | ||
|
|
e1ddcbb71f | ||
|
|
df94c98494 | ||
|
|
a7cfd9f24b | ||
|
|
e48beafc90 | ||
|
|
e6e41dba9d | ||
|
|
ccd501ea02 | ||
|
|
210715117c | ||
|
|
38cb2bf3c4 | ||
|
|
f2a0a0b804 | ||
|
|
035e1a9333 | ||
|
|
ec4667c8b2 | ||
|
|
f32b76f213 | ||
|
|
ee7fddf8d5 | ||
|
|
77e04407b9 | ||
|
|
7b70713fcb | ||
|
|
ad55af04cc | ||
|
|
e35e2c4343 | ||
|
|
d58f269281 | ||
|
|
2a7043d677 | ||
|
|
31b5ff1c61 | ||
|
|
c674462a02 | ||
|
|
e3ff0c8e1b | ||
|
|
17b10c43fe | ||
|
|
343d4e5877 | ||
|
|
1078c7dd2b | ||
|
|
4c630bc66e | ||
|
|
f5190f28d1 | ||
|
|
edfc6be63c | ||
|
|
61551ffea3 | ||
|
|
0fedd8a395 | ||
|
|
b090c33ca1 | ||
|
|
3fb96506bd | ||
|
|
dcf879f6fb | ||
|
|
4e01633202 | ||
|
|
ff3f04ff48 | ||
|
|
91fda5d04f | ||
|
|
77e06c57f9 | ||
|
|
0f75c35392 | ||
|
|
45473b3e72 | ||
|
|
a96556b8f4 | ||
|
|
ce8fe38ffc | ||
|
|
6e86f69f95 | ||
|
|
7661fae4b3 | ||
|
|
ba080cb4dd | ||
|
|
3860812323 | ||
|
|
2639184f46 | ||
|
|
61966fba1f | ||
|
|
54b512f9e0 | ||
|
|
667d23e79e | ||
|
|
416177ae4c | ||
|
|
72cc748aa8 | ||
|
|
9299660388 | ||
|
|
2cb82f326f | ||
|
|
f81d2ebcc4 | ||
|
|
048e2b1bfe | ||
|
|
5fae7d4de7 | ||
|
|
0f32fffe79 | ||
|
|
0233525e99 | ||
|
|
20b171bd16 | ||
|
|
3f2274cd8d | ||
|
|
c59e059976 | ||
|
|
9933039094 | ||
|
|
b886eb3cf0 | ||
|
|
53c944e8bc | ||
|
|
977f5570be | ||
|
|
609b55f530 | ||
|
|
2223afa0e9 | ||
|
|
3479ea6e80 | ||
|
|
63a876ca3c | ||
|
|
df0f101fbd | ||
|
|
0abb6a1205 | ||
|
|
d52f1d4b44 | ||
|
|
e27ec5de8c | ||
|
|
83488b4ed0 | ||
|
|
271a632f1c | ||
|
|
9a0e3a8425 | ||
|
|
1c1b86f495 | ||
|
|
1420b86aa7 | ||
|
|
22053d18e4 | ||
|
|
3b4db7a3bc | ||
|
|
db15dfaf5e | ||
|
|
1afadd7354 | ||
|
|
9ac2e71187 | ||
|
|
3bde21bb06 | ||
|
|
672d769c68 | ||
|
|
46c343f81d | ||
|
|
17058dd751 | ||
|
|
346152f67d | ||
|
|
dd14643848 | ||
|
|
1dac0ec7cf | ||
|
|
7c0a3efea6 | ||
|
|
671a8ae554 | ||
|
|
baa71d6a08 | ||
|
|
638f2303bb | ||
|
|
a4d0901e89 | ||
|
|
f85f2fbcc2 | ||
|
|
fbcd80948e | ||
|
|
9d6a83dcca | ||
|
|
a251a53571 | ||
|
|
63afce3692 | ||
|
|
e07646bade | ||
|
|
ddb7101fa5 | ||
|
|
3f42357e5f | ||
|
|
3b08d4d582 | ||
|
|
049f768bc7 | ||
|
|
19c295ec03 | ||
|
|
a6b5f12daf | ||
|
|
4bd6961020 | ||
|
|
fd0799fd71 | ||
|
|
b91820afd3 | ||
|
|
0315e4cdc2 | ||
|
|
654463c28f | ||
|
|
f1ad727f8e | ||
|
|
10cccc07cd | ||
|
|
a498c268c5 | ||
|
|
fa8499719a | ||
|
|
1fcc6900ff | ||
|
|
45708a06f1 | ||
|
|
792397c2a9 | ||
|
|
36e4e67025 | ||
|
|
6077ae6064 | ||
|
|
eb7f690ceb | ||
|
|
ef0e08b8ed | ||
|
|
3bcdf3e3ad | ||
|
|
fccec94805 | ||
|
|
bee9fdd207 | ||
|
|
0ae5d81deb | ||
|
|
ffc59f5b08 | ||
|
|
f5f8c4a883 | ||
|
|
e693e3d466 | ||
|
|
e4928f3a10 | ||
|
|
514dc43923 | ||
|
|
b539462319 | ||
|
|
aa7e069044 | ||
|
|
3b0ff94e3f | ||
|
|
5ab1c18530 | ||
|
|
36013c35d9 | ||
|
|
b155415d7d | ||
|
|
d7f68ec1c9 | ||
|
|
af09510f6a | ||
|
|
a2bdfb0dd3 | ||
|
|
67247b5d6a | ||
|
|
5f2dfcb94e | ||
|
|
67491483b7 | ||
|
|
54a4f784a4 | ||
|
|
5aecb148a2 | ||
|
|
f49a003bd9 | ||
|
|
feb384acca | ||
|
|
c9718dc27a | ||
|
|
0b42045053 | ||
|
|
d8f7c6bf81 | ||
|
|
c8bd578415 | ||
|
|
5dfd9a2429 | ||
|
|
0324259da3 | ||
|
|
7af9aa61fa | ||
|
|
55bb3012ea | ||
|
|
ca919d73f9 | ||
|
|
70051735f6 | ||
|
|
2ad616780f | ||
|
|
fa43e5b0dd | ||
|
|
1d42b6e726 | ||
|
|
a3493dbb74 | ||
|
|
59a07324ec | ||
|
|
4d8663ebc8 | ||
|
|
2e7bf85e7a | ||
|
|
35e4897256 | ||
|
|
68ee3f8ea0 | ||
|
|
cf1ccd1e14 | ||
|
|
f56901b473 | ||
|
|
cec372f9bb | ||
|
|
8355dd7905 | ||
|
|
8151331375 | ||
|
|
b06e41bed2 | ||
|
|
1179d7e75a | ||
|
|
2ec2dcf9c6 | ||
|
|
cbce8bfbc3 | ||
|
|
0f895a8cf9 | ||
|
|
c3ac209e5f | ||
|
|
192d76678e | ||
|
|
7bcf994064 | ||
|
|
e670324334 | ||
|
|
c23ddbad3f | ||
|
|
e6339e911d | ||
|
|
c0c64fe682 | ||
|
|
ae60879507 | ||
|
|
de60519ef6 | ||
|
|
44a00596a4 | ||
|
|
a57732f7dd | ||
|
|
63c0e22a2a | ||
|
|
2405851436 | ||
|
|
d9d2ad209d | ||
|
|
e1d4e37776 | ||
|
|
08ac2bc9a7 | ||
|
|
b213eb695b | ||
|
|
494448dcf7 | ||
|
|
854e818b74 | ||
|
|
38d3d5fa59 | ||
|
|
86bd26ee8a | ||
|
|
9cacf4a981 | ||
|
|
9184cf92dd | ||
|
|
38b9a55eab | ||
|
|
3369a9e685 | ||
|
|
553c939f1f | ||
|
|
67bc601258 | ||
|
|
9d570b3ed7 | ||
|
|
d4eb502389 | ||
|
|
50276ed981 | ||
|
|
2d21045424 | ||
|
|
eb607f7df8 | ||
|
|
eb033a221f | ||
|
|
5f6e68e7aa | ||
|
|
88682632f9 | ||
|
|
264d40e6ca | ||
|
|
de7d6294ea | ||
|
|
f41373dc46 | ||
|
|
1bbb98aaa9 | ||
|
|
cecb94213d | ||
|
|
0cdc9547d9 | ||
|
|
4c1504872f | ||
|
|
7086ad00ae | ||
|
|
222e0624a8 | ||
|
|
81bc8c7313 | ||
|
|
5134cac993 | ||
|
|
e401979851 | ||
|
|
4569d57f5b | ||
|
|
eff0c506fa | ||
|
|
c486bad2dd | ||
|
|
0e387426fa | ||
|
|
6ee4315eef | ||
|
|
7c07b16f80 | ||
|
|
77500b50d9 | ||
|
|
0cc75c6e10 | ||
|
|
82d97418b2 | ||
|
|
35a7acc058 | ||
|
|
bd32c871b7 | ||
|
|
8e63dd44b6 | ||
|
|
4eedf15870 | ||
|
|
a0e6ad0b7d | ||
|
|
4b90784183 | ||
|
|
ab6ec999c5 | ||
|
|
babea25649 | ||
|
|
e9ffde610b | ||
|
|
a05aa99c7e | ||
|
|
690149d555 | ||
|
|
ffd1631b14 | ||
|
|
185317c153 | ||
|
|
988f1244e5 | ||
|
|
38b855e495 | ||
|
|
0ed0c0abdb | ||
|
|
7a2ecff4f0 | ||
|
|
bee24e880f | ||
|
|
7ab5b8a0c2 | ||
|
|
089a2d08bf | ||
|
|
d8fb93edcf | ||
|
|
201d91b4f5 | ||
|
|
9da1803f29 | ||
|
|
1b98c2b279 | ||
|
|
a85511dad2 | ||
|
|
f75a4d9589 | ||
|
|
0d36cf00f8 | ||
|
|
4b8e880a96 | ||
|
|
1e5e09f0fa | ||
|
|
57db28e9e6 | ||
|
|
c610951a71 | ||
|
|
e5049a448e | ||
|
|
1f261d90f3 | ||
|
|
d2dd8d0cc5 | ||
|
|
e08362b667 | ||
|
|
2c809d55c0 | ||
|
|
529d53acc0 | ||
|
|
fd73d6fcab | ||
|
|
cdf63d0024 | ||
|
|
09a8ecbded | ||
|
|
6f98c5f25c | ||
|
|
70e41150c5 | ||
|
|
bc765b0867 | ||
|
|
9dbd72cffd | ||
|
|
084c0a19a2 | ||
|
|
85f95c4542 | ||
|
|
732ae4e46c | ||
|
|
c1a92d8520 | ||
|
|
69b2875060 | ||
|
|
7cb46d97f6 | ||
|
|
e31d77bc47 | ||
|
|
90d39b9cbd | ||
|
|
bc68c3a504 | ||
|
|
59bc52f527 | ||
|
|
d37e1d3dc3 | ||
|
|
34d9122b45 | ||
|
|
1f7218640c | ||
|
|
0078fa66a3 | ||
|
|
f4f9d6fd3f | ||
|
|
69c453b274 | ||
|
|
d54ee6c4dc | ||
|
|
b48f0314e7 | ||
|
|
e1b24c1d5c | ||
|
|
1c9b7ef918 | ||
|
|
8f70e79240 | ||
|
|
eabfd9d9f6 | ||
|
|
6a101e0da1 | ||
|
|
426c1044b6 | ||
|
|
875924a7f3 | ||
|
|
e835c5cee9 | ||
|
|
db54f77b73 | ||
|
|
67eb5e5734 | ||
|
|
758a5538c5 | ||
|
|
3ae112acff | ||
|
|
9454f76c0c | ||
|
|
944263f44b | ||
|
|
a1944fceab | ||
|
|
8d5c9fde3b | ||
|
|
d8688bbd93 | ||
|
|
306cd65353 | ||
|
|
8a85173150 | ||
|
|
b4a02ebc3f | ||
|
|
1f57577c54 | ||
|
|
ec0b7daca2 | ||
|
|
bdc0480e62 | ||
|
|
c145074daf | ||
|
|
f6a09bcbea | ||
|
|
d4a2fc6464 | ||
|
|
be50daba42 | ||
|
|
7b334ff2b7 | ||
|
|
5bbfddf70d | ||
|
|
358467a506 | ||
|
|
59507f18ec | ||
|
|
b198b4a02c | ||
|
|
5606f76ab4 | ||
|
|
0542f555ba | ||
|
|
18c9eb7b1e | ||
|
|
675e0dcff1 | ||
|
|
b66c6d017a | ||
|
|
bbf7490f32 | ||
|
|
5dffdbb382 | ||
|
|
ea637110ac | ||
|
|
3ae9d95354 | ||
|
|
a95e38485d | ||
|
|
c7c96feef7 | ||
|
|
7e387a1883 | ||
|
|
108bb9bd15 | ||
|
|
6141d5c3f2 | ||
|
|
a4da932d8d | ||
|
|
ab3b9ab19f | ||
|
|
f4a5b00116 | ||
|
|
09d2ef1a37 | ||
|
|
d18312d6de | ||
|
|
28bf5e9e9b |
5
.changeset/add-pi-adapter-support.md
Normal file
5
.changeset/add-pi-adapter-support.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@paperclipai/shared": minor
|
||||
---
|
||||
|
||||
Add support for Pi local adapter in constants and onboarding UI.
|
||||
44
.github/workflows/e2e.yml
vendored
Normal file
44
.github/workflows/e2e.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_llm:
|
||||
description: "Skip LLM-dependent assertions (default: true)"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
- run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run e2e tests
|
||||
run: pnpm run test:e2e
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
tests/e2e/playwright-report/
|
||||
tests/e2e/test-results/
|
||||
retention-days: 14
|
||||
49
.github/workflows/pr-policy.yml
vendored
Normal file
49
.github/workflows/pr-policy.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
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
Normal file
42
.github/workflows/pr-verify.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: PR Verify
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: pr-verify-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
81
.github/workflows/refresh-lockfile.yml
vendored
Normal file
81
.github/workflows/refresh-lockfile.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: Refresh Lockfile
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: refresh-lockfile-master
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Refresh pnpm lockfile
|
||||
run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
- name: Fail on unexpected file changes
|
||||
run: |
|
||||
changed="$(git status --porcelain)"
|
||||
if [ -z "$changed" ]; then
|
||||
echo "Lockfile is already up to date."
|
||||
exit 0
|
||||
fi
|
||||
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
|
||||
echo "Unexpected files changed during lockfile refresh:"
|
||||
echo "$changed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create or update pull request
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if git diff --quiet -- pnpm-lock.yaml; then
|
||||
echo "Lockfile unchanged, nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BRANCH="chore/refresh-lockfile"
|
||||
git config user.name "lockfile-bot"
|
||||
git config user.email "lockfile-bot@users.noreply.github.com"
|
||||
|
||||
git checkout -B "$BRANCH"
|
||||
git add pnpm-lock.yaml
|
||||
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
|
||||
git push --force origin "$BRANCH"
|
||||
|
||||
# Create PR if one doesn't already exist
|
||||
existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')
|
||||
if [ -z "$existing" ]; then
|
||||
gh pr create \
|
||||
--head "$BRANCH" \
|
||||
--title "chore(lockfile): refresh pnpm-lock.yaml" \
|
||||
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml."
|
||||
echo "Created new PR."
|
||||
else
|
||||
echo "PR #$existing already exists, branch updated via force push."
|
||||
fi
|
||||
132
.github/workflows/release.yml
vendored
Normal file
132
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Release channel
|
||||
required: true
|
||||
type: choice
|
||||
default: canary
|
||||
options:
|
||||
- canary
|
||||
- stable
|
||||
bump:
|
||||
description: Semantic version bump
|
||||
required: true
|
||||
type: choice
|
||||
default: patch
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
dry_run:
|
||||
description: Preview the release without publishing
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
if: github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
publish:
|
||||
if: github.ref == 'refs/heads/master'
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Run release script
|
||||
env:
|
||||
GITHUB_ACTIONS: "true"
|
||||
run: |
|
||||
args=("${{ inputs.bump }}")
|
||||
if [ "${{ inputs.channel }}" = "canary" ]; then
|
||||
args+=("--canary")
|
||||
fi
|
||||
if [ "${{ inputs.dry_run }}" = "true" ]; then
|
||||
args+=("--dry-run")
|
||||
fi
|
||||
./scripts/release.sh "${args[@]}"
|
||||
|
||||
- name: Push stable release commit and tag
|
||||
if: inputs.channel == 'stable' && !inputs.dry_run
|
||||
run: git push origin HEAD:master --follow-tags
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: inputs.channel == 'stable' && !inputs.dry_run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')"
|
||||
if [ -z "$version" ]; then
|
||||
echo "Error: no v* tag points at HEAD after stable release." >&2
|
||||
exit 1
|
||||
fi
|
||||
./scripts/create-github-release.sh "$version"
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -36,4 +36,8 @@ tmp/
|
||||
*.tmp
|
||||
.vscode/
|
||||
.claude/settings.local.json
|
||||
.paperclip-local/
|
||||
.paperclip-local/
|
||||
|
||||
# Playwright
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
41
CONTRIBUTING.md
Normal file
41
CONTRIBUTING.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Contributing Guide
|
||||
|
||||
Thanks for wanting to contribute!
|
||||
|
||||
We really appreciate both small fixes and thoughtful larger changes.
|
||||
|
||||
## Two Paths to Get Your Pull Request Accepted
|
||||
|
||||
### Path 1: Small, Focused Changes (Fastest way to get merged)
|
||||
- Pick **one** clear thing to fix/improve
|
||||
- Touch the **smallest possible number of files**
|
||||
- Make sure the change is very targeted and easy to review
|
||||
- All automated checks pass (including Greptile comments)
|
||||
- No new lint/test failures
|
||||
|
||||
These almost always get merged quickly when they're clean.
|
||||
|
||||
### Path 2: Bigger or Impactful Changes
|
||||
- **First** talk about it in Discord → #dev channel
|
||||
→ Describe what you're trying to solve
|
||||
→ Share rough ideas / approach
|
||||
- Once there's rough agreement, build it
|
||||
- In your PR include:
|
||||
- Before / After screenshots (or short video if UI/behavior change)
|
||||
- Clear description of what & why
|
||||
- Proof it works (manual testing notes)
|
||||
- All tests passing
|
||||
- All Greptile + other PR comments addressed
|
||||
|
||||
PRs that follow this path are **much** more likely to be accepted, even when they're large.
|
||||
|
||||
## General Rules (both paths)
|
||||
- Write clear commit messages
|
||||
- Keep PR title + description meaningful
|
||||
- One PR = one logical change (unless it's a small related group)
|
||||
- Run tests locally first
|
||||
- Be kind in discussions 😄
|
||||
|
||||
Questions? Just ask in #dev — we're happy to help.
|
||||
|
||||
Happy hacking!
|
||||
16
Dockerfile
16
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM node:20-bookworm-slim AS base
|
||||
FROM node:lts-trixie-slim AS base
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -15,19 +15,25 @@ COPY packages/db/package.json packages/db/
|
||||
COPY packages/adapter-utils/package.json packages/adapter-utils/
|
||||
COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/
|
||||
COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/
|
||||
COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/
|
||||
COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/
|
||||
COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/
|
||||
COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app /app
|
||||
COPY . .
|
||||
RUN pnpm --filter @paperclip/ui build
|
||||
RUN pnpm --filter @paperclip/server build
|
||||
RUN pnpm --filter @paperclipai/ui build
|
||||
RUN pnpm --filter @paperclipai/server build
|
||||
RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" && exit 1)
|
||||
|
||||
FROM base AS production
|
||||
WORKDIR /app
|
||||
COPY --from=build /app /app
|
||||
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest
|
||||
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOME=/paperclip \
|
||||
@@ -37,7 +43,7 @@ ENV NODE_ENV=production \
|
||||
PAPERCLIP_HOME=/paperclip \
|
||||
PAPERCLIP_INSTANCE_ID=default \
|
||||
PAPERCLIP_CONFIG=/paperclip/instances/default/config.json \
|
||||
PAPERCLIP_DEPLOYMENT_MODE=local_trusted \
|
||||
PAPERCLIP_DEPLOYMENT_MODE=authenticated \
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE=private
|
||||
|
||||
VOLUME ["/paperclip"]
|
||||
|
||||
40
Dockerfile.onboard-smoke
Normal file
40
Dockerfile.onboard-smoke
Normal file
@@ -0,0 +1,40 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ARG NODE_MAJOR=20
|
||||
ARG PAPERCLIPAI_VERSION=latest
|
||||
ARG HOST_UID=10001
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
PAPERCLIP_HOME=/paperclip \
|
||||
PAPERCLIP_OPEN_ON_LISTEN=false \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=3100 \
|
||||
HOME=/home/paperclip \
|
||||
LANG=en_US.UTF-8 \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
NPM_CONFIG_UPDATE_NOTIFIER=false \
|
||||
NODE_MAJOR=${NODE_MAJOR} \
|
||||
PAPERCLIPAI_VERSION=${PAPERCLIPAI_VERSION}
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg locales \
|
||||
&& mkdir -p /etc/apt/keyrings \
|
||||
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
|
||||
> /etc/apt/sources.list.d/nodesource.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& locale-gen en_US.UTF-8 \
|
||||
&& groupadd --gid 10001 paperclip \
|
||||
&& useradd --create-home --shell /bin/bash --uid "${HOST_UID}" --gid 10001 paperclip \
|
||||
&& mkdir -p /paperclip /home/paperclip/workspace \
|
||||
&& chown -R paperclip:paperclip /paperclip /home/paperclip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
VOLUME ["/paperclip"]
|
||||
WORKDIR /home/paperclip/workspace
|
||||
EXPOSE 3100
|
||||
USER paperclip
|
||||
|
||||
CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\""]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Paperclip AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
25
README.md
25
README.md
@@ -6,13 +6,13 @@
|
||||
<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/paperclip"><strong>Discord</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/paperclip"><img src="https://img.shields.io/discord/000000000?label=discord" alt="Discord" /></a>
|
||||
<a href="https://discord.gg/m4HZY7xNG3"><img src="https://img.shields.io/discord/000000000?label=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
@@ -174,7 +174,7 @@ Paperclip handles the hard orchestration details correctly.
|
||||
Open source. Self-hosted. No Paperclip account required.
|
||||
|
||||
```bash
|
||||
npx paperclipai onboard
|
||||
npx paperclipai onboard --yes
|
||||
```
|
||||
|
||||
Or manually:
|
||||
@@ -218,7 +218,8 @@ By default, agents run on scheduled heartbeats and event-based triggers (task as
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm dev # Full dev (API + UI)
|
||||
pnpm dev # Full dev (API + UI, watch mode)
|
||||
pnpm dev:once # Full dev without file watching
|
||||
pnpm dev:server # Server only
|
||||
pnpm build # Build all
|
||||
pnpm typecheck # Type checking
|
||||
@@ -233,9 +234,13 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- 🛒 **Clipmart** — Download and share entire company architectures
|
||||
- 🧩 **Plugin System** — Embed custom plugins (e.g. Reporting, Knowledge Base) into Paperclip
|
||||
- ☁️ **Cloud Agent Adapters** — Add more adapters for cloud-hosted agents
|
||||
- ⚪ Get OpenClaw onboarding easier
|
||||
- ⚪ Get cloud agents working e.g. Cursor / e2b agents
|
||||
- ⚪ ClipMart - buy and sell entire agent companies
|
||||
- ⚪ Easy agent configurations / easier to understand
|
||||
- ⚪ Better support for harness engineering
|
||||
- ⚪ Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc)
|
||||
- ⚪ Better docs
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -249,7 +254,7 @@ We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for deta
|
||||
|
||||
## Community
|
||||
|
||||
- [Discord](#) — Coming soon
|
||||
- [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
|
||||
|
||||
@@ -259,6 +264,10 @@ We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for deta
|
||||
|
||||
MIT © 2026 Paperclip
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left)
|
||||
|
||||
<br/>
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,75 @@
|
||||
# paperclipai
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.2.7
|
||||
- @paperclipai/adapter-utils@0.2.7
|
||||
- @paperclipai/db@0.2.7
|
||||
- @paperclipai/adapter-claude-local@0.2.7
|
||||
- @paperclipai/adapter-codex-local@0.2.7
|
||||
- @paperclipai/adapter-openclaw@0.2.7
|
||||
- @paperclipai/server@0.2.7
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.2.6
|
||||
- @paperclipai/adapter-utils@0.2.6
|
||||
- @paperclipai/db@0.2.6
|
||||
- @paperclipai/adapter-claude-local@0.2.6
|
||||
- @paperclipai/adapter-codex-local@0.2.6
|
||||
- @paperclipai/adapter-openclaw@0.2.6
|
||||
- @paperclipai/server@0.2.6
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.2.5
|
||||
- @paperclipai/adapter-utils@0.2.5
|
||||
- @paperclipai/db@0.2.5
|
||||
- @paperclipai/adapter-claude-local@0.2.5
|
||||
- @paperclipai/adapter-codex-local@0.2.5
|
||||
- @paperclipai/adapter-openclaw@0.2.5
|
||||
- @paperclipai/server@0.2.5
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.2.4
|
||||
- @paperclipai/adapter-utils@0.2.4
|
||||
- @paperclipai/db@0.2.4
|
||||
- @paperclipai/adapter-claude-local@0.2.4
|
||||
- @paperclipai/adapter-codex-local@0.2.4
|
||||
- @paperclipai/adapter-openclaw@0.2.4
|
||||
- @paperclipai/server@0.2.4
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.2.3
|
||||
- @paperclipai/adapter-utils@0.2.3
|
||||
- @paperclipai/db@0.2.3
|
||||
- @paperclipai/adapter-claude-local@0.2.3
|
||||
- @paperclipai/adapter-codex-local@0.2.3
|
||||
- @paperclipai/adapter-openclaw@0.2.3
|
||||
- @paperclipai/server@0.2.3
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -21,7 +21,7 @@ const workspacePaths = [
|
||||
"packages/adapter-utils",
|
||||
"packages/adapters/claude-local",
|
||||
"packages/adapters/codex-local",
|
||||
"packages/adapters/openclaw",
|
||||
"packages/adapters/openclaw-gateway",
|
||||
];
|
||||
|
||||
// Workspace packages that should NOT be bundled — they'll be published
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperclipai",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.7",
|
||||
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -36,7 +36,10 @@
|
||||
"@clack/prompts": "^0.10.0",
|
||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||
"@paperclipai/adapter-cursor-local": "workspace:*",
|
||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*",
|
||||
"@paperclipai/server": "workspace:*",
|
||||
@@ -44,6 +47,7 @@
|
||||
"drizzle-orm": "0.38.4",
|
||||
"dotenv": "^17.0.1",
|
||||
"commander": "^13.1.0",
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -21,6 +21,12 @@ function writeBaseConfig(configPath: string) {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: "/tmp/paperclip-db",
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: "/tmp/paperclip-backups",
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
@@ -36,6 +42,7 @@ function writeBaseConfig(configPath: string) {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
@@ -68,4 +75,3 @@ describe("allowed-hostname command", () => {
|
||||
expect(raw.server.allowedHostnames).toEqual(["dotta-macbook-pro"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
99
cli/src/__tests__/doctor.test.ts
Normal file
99
cli/src/__tests__/doctor.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { doctor } from "../commands/doctor.js";
|
||||
import { writeConfig } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function createTempConfig(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-"));
|
||||
const configPath = path.join(root, ".paperclip", "config.json");
|
||||
const runtimeRoot = path.join(root, "runtime");
|
||||
|
||||
const config: PaperclipConfig = {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
|
||||
embeddedPostgresPort: 55432,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(runtimeRoot, "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(runtimeRoot, "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3199,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(runtimeRoot, "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
writeConfig(config, configPath);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
describe("doctor", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
it("re-runs repairable checks so repaired failures do not remain blocking", async () => {
|
||||
const configPath = createTempConfig();
|
||||
|
||||
const summary = await doctor({
|
||||
config: configPath,
|
||||
repair: true,
|
||||
yes: true,
|
||||
});
|
||||
|
||||
expect(summary.failed).toBe(0);
|
||||
expect(summary.warned).toBe(0);
|
||||
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy();
|
||||
});
|
||||
});
|
||||
125
cli/src/__tests__/worktree.test.ts
Normal file
125
cli/src/__tests__/worktree.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWorktreeConfig,
|
||||
buildWorktreeEnvEntries,
|
||||
formatShellExports,
|
||||
resolveWorktreeSeedPlan,
|
||||
resolveWorktreeLocalPaths,
|
||||
rewriteLocalUrlPort,
|
||||
sanitizeWorktreeInstanceId,
|
||||
} from "../commands/worktree-lib.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
|
||||
function buildSourceConfig(): PaperclipConfig {
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-09T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: "/tmp/main/db",
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: "/tmp/main/backups",
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: "/tmp/main/logs",
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "authenticated",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: ["localhost"],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "explicit",
|
||||
publicBaseUrl: "http://127.0.0.1:3100",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: "/tmp/main/storage",
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: "/tmp/main/secrets/master.key",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("worktree helpers", () => {
|
||||
it("sanitizes instance ids", () => {
|
||||
expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support");
|
||||
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
|
||||
});
|
||||
|
||||
it("rewrites loopback auth URLs to the new port only", () => {
|
||||
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
|
||||
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
|
||||
});
|
||||
|
||||
it("builds isolated config and env paths for a worktree", () => {
|
||||
const paths = resolveWorktreeLocalPaths({
|
||||
cwd: "/tmp/paperclip-feature",
|
||||
homeDir: "/tmp/paperclip-worktrees",
|
||||
instanceId: "feature-worktree-support",
|
||||
});
|
||||
const config = buildWorktreeConfig({
|
||||
sourceConfig: buildSourceConfig(),
|
||||
paths,
|
||||
serverPort: 3110,
|
||||
databasePort: 54339,
|
||||
now: new Date("2026-03-09T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(config.database.embeddedPostgresDataDir).toBe(
|
||||
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"),
|
||||
);
|
||||
expect(config.database.embeddedPostgresPort).toBe(54339);
|
||||
expect(config.server.port).toBe(3110);
|
||||
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/");
|
||||
expect(config.storage.localDisk.baseDir).toBe(
|
||||
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
|
||||
);
|
||||
|
||||
const env = buildWorktreeEnvEntries(paths);
|
||||
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
|
||||
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
|
||||
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
|
||||
});
|
||||
|
||||
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
|
||||
const minimal = resolveWorktreeSeedPlan("minimal");
|
||||
const full = resolveWorktreeSeedPlan("full");
|
||||
|
||||
expect(minimal.excludedTables).toContain("heartbeat_runs");
|
||||
expect(minimal.excludedTables).toContain("heartbeat_run_events");
|
||||
expect(minimal.excludedTables).toContain("workspace_runtime_services");
|
||||
expect(minimal.excludedTables).toContain("agent_task_sessions");
|
||||
expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]);
|
||||
|
||||
expect(full.excludedTables).toEqual([]);
|
||||
expect(full.nullifyColumns).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
|
||||
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
|
||||
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
|
||||
import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
|
||||
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
||||
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
||||
import { processCLIAdapter } from "./process/index.js";
|
||||
import { httpCLIAdapter } from "./http/index.js";
|
||||
|
||||
@@ -15,13 +18,37 @@ const codexLocalCLIAdapter: CLIAdapterModule = {
|
||||
formatStdoutEvent: printCodexStreamEvent,
|
||||
};
|
||||
|
||||
const openclawCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw",
|
||||
formatStdoutEvent: printOpenClawStreamEvent,
|
||||
const openCodeLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "opencode_local",
|
||||
formatStdoutEvent: printOpenCodeStreamEvent,
|
||||
};
|
||||
|
||||
const piLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "pi_local",
|
||||
formatStdoutEvent: printPiStreamEvent,
|
||||
};
|
||||
|
||||
const cursorLocalCLIAdapter: CLIAdapterModule = {
|
||||
type: "cursor",
|
||||
formatStdoutEvent: printCursorStreamEvent,
|
||||
};
|
||||
|
||||
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||
type: "openclaw_gateway",
|
||||
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||
};
|
||||
|
||||
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||
[claudeLocalCLIAdapter, codexLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
||||
[
|
||||
claudeLocalCLIAdapter,
|
||||
codexLocalCLIAdapter,
|
||||
openCodeLocalCLIAdapter,
|
||||
piLocalCLIAdapter,
|
||||
cursorLocalCLIAdapter,
|
||||
openclawGatewayCLIAdapter,
|
||||
processCLIAdapter,
|
||||
httpCLIAdapter,
|
||||
].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getCLIAdapter(type: string): CLIAdapterModule {
|
||||
|
||||
@@ -39,15 +39,7 @@ export async function databaseCheck(config: PaperclipConfig, configPath?: string
|
||||
const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath);
|
||||
const reportedPath = dataDir;
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
return {
|
||||
name: "Database",
|
||||
status: "warn",
|
||||
message: `Embedded PostgreSQL data directory does not exist: ${reportedPath}`,
|
||||
canRepair: true,
|
||||
repair: () => {
|
||||
fs.mkdirSync(reportedPath, { recursive: true });
|
||||
},
|
||||
};
|
||||
fs.mkdirSync(reportedPath, { recursive: true });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,20 +5,16 @@ export async function llmCheck(config: PaperclipConfig): Promise<CheckResult> {
|
||||
if (!config.llm) {
|
||||
return {
|
||||
name: "LLM provider",
|
||||
status: "warn",
|
||||
message: "No LLM provider configured",
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section llm` to set one up",
|
||||
status: "pass",
|
||||
message: "No LLM provider configured (optional)",
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.llm.apiKey) {
|
||||
return {
|
||||
name: "LLM provider",
|
||||
status: "warn",
|
||||
message: `${config.llm.provider} configured but no API key set`,
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclipai configure --section llm`",
|
||||
status: "pass",
|
||||
message: `${config.llm.provider} configured but no API key set (optional)`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,15 +8,7 @@ export function logCheck(config: PaperclipConfig, configPath?: string): CheckRes
|
||||
const reportedDir = logDir;
|
||||
|
||||
if (!fs.existsSync(logDir)) {
|
||||
return {
|
||||
name: "Log directory",
|
||||
status: "warn",
|
||||
message: `Log directory does not exist: ${reportedDir}`,
|
||||
canRepair: true,
|
||||
repair: () => {
|
||||
fs.mkdirSync(reportedDir, { recursive: true });
|
||||
},
|
||||
};
|
||||
fs.mkdirSync(reportedDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -7,16 +7,7 @@ export function storageCheck(config: PaperclipConfig, configPath?: string): Chec
|
||||
if (config.storage.provider === "local_disk") {
|
||||
const baseDir = resolveRuntimeLikePath(config.storage.localDisk.baseDir, configPath);
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
return {
|
||||
name: "Storage",
|
||||
status: "warn",
|
||||
message: `Local storage directory does not exist: ${baseDir}`,
|
||||
canRepair: true,
|
||||
repair: () => {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
},
|
||||
repairHint: "Run with --repair to create local storage directory",
|
||||
};
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -104,8 +104,10 @@ export class PaperclipApiClient {
|
||||
|
||||
function buildUrl(apiBase: string, path: string): string {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const [pathname, query] = normalizedPath.split("?");
|
||||
const url = new URL(apiBase);
|
||||
url.pathname = `${url.pathname.replace(/\/+$/, "")}${normalizedPath}`;
|
||||
url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`;
|
||||
if (query) url.search = query;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
|
||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
@@ -13,7 +14,8 @@ function createInviteToken() {
|
||||
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
function resolveDbUrl(configPath?: string) {
|
||||
function resolveDbUrl(configPath?: string, explicitDbUrl?: string) {
|
||||
if (explicitDbUrl) return explicitDbUrl;
|
||||
const config = readConfig(configPath);
|
||||
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
||||
if (config?.database.mode === "postgres" && config.database.connectionString) {
|
||||
@@ -28,6 +30,12 @@ function resolveDbUrl(configPath?: string) {
|
||||
|
||||
function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
|
||||
if (explicitBaseUrl) return explicitBaseUrl.replace(/\/+$/, "");
|
||||
const fromEnv =
|
||||
process.env.PAPERCLIP_PUBLIC_URL ??
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
|
||||
process.env.BETTER_AUTH_URL ??
|
||||
process.env.BETTER_AUTH_BASE_URL;
|
||||
if (fromEnv?.trim()) return fromEnv.trim().replace(/\/+$/, "");
|
||||
const config = readConfig(configPath);
|
||||
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
|
||||
return config.auth.publicBaseUrl.replace(/\/+$/, "");
|
||||
@@ -43,8 +51,10 @@ export async function bootstrapCeoInvite(opts: {
|
||||
force?: boolean;
|
||||
expiresHours?: number;
|
||||
baseUrl?: string;
|
||||
dbUrl?: string;
|
||||
}) {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
loadPaperclipEnvFile(configPath);
|
||||
const config = readConfig(configPath);
|
||||
if (!config) {
|
||||
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
|
||||
@@ -56,7 +66,7 @@ export async function bootstrapCeoInvite(opts: {
|
||||
return;
|
||||
}
|
||||
|
||||
const dbUrl = resolveDbUrl(configPath);
|
||||
const dbUrl = resolveDbUrl(configPath, opts.dbUrl);
|
||||
if (!dbUrl) {
|
||||
p.log.error(
|
||||
"Could not resolve database connection for bootstrap.",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Command } from "commander";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
formatInlineRecord,
|
||||
@@ -13,6 +17,107 @@ interface AgentListOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
interface AgentLocalCliOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
keyName?: string;
|
||||
installSkills?: boolean;
|
||||
}
|
||||
|
||||
interface CreatedAgentKey {
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface SkillsInstallSummary {
|
||||
tool: "codex" | "claude";
|
||||
target: string;
|
||||
linked: string[];
|
||||
skipped: string[];
|
||||
failed: Array<{ name: string; error: string }>;
|
||||
}
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills
|
||||
path.resolve(process.cwd(), "skills"),
|
||||
];
|
||||
|
||||
function codexSkillsHome(): string {
|
||||
const fromEnv = process.env.CODEX_HOME?.trim();
|
||||
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex");
|
||||
return path.join(base, "skills");
|
||||
}
|
||||
|
||||
function claudeSkillsHome(): string {
|
||||
const fromEnv = process.env.CLAUDE_HOME?.trim();
|
||||
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude");
|
||||
return path.join(base, "skills");
|
||||
}
|
||||
|
||||
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function installSkillsForTarget(
|
||||
sourceSkillsDir: string,
|
||||
targetSkillsDir: string,
|
||||
tool: "codex" | "claude",
|
||||
): Promise<SkillsInstallSummary> {
|
||||
const summary: SkillsInstallSummary = {
|
||||
tool,
|
||||
target: targetSkillsDir,
|
||||
linked: [],
|
||||
skipped: [],
|
||||
failed: [],
|
||||
};
|
||||
|
||||
await fs.mkdir(targetSkillsDir, { recursive: true });
|
||||
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(sourceSkillsDir, entry.name);
|
||||
const target = path.join(targetSkillsDir, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) {
|
||||
summary.skipped.push(entry.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
summary.linked.push(entry.name);
|
||||
} catch (err) {
|
||||
summary.failed.push({
|
||||
name: entry.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
function buildAgentEnvExports(input: {
|
||||
apiBase: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
apiKey: string;
|
||||
}): string {
|
||||
const escaped = (value: string) => value.replace(/'/g, "'\"'\"'");
|
||||
return [
|
||||
`export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`,
|
||||
`export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`,
|
||||
`export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`,
|
||||
`export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function registerAgentCommands(program: Command): void {
|
||||
const agent = program.command("agent").description("Agent operations");
|
||||
|
||||
@@ -71,4 +176,102 @@ export function registerAgentCommands(program: Command): void {
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
agent
|
||||
.command("local-cli")
|
||||
.description(
|
||||
"Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports",
|
||||
)
|
||||
.argument("<agentRef>", "Agent ID or shortname/url-key")
|
||||
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||
.option("--key-name <name>", "API key label", "local-cli")
|
||||
.option(
|
||||
"--no-install-skills",
|
||||
"Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills",
|
||||
)
|
||||
.action(async (agentRef: string, opts: AgentLocalCliOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const query = new URLSearchParams({ companyId: ctx.companyId ?? "" });
|
||||
const agentRow = await ctx.api.get<Agent>(
|
||||
`/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`,
|
||||
);
|
||||
if (!agentRow) {
|
||||
throw new Error(`Agent not found: ${agentRef}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString().replaceAll(":", "-");
|
||||
const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`;
|
||||
const key = await ctx.api.post<CreatedAgentKey>(`/api/agents/${agentRow.id}/keys`, { name: keyName });
|
||||
if (!key) {
|
||||
throw new Error("Failed to create API key");
|
||||
}
|
||||
|
||||
const installSummaries: SkillsInstallSummary[] = [];
|
||||
if (opts.installSkills !== false) {
|
||||
const skillsDir = await resolvePaperclipSkillsDir();
|
||||
if (!skillsDir) {
|
||||
throw new Error(
|
||||
"Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.",
|
||||
);
|
||||
}
|
||||
|
||||
installSummaries.push(
|
||||
await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"),
|
||||
await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"),
|
||||
);
|
||||
}
|
||||
|
||||
const exportsText = buildAgentEnvExports({
|
||||
apiBase: ctx.api.apiBase,
|
||||
companyId: agentRow.companyId,
|
||||
agentId: agentRow.id,
|
||||
apiKey: key.token,
|
||||
});
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(
|
||||
{
|
||||
agent: {
|
||||
id: agentRow.id,
|
||||
name: agentRow.name,
|
||||
urlKey: agentRow.urlKey,
|
||||
companyId: agentRow.companyId,
|
||||
},
|
||||
key: {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
createdAt: key.createdAt,
|
||||
token: key.token,
|
||||
},
|
||||
skills: installSummaries,
|
||||
exports: exportsText,
|
||||
},
|
||||
{ json: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Agent: ${agentRow.name} (${agentRow.id})`);
|
||||
console.log(`API key created: ${key.name} (${key.id})`);
|
||||
if (installSummaries.length > 0) {
|
||||
for (const summary of installSummaries) {
|
||||
console.log(
|
||||
`${summary.tool}: linked=${summary.linked.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
|
||||
);
|
||||
for (const failed of summary.failed) {
|
||||
console.log(` failed ${failed.name}: ${failed.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
console.log("# Run this in your shell before launching codex/claude:");
|
||||
console.log(exportsText);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { defaultSecretsConfig, promptSecrets } from "../prompts/secrets.js";
|
||||
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
|
||||
import { promptServer } from "../prompts/server.js";
|
||||
import {
|
||||
resolveDefaultBackupDir,
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
resolveDefaultLogsDir,
|
||||
resolvePaperclipInstanceId,
|
||||
@@ -39,6 +40,12 @@ function defaultConfig(): PaperclipConfig {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: resolveDefaultBackupDir(instanceId),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
@@ -54,6 +61,7 @@ function defaultConfig(): PaperclipConfig {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
@@ -118,7 +126,7 @@ export async function configure(opts: {
|
||||
|
||||
switch (section) {
|
||||
case "database":
|
||||
config.database = await promptDatabase();
|
||||
config.database = await promptDatabase(config.database);
|
||||
break;
|
||||
case "llm": {
|
||||
const llm = await promptLlm();
|
||||
|
||||
102
cli/src/commands/db-backup.ts
Normal file
102
cli/src/commands/db-backup.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import path from "node:path";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { formatDatabaseBackupResult, runDatabaseBackup } from "@paperclipai/db";
|
||||
import {
|
||||
expandHomePrefix,
|
||||
resolveDefaultBackupDir,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
|
||||
type DbBackupOptions = {
|
||||
config?: string;
|
||||
dir?: string;
|
||||
retentionDays?: number;
|
||||
filenamePrefix?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
function resolveConnectionString(configPath?: string): { value: string; source: string } {
|
||||
const envUrl = process.env.DATABASE_URL?.trim();
|
||||
if (envUrl) return { value: envUrl, source: "DATABASE_URL" };
|
||||
|
||||
const config = readConfig(configPath);
|
||||
if (config?.database.mode === "postgres" && config.database.connectionString?.trim()) {
|
||||
return { value: config.database.connectionString.trim(), source: "config.database.connectionString" };
|
||||
}
|
||||
|
||||
const port = config?.database.embeddedPostgresPort ?? 54329;
|
||||
return {
|
||||
value: `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`,
|
||||
source: `embedded-postgres@${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRetentionDays(value: number | undefined, fallback: number): number {
|
||||
const candidate = value ?? fallback;
|
||||
if (!Number.isInteger(candidate) || candidate < 1) {
|
||||
throw new Error(`Invalid retention days '${String(candidate)}'. Use a positive integer.`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function resolveBackupDir(raw: string): string {
|
||||
return path.resolve(expandHomePrefix(raw.trim()));
|
||||
}
|
||||
|
||||
export async function dbBackupCommand(opts: DbBackupOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclip db:backup ")));
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const config = readConfig(opts.config);
|
||||
const connection = resolveConnectionString(opts.config);
|
||||
const defaultDir = resolveDefaultBackupDir(resolvePaperclipInstanceId());
|
||||
const configuredDir = opts.dir?.trim() || config?.database.backup.dir || defaultDir;
|
||||
const backupDir = resolveBackupDir(configuredDir);
|
||||
const retentionDays = normalizeRetentionDays(
|
||||
opts.retentionDays,
|
||||
config?.database.backup.retentionDays ?? 30,
|
||||
);
|
||||
const filenamePrefix = opts.filenamePrefix?.trim() || "paperclip";
|
||||
|
||||
p.log.message(pc.dim(`Config: ${configPath}`));
|
||||
p.log.message(pc.dim(`Connection source: ${connection.source}`));
|
||||
p.log.message(pc.dim(`Backup dir: ${backupDir}`));
|
||||
p.log.message(pc.dim(`Retention: ${retentionDays} day(s)`));
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start("Creating database backup...");
|
||||
try {
|
||||
const result = await runDatabaseBackup({
|
||||
connectionString: connection.value,
|
||||
backupDir,
|
||||
retentionDays,
|
||||
filenamePrefix,
|
||||
});
|
||||
spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
backupFile: result.backupFile,
|
||||
sizeBytes: result.sizeBytes,
|
||||
prunedCount: result.prunedCount,
|
||||
backupDir,
|
||||
retentionDays,
|
||||
connectionSource: connection.source,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
p.outro(pc.green("Backup completed."));
|
||||
} catch (err) {
|
||||
spinner.stop(pc.red("Backup failed."));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
storageCheck,
|
||||
type CheckResult,
|
||||
} from "../checks/index.js";
|
||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
|
||||
const STATUS_ICON = {
|
||||
@@ -31,6 +32,7 @@ export async function doctor(opts: {
|
||||
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
loadPaperclipEnvFile(configPath);
|
||||
const results: CheckResult[] = [];
|
||||
|
||||
// 1. Config check (must pass before others)
|
||||
@@ -64,28 +66,40 @@ export async function doctor(opts: {
|
||||
printResult(deploymentAuthResult);
|
||||
|
||||
// 3. Agent JWT check
|
||||
const jwtResult = agentJwtSecretCheck(opts.config);
|
||||
results.push(jwtResult);
|
||||
printResult(jwtResult);
|
||||
await maybeRepair(jwtResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => agentJwtSecretCheck(opts.config),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 4. Secrets adapter check
|
||||
const secretsResult = secretsCheck(config, configPath);
|
||||
results.push(secretsResult);
|
||||
printResult(secretsResult);
|
||||
await maybeRepair(secretsResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => secretsCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 5. Storage check
|
||||
const storageResult = storageCheck(config, configPath);
|
||||
results.push(storageResult);
|
||||
printResult(storageResult);
|
||||
await maybeRepair(storageResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => storageCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 6. Database check
|
||||
const dbResult = await databaseCheck(config, configPath);
|
||||
results.push(dbResult);
|
||||
printResult(dbResult);
|
||||
await maybeRepair(dbResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => databaseCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 7. LLM check
|
||||
const llmResult = await llmCheck(config);
|
||||
@@ -93,10 +107,13 @@ export async function doctor(opts: {
|
||||
printResult(llmResult);
|
||||
|
||||
// 8. Log directory check
|
||||
const logResult = logCheck(config, configPath);
|
||||
results.push(logResult);
|
||||
printResult(logResult);
|
||||
await maybeRepair(logResult, opts);
|
||||
results.push(
|
||||
await runRepairableCheck({
|
||||
run: () => logCheck(config, configPath),
|
||||
configPath,
|
||||
opts,
|
||||
}),
|
||||
);
|
||||
|
||||
// 9. Port check
|
||||
const portResult = await portCheck(config);
|
||||
@@ -118,9 +135,9 @@ function printResult(result: CheckResult): void {
|
||||
async function maybeRepair(
|
||||
result: CheckResult,
|
||||
opts: { repair?: boolean; yes?: boolean },
|
||||
): Promise<void> {
|
||||
if (result.status === "pass" || !result.canRepair || !result.repair) return;
|
||||
if (!opts.repair) return;
|
||||
): Promise<boolean> {
|
||||
if (result.status === "pass" || !result.canRepair || !result.repair) return false;
|
||||
if (!opts.repair) return false;
|
||||
|
||||
let shouldRepair = opts.yes;
|
||||
if (!shouldRepair) {
|
||||
@@ -128,7 +145,7 @@ async function maybeRepair(
|
||||
message: `Repair "${result.name}"?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (p.isCancel(answer)) return;
|
||||
if (p.isCancel(answer)) return false;
|
||||
shouldRepair = answer;
|
||||
}
|
||||
|
||||
@@ -136,10 +153,30 @@ async function maybeRepair(
|
||||
try {
|
||||
await result.repair();
|
||||
p.log.success(`Repaired: ${result.name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
p.log.error(`Repair failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runRepairableCheck(input: {
|
||||
run: () => CheckResult | Promise<CheckResult>;
|
||||
configPath: string;
|
||||
opts: { repair?: boolean; yes?: boolean };
|
||||
}): Promise<CheckResult> {
|
||||
let result = await input.run();
|
||||
printResult(result);
|
||||
|
||||
const repaired = await maybeRepair(result, input.opts);
|
||||
if (!repaired) return result;
|
||||
|
||||
// Repairs may create/update the adjacent .env file or other local resources.
|
||||
loadPaperclipEnvFile(input.configPath);
|
||||
result = await input.run();
|
||||
printResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } {
|
||||
|
||||
@@ -118,6 +118,29 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
|
||||
const dbUrl = process.env.DATABASE_URL ?? config?.database?.connectionString ?? "";
|
||||
const databaseMode = config?.database?.mode ?? "embedded-postgres";
|
||||
const dbUrlSource: EnvSource = process.env.DATABASE_URL ? "env" : config?.database?.connectionString ? "config" : "missing";
|
||||
const publicUrl =
|
||||
process.env.PAPERCLIP_PUBLIC_URL ??
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
|
||||
process.env.BETTER_AUTH_URL ??
|
||||
process.env.BETTER_AUTH_BASE_URL ??
|
||||
config?.auth?.publicBaseUrl ??
|
||||
"";
|
||||
const publicUrlSource: EnvSource =
|
||||
process.env.PAPERCLIP_PUBLIC_URL
|
||||
? "env"
|
||||
: process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL || process.env.BETTER_AUTH_URL || process.env.BETTER_AUTH_BASE_URL
|
||||
? "env"
|
||||
: config?.auth?.publicBaseUrl
|
||||
? "config"
|
||||
: "missing";
|
||||
let trustedOriginsDefault = "";
|
||||
if (publicUrl) {
|
||||
try {
|
||||
trustedOriginsDefault = new URL(publicUrl).origin;
|
||||
} catch {
|
||||
trustedOriginsDefault = "";
|
||||
}
|
||||
}
|
||||
|
||||
const heartbeatInterval = process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS;
|
||||
const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true";
|
||||
@@ -192,6 +215,24 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
|
||||
required: false,
|
||||
note: "HTTP listen port",
|
||||
},
|
||||
{
|
||||
key: "PAPERCLIP_PUBLIC_URL",
|
||||
value: publicUrl,
|
||||
source: publicUrlSource,
|
||||
required: false,
|
||||
note: "Canonical public URL for auth/callback/invite origin wiring",
|
||||
},
|
||||
{
|
||||
key: "BETTER_AUTH_TRUSTED_ORIGINS",
|
||||
value: process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? trustedOriginsDefault,
|
||||
source: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
? "env"
|
||||
: trustedOriginsDefault
|
||||
? "default"
|
||||
: "missing",
|
||||
required: false,
|
||||
note: "Comma-separated auth origin allowlist (auto-derived from PAPERCLIP_PUBLIC_URL when possible)",
|
||||
},
|
||||
{
|
||||
key: "PAPERCLIP_AGENT_JWT_TTL_SECONDS",
|
||||
value: process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS ?? DEFAULT_AGENT_JWT_TTL_SECONDS,
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import path from "node:path";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
AUTH_BASE_URL_MODES,
|
||||
DEPLOYMENT_EXPOSURES,
|
||||
DEPLOYMENT_MODES,
|
||||
SECRET_PROVIDERS,
|
||||
STORAGE_PROVIDERS,
|
||||
type AuthBaseUrlMode,
|
||||
type DeploymentExposure,
|
||||
type DeploymentMode,
|
||||
type SecretProvider,
|
||||
type StorageProvider,
|
||||
} from "@paperclipai/shared";
|
||||
import { configExists, readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js";
|
||||
@@ -12,6 +25,8 @@ import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
|
||||
import { promptServer } from "../prompts/server.js";
|
||||
import {
|
||||
describeLocalInstancePaths,
|
||||
expandHomePrefix,
|
||||
resolveDefaultBackupDir,
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
resolveDefaultLogsDir,
|
||||
resolvePaperclipInstanceId,
|
||||
@@ -28,32 +43,194 @@ type OnboardOptions = {
|
||||
invokedByRun?: boolean;
|
||||
};
|
||||
|
||||
function quickstartDefaults(): Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets"> {
|
||||
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
|
||||
|
||||
const ONBOARD_ENV_KEYS = [
|
||||
"PAPERCLIP_PUBLIC_URL",
|
||||
"DATABASE_URL",
|
||||
"PAPERCLIP_DB_BACKUP_ENABLED",
|
||||
"PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES",
|
||||
"PAPERCLIP_DB_BACKUP_RETENTION_DAYS",
|
||||
"PAPERCLIP_DB_BACKUP_DIR",
|
||||
"PAPERCLIP_DEPLOYMENT_MODE",
|
||||
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
||||
"HOST",
|
||||
"PORT",
|
||||
"SERVE_UI",
|
||||
"PAPERCLIP_ALLOWED_HOSTNAMES",
|
||||
"PAPERCLIP_AUTH_BASE_URL_MODE",
|
||||
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
|
||||
"BETTER_AUTH_URL",
|
||||
"BETTER_AUTH_BASE_URL",
|
||||
"PAPERCLIP_STORAGE_PROVIDER",
|
||||
"PAPERCLIP_STORAGE_LOCAL_DIR",
|
||||
"PAPERCLIP_STORAGE_S3_BUCKET",
|
||||
"PAPERCLIP_STORAGE_S3_REGION",
|
||||
"PAPERCLIP_STORAGE_S3_ENDPOINT",
|
||||
"PAPERCLIP_STORAGE_S3_PREFIX",
|
||||
"PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE",
|
||||
"PAPERCLIP_SECRETS_PROVIDER",
|
||||
"PAPERCLIP_SECRETS_STRICT_MODE",
|
||||
"PAPERCLIP_SECRETS_MASTER_KEY_FILE",
|
||||
] as const;
|
||||
|
||||
function parseBooleanFromEnv(rawValue: string | undefined): boolean | null {
|
||||
if (rawValue === undefined) return null;
|
||||
const lower = rawValue.trim().toLowerCase();
|
||||
if (lower === "true" || lower === "1" || lower === "yes") return true;
|
||||
if (lower === "false" || lower === "0" || lower === "no") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseNumberFromEnv(rawValue: string | undefined): number | null {
|
||||
if (!rawValue) return null;
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseEnumFromEnv<T extends string>(rawValue: string | undefined, allowedValues: readonly T[]): T | null {
|
||||
if (!rawValue) return null;
|
||||
return allowedValues.includes(rawValue as T) ? (rawValue as T) : null;
|
||||
}
|
||||
|
||||
function resolvePathFromEnv(rawValue: string | undefined): string | null {
|
||||
if (!rawValue || rawValue.trim().length === 0) return null;
|
||||
return path.resolve(expandHomePrefix(rawValue.trim()));
|
||||
}
|
||||
|
||||
function quickstartDefaultsFromEnv(): {
|
||||
defaults: OnboardDefaults;
|
||||
usedEnvKeys: string[];
|
||||
ignoredEnvKeys: Array<{ key: string; reason: string }>;
|
||||
} {
|
||||
const instanceId = resolvePaperclipInstanceId();
|
||||
return {
|
||||
const defaultStorage = defaultStorageConfig();
|
||||
const defaultSecrets = defaultSecretsConfig();
|
||||
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
|
||||
const publicUrl =
|
||||
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
|
||||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
|
||||
process.env.BETTER_AUTH_URL?.trim() ||
|
||||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
|
||||
undefined;
|
||||
const deploymentMode =
|
||||
parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted";
|
||||
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
|
||||
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE,
|
||||
DEPLOYMENT_EXPOSURES,
|
||||
);
|
||||
const deploymentExposure =
|
||||
deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private");
|
||||
const authPublicBaseUrl = publicUrl;
|
||||
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
|
||||
process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
|
||||
AUTH_BASE_URL_MODES,
|
||||
);
|
||||
const authBaseUrlMode = authBaseUrlModeFromEnv ?? (authPublicBaseUrl ? "explicit" : "auto");
|
||||
const allowedHostnamesFromEnv = process.env.PAPERCLIP_ALLOWED_HOSTNAMES
|
||||
? process.env.PAPERCLIP_ALLOWED_HOSTNAMES
|
||||
.split(",")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => value.length > 0)
|
||||
: [];
|
||||
const hostnameFromPublicUrl = publicUrl
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(publicUrl).hostname.trim().toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
const storageProvider =
|
||||
parseEnumFromEnv<StorageProvider>(process.env.PAPERCLIP_STORAGE_PROVIDER, STORAGE_PROVIDERS) ??
|
||||
defaultStorage.provider;
|
||||
const secretsProvider =
|
||||
parseEnumFromEnv<SecretProvider>(process.env.PAPERCLIP_SECRETS_PROVIDER, SECRET_PROVIDERS) ??
|
||||
defaultSecrets.provider;
|
||||
const databaseBackupEnabled = parseBooleanFromEnv(process.env.PAPERCLIP_DB_BACKUP_ENABLED) ?? true;
|
||||
const databaseBackupIntervalMinutes = Math.max(
|
||||
1,
|
||||
parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES) ?? 60,
|
||||
);
|
||||
const databaseBackupRetentionDays = Math.max(
|
||||
1,
|
||||
parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) ?? 30,
|
||||
);
|
||||
const defaults: OnboardDefaults = {
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
mode: databaseUrl ? "postgres" : "embedded-postgres",
|
||||
...(databaseUrl ? { connectionString: databaseUrl } : {}),
|
||||
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: databaseBackupEnabled,
|
||||
intervalMinutes: databaseBackupIntervalMinutes,
|
||||
retentionDays: databaseBackupRetentionDays,
|
||||
dir: resolvePathFromEnv(process.env.PAPERCLIP_DB_BACKUP_DIR) ?? resolveDefaultBackupDir(instanceId),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: resolveDefaultLogsDir(instanceId),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
deploymentMode,
|
||||
exposure: deploymentExposure,
|
||||
host: process.env.HOST ?? "127.0.0.1",
|
||||
port: Number(process.env.PORT) || 3100,
|
||||
allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])),
|
||||
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
baseUrlMode: authBaseUrlMode,
|
||||
disableSignUp: false,
|
||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
},
|
||||
storage: {
|
||||
provider: storageProvider,
|
||||
localDisk: {
|
||||
baseDir:
|
||||
resolvePathFromEnv(process.env.PAPERCLIP_STORAGE_LOCAL_DIR) ?? defaultStorage.localDisk.baseDir,
|
||||
},
|
||||
s3: {
|
||||
bucket: process.env.PAPERCLIP_STORAGE_S3_BUCKET ?? defaultStorage.s3.bucket,
|
||||
region: process.env.PAPERCLIP_STORAGE_S3_REGION ?? defaultStorage.s3.region,
|
||||
endpoint: process.env.PAPERCLIP_STORAGE_S3_ENDPOINT ?? defaultStorage.s3.endpoint,
|
||||
prefix: process.env.PAPERCLIP_STORAGE_S3_PREFIX ?? defaultStorage.s3.prefix,
|
||||
forcePathStyle:
|
||||
parseBooleanFromEnv(process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE) ??
|
||||
defaultStorage.s3.forcePathStyle,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: secretsProvider,
|
||||
strictMode: parseBooleanFromEnv(process.env.PAPERCLIP_SECRETS_STRICT_MODE) ?? defaultSecrets.strictMode,
|
||||
localEncrypted: {
|
||||
keyFilePath:
|
||||
resolvePathFromEnv(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
|
||||
defaultSecrets.localEncrypted.keyFilePath,
|
||||
},
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
};
|
||||
const ignoredEnvKeys: Array<{ key: string; reason: string }> = [];
|
||||
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) {
|
||||
ignoredEnvKeys.push({
|
||||
key: "PAPERCLIP_DEPLOYMENT_EXPOSURE",
|
||||
reason: "Ignored because deployment mode local_trusted always forces private exposure",
|
||||
});
|
||||
}
|
||||
|
||||
const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key));
|
||||
const usedEnvKeys = ONBOARD_ENV_KEYS.filter(
|
||||
(key) => process.env[key] !== undefined && !ignoredKeySet.has(key),
|
||||
);
|
||||
return { defaults, usedEnvKeys, ignoredEnvKeys };
|
||||
}
|
||||
|
||||
function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "database" | "server">): boolean {
|
||||
return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres";
|
||||
}
|
||||
|
||||
export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
@@ -109,6 +286,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
}
|
||||
|
||||
let llm: PaperclipConfig["llm"] | undefined;
|
||||
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
|
||||
let {
|
||||
database,
|
||||
logging,
|
||||
@@ -116,11 +294,11 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
auth,
|
||||
storage,
|
||||
secrets,
|
||||
} = quickstartDefaults();
|
||||
} = derivedDefaults;
|
||||
|
||||
if (setupMode === "advanced") {
|
||||
p.log.step(pc.bold("Database"));
|
||||
database = await promptDatabase();
|
||||
database = await promptDatabase(database);
|
||||
|
||||
if (database.mode === "postgres" && database.connectionString) {
|
||||
const s = p.spinner();
|
||||
@@ -184,13 +362,20 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
logging = await promptLogging();
|
||||
|
||||
p.log.step(pc.bold("Server"));
|
||||
({ server, auth } = await promptServer());
|
||||
({ server, auth } = await promptServer({ currentServer: server, currentAuth: auth }));
|
||||
|
||||
p.log.step(pc.bold("Storage"));
|
||||
storage = await promptStorage(defaultStorageConfig());
|
||||
storage = await promptStorage(storage);
|
||||
|
||||
p.log.step(pc.bold("Secrets"));
|
||||
secrets = defaultSecretsConfig();
|
||||
const secretsDefaults = defaultSecretsConfig();
|
||||
secrets = {
|
||||
provider: secrets.provider ?? secretsDefaults.provider,
|
||||
strictMode: secrets.strictMode ?? secretsDefaults.strictMode,
|
||||
localEncrypted: {
|
||||
keyFilePath: secrets.localEncrypted?.keyFilePath ?? secretsDefaults.localEncrypted.keyFilePath,
|
||||
},
|
||||
};
|
||||
p.log.message(
|
||||
pc.dim(
|
||||
`Using defaults: provider=${secrets.provider}, strictMode=${secrets.strictMode}, keyFile=${secrets.localEncrypted.keyFilePath}`,
|
||||
@@ -198,9 +383,17 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
);
|
||||
} else {
|
||||
p.log.step(pc.bold("Quickstart"));
|
||||
p.log.message(
|
||||
pc.dim("Using local defaults: embedded database, no LLM provider, file storage, and local encrypted secrets."),
|
||||
);
|
||||
p.log.message(pc.dim("Using quickstart defaults."));
|
||||
if (usedEnvKeys.length > 0) {
|
||||
p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`));
|
||||
} else {
|
||||
p.log.message(
|
||||
pc.dim("No environment overrides detected: embedded database, file storage, local encrypted secrets."),
|
||||
);
|
||||
}
|
||||
for (const ignored of ignoredEnvKeys) {
|
||||
p.log.message(pc.dim(`Ignored ${ignored.key}: ${ignored.reason}`));
|
||||
}
|
||||
}
|
||||
|
||||
const jwtSecret = ensureAgentJwtSecret(configPath);
|
||||
@@ -261,7 +454,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
"Next commands",
|
||||
);
|
||||
|
||||
if (server.deploymentMode === "authenticated") {
|
||||
if (canCreateBootstrapInviteImmediately({ database, server })) {
|
||||
p.log.step("Generating bootstrap CEO invite");
|
||||
await bootstrapCeoInvite({ config: configPath });
|
||||
}
|
||||
@@ -284,5 +477,15 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") {
|
||||
p.log.info(
|
||||
[
|
||||
"Bootstrap CEO invite will be created after the server starts.",
|
||||
`Next: ${pc.cyan("paperclipai run")}`,
|
||||
`Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
p.outro("You're all set!");
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
|
||||
import { onboard } from "./onboard.js";
|
||||
import { doctor } from "./doctor.js";
|
||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||
import { configExists, resolveConfigPath } from "../config/store.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { readConfig } from "../config/store.js";
|
||||
import {
|
||||
describeLocalInstancePaths,
|
||||
resolvePaperclipHomeDir,
|
||||
@@ -19,6 +23,13 @@ interface RunOptions {
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
interface StartedServer {
|
||||
apiUrl: string;
|
||||
databaseUrl: string;
|
||||
host: string;
|
||||
listenPort: number;
|
||||
}
|
||||
|
||||
export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
const instanceId = resolvePaperclipInstanceId(opts.instance);
|
||||
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
||||
@@ -31,6 +42,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
process.env.PAPERCLIP_CONFIG = configPath;
|
||||
loadPaperclipEnvFile(configPath);
|
||||
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
|
||||
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
|
||||
@@ -60,8 +72,23 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = readConfig(configPath);
|
||||
if (!config) {
|
||||
p.log.error(`No config found at ${configPath}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
p.log.step("Starting Paperclip server...");
|
||||
await importServerEntry();
|
||||
const startedServer = await importServerEntry();
|
||||
|
||||
if (shouldGenerateBootstrapInviteAfterStart(config)) {
|
||||
p.log.step("Generating bootstrap CEO invite");
|
||||
await bootstrapCeoInvite({
|
||||
config: configPath,
|
||||
dbUrl: startedServer.databaseUrl,
|
||||
baseUrl: startedServer.apiUrl.replace(/\/api$/, ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(err: unknown): string {
|
||||
@@ -84,6 +111,15 @@ function isModuleNotFoundError(err: unknown): boolean {
|
||||
return err.message.includes("Cannot find module");
|
||||
}
|
||||
|
||||
function getMissingModuleSpecifier(err: unknown): string | null {
|
||||
if (!(err instanceof Error)) return null;
|
||||
const packageMatch = err.message.match(/Cannot find package '([^']+)' imported from/);
|
||||
if (packageMatch?.[1]) return packageMatch[1];
|
||||
const moduleMatch = err.message.match(/Cannot find module '([^']+)'/);
|
||||
if (moduleMatch?.[1]) return moduleMatch[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
function maybeEnableUiDevMiddleware(entrypoint: string): void {
|
||||
if (process.env.PAPERCLIP_UI_DEV_MIDDLEWARE !== undefined) return;
|
||||
const normalized = entrypoint.replaceAll("\\", "/");
|
||||
@@ -92,21 +128,24 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function importServerEntry(): Promise<void> {
|
||||
async function importServerEntry(): Promise<StartedServer> {
|
||||
// Dev mode: try local workspace path (monorepo with tsx)
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
|
||||
if (fs.existsSync(devEntry)) {
|
||||
maybeEnableUiDevMiddleware(devEntry);
|
||||
await import(pathToFileURL(devEntry).href);
|
||||
return;
|
||||
const mod = await import(pathToFileURL(devEntry).href);
|
||||
return await startServerFromModule(mod, devEntry);
|
||||
}
|
||||
|
||||
// Production mode: import the published @paperclipai/server package
|
||||
try {
|
||||
await import("@paperclipai/server");
|
||||
const mod = await import("@paperclipai/server");
|
||||
return await startServerFromModule(mod, "@paperclipai/server");
|
||||
} catch (err) {
|
||||
if (isModuleNotFoundError(err)) {
|
||||
const missingSpecifier = getMissingModuleSpecifier(err);
|
||||
const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server";
|
||||
if (isModuleNotFoundError(err) && missingServerEntrypoint) {
|
||||
throw new Error(
|
||||
`Could not locate a Paperclip server entrypoint.\n` +
|
||||
`Tried: ${devEntry}, @paperclipai/server\n` +
|
||||
@@ -119,3 +158,15 @@ async function importServerEntry(): Promise<void> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldGenerateBootstrapInviteAfterStart(config: PaperclipConfig): boolean {
|
||||
return config.server.deploymentMode === "authenticated" && config.database.mode === "embedded-postgres";
|
||||
}
|
||||
|
||||
async function startServerFromModule(mod: unknown, label: string): Promise<StartedServer> {
|
||||
const startServer = (mod as { startServer?: () => Promise<StartedServer> }).startServer;
|
||||
if (typeof startServer !== "function") {
|
||||
throw new Error(`Paperclip server entrypoint did not export startServer(): ${label}`);
|
||||
}
|
||||
return await startServer();
|
||||
}
|
||||
|
||||
217
cli/src/commands/worktree-lib.ts
Normal file
217
cli/src/commands/worktree-lib.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import path from "node:path";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { expandHomePrefix } from "../config/home.js";
|
||||
|
||||
export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees";
|
||||
export const WORKTREE_SEED_MODES = ["minimal", "full"] as const;
|
||||
|
||||
export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number];
|
||||
|
||||
export type WorktreeSeedPlan = {
|
||||
mode: WorktreeSeedMode;
|
||||
excludedTables: string[];
|
||||
nullifyColumns: Record<string, string[]>;
|
||||
};
|
||||
|
||||
const MINIMAL_WORKTREE_EXCLUDED_TABLES = [
|
||||
"activity_log",
|
||||
"agent_runtime_state",
|
||||
"agent_task_sessions",
|
||||
"agent_wakeup_requests",
|
||||
"cost_events",
|
||||
"heartbeat_run_events",
|
||||
"heartbeat_runs",
|
||||
"workspace_runtime_services",
|
||||
];
|
||||
|
||||
const MINIMAL_WORKTREE_NULLIFIED_COLUMNS: Record<string, string[]> = {
|
||||
issues: ["checkout_run_id", "execution_run_id"],
|
||||
};
|
||||
|
||||
export type WorktreeLocalPaths = {
|
||||
cwd: string;
|
||||
repoConfigDir: string;
|
||||
configPath: string;
|
||||
envPath: string;
|
||||
homeDir: string;
|
||||
instanceId: string;
|
||||
instanceRoot: string;
|
||||
contextPath: string;
|
||||
embeddedPostgresDataDir: string;
|
||||
backupDir: string;
|
||||
logDir: string;
|
||||
secretsKeyFilePath: string;
|
||||
storageDir: string;
|
||||
};
|
||||
|
||||
export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode {
|
||||
return (WORKTREE_SEED_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPlan {
|
||||
if (mode === "full") {
|
||||
return {
|
||||
mode,
|
||||
excludedTables: [],
|
||||
nullifyColumns: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
excludedTables: [...MINIMAL_WORKTREE_EXCLUDED_TABLES],
|
||||
nullifyColumns: {
|
||||
...MINIMAL_WORKTREE_NULLIFIED_COLUMNS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function nonEmpty(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const value = hostname.trim().toLowerCase();
|
||||
return value === "127.0.0.1" || value === "localhost" || value === "::1";
|
||||
}
|
||||
|
||||
export function sanitizeWorktreeInstanceId(rawValue: string): string {
|
||||
const trimmed = rawValue.trim().toLowerCase();
|
||||
const normalized = trimmed
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-_]+|[-_]+$/g, "");
|
||||
return normalized || "worktree";
|
||||
}
|
||||
|
||||
export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string): string {
|
||||
return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd));
|
||||
}
|
||||
|
||||
export function resolveWorktreeLocalPaths(opts: {
|
||||
cwd: string;
|
||||
homeDir?: string;
|
||||
instanceId: string;
|
||||
}): WorktreeLocalPaths {
|
||||
const cwd = path.resolve(opts.cwd);
|
||||
const homeDir = path.resolve(expandHomePrefix(opts.homeDir ?? DEFAULT_WORKTREE_HOME));
|
||||
const instanceRoot = path.resolve(homeDir, "instances", opts.instanceId);
|
||||
const repoConfigDir = path.resolve(cwd, ".paperclip");
|
||||
return {
|
||||
cwd,
|
||||
repoConfigDir,
|
||||
configPath: path.resolve(repoConfigDir, "config.json"),
|
||||
envPath: path.resolve(repoConfigDir, ".env"),
|
||||
homeDir,
|
||||
instanceId: opts.instanceId,
|
||||
instanceRoot,
|
||||
contextPath: path.resolve(homeDir, "context.json"),
|
||||
embeddedPostgresDataDir: path.resolve(instanceRoot, "db"),
|
||||
backupDir: path.resolve(instanceRoot, "data", "backups"),
|
||||
logDir: path.resolve(instanceRoot, "logs"),
|
||||
secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"),
|
||||
storageDir: path.resolve(instanceRoot, "data", "storage"),
|
||||
};
|
||||
}
|
||||
|
||||
export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined {
|
||||
if (!rawUrl) return undefined;
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
|
||||
parsed.port = String(port);
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWorktreeConfig(input: {
|
||||
sourceConfig: PaperclipConfig | null;
|
||||
paths: WorktreeLocalPaths;
|
||||
serverPort: number;
|
||||
databasePort: number;
|
||||
now?: Date;
|
||||
}): PaperclipConfig {
|
||||
const { sourceConfig, paths, serverPort, databasePort } = input;
|
||||
const nowIso = (input.now ?? new Date()).toISOString();
|
||||
|
||||
const source = sourceConfig;
|
||||
const authPublicBaseUrl = rewriteLocalUrlPort(source?.auth.publicBaseUrl, serverPort);
|
||||
|
||||
return {
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: nowIso,
|
||||
source: "configure",
|
||||
},
|
||||
...(source?.llm ? { llm: source.llm } : {}),
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: paths.embeddedPostgresDataDir,
|
||||
embeddedPostgresPort: databasePort,
|
||||
backup: {
|
||||
enabled: source?.database.backup.enabled ?? true,
|
||||
intervalMinutes: source?.database.backup.intervalMinutes ?? 60,
|
||||
retentionDays: source?.database.backup.retentionDays ?? 30,
|
||||
dir: paths.backupDir,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: source?.logging.mode ?? "file",
|
||||
logDir: paths.logDir,
|
||||
},
|
||||
server: {
|
||||
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
|
||||
exposure: source?.server.exposure ?? "private",
|
||||
host: source?.server.host ?? "127.0.0.1",
|
||||
port: serverPort,
|
||||
allowedHostnames: source?.server.allowedHostnames ?? [],
|
||||
serveUi: source?.server.serveUi ?? true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: source?.auth.baseUrlMode ?? "auto",
|
||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
disableSignUp: source?.auth.disableSignUp ?? false,
|
||||
},
|
||||
storage: {
|
||||
provider: source?.storage.provider ?? "local_disk",
|
||||
localDisk: {
|
||||
baseDir: paths.storageDir,
|
||||
},
|
||||
s3: {
|
||||
bucket: source?.storage.s3.bucket ?? "paperclip",
|
||||
region: source?.storage.s3.region ?? "us-east-1",
|
||||
endpoint: source?.storage.s3.endpoint,
|
||||
prefix: source?.storage.s3.prefix ?? "",
|
||||
forcePathStyle: source?.storage.s3.forcePathStyle ?? false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: source?.secrets.provider ?? "local_encrypted",
|
||||
strictMode: source?.secrets.strictMode ?? false,
|
||||
localEncrypted: {
|
||||
keyFilePath: paths.secretsKeyFilePath,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record<string, string> {
|
||||
return {
|
||||
PAPERCLIP_HOME: paths.homeDir,
|
||||
PAPERCLIP_INSTANCE_ID: paths.instanceId,
|
||||
PAPERCLIP_CONFIG: paths.configPath,
|
||||
PAPERCLIP_CONTEXT: paths.contextPath,
|
||||
};
|
||||
}
|
||||
|
||||
function shellEscape(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
export function formatShellExports(entries: Record<string, string>): string {
|
||||
return Object.entries(entries)
|
||||
.filter(([, value]) => typeof value === "string" && value.trim().length > 0)
|
||||
.map(([key, value]) => `export ${key}=${shellEscape(value)}`)
|
||||
.join("\n");
|
||||
}
|
||||
403
cli/src/commands/worktree.ts
Normal file
403
cli/src/commands/worktree.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
ensurePostgresDatabase,
|
||||
formatDatabaseBackupResult,
|
||||
runDatabaseBackup,
|
||||
runDatabaseRestore,
|
||||
} from "@paperclipai/db";
|
||||
import type { Command } from "commander";
|
||||
import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js";
|
||||
import { expandHomePrefix } from "../config/home.js";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
import {
|
||||
buildWorktreeConfig,
|
||||
buildWorktreeEnvEntries,
|
||||
DEFAULT_WORKTREE_HOME,
|
||||
formatShellExports,
|
||||
isWorktreeSeedMode,
|
||||
resolveSuggestedWorktreeName,
|
||||
resolveWorktreeSeedPlan,
|
||||
resolveWorktreeLocalPaths,
|
||||
sanitizeWorktreeInstanceId,
|
||||
type WorktreeSeedMode,
|
||||
type WorktreeLocalPaths,
|
||||
} from "./worktree-lib.js";
|
||||
|
||||
type WorktreeInitOptions = {
|
||||
name?: string;
|
||||
instance?: string;
|
||||
home?: string;
|
||||
fromConfig?: string;
|
||||
fromDataDir?: string;
|
||||
fromInstance?: string;
|
||||
serverPort?: number;
|
||||
dbPort?: number;
|
||||
seed?: boolean;
|
||||
seedMode?: string;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
type WorktreeEnvOptions = {
|
||||
config?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresCtor = new (opts: {
|
||||
databaseDir: string;
|
||||
user: string;
|
||||
password: string;
|
||||
port: number;
|
||||
persistent: boolean;
|
||||
onLog?: (message: unknown) => void;
|
||||
onError?: (message: unknown) => void;
|
||||
}) => EmbeddedPostgresInstance;
|
||||
|
||||
type EmbeddedPostgresHandle = {
|
||||
port: number;
|
||||
startedByThisProcess: boolean;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
function nonEmpty(value: string | null | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readPidFilePort(postmasterPidFile: string): number | null {
|
||||
if (!existsSync(postmasterPidFile)) return null;
|
||||
try {
|
||||
const lines = readFileSync(postmasterPidFile, "utf8").split("\n");
|
||||
const port = Number(lines[3]?.trim());
|
||||
return Number.isInteger(port) && port > 0 ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
|
||||
if (!existsSync(postmasterPidFile)) return null;
|
||||
try {
|
||||
const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim());
|
||||
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||
process.kill(pid, 0);
|
||||
return pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortAvailable(port: number): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findAvailablePort(preferredPort: number, reserved = new Set<number>()): Promise<number> {
|
||||
let port = Math.max(1, Math.trunc(preferredPort));
|
||||
while (reserved.has(port) || !(await isPortAvailable(port))) {
|
||||
port += 1;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function detectGitBranchName(cwd: string): string | null {
|
||||
try {
|
||||
const value = execFileSync("git", ["branch", "--show-current"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return nonEmpty(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
|
||||
if (opts.fromConfig) return path.resolve(opts.fromConfig);
|
||||
const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip"));
|
||||
const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default");
|
||||
return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json");
|
||||
}
|
||||
|
||||
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
|
||||
if (config.database.mode === "postgres") {
|
||||
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
"Source instance uses postgres mode but has no connection string in config or adjacent .env.",
|
||||
);
|
||||
}
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
const port = portOverride ?? config.database.embeddedPostgresPort;
|
||||
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
||||
}
|
||||
|
||||
async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise<EmbeddedPostgresHandle> {
|
||||
const moduleName = "embedded-postgres";
|
||||
let EmbeddedPostgres: EmbeddedPostgresCtor;
|
||||
try {
|
||||
const mod = await import(moduleName);
|
||||
EmbeddedPostgres = mod.default as EmbeddedPostgresCtor;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
|
||||
const runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||
if (runningPid) {
|
||||
return {
|
||||
port: readPidFilePort(postmasterPidFile) ?? preferredPort,
|
||||
startedByThisProcess: false,
|
||||
stop: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const port = await findAvailablePort(preferredPort);
|
||||
const instance = new EmbeddedPostgres({
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port,
|
||||
persistent: true,
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
|
||||
await instance.initialise();
|
||||
}
|
||||
if (existsSync(postmasterPidFile)) {
|
||||
rmSync(postmasterPidFile, { force: true });
|
||||
}
|
||||
await instance.start();
|
||||
|
||||
return {
|
||||
port,
|
||||
startedByThisProcess: true,
|
||||
stop: async () => {
|
||||
await instance.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedWorktreeDatabase(input: {
|
||||
sourceConfigPath: string;
|
||||
sourceConfig: PaperclipConfig;
|
||||
targetConfig: PaperclipConfig;
|
||||
targetPaths: WorktreeLocalPaths;
|
||||
instanceId: string;
|
||||
seedMode: WorktreeSeedMode;
|
||||
}): Promise<string> {
|
||||
const seedPlan = resolveWorktreeSeedPlan(input.seedMode);
|
||||
const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath);
|
||||
const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile);
|
||||
let sourceHandle: EmbeddedPostgresHandle | null = null;
|
||||
let targetHandle: EmbeddedPostgresHandle | null = null;
|
||||
|
||||
try {
|
||||
if (input.sourceConfig.database.mode === "embedded-postgres") {
|
||||
sourceHandle = await ensureEmbeddedPostgres(
|
||||
input.sourceConfig.database.embeddedPostgresDataDir,
|
||||
input.sourceConfig.database.embeddedPostgresPort,
|
||||
);
|
||||
}
|
||||
const sourceConnectionString = resolveSourceConnectionString(
|
||||
input.sourceConfig,
|
||||
sourceEnvEntries,
|
||||
sourceHandle?.port,
|
||||
);
|
||||
const backup = await runDatabaseBackup({
|
||||
connectionString: sourceConnectionString,
|
||||
backupDir: path.resolve(input.targetPaths.backupDir, "seed"),
|
||||
retentionDays: 7,
|
||||
filenamePrefix: `${input.instanceId}-seed`,
|
||||
includeMigrationJournal: true,
|
||||
excludeTables: seedPlan.excludedTables,
|
||||
nullifyColumns: seedPlan.nullifyColumns,
|
||||
});
|
||||
|
||||
targetHandle = await ensureEmbeddedPostgres(
|
||||
input.targetConfig.database.embeddedPostgresDataDir,
|
||||
input.targetConfig.database.embeddedPostgresPort,
|
||||
);
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/postgres`;
|
||||
await ensurePostgresDatabase(adminConnectionString, "paperclip");
|
||||
const targetConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/paperclip`;
|
||||
await runDatabaseRestore({
|
||||
connectionString: targetConnectionString,
|
||||
backupFile: backup.backupFile,
|
||||
});
|
||||
await applyPendingMigrations(targetConnectionString);
|
||||
|
||||
return formatDatabaseBackupResult(backup);
|
||||
} finally {
|
||||
if (targetHandle?.startedByThisProcess) {
|
||||
await targetHandle.stop();
|
||||
}
|
||||
if (sourceHandle?.startedByThisProcess) {
|
||||
await sourceHandle.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree init ")));
|
||||
|
||||
const cwd = process.cwd();
|
||||
const name = resolveSuggestedWorktreeName(
|
||||
cwd,
|
||||
opts.name ?? detectGitBranchName(cwd) ?? undefined,
|
||||
);
|
||||
const seedMode = opts.seedMode ?? "minimal";
|
||||
if (!isWorktreeSeedMode(seedMode)) {
|
||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||
}
|
||||
const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name);
|
||||
const paths = resolveWorktreeLocalPaths({
|
||||
cwd,
|
||||
homeDir: opts.home ?? DEFAULT_WORKTREE_HOME,
|
||||
instanceId,
|
||||
});
|
||||
const sourceConfigPath = resolveSourceConfigPath(opts);
|
||||
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
|
||||
|
||||
if ((existsSync(paths.configPath) || existsSync(paths.instanceRoot)) && !opts.force) {
|
||||
throw new Error(
|
||||
`Worktree config already exists at ${paths.configPath} or instance data exists at ${paths.instanceRoot}. Re-run with --force to replace it.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.force) {
|
||||
rmSync(paths.repoConfigDir, { recursive: true, force: true });
|
||||
rmSync(paths.instanceRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
|
||||
const serverPort = await findAvailablePort(preferredServerPort);
|
||||
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
|
||||
const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort]));
|
||||
const targetConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths,
|
||||
serverPort,
|
||||
databasePort,
|
||||
});
|
||||
|
||||
writeConfig(targetConfig, paths.configPath);
|
||||
mergePaperclipEnvEntries(buildWorktreeEnvEntries(paths), paths.envPath);
|
||||
ensureAgentJwtSecret(paths.configPath);
|
||||
loadPaperclipEnvFile(paths.configPath);
|
||||
|
||||
let seedSummary: string | null = null;
|
||||
if (opts.seed !== false) {
|
||||
if (!sourceConfig) {
|
||||
throw new Error(
|
||||
`Cannot seed worktree database because source config was not found at ${sourceConfigPath}. Use --no-seed or provide --from-config.`,
|
||||
);
|
||||
}
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`);
|
||||
try {
|
||||
seedSummary = await seedWorktreeDatabase({
|
||||
sourceConfigPath,
|
||||
sourceConfig,
|
||||
targetConfig,
|
||||
targetPaths: paths,
|
||||
instanceId,
|
||||
seedMode,
|
||||
});
|
||||
spinner.stop(`Seeded isolated worktree database (${seedMode}).`);
|
||||
} catch (error) {
|
||||
spinner.stop(pc.red("Failed to seed worktree database."));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
p.log.message(pc.dim(`Repo config: ${paths.configPath}`));
|
||||
p.log.message(pc.dim(`Repo env: ${paths.envPath}`));
|
||||
p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`));
|
||||
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
|
||||
p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`));
|
||||
if (seedSummary) {
|
||||
p.log.message(pc.dim(`Seed mode: ${seedMode}`));
|
||||
p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`));
|
||||
}
|
||||
p.outro(
|
||||
pc.green(
|
||||
`Worktree ready. Run Paperclip inside this repo and the CLI/server will use ${paths.instanceId} automatically.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void> {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const envPath = resolvePaperclipEnvFile(configPath);
|
||||
const envEntries = readPaperclipEnvEntries(envPath);
|
||||
const out = {
|
||||
PAPERCLIP_CONFIG: configPath,
|
||||
...(envEntries.PAPERCLIP_HOME ? { PAPERCLIP_HOME: envEntries.PAPERCLIP_HOME } : {}),
|
||||
...(envEntries.PAPERCLIP_INSTANCE_ID ? { PAPERCLIP_INSTANCE_ID: envEntries.PAPERCLIP_INSTANCE_ID } : {}),
|
||||
...(envEntries.PAPERCLIP_CONTEXT ? { PAPERCLIP_CONTEXT: envEntries.PAPERCLIP_CONTEXT } : {}),
|
||||
...envEntries,
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(formatShellExports(out));
|
||||
}
|
||||
|
||||
export function registerWorktreeCommands(program: Command): void {
|
||||
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
||||
|
||||
worktree
|
||||
.command("init")
|
||||
.description("Create repo-local config/env and an isolated instance for this worktree")
|
||||
.option("--name <name>", "Display name used to derive the instance id")
|
||||
.option("--instance <id>", "Explicit isolated instance id")
|
||||
.option("--home <path>", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.option("--from-config <path>", "Source config.json to seed from")
|
||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||
.option("--from-instance <id>", "Source instance id when deriving the source config", "default")
|
||||
.option("--server-port <port>", "Preferred server port", (value) => Number(value))
|
||||
.option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value))
|
||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
||||
.option("--no-seed", "Skip database seeding from the source instance")
|
||||
.option("--force", "Replace existing repo-local config and isolated instance data", false)
|
||||
.action(worktreeInitCommand);
|
||||
|
||||
worktree
|
||||
.command("env")
|
||||
.description("Print shell exports for the current worktree-local Paperclip instance")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("--json", "Print JSON instead of shell exports")
|
||||
.action(worktreeEnvCommand);
|
||||
}
|
||||
@@ -25,17 +25,25 @@ function parseEnvFile(contents: string) {
|
||||
function renderEnvFile(entries: Record<string, string>) {
|
||||
const lines = [
|
||||
"# Paperclip environment variables",
|
||||
"# Generated by `paperclipai onboard`",
|
||||
"# Generated by Paperclip CLI commands",
|
||||
...Object.entries(entries).map(([key, value]) => `${key}=${value}`),
|
||||
"",
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function resolvePaperclipEnvFile(configPath?: string): string {
|
||||
return resolveEnvFilePath(configPath);
|
||||
}
|
||||
|
||||
export function resolveAgentJwtEnvFile(configPath?: string): string {
|
||||
return resolveEnvFilePath(configPath);
|
||||
}
|
||||
|
||||
export function loadPaperclipEnvFile(configPath?: string): void {
|
||||
loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
|
||||
}
|
||||
|
||||
export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
|
||||
if (loadedEnvFiles.has(filePath)) return;
|
||||
|
||||
@@ -78,13 +86,33 @@ export function ensureAgentJwtSecret(configPath?: string): { secret: string; cre
|
||||
}
|
||||
|
||||
export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void {
|
||||
mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath);
|
||||
}
|
||||
|
||||
export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record<string, string> {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
return parseEnvFile(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
export function writePaperclipEnvEntries(entries: Record<string, string>, filePath = resolveEnvFilePath()): void {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const current = fs.existsSync(filePath) ? parseEnvFile(fs.readFileSync(filePath, "utf-8")) : {};
|
||||
current[JWT_SECRET_ENV_KEY] = secret;
|
||||
|
||||
fs.writeFileSync(filePath, renderEnvFile(current), {
|
||||
fs.writeFileSync(filePath, renderEnvFile(entries), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
export function mergePaperclipEnvEntries(
|
||||
entries: Record<string, string>,
|
||||
filePath = resolveEnvFilePath(),
|
||||
): Record<string, string> {
|
||||
const current = readPaperclipEnvEntries(filePath);
|
||||
const next = {
|
||||
...current,
|
||||
...Object.fromEntries(
|
||||
Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0),
|
||||
),
|
||||
};
|
||||
writePaperclipEnvEntries(next, filePath);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ export function resolveDefaultStorageDir(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "storage");
|
||||
}
|
||||
|
||||
export function resolveDefaultBackupDir(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "backups");
|
||||
}
|
||||
|
||||
export function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
@@ -64,6 +68,7 @@ export function describeLocalInstancePaths(instanceId?: string) {
|
||||
instanceRoot,
|
||||
configPath: resolveDefaultConfigPath(resolvedInstanceId),
|
||||
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(resolvedInstanceId),
|
||||
backupDir: resolveDefaultBackupDir(resolvedInstanceId),
|
||||
logDir: resolveDefaultLogsDir(resolvedInstanceId),
|
||||
secretsKeyFilePath: resolveDefaultSecretsKeyFilePath(resolvedInstanceId),
|
||||
storageDir: resolveDefaultStorageDir(resolvedInstanceId),
|
||||
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
paperclipConfigSchema,
|
||||
configMetaSchema,
|
||||
llmConfigSchema,
|
||||
databaseBackupConfigSchema,
|
||||
databaseConfigSchema,
|
||||
loggingConfigSchema,
|
||||
serverConfigSchema,
|
||||
@@ -13,6 +14,7 @@ export {
|
||||
secretsLocalEncryptedConfigSchema,
|
||||
type PaperclipConfig,
|
||||
type LlmConfig,
|
||||
type DatabaseBackupConfig,
|
||||
type DatabaseConfig,
|
||||
type LoggingConfig,
|
||||
type ServerConfig,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { addAllowedHostname } from "./commands/allowed-hostname.js";
|
||||
import { heartbeatRun } from "./commands/heartbeat-run.js";
|
||||
import { runCommand } from "./commands/run.js";
|
||||
import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js";
|
||||
import { dbBackupCommand } from "./commands/db-backup.js";
|
||||
import { registerContextCommands } from "./commands/client/context.js";
|
||||
import { registerCompanyCommands } from "./commands/client/company.js";
|
||||
import { registerIssueCommands } from "./commands/client/issue.js";
|
||||
@@ -15,6 +16,8 @@ import { registerApprovalCommands } from "./commands/client/approval.js";
|
||||
import { registerActivityCommands } from "./commands/client/activity.js";
|
||||
import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
@@ -23,7 +26,7 @@ const DATA_DIR_OPTION_HELP =
|
||||
program
|
||||
.name("paperclipai")
|
||||
.description("Paperclip CLI — setup, diagnose, and configure your instance")
|
||||
.version("0.2.2");
|
||||
.version("0.2.7");
|
||||
|
||||
program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
|
||||
@@ -32,6 +35,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
hasConfigOption: optionNames.has("config"),
|
||||
hasContextOption: optionNames.has("context"),
|
||||
});
|
||||
loadPaperclipEnvFile(options.config);
|
||||
});
|
||||
|
||||
program
|
||||
@@ -70,6 +74,19 @@ program
|
||||
.option("-s, --section <section>", "Section to configure (llm, database, logging, server, storage, secrets)")
|
||||
.action(configure);
|
||||
|
||||
program
|
||||
.command("db:backup")
|
||||
.description("Create a one-off database backup using current config")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
|
||||
.option("--dir <path>", "Backup output directory (overrides config)")
|
||||
.option("--retention-days <days>", "Retention window used for pruning", (value) => Number(value))
|
||||
.option("--filename-prefix <prefix>", "Backup filename prefix", "paperclip")
|
||||
.option("--json", "Print backup metadata as JSON")
|
||||
.action(async (opts) => {
|
||||
await dbBackupCommand(opts);
|
||||
});
|
||||
|
||||
program
|
||||
.command("allowed-hostname")
|
||||
.description("Allow a hostname for authenticated/private mode access")
|
||||
@@ -118,6 +135,7 @@ registerAgentCommands(program);
|
||||
registerApprovalCommands(program);
|
||||
registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
|
||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import type { DatabaseConfig } from "../config/schema.js";
|
||||
import { resolveDefaultEmbeddedPostgresDir, resolvePaperclipInstanceId } from "../config/home.js";
|
||||
import {
|
||||
resolveDefaultBackupDir,
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
|
||||
export async function promptDatabase(): Promise<DatabaseConfig> {
|
||||
const defaultEmbeddedDir = resolveDefaultEmbeddedPostgresDir(resolvePaperclipInstanceId());
|
||||
export async function promptDatabase(current?: DatabaseConfig): Promise<DatabaseConfig> {
|
||||
const instanceId = resolvePaperclipInstanceId();
|
||||
const defaultEmbeddedDir = resolveDefaultEmbeddedPostgresDir(instanceId);
|
||||
const defaultBackupDir = resolveDefaultBackupDir(instanceId);
|
||||
const base: DatabaseConfig = current ?? {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: defaultEmbeddedDir,
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: defaultBackupDir,
|
||||
},
|
||||
};
|
||||
|
||||
const mode = await p.select({
|
||||
message: "Database mode",
|
||||
@@ -11,6 +28,7 @@ export async function promptDatabase(): Promise<DatabaseConfig> {
|
||||
{ value: "embedded-postgres" as const, label: "Embedded PostgreSQL (managed locally)", hint: "recommended" },
|
||||
{ value: "postgres" as const, label: "PostgreSQL (external server)" },
|
||||
],
|
||||
initialValue: base.mode,
|
||||
});
|
||||
|
||||
if (p.isCancel(mode)) {
|
||||
@@ -18,9 +36,14 @@ export async function promptDatabase(): Promise<DatabaseConfig> {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let connectionString: string | undefined = base.connectionString;
|
||||
let embeddedPostgresDataDir = base.embeddedPostgresDataDir || defaultEmbeddedDir;
|
||||
let embeddedPostgresPort = base.embeddedPostgresPort || 54329;
|
||||
|
||||
if (mode === "postgres") {
|
||||
const connectionString = await p.text({
|
||||
const value = await p.text({
|
||||
message: "PostgreSQL connection string",
|
||||
defaultValue: base.connectionString ?? "",
|
||||
placeholder: "postgres://user:pass@localhost:5432/paperclip",
|
||||
validate: (val) => {
|
||||
if (!val) return "Connection string is required for PostgreSQL mode";
|
||||
@@ -28,48 +51,107 @@ export async function promptDatabase(): Promise<DatabaseConfig> {
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(connectionString)) {
|
||||
if (p.isCancel(value)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "postgres",
|
||||
connectionString,
|
||||
embeddedPostgresDataDir: defaultEmbeddedDir,
|
||||
embeddedPostgresPort: 54329,
|
||||
};
|
||||
connectionString = value;
|
||||
} else {
|
||||
const dataDir = await p.text({
|
||||
message: "Embedded PostgreSQL data directory",
|
||||
defaultValue: base.embeddedPostgresDataDir || defaultEmbeddedDir,
|
||||
placeholder: defaultEmbeddedDir,
|
||||
});
|
||||
|
||||
if (p.isCancel(dataDir)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
embeddedPostgresDataDir = dataDir || defaultEmbeddedDir;
|
||||
|
||||
const portValue = await p.text({
|
||||
message: "Embedded PostgreSQL port",
|
||||
defaultValue: String(base.embeddedPostgresPort || 54329),
|
||||
placeholder: "54329",
|
||||
validate: (val) => {
|
||||
const n = Number(val);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) return "Port must be an integer between 1 and 65535";
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(portValue)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
embeddedPostgresPort = Number(portValue || "54329");
|
||||
connectionString = undefined;
|
||||
}
|
||||
|
||||
const embeddedPostgresDataDir = await p.text({
|
||||
message: "Embedded PostgreSQL data directory",
|
||||
defaultValue: defaultEmbeddedDir,
|
||||
placeholder: defaultEmbeddedDir,
|
||||
const backupEnabled = await p.confirm({
|
||||
message: "Enable automatic database backups?",
|
||||
initialValue: base.backup.enabled,
|
||||
});
|
||||
|
||||
if (p.isCancel(embeddedPostgresDataDir)) {
|
||||
if (p.isCancel(backupEnabled)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const embeddedPostgresPort = await p.text({
|
||||
message: "Embedded PostgreSQL port",
|
||||
defaultValue: "54329",
|
||||
placeholder: "54329",
|
||||
const backupDirInput = await p.text({
|
||||
message: "Backup directory",
|
||||
defaultValue: base.backup.dir || defaultBackupDir,
|
||||
placeholder: defaultBackupDir,
|
||||
validate: (val) => (!val || val.trim().length === 0 ? "Backup directory is required" : undefined),
|
||||
});
|
||||
if (p.isCancel(backupDirInput)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const backupIntervalInput = await p.text({
|
||||
message: "Backup interval (minutes)",
|
||||
defaultValue: String(base.backup.intervalMinutes || 60),
|
||||
placeholder: "60",
|
||||
validate: (val) => {
|
||||
const n = Number(val);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) return "Port must be an integer between 1 and 65535";
|
||||
if (!Number.isInteger(n) || n < 1) return "Interval must be a positive integer";
|
||||
if (n > 10080) return "Interval must be 10080 minutes (7 days) or less";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(backupIntervalInput)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (p.isCancel(embeddedPostgresPort)) {
|
||||
const backupRetentionInput = await p.text({
|
||||
message: "Backup retention (days)",
|
||||
defaultValue: String(base.backup.retentionDays || 30),
|
||||
placeholder: "30",
|
||||
validate: (val) => {
|
||||
const n = Number(val);
|
||||
if (!Number.isInteger(n) || n < 1) return "Retention must be a positive integer";
|
||||
if (n > 3650) return "Retention must be 3650 days or less";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(backupRetentionInput)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: embeddedPostgresDataDir || defaultEmbeddedDir,
|
||||
embeddedPostgresPort: Number(embeddedPostgresPort || "54329"),
|
||||
mode,
|
||||
connectionString,
|
||||
embeddedPostgresDataDir,
|
||||
embeddedPostgresPort,
|
||||
backup: {
|
||||
enabled: backupEnabled,
|
||||
intervalMinutes: Number(backupIntervalInput || "60"),
|
||||
retentionDays: Number(backupRetentionInput || "30"),
|
||||
dir: backupDirInput || defaultBackupDir,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function promptServer(opts?: {
|
||||
}
|
||||
|
||||
const port = Number(portStr) || 3100;
|
||||
let auth: AuthConfig = { baseUrlMode: "auto" };
|
||||
let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false };
|
||||
if (deploymentMode === "authenticated" && exposure === "public") {
|
||||
const urlInput = await p.text({
|
||||
message: "Public base URL",
|
||||
@@ -139,17 +139,26 @@ export async function promptServer(opts?: {
|
||||
}
|
||||
auth = {
|
||||
baseUrlMode: "explicit",
|
||||
disableSignUp: false,
|
||||
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
|
||||
};
|
||||
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
|
||||
auth = {
|
||||
baseUrlMode: "explicit",
|
||||
disableSignUp: false,
|
||||
publicBaseUrl: currentAuth.publicBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
server: { deploymentMode, exposure, host: hostStr.trim(), port, allowedHostnames, serveUi: true },
|
||||
server: {
|
||||
deploymentMode,
|
||||
exposure,
|
||||
host: hostStr.trim(),
|
||||
port,
|
||||
allowedHostnames,
|
||||
serveUi: currentServer?.serveUi ?? true,
|
||||
},
|
||||
auth,
|
||||
};
|
||||
}
|
||||
|
||||
14
doc/CLI.md
14
doc/CLI.md
@@ -116,6 +116,20 @@ pnpm paperclipai issue release <issue-id>
|
||||
```sh
|
||||
pnpm paperclipai agent list --company-id <company-id>
|
||||
pnpm paperclipai agent get <agent-id>
|
||||
pnpm paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>
|
||||
```
|
||||
|
||||
`agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent:
|
||||
|
||||
- creates a new long-lived agent API key
|
||||
- installs missing Paperclip skills into `~/.codex/skills` and `~/.claude/skills`
|
||||
- prints `export ...` lines for `PAPERCLIP_API_URL`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_API_KEY`
|
||||
|
||||
Example for shortname-based local setup:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||
```
|
||||
|
||||
## Approval Commands
|
||||
|
||||
@@ -15,6 +15,14 @@ Current implementation status:
|
||||
- Node.js 20+
|
||||
- pnpm 9+
|
||||
|
||||
## Dependency Lockfile Policy
|
||||
|
||||
GitHub Actions owns `pnpm-lock.yaml`.
|
||||
|
||||
- Do not commit `pnpm-lock.yaml` in pull requests.
|
||||
- Pull request CI validates dependency resolution when manifests change.
|
||||
- Pushes to `master` regenerate `pnpm-lock.yaml` with `pnpm install --lockfile-only --no-frozen-lockfile`, commit it back if needed, and then run verification with `--frozen-lockfile`.
|
||||
|
||||
## Start Dev
|
||||
|
||||
From repo root:
|
||||
@@ -29,6 +37,8 @@ This starts:
|
||||
- API server: `http://localhost:3100`
|
||||
- UI: served by the API server in dev middleware mode (same origin as API)
|
||||
|
||||
`pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching.
|
||||
|
||||
Tailscale/private-auth dev mode:
|
||||
|
||||
```sh
|
||||
@@ -114,6 +124,50 @@ When a local agent run has no resolved project/session workspace, Paperclip fall
|
||||
|
||||
This path honors `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` in non-default setups.
|
||||
|
||||
## Worktree-local Instances
|
||||
|
||||
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.
|
||||
|
||||
Instead, create a repo-local Paperclip config plus an isolated instance for the worktree:
|
||||
|
||||
```sh
|
||||
paperclipai worktree init
|
||||
```
|
||||
|
||||
This command:
|
||||
|
||||
- writes repo-local files at `.paperclip/config.json` and `.paperclip/.env`
|
||||
- creates an isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`
|
||||
- picks a free app port and embedded PostgreSQL port
|
||||
- by default seeds the isolated DB in `minimal` mode from your main instance via a logical SQL snapshot
|
||||
|
||||
Seed modes:
|
||||
|
||||
- `minimal` keeps core app state like companies, projects, issues, comments, approvals, and auth state, preserves schema for all tables, but omits row data from heavy operational history such as heartbeat runs, wake requests, activity logs, runtime services, and agent session state
|
||||
- `full` makes a full logical clone of the source instance
|
||||
- `--no-seed` creates an empty isolated instance
|
||||
|
||||
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
|
||||
|
||||
Print shell exports explicitly when needed:
|
||||
|
||||
```sh
|
||||
paperclipai worktree env
|
||||
# or:
|
||||
eval "$(paperclipai worktree env)"
|
||||
```
|
||||
|
||||
Useful options:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## Quick Health Checks
|
||||
|
||||
In another terminal:
|
||||
@@ -141,6 +195,36 @@ pnpm dev
|
||||
|
||||
If you set `DATABASE_URL`, the server will use that instead of embedded PostgreSQL.
|
||||
|
||||
## Automatic DB Backups
|
||||
|
||||
Paperclip can run automatic DB backups on a timer. Defaults:
|
||||
|
||||
- enabled
|
||||
- every 60 minutes
|
||||
- retain 30 days
|
||||
- backup dir: `~/.paperclip/instances/default/data/backups`
|
||||
|
||||
Configure these in:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai configure --section database
|
||||
```
|
||||
|
||||
Run a one-off backup manually:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai db:backup
|
||||
# or:
|
||||
pnpm db:backup
|
||||
```
|
||||
|
||||
Environment overrides:
|
||||
|
||||
- `PAPERCLIP_DB_BACKUP_ENABLED=true|false`
|
||||
- `PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES=<minutes>`
|
||||
- `PAPERCLIP_DB_BACKUP_RETENTION_DAYS=<days>`
|
||||
- `PAPERCLIP_DB_BACKUP_DIR=/absolute/or/~/path`
|
||||
|
||||
## Secrets in Dev
|
||||
|
||||
Agent env vars now support secret references. By default, secret values are stored with local encryption and only secret refs are persisted in agent config.
|
||||
@@ -216,5 +300,61 @@ Agent-oriented invite onboarding now exposes machine-readable API docs:
|
||||
|
||||
- `GET /api/invites/:token` returns invite summary plus onboarding and skills index links.
|
||||
- `GET /api/invites/:token/onboarding` returns onboarding manifest details (registration endpoint, claim endpoint template, skill install hints).
|
||||
- `GET /api/invites/:token/onboarding.txt` returns a plain-text onboarding doc intended for both human operators and agents (llm.txt-style handoff), including optional inviter message and suggested network host candidates.
|
||||
- `GET /api/skills/index` lists available skill documents.
|
||||
- `GET /api/skills/paperclip` returns the Paperclip heartbeat skill markdown.
|
||||
|
||||
## OpenClaw Join Smoke Test
|
||||
|
||||
Run the end-to-end OpenClaw join smoke harness:
|
||||
|
||||
```sh
|
||||
pnpm smoke:openclaw-join
|
||||
```
|
||||
|
||||
What it validates:
|
||||
|
||||
- invite creation for agent-only join
|
||||
- agent join request using `adapterType=openclaw`
|
||||
- board approval + one-time API key claim semantics
|
||||
- callback delivery on wakeup to a dockerized OpenClaw-style webhook receiver
|
||||
|
||||
Required permissions:
|
||||
|
||||
- This script performs board-governed actions (create invite, approve join, wakeup another agent).
|
||||
- In authenticated mode, run with board auth via `PAPERCLIP_AUTH_HEADER` or `PAPERCLIP_COOKIE`.
|
||||
|
||||
Optional auth flags (for authenticated mode):
|
||||
|
||||
- `PAPERCLIP_AUTH_HEADER` (for example `Bearer ...`)
|
||||
- `PAPERCLIP_COOKIE` (session cookie header value)
|
||||
|
||||
## OpenClaw Docker UI One-Command Script
|
||||
|
||||
To boot OpenClaw in Docker and print a host-browser dashboard URL in one command:
|
||||
|
||||
```sh
|
||||
pnpm smoke:openclaw-docker-ui
|
||||
```
|
||||
|
||||
This script lives at `scripts/smoke/openclaw-docker-ui.sh` and automates clone/build/config/start for Compose-based local OpenClaw UI testing.
|
||||
|
||||
Pairing behavior for this smoke script:
|
||||
|
||||
- default `OPENCLAW_DISABLE_DEVICE_AUTH=1` (no Control UI pairing prompt for local smoke; no extra pairing env vars required)
|
||||
- set `OPENCLAW_DISABLE_DEVICE_AUTH=0` to require standard device pairing
|
||||
|
||||
Model behavior for this smoke script:
|
||||
|
||||
- defaults to OpenAI models (`openai/gpt-5.2` + OpenAI fallback) so it does not require Anthropic auth by default
|
||||
|
||||
State behavior for this smoke script:
|
||||
|
||||
- defaults to isolated config dir `~/.openclaw-paperclip-smoke`
|
||||
- resets smoke agent state each run by default (`OPENCLAW_RESET_STATE=1`) to avoid stale provider/auth drift
|
||||
|
||||
Networking behavior for this smoke script:
|
||||
|
||||
- auto-detects and prints a Paperclip host URL reachable from inside OpenClaw Docker
|
||||
- default container-side host alias is `host.docker.internal` (override with `PAPERCLIP_HOST_FROM_CONTAINER` / `PAPERCLIP_HOST_PORT`)
|
||||
- if Paperclip rejects container hostnames in authenticated/private mode, allow `host.docker.internal` via `pnpm paperclipai allowed-hostname host.docker.internal` and restart Paperclip
|
||||
|
||||
@@ -42,6 +42,32 @@ Optional overrides:
|
||||
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=./data/pc docker compose -f docker-compose.quickstart.yml up --build
|
||||
```
|
||||
|
||||
If you change host port or use a non-local domain, set `PAPERCLIP_PUBLIC_URL` to the external URL you will use in browser/auth flows.
|
||||
|
||||
## Authenticated Compose (Single Public URL)
|
||||
|
||||
For authenticated deployments, set one canonical public URL and let Paperclip derive auth/callback defaults:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
paperclip:
|
||||
environment:
|
||||
PAPERCLIP_DEPLOYMENT_MODE: authenticated
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE: private
|
||||
PAPERCLIP_PUBLIC_URL: https://desk.koker.net
|
||||
```
|
||||
|
||||
`PAPERCLIP_PUBLIC_URL` is used as the primary source for:
|
||||
|
||||
- auth public base URL
|
||||
- Better Auth base URL defaults
|
||||
- bootstrap invite URL defaults
|
||||
- hostname allowlist defaults (hostname extracted from URL)
|
||||
|
||||
Granular overrides remain available if needed (`PAPERCLIP_AUTH_PUBLIC_BASE_URL`, `BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `PAPERCLIP_ALLOWED_HOSTNAMES`).
|
||||
|
||||
Set `PAPERCLIP_ALLOWED_HOSTNAMES` explicitly only when you need additional hostnames beyond the public URL host (for example Tailscale/LAN aliases or multiple private hostnames).
|
||||
|
||||
## Claude + Codex Local Adapters in Docker
|
||||
|
||||
The image pre-installs:
|
||||
@@ -66,3 +92,35 @@ Notes:
|
||||
|
||||
- Without API keys, the app still runs normally.
|
||||
- Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites.
|
||||
|
||||
## Onboard Smoke Test (Ubuntu + npm only)
|
||||
|
||||
Use this when you want to mimic a fresh machine that only has Ubuntu + npm and verify:
|
||||
|
||||
- `npx paperclipai onboard --yes` completes
|
||||
- the server binds to `0.0.0.0:3100` so host access works
|
||||
- onboard/run banners and startup logs are visible in your terminal
|
||||
|
||||
Build + run:
|
||||
|
||||
```sh
|
||||
./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
Open: `http://localhost:3131` (default smoke host port)
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Persistent data is mounted at `./data/docker-onboard-smoke` by default.
|
||||
- Container runtime user id defaults to your local `id -u` so the mounted data dir stays writable while avoiding root runtime.
|
||||
- Smoke script defaults to `authenticated/private` mode so `HOST=0.0.0.0` can be exposed to the host.
|
||||
- Smoke script defaults host port to `3131` to avoid conflicts with local Paperclip on `3100`.
|
||||
- Run the script in the foreground to watch the onboarding flow; stop with `Ctrl+C` after validation.
|
||||
- The image definition is in `Dockerfile.onboard-smoke`.
|
||||
|
||||
94
doc/OPENCLAW_ONBOARDING.md
Normal file
94
doc/OPENCLAW_ONBOARDING.md
Normal file
@@ -0,0 +1,94 @@
|
||||
Use this exact checklist.
|
||||
|
||||
1. Start Paperclip in auth mode.
|
||||
```bash
|
||||
cd <paperclip-repo-root>
|
||||
pnpm dev --tailscale-auth
|
||||
```
|
||||
Then verify:
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:3100/api/health | jq
|
||||
```
|
||||
|
||||
2. Start a clean/stock OpenClaw Docker.
|
||||
```bash
|
||||
OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
|
||||
```
|
||||
Open the printed `Dashboard URL` (includes `#token=...`) in your browser.
|
||||
|
||||
3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`.
|
||||
|
||||
4. Use the OpenClaw invite prompt flow.
|
||||
- In the Invites section, click `Generate OpenClaw Invite Prompt`.
|
||||
- Copy the generated prompt from `OpenClaw Invite Prompt`.
|
||||
- Paste it into OpenClaw main chat as one message.
|
||||
- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.`
|
||||
|
||||
Security/control note:
|
||||
- The OpenClaw invite prompt is created from a controlled endpoint:
|
||||
- `POST /api/companies/{companyId}/openclaw/invite-prompt`
|
||||
- board users with invite permission can call it
|
||||
- agent callers are limited to the company CEO agent
|
||||
|
||||
5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents.
|
||||
|
||||
6. Gateway preflight (required before task tests).
|
||||
- Confirm the created agent uses `openclaw_gateway` (not `openclaw`).
|
||||
- Confirm gateway URL is `ws://...` or `wss://...`.
|
||||
- Confirm gateway token is non-trivial (not empty / not 1-char placeholder).
|
||||
- The OpenClaw Gateway adapter UI should not expose `disableDeviceAuth` for normal onboarding.
|
||||
- Confirm pairing mode is explicit:
|
||||
- required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem`
|
||||
- do not rely on `disableDeviceAuth` for normal onboarding
|
||||
- If you can run API checks with board auth:
|
||||
```bash
|
||||
AGENT_ID="<newly-created-agent-id>"
|
||||
curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT_ID" | jq '{adapterType,adapterConfig:{url:.adapterConfig.url,tokenLen:(.adapterConfig.headers["x-openclaw-token"] // .adapterConfig.headers["x-openclaw-auth"] // "" | length),disableDeviceAuth:(.adapterConfig.disableDeviceAuth // false),hasDeviceKey:(.adapterConfig.devicePrivateKeyPem // "" | length > 0)}}'
|
||||
```
|
||||
- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`.
|
||||
|
||||
Pairing handshake note:
|
||||
- Clean run expectation: first task should succeed without manual pairing commands.
|
||||
- The adapter attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid).
|
||||
- If auto-pair cannot complete (for example token mismatch or no pending request), the first gateway run may still return `pairing required`.
|
||||
- This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself.
|
||||
- Approve it in OpenClaw, then retry the task.
|
||||
- For local docker smoke, you can approve from host:
|
||||
```bash
|
||||
docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'openclaw devices approve --latest --json --url "ws://127.0.0.1:18789" --token "$(node -p \"require(process.env.HOME+\\\"/.openclaw/openclaw.json\\\").gateway.auth.token\")"'
|
||||
```
|
||||
- You can inspect pending vs paired devices:
|
||||
```bash
|
||||
docker exec openclaw-docker-openclaw-gateway-1 sh -lc 'TOK="$(node -e \"const fs=require(\\\"fs\\\");const c=JSON.parse(fs.readFileSync(\\\"/home/node/.openclaw/openclaw.json\\\",\\\"utf8\\\"));process.stdout.write(c.gateway?.auth?.token||\\\"\\\");\")\"; openclaw devices list --json --url \"ws://127.0.0.1:18789\" --token \"$TOK\"'
|
||||
```
|
||||
|
||||
7. Case A (manual issue test).
|
||||
- Create an issue assigned to the OpenClaw agent.
|
||||
- Put instructions: “post comment `OPENCLAW_CASE_A_OK_<timestamp>` and mark done.”
|
||||
- Verify in UI: issue status becomes `done` and comment exists.
|
||||
|
||||
8. Case B (message tool test).
|
||||
- Create another issue assigned to OpenClaw.
|
||||
- Instructions: “send `OPENCLAW_CASE_B_OK_<timestamp>` to main webchat via message tool, then comment same marker on issue, then mark done.”
|
||||
- Verify both:
|
||||
- marker comment on issue
|
||||
- marker text appears in OpenClaw main chat
|
||||
|
||||
9. Case C (new session memory/skills test).
|
||||
- In OpenClaw, start `/new` session.
|
||||
- Ask it to create a new CLA issue in Paperclip with unique title `OPENCLAW_CASE_C_CREATED_<timestamp>`.
|
||||
- Verify in Paperclip UI that new issue exists.
|
||||
|
||||
10. Watch logs during test (optional but helpful):
|
||||
```bash
|
||||
docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.paperclip-openclaw.override.yml logs -f openclaw-gateway
|
||||
```
|
||||
|
||||
11. Expected pass criteria.
|
||||
- Preflight: `openclaw_gateway` + non-placeholder token (`tokenLen >= 16`).
|
||||
- Pairing mode: stable `devicePrivateKeyPem` configured with device auth enabled (default path).
|
||||
- Case A: `done` + marker comment.
|
||||
- Case B: `done` + marker comment + main-chat message visible.
|
||||
- Case C: original task done and new issue created from `/new` session.
|
||||
|
||||
If you want, I can also give you a single “observer mode” command that runs the stock smoke harness while you watch the same steps live in UI.
|
||||
@@ -1,196 +1,120 @@
|
||||
# Publishing to npm
|
||||
|
||||
This document covers how to build and publish the `paperclipai` CLI package to npm.
|
||||
Low-level reference for how Paperclip packages are built for npm.
|
||||
|
||||
## Prerequisites
|
||||
For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This document is only about packaging internals and the scripts that produce publishable artifacts.
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm 9.15+
|
||||
- An npm account with publish access to the `paperclipai` package
|
||||
- Logged in to npm: `npm login`
|
||||
## Current Release Entry Points
|
||||
|
||||
## One-Command Publish
|
||||
Use these scripts instead of older one-off publish commands:
|
||||
|
||||
The fastest way to publish — bumps version, builds, publishes, restores, commits, and tags in one shot:
|
||||
- [`scripts/release-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 a stable push
|
||||
|
||||
```bash
|
||||
./scripts/bump-and-publish.sh patch # 0.1.1 → 0.1.2
|
||||
./scripts/bump-and-publish.sh minor # 0.1.1 → 0.2.0
|
||||
./scripts/bump-and-publish.sh major # 0.1.1 → 1.0.0
|
||||
./scripts/bump-and-publish.sh 2.0.0 # set explicit version
|
||||
./scripts/bump-and-publish.sh patch --dry-run # everything except npm publish
|
||||
```
|
||||
## Why the CLI needs special packaging
|
||||
|
||||
The script runs all 6 steps below in order. It requires a clean working tree and an active `npm login` session (unless `--dry-run`). After it finishes, push:
|
||||
The CLI package, `paperclipai`, imports code from workspace packages such as:
|
||||
|
||||
```bash
|
||||
git push && git push origin v<version>
|
||||
```
|
||||
- `@paperclipai/server`
|
||||
- `@paperclipai/db`
|
||||
- `@paperclipai/shared`
|
||||
- adapter packages under `packages/adapters/`
|
||||
|
||||
## Manual Step-by-Step
|
||||
Those workspace references use `workspace:*` during development. npm cannot install those references directly for end users, so the release build has to transform the CLI into a publishable standalone package.
|
||||
|
||||
If you prefer to run each step individually:
|
||||
## `build-npm.sh`
|
||||
|
||||
### Quick Reference
|
||||
|
||||
```bash
|
||||
# Bump version
|
||||
./scripts/version-bump.sh patch # 0.1.0 → 0.1.1
|
||||
|
||||
# Build
|
||||
./scripts/build-npm.sh
|
||||
|
||||
# Preview what will be published
|
||||
cd cli && npm pack --dry-run
|
||||
|
||||
# Publish
|
||||
cd cli && npm publish --access public
|
||||
|
||||
# Restore dev package.json
|
||||
mv cli/package.dev.json cli/package.json
|
||||
```
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Bump the version
|
||||
|
||||
```bash
|
||||
./scripts/version-bump.sh <patch|minor|major|X.Y.Z>
|
||||
```
|
||||
|
||||
This updates the version in two places:
|
||||
|
||||
- `cli/package.json` — the source of truth
|
||||
- `cli/src/index.ts` — the Commander `.version()` call
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
./scripts/version-bump.sh patch # 0.1.0 → 0.1.1
|
||||
./scripts/version-bump.sh minor # 0.1.0 → 0.2.0
|
||||
./scripts/version-bump.sh major # 0.1.0 → 1.0.0
|
||||
./scripts/version-bump.sh 1.2.3 # set explicit version
|
||||
```
|
||||
|
||||
### 2. Build
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./scripts/build-npm.sh
|
||||
```
|
||||
|
||||
The build script runs five steps:
|
||||
This script does six things:
|
||||
|
||||
1. **Forbidden token check** — scans tracked files for tokens listed in `.git/hooks/forbidden-tokens.txt`. If the file is missing (e.g. on a contributor's machine), the check passes silently. The script never prints which tokens it's searching for.
|
||||
2. **TypeScript type-check** — runs `pnpm -r typecheck` across all workspace packages.
|
||||
3. **esbuild bundle** — bundles the CLI entry point (`cli/src/index.ts`) and all workspace package code (`@paperclipai/*`) into a single file at `cli/dist/index.js`. External npm dependencies (express, postgres, etc.) are kept as regular imports.
|
||||
4. **Generate publishable package.json** — replaces `cli/package.json` with a version that has real npm dependency ranges instead of `workspace:*` references (see [package.dev.json](#packagedevjson) below).
|
||||
5. **Summary** — prints the bundle size and next steps.
|
||||
1. Runs the forbidden token check unless `--skip-checks` is supplied
|
||||
2. Runs `pnpm -r typecheck`
|
||||
3. Bundles the CLI entrypoint with esbuild into `cli/dist/index.js`
|
||||
4. Verifies the bundled entrypoint with `node --check`
|
||||
5. Rewrites `cli/package.json` into a publishable npm manifest and stores the dev copy as `cli/package.dev.json`
|
||||
6. Copies the repo `README.md` into `cli/README.md` for npm package metadata
|
||||
|
||||
To skip the forbidden token check (e.g. in CI without the token list):
|
||||
`build-npm.sh` is used by the release script so that npm users install a real package rather than unresolved workspace dependencies.
|
||||
|
||||
## Publishable CLI layout
|
||||
|
||||
During development, [`cli/package.json`](../cli/package.json) contains workspace references.
|
||||
|
||||
During release preparation:
|
||||
|
||||
- `cli/package.json` becomes a publishable manifest with external npm dependency ranges
|
||||
- `cli/package.dev.json` stores the development manifest temporarily
|
||||
- `cli/dist/index.js` contains the bundled CLI entrypoint
|
||||
- `cli/README.md` is copied in for npm metadata
|
||||
|
||||
After release finalization, the release script restores the development manifest and removes the temporary README copy.
|
||||
|
||||
## Package discovery
|
||||
|
||||
The release tooling scans the workspace for public packages under:
|
||||
|
||||
- `packages/`
|
||||
- `server/`
|
||||
- `cli/`
|
||||
|
||||
`ui/` remains ignored for npm publishing because it is private.
|
||||
|
||||
This matters because all public packages are versioned and published together as one release unit.
|
||||
|
||||
## Canary packaging model
|
||||
|
||||
Canaries are published as semver prereleases such as:
|
||||
|
||||
- `1.2.3-canary.0`
|
||||
- `1.2.3-canary.1`
|
||||
|
||||
They are published under the npm dist-tag `canary`.
|
||||
|
||||
This means:
|
||||
|
||||
- `npx paperclipai@canary onboard` can install them explicitly
|
||||
- `npx paperclipai onboard` continues to resolve `latest`
|
||||
- the stable changelog can stay at `releases/v1.2.3.md`
|
||||
|
||||
## Stable packaging model
|
||||
|
||||
Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`.
|
||||
|
||||
The stable publish flow also creates the local release commit and git tag. Pushing the commit/tag and creating the GitHub Release happen afterward as separate maintainer steps.
|
||||
|
||||
## Rollback model
|
||||
|
||||
Rollback does not unpublish packages.
|
||||
|
||||
Instead, the maintainer should move the `latest` dist-tag back to the previous good stable version with:
|
||||
|
||||
```bash
|
||||
./scripts/build-npm.sh --skip-checks
|
||||
./scripts/rollback-latest.sh <stable-version>
|
||||
```
|
||||
|
||||
### 3. Preview (optional)
|
||||
That keeps history intact while restoring the default install path quickly.
|
||||
|
||||
See what npm will publish:
|
||||
## Notes for CI
|
||||
|
||||
```bash
|
||||
cd cli && npm pack --dry-run
|
||||
```
|
||||
The repo includes a manual GitHub Actions release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
|
||||
|
||||
### 4. Publish
|
||||
Recommended CI release setup:
|
||||
|
||||
```bash
|
||||
cd cli && npm publish --access public
|
||||
```
|
||||
- use npm trusted publishing via GitHub OIDC
|
||||
- require approval through the `npm-release` environment
|
||||
- run releases from `master`
|
||||
- use canary first, then stable
|
||||
|
||||
### 5. Restore dev package.json
|
||||
## Related Files
|
||||
|
||||
After publishing, restore the workspace-aware `package.json`:
|
||||
|
||||
```bash
|
||||
mv cli/package.dev.json cli/package.json
|
||||
```
|
||||
|
||||
### 6. Commit and tag
|
||||
|
||||
```bash
|
||||
git add cli/package.json cli/src/index.ts
|
||||
git commit -m "chore: bump version to X.Y.Z"
|
||||
git tag vX.Y.Z
|
||||
```
|
||||
|
||||
## package.dev.json
|
||||
|
||||
During development, `cli/package.json` contains `workspace:*` references like:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@paperclipai/server": "workspace:*",
|
||||
"@paperclipai/db": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These tell pnpm to resolve those packages from the local monorepo. This is great for development but **npm doesn't understand `workspace:*`** — publishing with these references would cause install failures for users.
|
||||
|
||||
The build script solves this with a two-file swap:
|
||||
|
||||
1. **Before building:** `cli/package.json` has `workspace:*` refs (the dev version).
|
||||
2. **During build (`build-npm.sh` step 4):**
|
||||
- The dev `package.json` is copied to `package.dev.json` as a backup.
|
||||
- `generate-npm-package-json.mjs` reads every workspace package's `package.json`, collects all their external npm dependencies, and writes a new `cli/package.json` with those real dependency ranges — no `workspace:*` refs.
|
||||
3. **After publishing:** you restore the dev version with `mv package.dev.json package.json`.
|
||||
|
||||
The generated publishable `package.json` looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "paperclipai",
|
||||
"version": "0.1.0",
|
||||
"bin": { "paperclipai": "./dist/index.js" },
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"postgres": "^3.4.5",
|
||||
"commander": "^13.1.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`package.dev.json` is listed in `.gitignore` — it only exists temporarily on disk during the build/publish cycle.
|
||||
|
||||
## How the bundle works
|
||||
|
||||
The CLI is a monorepo package that imports code from `@paperclipai/server`, `@paperclipai/db`, `@paperclipai/shared`, and several adapter packages. These workspace packages don't exist on npm.
|
||||
|
||||
**esbuild** bundles all workspace TypeScript code into a single `dist/index.js` file (~250kb). External npm packages (express, postgres, zod, etc.) are left as normal `import` statements — they get installed by npm when a user runs `npx paperclipai onboard`.
|
||||
|
||||
The esbuild configuration lives at `cli/esbuild.config.mjs`. It automatically reads every workspace package's `package.json` to determine which dependencies are external (real npm packages) vs. internal (workspace code to bundle).
|
||||
|
||||
## Forbidden token enforcement
|
||||
|
||||
The build process includes the same forbidden-token check used by the git pre-commit hook. This catches any accidentally committed tokens before they reach npm.
|
||||
|
||||
- Token list: `.git/hooks/forbidden-tokens.txt` (one token per line, `#` comments supported)
|
||||
- The file lives inside `.git/` and is never committed
|
||||
- If the file is missing, the check passes — contributors without the list can still build
|
||||
- The script never prints which tokens are being searched for
|
||||
- Matches are printed so you know which files to fix, but not which token triggered it
|
||||
|
||||
Run the check standalone:
|
||||
|
||||
```bash
|
||||
pnpm check:tokens
|
||||
```
|
||||
|
||||
## npm scripts reference
|
||||
|
||||
| Script | Command | Description |
|
||||
|---|---|---|
|
||||
| `bump-and-publish` | `pnpm bump-and-publish <type>` | One-command bump + build + publish + commit + tag |
|
||||
| `build:npm` | `pnpm build:npm` | Full build (check + typecheck + bundle + package.json) |
|
||||
| `version:bump` | `pnpm version:bump <type>` | Bump CLI version |
|
||||
| `check:tokens` | `pnpm check:tokens` | Run forbidden token check only |
|
||||
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
|
||||
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
|
||||
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
|
||||
- [`doc/RELEASING.md`](RELEASING.md)
|
||||
|
||||
528
doc/RELEASING.md
Normal file
528
doc/RELEASING.md
Normal file
@@ -0,0 +1,528 @@
|
||||
# Releasing Paperclip
|
||||
|
||||
Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface.
|
||||
|
||||
This document is intentionally practical:
|
||||
|
||||
- TL;DR command sequences are at the top.
|
||||
- Detailed checklists come next.
|
||||
- Motivation, failure handling, and rollback playbooks follow after that.
|
||||
|
||||
## Release Surfaces
|
||||
|
||||
Every Paperclip release has four separate surfaces:
|
||||
|
||||
1. **Verification** — the exact git SHA must pass typecheck, tests, and build.
|
||||
2. **npm** — `paperclipai` and the public workspace packages are published.
|
||||
3. **GitHub** — the stable release gets a git tag and a GitHub Release.
|
||||
4. **Website / announcements** — the stable changelog is published externally and announced.
|
||||
|
||||
Treat those as related but separate. npm can succeed while the GitHub Release is still pending. GitHub can be correct while the website changelog is stale. A maintainer release is done only when all four surfaces are handled.
|
||||
|
||||
## TL;DR
|
||||
|
||||
### Canary release
|
||||
|
||||
Use this when you want an installable prerelease without changing `latest`.
|
||||
|
||||
```bash
|
||||
# 0. Confirm master already has the CI-owned lockfile refresh merged
|
||||
# If package manifests changed recently, wait for the refresh-lockfile PR first.
|
||||
|
||||
# 1. Preflight the canary candidate
|
||||
./scripts/release-preflight.sh canary patch
|
||||
|
||||
# 2. Draft or update the stable changelog for the intended stable version
|
||||
VERSION=0.2.8
|
||||
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. Preview the canary release
|
||||
./scripts/release.sh patch --canary --dry-run
|
||||
|
||||
# 4. Publish the canary
|
||||
./scripts/release.sh patch --canary
|
||||
|
||||
# 5. Smoke test what users will actually install
|
||||
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
|
||||
# Users install with:
|
||||
npx paperclipai@canary onboard
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary`
|
||||
- `latest` is unchanged
|
||||
- no git tag is created
|
||||
- no GitHub Release is created
|
||||
- the working tree returns to clean after the script finishes
|
||||
- after stable `0.2.7`, a patch canary targets `0.2.8-canary.0`, never `0.2.7-canary.N`
|
||||
|
||||
### Stable release
|
||||
|
||||
Use this only after the canary SHA is good enough to become the public default.
|
||||
|
||||
```bash
|
||||
# 0. Confirm master already has the CI-owned lockfile refresh merged
|
||||
# If package manifests changed recently, wait for the refresh-lockfile PR first.
|
||||
|
||||
# 1. Start from the vetted commit
|
||||
git checkout master
|
||||
git pull
|
||||
|
||||
# 2. Preflight the stable candidate
|
||||
./scripts/release-preflight.sh stable patch
|
||||
|
||||
# 3. Confirm the stable changelog exists
|
||||
VERSION=0.2.8
|
||||
ls "releases/v${VERSION}.md"
|
||||
|
||||
# 4. Preview the stable publish
|
||||
./scripts/release.sh patch --dry-run
|
||||
|
||||
# 5. Publish the stable release to npm and create the local release commit + tag
|
||||
./scripts/release.sh patch
|
||||
|
||||
# 6. Push the release commit and tag
|
||||
git push public-gh HEAD:master --follow-tags
|
||||
|
||||
# 7. Create or update the GitHub Release from the pushed tag
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- npm gets stable `X.Y.Z` under dist-tag `latest`
|
||||
- a local git commit and tag `vX.Y.Z` are created
|
||||
- after push, GitHub gets the matching Release
|
||||
- the website and announcement steps still need to be handled manually
|
||||
|
||||
### Emergency rollback
|
||||
|
||||
If `latest` is broken after publish, repoint it to the last known good stable version first, then work on the fix.
|
||||
|
||||
```bash
|
||||
# Preview
|
||||
./scripts/rollback-latest.sh X.Y.Z --dry-run
|
||||
|
||||
# Roll back latest for every public package
|
||||
./scripts/rollback-latest.sh X.Y.Z
|
||||
```
|
||||
|
||||
This does **not** unpublish anything. It only moves the `latest` dist-tag back to the last good stable release.
|
||||
|
||||
### Standalone onboarding smoke
|
||||
|
||||
You already have a script for isolated onboarding verification:
|
||||
|
||||
```bash
|
||||
HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
This is the best existing fit when you want:
|
||||
|
||||
- a standalone Paperclip data dir
|
||||
- a dedicated host port
|
||||
- an end-to-end `npx paperclipai ... onboard` check
|
||||
|
||||
In authenticated/private mode, the expected result is a full authenticated onboarding flow, including printing the bootstrap CEO invite once startup completes.
|
||||
|
||||
If you want to exercise onboarding from a fresh local checkout rather than npm, use:
|
||||
|
||||
```bash
|
||||
./scripts/clean-onboard-git.sh
|
||||
```
|
||||
|
||||
That is not a required release step every time, but it is a useful higher-confidence check when onboarding is the main risk area or when you need to verify what the current codebase does before publishing.
|
||||
|
||||
If you want to exercise onboarding from the current committed ref in your local repo, use:
|
||||
|
||||
```bash
|
||||
./scripts/clean-onboard-ref.sh
|
||||
PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh
|
||||
./scripts/clean-onboard-ref.sh HEAD
|
||||
```
|
||||
|
||||
This uses the current committed `HEAD` in a detached temp worktree. It does **not** include uncommitted local edits.
|
||||
|
||||
### GitHub Actions release
|
||||
|
||||
There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens.
|
||||
|
||||
Use it from the Actions tab:
|
||||
|
||||
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 `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 release commit and tag, and create the GitHub Release
|
||||
|
||||
## Release Checklist
|
||||
|
||||
### Before any publish
|
||||
|
||||
- [ ] The working tree is clean, including untracked files
|
||||
- [ ] The target branch and SHA are the ones you actually want to release
|
||||
- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`
|
||||
- [ ] The required verification gate passed on that exact SHA
|
||||
- [ ] The bump type is correct for the user-visible impact
|
||||
- [ ] The stable changelog file exists or is ready to be written at `releases/vX.Y.Z.md`
|
||||
- [ ] You know which previous stable version you would roll back to if needed
|
||||
|
||||
### Before a canary
|
||||
|
||||
- [ ] You are intentionally testing something that should be installable before it becomes default
|
||||
- [ ] You are comfortable with users installing it via `npx paperclipai@canary onboard`
|
||||
- [ ] You understand that each canary is a new immutable npm version such as `1.2.3-canary.1`
|
||||
|
||||
### Before a stable
|
||||
|
||||
- [ ] The candidate has already passed smoke testing
|
||||
- [ ] The changelog should be the stable version only, for example `v1.2.3`
|
||||
- [ ] You are ready to push the release commit and tag immediately after npm publish
|
||||
- [ ] You are ready to create the GitHub Release immediately after the push
|
||||
- [ ] You have a post-release website / announcement plan
|
||||
|
||||
### 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`
|
||||
- [ ] The website changelog is updated
|
||||
- [ ] Any announcement copy matches the shipped release, not the canary
|
||||
|
||||
## Verification Gate
|
||||
|
||||
The repository standard is:
|
||||
|
||||
```bash
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
|
||||
This matches [`.github/workflows/pr-verify.yml`](../.github/workflows/pr-verify.yml). Run it before claiming a release candidate is ready.
|
||||
|
||||
The release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml) installs with `pnpm install --frozen-lockfile`. That is intentional. Releases must use the exact dependency graph already committed on `master`; if manifests changed and the CI-owned lockfile refresh has not landed yet, the release should fail until that prerequisite is merged.
|
||||
|
||||
For release work, prefer:
|
||||
|
||||
```bash
|
||||
./scripts/release-preflight.sh canary <patch|minor|major>
|
||||
./scripts/release-preflight.sh stable <patch|minor|major>
|
||||
```
|
||||
|
||||
That script runs the verification gate and prints the computed target versions before you publish anything.
|
||||
|
||||
## Versioning Policy
|
||||
|
||||
### Stable versions
|
||||
|
||||
Stable releases use normal semver:
|
||||
|
||||
- `patch` for bug fixes
|
||||
- `minor` for additive features, endpoints, and additive migrations
|
||||
- `major` for destructive migrations, removed APIs, or other breaking behavior
|
||||
|
||||
### Canary versions
|
||||
|
||||
Canaries are semver prereleases of the **intended stable version**:
|
||||
|
||||
- `1.2.3-canary.0`
|
||||
- `1.2.3-canary.1`
|
||||
- `1.2.3-canary.2`
|
||||
|
||||
That gives you three useful properties:
|
||||
|
||||
1. Users can install the prerelease explicitly with `@canary`
|
||||
2. `latest` stays safe
|
||||
3. The stable changelog can remain just `v1.2.3`
|
||||
|
||||
We do **not** create separate changelog files for canary versions.
|
||||
|
||||
Concrete example:
|
||||
|
||||
- if the latest stable release is `0.2.7`, a patch canary is `0.2.8-canary.0`
|
||||
- `0.2.7-canary.0` is invalid, because `0.2.7` is already the shipped stable version
|
||||
|
||||
## Changelog Policy
|
||||
|
||||
The maintainer changelog source of truth is:
|
||||
|
||||
- `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
|
||||
|
||||
Package-level `CHANGELOG.md` files are generated as part of the release mechanics. They are not the main release narrative.
|
||||
|
||||
## Detailed Workflow
|
||||
|
||||
### 1. Decide the bump
|
||||
|
||||
Run preflight first:
|
||||
|
||||
```bash
|
||||
./scripts/release-preflight.sh canary <patch|minor|major>
|
||||
# or
|
||||
./scripts/release-preflight.sh stable <patch|minor|major>
|
||||
```
|
||||
|
||||
That command:
|
||||
|
||||
- verifies the worktree is clean, including untracked files
|
||||
- shows the last stable tag and computed next versions
|
||||
- shows the commit range since the last stable tag
|
||||
- highlights migration and breaking-change signals
|
||||
- runs `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build`
|
||||
|
||||
If you want the raw inputs separately, review the range since the last stable tag:
|
||||
|
||||
```bash
|
||||
LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1)
|
||||
git log "${LAST_TAG}..HEAD" --oneline --no-merges
|
||||
git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/
|
||||
git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/
|
||||
git log "${LAST_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
|
||||
```
|
||||
|
||||
Use the higher bump if there is any doubt.
|
||||
|
||||
### 2. Write the stable changelog first
|
||||
|
||||
Create or update:
|
||||
|
||||
```bash
|
||||
VERSION=X.Y.Z
|
||||
claude -p "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."
|
||||
```
|
||||
|
||||
This is deliberate. The release notes should describe the stable story, not the canary mechanics.
|
||||
|
||||
### 3. Publish one or more canaries
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <patch|minor|major> --canary
|
||||
```
|
||||
|
||||
What the script does:
|
||||
|
||||
1. Verifies the working tree is clean
|
||||
2. Computes the intended stable version from the last stable tag
|
||||
3. Computes the next canary ordinal from npm
|
||||
4. Versions the public packages to `X.Y.Z-canary.N`
|
||||
5. Builds the workspace and publishable CLI
|
||||
6. Publishes to npm under dist-tag `canary`
|
||||
7. Cleans up the temporary versioning state so your branch returns to clean
|
||||
|
||||
This means the script is safe to repeat as many times as needed while iterating:
|
||||
|
||||
- `1.2.3-canary.0`
|
||||
- `1.2.3-canary.1`
|
||||
- `1.2.3-canary.2`
|
||||
|
||||
The target stable release can still remain `1.2.3`.
|
||||
|
||||
Guardrail:
|
||||
|
||||
- the canary is always derived from the **next stable version**
|
||||
- after stable `0.2.7`, the next patch canary is `0.2.8-canary.0`
|
||||
- the scripts refuse to publish `0.2.7-canary.N` once `0.2.7` is already the stable release
|
||||
|
||||
### 4. Smoke test the canary
|
||||
|
||||
Run the actual install path in Docker:
|
||||
|
||||
```bash
|
||||
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
Useful isolated variants:
|
||||
|
||||
```bash
|
||||
HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
If you want to smoke onboarding from the current codebase rather than npm, run:
|
||||
|
||||
```bash
|
||||
./scripts/clean-onboard-git.sh
|
||||
./scripts/clean-onboard-ref.sh
|
||||
```
|
||||
|
||||
Minimum checks:
|
||||
|
||||
- [ ] `npx paperclipai@canary onboard` installs
|
||||
- [ ] onboarding completes without crashes
|
||||
- [ ] the server boots
|
||||
- [ ] the UI loads
|
||||
- [ ] basic company creation and dashboard load work
|
||||
|
||||
### 5. Publish stable from the vetted commit
|
||||
|
||||
Once the candidate SHA is good, run the stable flow on that exact commit:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <patch|minor|major>
|
||||
```
|
||||
|
||||
What the script does:
|
||||
|
||||
1. Verifies the working tree is clean
|
||||
2. Versions the public packages to the stable semver
|
||||
3. Builds the workspace and CLI publish bundle
|
||||
4. Publishes to npm under `latest`
|
||||
5. Restores temporary publish artifacts
|
||||
6. Creates the local release commit and git tag
|
||||
|
||||
What it does **not** do:
|
||||
|
||||
- it does not push for you
|
||||
- it does not update the website
|
||||
- it does not announce the release for you
|
||||
|
||||
### 6. Push the release and create the GitHub Release
|
||||
|
||||
After a stable publish succeeds:
|
||||
|
||||
```bash
|
||||
git push public-gh HEAD:master --follow-tags
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
```
|
||||
|
||||
The GitHub release notes come from:
|
||||
|
||||
- `releases/vX.Y.Z.md`
|
||||
|
||||
### 7. Complete the external surfaces
|
||||
|
||||
After GitHub is correct:
|
||||
|
||||
- publish the changelog on the website
|
||||
- write the announcement copy
|
||||
- ensure public docs and install guidance point to the stable version
|
||||
|
||||
## GitHub Actions and npm Trusted Publishing
|
||||
|
||||
If you want GitHub to own the actual npm publish, use [`.github/workflows/release.yml`](../.github/workflows/release.yml) together with npm trusted publishing.
|
||||
|
||||
Recommended setup:
|
||||
|
||||
1. Configure the GitHub Actions workflow as a trusted publisher for **every public package** on npm
|
||||
2. Use the `npm-release` GitHub environment with required reviewers
|
||||
3. Run stable publishes from `master` only
|
||||
4. Keep the workflow manual via `workflow_dispatch`
|
||||
|
||||
Why this is the right shape:
|
||||
|
||||
- no long-lived npm token needs to live in GitHub secrets
|
||||
- reviewers can approve the publish step at the environment gate
|
||||
- the workflow reruns verification on the release SHA before publish
|
||||
- stable and canary use the same mechanics
|
||||
|
||||
## Failure Playbooks
|
||||
|
||||
### If the canary fails before publish
|
||||
|
||||
Nothing shipped. Fix the code and rerun the canary workflow.
|
||||
|
||||
### If the canary publishes but the smoke test fails
|
||||
|
||||
Do **not** publish stable.
|
||||
|
||||
Instead:
|
||||
|
||||
1. Fix the issue
|
||||
2. Publish another canary
|
||||
3. Re-run smoke testing
|
||||
|
||||
The canary version number will increase, but the stable target version can remain the same.
|
||||
|
||||
### If the stable npm publish succeeds but push fails
|
||||
|
||||
This is a partial release. npm is already live.
|
||||
|
||||
Do this immediately:
|
||||
|
||||
1. Fix the git issue
|
||||
2. Push the release commit and tag from the same checkout
|
||||
3. Create the GitHub Release
|
||||
|
||||
Do **not** publish the same version again.
|
||||
|
||||
### If the stable release is bad after `latest` moves
|
||||
|
||||
Use the rollback script first:
|
||||
|
||||
```bash
|
||||
./scripts/rollback-latest.sh <last-good-version>
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
1. open an incident note or maintainer comment
|
||||
2. fix forward on a new patch release
|
||||
3. update the changelog / release notes if the user-facing guidance changed
|
||||
|
||||
### If the GitHub Release is wrong
|
||||
|
||||
Edit it by re-running:
|
||||
|
||||
```bash
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
```
|
||||
|
||||
This updates the release notes if the GitHub Release already exists.
|
||||
|
||||
### If the website changelog is wrong
|
||||
|
||||
Fix the website independently. Do not republish npm just to repair the website surface.
|
||||
|
||||
## Rollback Strategy
|
||||
|
||||
The default rollback strategy is **dist-tag rollback, then fix forward**.
|
||||
|
||||
Why:
|
||||
|
||||
- npm versions are immutable
|
||||
- users need `npx paperclipai onboard` to recover quickly
|
||||
- moving `latest` back is faster and safer than trying to delete history
|
||||
|
||||
Rollback procedure:
|
||||
|
||||
1. identify the last known good stable version
|
||||
2. run `./scripts/rollback-latest.sh <version>`
|
||||
3. verify `npm view paperclipai@latest version`
|
||||
4. fix forward with a new stable release
|
||||
|
||||
## Scripts Reference
|
||||
|
||||
- [`scripts/release.sh`](../scripts/release.sh) — stable and canary npm publish flow
|
||||
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — clean-tree, version-plan, and verification-gate preflight
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after push
|
||||
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable release
|
||||
- [`scripts/docker-onboard-smoke.sh`](../scripts/docker-onboard-smoke.sh) — Docker smoke test for the installed CLI
|
||||
|
||||
## 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
|
||||
1335
doc/plans/workspace-strategy-and-git-worktrees.md
Normal file
1335
doc/plans/workspace-strategy-and-git-worktrees.md
Normal file
File diff suppressed because it is too large
Load Diff
1617
doc/plugins/PLUGIN_SPEC.md
Normal file
1617
doc/plugins/PLUGIN_SPEC.md
Normal file
File diff suppressed because it is too large
Load Diff
1738
doc/plugins/ideas-from-opencode.md
Normal file
1738
doc/plugins/ideas-from-opencode.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,5 +10,9 @@ services:
|
||||
PAPERCLIP_HOME: "/paperclip"
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||
PAPERCLIP_DEPLOYMENT_MODE: "authenticated"
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private"
|
||||
PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}"
|
||||
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}"
|
||||
volumes:
|
||||
- "${PAPERCLIP_DATA_DIR:-./data/docker-paperclip}:/paperclip"
|
||||
|
||||
@@ -5,6 +5,11 @@ services:
|
||||
POSTGRES_USER: paperclip
|
||||
POSTGRES_PASSWORD: paperclip
|
||||
POSTGRES_DB: paperclip
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U paperclip -d paperclip"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
@@ -18,8 +23,16 @@ services:
|
||||
DATABASE_URL: postgres://paperclip:paperclip@db:5432/paperclip
|
||||
PORT: "3100"
|
||||
SERVE_UI: "true"
|
||||
PAPERCLIP_DEPLOYMENT_MODE: "authenticated"
|
||||
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private"
|
||||
PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}"
|
||||
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}"
|
||||
volumes:
|
||||
- paperclip-data:/paperclip
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
paperclip-data:
|
||||
|
||||
8
docker/openclaw-smoke/Dockerfile
Normal file
8
docker/openclaw-smoke/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY server.mjs /app/server.mjs
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["node", "/app/server.mjs"]
|
||||
103
docker/openclaw-smoke/server.mjs
Normal file
103
docker/openclaw-smoke/server.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
import http from "node:http";
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? "8787", 10);
|
||||
const webhookPath = process.env.OPENCLAW_SMOKE_PATH?.trim() || "/webhook";
|
||||
const expectedAuthHeader = process.env.OPENCLAW_SMOKE_AUTH?.trim() || "";
|
||||
const maxBodyBytes = 1_000_000;
|
||||
const maxEvents = 200;
|
||||
|
||||
const events = [];
|
||||
let nextId = 1;
|
||||
|
||||
function writeJson(res, status, payload) {
|
||||
res.statusCode = status;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
req.on("data", (chunk) => {
|
||||
total += chunk.length;
|
||||
if (total > maxBodyBytes) {
|
||||
reject(new Error("payload_too_large"));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
resolve(Buffer.concat(chunks).toString("utf8"));
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function trimEvents() {
|
||||
if (events.length <= maxEvents) return;
|
||||
events.splice(0, events.length - maxEvents);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const method = req.method ?? "GET";
|
||||
const url = req.url ?? "/";
|
||||
|
||||
if (method === "GET" && url === "/health") {
|
||||
writeJson(res, 200, { ok: true, webhookPath, events: events.length });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && url === "/events") {
|
||||
writeJson(res, 200, { count: events.length, events });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && url === "/reset") {
|
||||
events.length = 0;
|
||||
writeJson(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && url === webhookPath) {
|
||||
const authorization = req.headers.authorization ?? "";
|
||||
if (expectedAuthHeader && authorization !== expectedAuthHeader) {
|
||||
writeJson(res, 401, { error: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readBody(req);
|
||||
let body = null;
|
||||
try {
|
||||
body = raw.length > 0 ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
body = { raw };
|
||||
}
|
||||
|
||||
const event = {
|
||||
id: `evt-${nextId++}`,
|
||||
receivedAt: new Date().toISOString(),
|
||||
method,
|
||||
path: url,
|
||||
authorizationPresent: Boolean(authorization),
|
||||
body,
|
||||
};
|
||||
events.push(event);
|
||||
trimEvents();
|
||||
writeJson(res, 200, { ok: true, received: true, eventId: event.id, count: events.length });
|
||||
} catch (err) {
|
||||
const code = err instanceof Error && err.message === "payload_too_large" ? 413 : 500;
|
||||
writeJson(res, code, { error: err instanceof Error ? err.message : "unknown_error" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(res, 404, { error: "not_found" });
|
||||
});
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[openclaw-smoke] listening on :${port} path=${webhookPath}`);
|
||||
});
|
||||
@@ -47,6 +47,14 @@ If resume fails with an unknown session error, the adapter automatically retries
|
||||
|
||||
The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory.
|
||||
|
||||
For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||
```
|
||||
|
||||
This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent.
|
||||
|
||||
## Environment Test
|
||||
|
||||
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
|
||||
|
||||
@@ -30,6 +30,14 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes
|
||||
|
||||
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
|
||||
|
||||
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||
```
|
||||
|
||||
This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent.
|
||||
|
||||
## Environment Test
|
||||
|
||||
The environment test checks:
|
||||
|
||||
@@ -5,6 +5,10 @@ summary: Guide to building a custom adapter
|
||||
|
||||
Build a custom adapter to connect Paperclip to any agent runtime.
|
||||
|
||||
<Tip>
|
||||
If you're using Claude Code, the `create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step.
|
||||
</Tip>
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -20,6 +20,8 @@ When a heartbeat fires, Paperclip:
|
||||
|---------|----------|-------------|
|
||||
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
|
||||
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
|
||||
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
|
||||
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook |
|
||||
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
|
||||
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
|
||||
|
||||
@@ -52,7 +54,7 @@ Three registries consume these modules:
|
||||
|
||||
## Choosing an Adapter
|
||||
|
||||
- **Need a coding agent?** Use `claude_local` or `codex_local`
|
||||
- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local`
|
||||
- **Need to run a script or command?** Use `process`
|
||||
- **Need to call an external service?** Use `http`
|
||||
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)
|
||||
|
||||
@@ -123,6 +123,18 @@ GET /api/companies/{companyId}/org
|
||||
|
||||
Returns the full organizational tree for the company.
|
||||
|
||||
## List Adapter Models
|
||||
|
||||
```
|
||||
GET /api/companies/{companyId}/adapters/{adapterType}/models
|
||||
```
|
||||
|
||||
Returns selectable models for an adapter type.
|
||||
|
||||
- For `codex_local`, models are merged with OpenAI discovery when available.
|
||||
- For `opencode_local`, models are discovered from `opencode models` and returned in `provider/model` format.
|
||||
- `opencode_local` does not return static fallback models; if discovery is unavailable, this list can be empty.
|
||||
|
||||
## Config Revisions
|
||||
|
||||
```
|
||||
|
||||
@@ -48,12 +48,20 @@ pnpm dev --tailscale-auth
|
||||
|
||||
This binds the server to `0.0.0.0` for private-network access.
|
||||
|
||||
Alias:
|
||||
|
||||
```sh
|
||||
pnpm dev --authenticated-private
|
||||
```
|
||||
|
||||
Allow additional private hostnames:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai allowed-hostname dotta-macbook-pro
|
||||
```
|
||||
|
||||
For full setup and troubleshooting, see [Tailscale Private Access](/deploy/tailscale-private-access).
|
||||
|
||||
## Health Checks
|
||||
|
||||
```sh
|
||||
|
||||
77
docs/deploy/tailscale-private-access.md
Normal file
77
docs/deploy/tailscale-private-access.md
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: Tailscale Private Access
|
||||
summary: Run Paperclip with Tailscale-friendly host binding and connect from other devices
|
||||
---
|
||||
|
||||
Use this when you want to access Paperclip over Tailscale (or a private LAN/VPN) instead of only `localhost`.
|
||||
|
||||
## 1. Start Paperclip in private authenticated mode
|
||||
|
||||
```sh
|
||||
pnpm dev --tailscale-auth
|
||||
```
|
||||
|
||||
This configures:
|
||||
|
||||
- `PAPERCLIP_DEPLOYMENT_MODE=authenticated`
|
||||
- `PAPERCLIP_DEPLOYMENT_EXPOSURE=private`
|
||||
- `PAPERCLIP_AUTH_BASE_URL_MODE=auto`
|
||||
- `HOST=0.0.0.0` (bind on all interfaces)
|
||||
|
||||
Equivalent flag:
|
||||
|
||||
```sh
|
||||
pnpm dev --authenticated-private
|
||||
```
|
||||
|
||||
## 2. Find your reachable Tailscale address
|
||||
|
||||
From the machine running Paperclip:
|
||||
|
||||
```sh
|
||||
tailscale ip -4
|
||||
```
|
||||
|
||||
You can also use your Tailscale MagicDNS hostname (for example `my-macbook.tailnet.ts.net`).
|
||||
|
||||
## 3. Open Paperclip from another device
|
||||
|
||||
Use the Tailscale IP or MagicDNS host with the Paperclip port:
|
||||
|
||||
```txt
|
||||
http://<tailscale-host-or-ip>:3100
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```txt
|
||||
http://my-macbook.tailnet.ts.net:3100
|
||||
```
|
||||
|
||||
## 4. Allow custom private hostnames when needed
|
||||
|
||||
If you access Paperclip with a custom private hostname, add it to the allowlist:
|
||||
|
||||
```sh
|
||||
pnpm paperclipai allowed-hostname my-macbook.tailnet.ts.net
|
||||
```
|
||||
|
||||
## 5. Verify the server is reachable
|
||||
|
||||
From a remote Tailscale-connected device:
|
||||
|
||||
```sh
|
||||
curl http://<tailscale-host-or-ip>:3100/api/health
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
```json
|
||||
{"status":"ok"}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Login or redirect errors on a private hostname: add it with `paperclipai allowed-hostname`.
|
||||
- App only works on `localhost`: make sure you started with `--tailscale-auth` (or set `HOST=0.0.0.0` in private mode).
|
||||
- Can connect locally but not remotely: verify both devices are on the same Tailscale network and port `3100` is reachable.
|
||||
@@ -9,6 +9,10 @@
|
||||
"dark": "#1D4ED8"
|
||||
},
|
||||
"favicon": "/favicon.svg",
|
||||
"logo": {
|
||||
"dark": "/images/logo-dark.svg",
|
||||
"light": "/images/logo-light.svg"
|
||||
},
|
||||
"topbarLinks": [
|
||||
{
|
||||
"name": "GitHub",
|
||||
@@ -69,6 +73,7 @@
|
||||
"pages": [
|
||||
"deploy/overview",
|
||||
"deploy/local-development",
|
||||
"deploy/tailscale-private-access",
|
||||
"deploy/docker",
|
||||
"deploy/deployment-modes",
|
||||
"deploy/database",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="#2563EB"/>
|
||||
<path d="M10 8h6a6 6 0 0 1 0 12h-2v4h-4V8zm4 8h2a2 2 0 0 0 0-4h-2v4z" fill="white"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="-4" y="-4" width="32" height="32" rx="6" fill="#2563EB"/>
|
||||
<path stroke="#ffffff" stroke-width="2" d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 222 B After Width: | Height: | Size: 367 B |
@@ -27,6 +27,14 @@ Create agents from the Agents page. Each agent requires:
|
||||
- **Adapter config** — runtime-specific settings (working directory, model, prompt, etc.)
|
||||
- **Capabilities** — short description of what this agent does
|
||||
|
||||
Common adapter choices:
|
||||
- `claude_local` / `codex_local` / `opencode_local` for local coding agents
|
||||
- `openclaw` / `http` for webhook-based external agents
|
||||
- `process` for generic local command execution
|
||||
|
||||
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).
|
||||
Paperclip validates the selected model against live `opencode models` output.
|
||||
|
||||
## Agent Hiring via Governance
|
||||
|
||||
Agents can request to hire subordinates. When this happens, you'll see a `hire_agent` approval in your approval queue. Review the proposed agent config and approve or reject.
|
||||
|
||||
@@ -2,6 +2,97 @@
|
||||
|
||||
How to get OpenClaw running in a Docker container for local development and testing the Paperclip OpenClaw adapter integration.
|
||||
|
||||
## Automated Join Smoke Test (Recommended First)
|
||||
|
||||
Paperclip includes an end-to-end join smoke harness:
|
||||
|
||||
```bash
|
||||
pnpm smoke:openclaw-join
|
||||
```
|
||||
|
||||
The harness automates:
|
||||
|
||||
- invite creation (`allowedJoinTypes=agent`)
|
||||
- OpenClaw agent join request (`adapterType=openclaw`)
|
||||
- board approval
|
||||
- one-time API key claim (including invalid/replay claim checks)
|
||||
- wakeup callback delivery to a dockerized OpenClaw-style webhook receiver
|
||||
|
||||
By default, this uses a preconfigured Docker receiver image (`docker/openclaw-smoke`) so the run is deterministic and requires no manual OpenClaw config edits.
|
||||
|
||||
Permissions note:
|
||||
|
||||
- The harness performs board-governed actions (invite creation, join approval, wakeup of the new agent).
|
||||
- In authenticated mode, provide board/operator auth or the run exits early with an explicit permissions error.
|
||||
|
||||
## One-Command OpenClaw Gateway UI (Manual Docker Flow)
|
||||
|
||||
To spin up OpenClaw in Docker and print a host-browser dashboard URL in one command:
|
||||
|
||||
```bash
|
||||
pnpm smoke:openclaw-docker-ui
|
||||
```
|
||||
|
||||
Default behavior is zero-flag: you can run the command as-is with no pairing-related env vars.
|
||||
|
||||
What this command does:
|
||||
|
||||
- clones/updates `openclaw/openclaw` in `/tmp/openclaw-docker`
|
||||
- builds `openclaw:local` (unless `OPENCLAW_BUILD=0`)
|
||||
- writes isolated smoke config under `~/.openclaw-paperclip-smoke/openclaw.json` and Docker `.env`
|
||||
- pins agent model defaults to OpenAI (`openai/gpt-5.2` with OpenAI fallback)
|
||||
- starts `openclaw-gateway` via Compose (with required `/tmp` tmpfs override)
|
||||
- probes and prints a Paperclip host URL that is reachable from inside OpenClaw Docker
|
||||
- waits for health and prints:
|
||||
- `http://127.0.0.1:18789/#token=...`
|
||||
- disables Control UI device pairing by default for local smoke ergonomics
|
||||
|
||||
Environment knobs:
|
||||
|
||||
- `OPENAI_API_KEY` (required; loaded from env or `~/.secrets`)
|
||||
- `OPENCLAW_DOCKER_DIR` (default `/tmp/openclaw-docker`)
|
||||
- `OPENCLAW_GATEWAY_PORT` (default `18789`)
|
||||
- `OPENCLAW_GATEWAY_TOKEN` (default random)
|
||||
- `OPENCLAW_BUILD=0` to skip rebuild
|
||||
- `OPENCLAW_OPEN_BROWSER=1` to auto-open the URL on macOS
|
||||
- `OPENCLAW_DISABLE_DEVICE_AUTH=1` (default) disables Control UI device pairing for local smoke
|
||||
- `OPENCLAW_DISABLE_DEVICE_AUTH=0` keeps pairing enabled (then approve browser with `devices` CLI commands)
|
||||
- `OPENCLAW_MODEL_PRIMARY` (default `openai/gpt-5.2`)
|
||||
- `OPENCLAW_MODEL_FALLBACK` (default `openai/gpt-5.2-chat-latest`)
|
||||
- `OPENCLAW_CONFIG_DIR` (default `~/.openclaw-paperclip-smoke`)
|
||||
- `OPENCLAW_RESET_STATE=1` (default) resets smoke agent state on each run to avoid stale auth/session drift
|
||||
- `PAPERCLIP_HOST_PORT` (default `3100`)
|
||||
- `PAPERCLIP_HOST_FROM_CONTAINER` (default `host.docker.internal`)
|
||||
|
||||
### Authenticated mode
|
||||
|
||||
If your Paperclip deployment is `authenticated`, provide auth context:
|
||||
|
||||
```bash
|
||||
PAPERCLIP_AUTH_HEADER="Bearer <token>" pnpm smoke:openclaw-join
|
||||
# or
|
||||
PAPERCLIP_COOKIE="your_session_cookie=..." pnpm smoke:openclaw-join
|
||||
```
|
||||
|
||||
### Network topology tips
|
||||
|
||||
- Local same-host smoke: default callback uses `http://127.0.0.1:<port>/webhook`.
|
||||
- Inside OpenClaw Docker, `127.0.0.1` points to the container itself, not your host Paperclip server.
|
||||
- For invite/onboarding URLs consumed by OpenClaw in Docker, use the script-printed Paperclip URL (typically `http://host.docker.internal:3100`).
|
||||
- If Paperclip rejects the container-visible host with a hostname error, allow it from host:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai allowed-hostname host.docker.internal
|
||||
```
|
||||
|
||||
Then restart Paperclip and rerun the smoke script.
|
||||
- Docker/remote OpenClaw: prefer a reachable hostname (Docker host alias, Tailscale hostname, or public domain).
|
||||
- Authenticated/private mode: ensure hostnames are in the allowed list when required:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai allowed-hostname <host>
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker Desktop v29+** (with Docker Sandbox support)
|
||||
|
||||
6
docs/images/logo-dark.svg
Normal file
6
docs/images/logo-dark.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="140" height="32" viewBox="0 0 140 32" fill="none">
|
||||
<g stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="#e4e4e7" stroke-width="2" d="m18 4-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
|
||||
</g>
|
||||
<text x="32" y="22" font-family="system-ui, -apple-system, sans-serif" font-size="18" font-weight="600" fill="#e4e4e7">Paperclip</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
6
docs/images/logo-light.svg
Normal file
6
docs/images/logo-light.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="140" height="32" viewBox="0 0 140 32" fill="none">
|
||||
<g stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="#18181b" stroke-width="2" d="m18 4-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/>
|
||||
</g>
|
||||
<text x="32" y="22" font-family="system-ui, -apple-system, sans-serif" font-size="18" font-weight="600" fill="#18181b">Paperclip</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
524
docs/specs/cliphub-plan.md
Normal file
524
docs/specs/cliphub-plan.md
Normal file
@@ -0,0 +1,524 @@
|
||||
# ClipHub: Marketplace for Paperclip Team Configurations
|
||||
|
||||
> The "app store" for whole-company AI teams — pre-built Paperclip configurations, agent blueprints, skills, and governance templates that ship real work from day one.
|
||||
|
||||
## 1. Vision & Positioning
|
||||
|
||||
**ClipHub** sells **entire team configurations** — org charts, agent roles, inter-agent workflows, governance rules, and project templates — for Paperclip-managed companies.
|
||||
|
||||
| Dimension | ClipHub |
|
||||
|---|---|
|
||||
| Unit of sale | Team blueprint (multi-agent org) |
|
||||
| Buyer | Founder / team lead spinning up an AI company |
|
||||
| Install target | Paperclip company (agents, projects, governance) |
|
||||
| Value prop | "Skip org design — get a shipping team in minutes" |
|
||||
| Price range | $0–$499 per blueprint (+ individual add-ons) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Product Taxonomy
|
||||
|
||||
### 2.1 Team Blueprints (primary product)
|
||||
|
||||
A complete Paperclip company configuration:
|
||||
|
||||
- **Org chart**: Agents with roles, titles, reporting chains, capabilities
|
||||
- **Agent configs**: Adapter type, model, prompt templates, instructions paths
|
||||
- **Governance rules**: Approval flows, budget limits, escalation chains
|
||||
- **Project templates**: Pre-configured projects with workspace settings
|
||||
- **Skills & instructions**: AGENTS.md / skill files bundled per agent
|
||||
|
||||
**Examples:**
|
||||
- "SaaS Startup Team" — CEO, CTO, Engineer, CMO, Designer ($199)
|
||||
- "Content Agency" — Editor-in-Chief, 3 Writers, SEO Analyst, Social Manager ($149)
|
||||
- "Dev Shop" — CTO, 2 Engineers, QA, DevOps ($99)
|
||||
- "Solo Founder + Crew" — CEO agent + 3 ICs across eng/marketing/ops ($79)
|
||||
|
||||
### 2.2 Agent Blueprints (individual agents within a team context)
|
||||
|
||||
Single-agent configurations designed to plug into a Paperclip org:
|
||||
|
||||
- Role definition, prompt template, adapter config
|
||||
- Reporting chain expectations (who they report to)
|
||||
- Skill bundles included
|
||||
- Governance defaults (budget, permissions)
|
||||
|
||||
**Examples:**
|
||||
- "Staff Engineer" — ships production code, manages PRs ($29)
|
||||
- "Growth Marketer" — content pipeline, SEO, social ($39)
|
||||
- "DevOps Agent" — CI/CD, deployment, monitoring ($29)
|
||||
|
||||
### 2.3 Skills (modular capabilities)
|
||||
|
||||
Portable skill files that any Paperclip agent can use:
|
||||
|
||||
- Markdown skill files with instructions
|
||||
- Tool configurations and shell scripts
|
||||
- Compatible with Paperclip's skill loading system
|
||||
|
||||
**Examples:**
|
||||
- "Git PR Workflow" — standardized PR creation and review (Free)
|
||||
- "Deployment Pipeline" — Cloudflare/Vercel deploy skill ($9)
|
||||
- "Customer Support Triage" — ticket classification and routing ($19)
|
||||
|
||||
### 2.4 Governance Templates
|
||||
|
||||
Pre-built approval flows and policies:
|
||||
|
||||
- Budget thresholds and approval chains
|
||||
- Cross-team delegation rules
|
||||
- Escalation procedures
|
||||
- Billing code structures
|
||||
|
||||
**Examples:**
|
||||
- "Startup Governance" — lightweight, CEO approves > $50 (Free)
|
||||
- "Enterprise Governance" — multi-tier approval, audit trail ($49)
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Schemas
|
||||
|
||||
### 3.1 Listing
|
||||
|
||||
```typescript
|
||||
interface Listing {
|
||||
id: string;
|
||||
slug: string; // URL-friendly identifier
|
||||
type: 'team_blueprint' | 'agent_blueprint' | 'skill' | 'governance_template';
|
||||
title: string;
|
||||
tagline: string; // Short pitch (≤120 chars)
|
||||
description: string; // Markdown, full details
|
||||
|
||||
// Pricing
|
||||
price: number; // Cents (0 = free)
|
||||
currency: 'usd';
|
||||
|
||||
// Creator
|
||||
creatorId: string;
|
||||
creatorName: string;
|
||||
creatorAvatar: string | null;
|
||||
|
||||
// Categorization
|
||||
categories: string[]; // e.g. ['saas', 'engineering', 'marketing']
|
||||
tags: string[]; // e.g. ['claude', 'startup', '5-agent']
|
||||
agentCount: number | null; // For team blueprints
|
||||
|
||||
// Content
|
||||
previewImages: string[]; // Screenshots / org chart visuals
|
||||
readmeMarkdown: string; // Full README shown on detail page
|
||||
includedFiles: string[]; // List of files in the bundle
|
||||
|
||||
// Compatibility
|
||||
compatibleAdapters: string[]; // ['claude_local', 'codex_local', ...]
|
||||
requiredModels: string[]; // ['claude-opus-4-6', 'claude-sonnet-4-6']
|
||||
paperclipVersionMin: string; // Minimum Paperclip version
|
||||
|
||||
// Social proof
|
||||
installCount: number;
|
||||
rating: number | null; // 1.0–5.0
|
||||
reviewCount: number;
|
||||
|
||||
// Metadata
|
||||
version: string; // Semver
|
||||
publishedAt: string;
|
||||
updatedAt: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Team Blueprint Bundle
|
||||
|
||||
```typescript
|
||||
interface TeamBlueprint {
|
||||
listingId: string;
|
||||
|
||||
// Org structure
|
||||
agents: AgentBlueprint[];
|
||||
reportingChain: { agentSlug: string; reportsTo: string | null }[];
|
||||
|
||||
// Governance
|
||||
governance: {
|
||||
approvalRules: ApprovalRule[];
|
||||
budgetDefaults: { role: string; monthlyCents: number }[];
|
||||
escalationChain: string[]; // Agent slugs in escalation order
|
||||
};
|
||||
|
||||
// Projects
|
||||
projects: ProjectTemplate[];
|
||||
|
||||
// Company-level config
|
||||
companyDefaults: {
|
||||
name: string;
|
||||
defaultModel: string;
|
||||
defaultAdapter: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AgentBlueprint {
|
||||
slug: string; // e.g. 'cto', 'engineer-1'
|
||||
name: string;
|
||||
role: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
capabilities: string;
|
||||
promptTemplate: string;
|
||||
adapterType: string;
|
||||
adapterConfig: Record<string, any>;
|
||||
instructionsPath: string | null; // Path to AGENTS.md or similar
|
||||
skills: SkillBundle[];
|
||||
budgetMonthlyCents: number;
|
||||
permissions: {
|
||||
canCreateAgents: boolean;
|
||||
canApproveHires: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProjectTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
workspace: {
|
||||
cwd: string | null;
|
||||
repoUrl: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ApprovalRule {
|
||||
trigger: string; // e.g. 'hire_agent', 'budget_exceed'
|
||||
threshold: number | null;
|
||||
approverRole: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Creator / Seller
|
||||
|
||||
```typescript
|
||||
interface Creator {
|
||||
id: string;
|
||||
userId: string; // Auth provider ID
|
||||
displayName: string;
|
||||
bio: string;
|
||||
avatarUrl: string | null;
|
||||
website: string | null;
|
||||
listings: string[]; // Listing IDs
|
||||
totalInstalls: number;
|
||||
totalRevenue: number; // Cents earned
|
||||
joinedAt: string;
|
||||
verified: boolean;
|
||||
payoutMethod: 'stripe_connect';
|
||||
stripeAccountId: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Purchase / Install
|
||||
|
||||
```typescript
|
||||
interface Purchase {
|
||||
id: string;
|
||||
listingId: string;
|
||||
buyerUserId: string;
|
||||
buyerCompanyId: string | null; // Target Paperclip company
|
||||
pricePaidCents: number;
|
||||
paymentIntentId: string | null; // Stripe
|
||||
installedAt: string | null; // When deployed to company
|
||||
status: 'pending' | 'completed' | 'refunded';
|
||||
createdAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Review
|
||||
|
||||
```typescript
|
||||
interface Review {
|
||||
id: string;
|
||||
listingId: string;
|
||||
authorUserId: string;
|
||||
authorDisplayName: string;
|
||||
rating: number; // 1–5
|
||||
title: string;
|
||||
body: string; // Markdown
|
||||
verifiedPurchase: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Pages & Routes
|
||||
|
||||
### 4.1 Public Pages
|
||||
|
||||
| Route | Page | Description |
|
||||
|---|---|---|
|
||||
| `/` | Homepage | Hero, featured blueprints, popular skills, how it works |
|
||||
| `/browse` | Marketplace browse | Filterable grid of all listings |
|
||||
| `/browse?type=team_blueprint` | Team blueprints | Filtered to team configs |
|
||||
| `/browse?type=agent_blueprint` | Agent blueprints | Single-agent configs |
|
||||
| `/browse?type=skill` | Skills | Skill listings |
|
||||
| `/browse?type=governance_template` | Governance | Policy templates |
|
||||
| `/listings/:slug` | Listing detail | Full product page |
|
||||
| `/creators/:slug` | Creator profile | Bio, all listings, stats |
|
||||
| `/about` | About ClipHub | Mission, how it works |
|
||||
| `/pricing` | Pricing & fees | Creator revenue share, buyer info |
|
||||
|
||||
### 4.2 Authenticated Pages
|
||||
|
||||
| Route | Page | Description |
|
||||
|---|---|---|
|
||||
| `/dashboard` | Buyer dashboard | Purchased items, installed blueprints |
|
||||
| `/dashboard/purchases` | Purchase history | All transactions |
|
||||
| `/dashboard/installs` | Installations | Deployed blueprints with status |
|
||||
| `/creator` | Creator dashboard | Listing management, analytics |
|
||||
| `/creator/listings/new` | Create listing | Multi-step listing wizard |
|
||||
| `/creator/listings/:id/edit` | Edit listing | Modify existing listing |
|
||||
| `/creator/analytics` | Analytics | Revenue, installs, views |
|
||||
| `/creator/payouts` | Payouts | Stripe Connect payout history |
|
||||
|
||||
### 4.3 API Routes
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/listings` | Browse listings (filters: type, category, price range, sort) |
|
||||
| `GET` | `/api/listings/:slug` | Get listing detail |
|
||||
| `POST` | `/api/listings` | Create listing (creator auth) |
|
||||
| `PATCH` | `/api/listings/:id` | Update listing |
|
||||
| `DELETE` | `/api/listings/:id` | Archive listing |
|
||||
| `POST` | `/api/listings/:id/purchase` | Purchase listing (Stripe checkout) |
|
||||
| `POST` | `/api/listings/:id/install` | Install to Paperclip company |
|
||||
| `GET` | `/api/listings/:id/reviews` | Get reviews |
|
||||
| `POST` | `/api/listings/:id/reviews` | Submit review |
|
||||
| `GET` | `/api/creators/:slug` | Creator profile |
|
||||
| `GET` | `/api/creators/me` | Current creator profile |
|
||||
| `POST` | `/api/creators` | Register as creator |
|
||||
| `GET` | `/api/purchases` | Buyer's purchase history |
|
||||
| `GET` | `/api/analytics` | Creator analytics |
|
||||
|
||||
---
|
||||
|
||||
## 5. User Flows
|
||||
|
||||
### 5.1 Buyer: Browse → Purchase → Install
|
||||
|
||||
```
|
||||
Homepage → Browse marketplace → Filter by type/category
|
||||
→ Click listing → Read details, reviews, preview org chart
|
||||
→ Click "Buy" → Stripe checkout (or free install)
|
||||
→ Post-purchase: "Install to Company" button
|
||||
→ Select target Paperclip company (or create new)
|
||||
→ ClipHub API calls Paperclip API to:
|
||||
1. Create agents with configs from blueprint
|
||||
2. Set up reporting chains
|
||||
3. Create projects with workspace configs
|
||||
4. Apply governance rules
|
||||
5. Deploy skill files to agent instruction paths
|
||||
→ Redirect to Paperclip dashboard with new team running
|
||||
```
|
||||
|
||||
### 5.2 Creator: Build → Publish → Earn
|
||||
|
||||
```
|
||||
Sign up as creator → Connect Stripe
|
||||
→ "New Listing" wizard:
|
||||
Step 1: Type (team/agent/skill/governance)
|
||||
Step 2: Basic info (title, tagline, description, categories)
|
||||
Step 3: Upload bundle (JSON config + skill files + README)
|
||||
Step 4: Preview & org chart visualization
|
||||
Step 5: Pricing ($0–$499)
|
||||
Step 6: Publish
|
||||
→ Live on marketplace immediately
|
||||
→ Track installs, revenue, reviews on creator dashboard
|
||||
```
|
||||
|
||||
### 5.3 Creator: Export from Paperclip → Publish
|
||||
|
||||
```
|
||||
Running Paperclip company → "Export as Blueprint" (CLI or UI)
|
||||
→ Paperclip exports:
|
||||
- Agent configs (sanitized — no secrets)
|
||||
- Org chart / reporting chains
|
||||
- Governance rules
|
||||
- Project templates
|
||||
- Skill files
|
||||
→ Upload to ClipHub as new listing
|
||||
→ Edit details, set price, publish
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. UI Design Direction
|
||||
|
||||
### 6.1 Visual Language
|
||||
|
||||
- **Color palette**: Dark ink primary, warm sand backgrounds, accent color for CTAs (Paperclip brand blue/purple)
|
||||
- **Typography**: Clean sans-serif, strong hierarchy, monospace for technical details
|
||||
- **Cards**: Rounded corners, subtle shadows, clear pricing badges
|
||||
- **Org chart visuals**: Interactive tree/graph showing agent relationships in team blueprints
|
||||
|
||||
### 6.2 Key Design Elements
|
||||
|
||||
| Element | ClipHub |
|
||||
|---|---|
|
||||
| Product card | Org chart mini-preview + agent count badge |
|
||||
| Detail page | Interactive org chart + per-agent breakdown |
|
||||
| Install flow | One-click deploy to Paperclip company |
|
||||
| Social proof | "X companies running this blueprint" |
|
||||
| Preview | Live demo sandbox (stretch goal) |
|
||||
|
||||
### 6.3 Listing Card Design
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [Org Chart Mini-Preview] │
|
||||
│ ┌─CEO─┐ │
|
||||
│ ├─CTO─┤ │
|
||||
│ └─ENG──┘ │
|
||||
│ │
|
||||
│ SaaS Startup Team │
|
||||
│ "Ship your MVP with a 5-agent │
|
||||
│ engineering + marketing team" │
|
||||
│ │
|
||||
│ 👥 5 agents ⬇ 234 installs │
|
||||
│ ★ 4.7 (12 reviews) │
|
||||
│ │
|
||||
│ By @masinov $199 [Buy] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.4 Detail Page Sections
|
||||
|
||||
1. **Hero**: Title, tagline, price, install button, creator info
|
||||
2. **Org Chart**: Interactive visualization of agent hierarchy
|
||||
3. **Agent Breakdown**: Expandable cards for each agent — role, capabilities, model, skills
|
||||
4. **Governance**: Approval flows, budget structure, escalation chain
|
||||
5. **Included Projects**: Project templates with workspace configs
|
||||
6. **README**: Full markdown documentation
|
||||
7. **Reviews**: Star ratings + written reviews
|
||||
8. **Related Blueprints**: Cross-sell similar team configs
|
||||
9. **Creator Profile**: Mini bio, other listings
|
||||
|
||||
---
|
||||
|
||||
## 7. Installation Mechanics
|
||||
|
||||
### 7.1 Install API Flow
|
||||
|
||||
When a buyer clicks "Install to Company":
|
||||
|
||||
```
|
||||
POST /api/listings/:id/install
|
||||
{
|
||||
"targetCompanyId": "uuid", // Existing Paperclip company
|
||||
"overrides": { // Optional customization
|
||||
"agentModel": "claude-sonnet-4-6", // Override default model
|
||||
"budgetScale": 0.5, // Scale budgets
|
||||
"skipProjects": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The install handler:
|
||||
|
||||
1. Validates buyer owns the purchase
|
||||
2. Validates target company access
|
||||
3. For each agent in blueprint:
|
||||
- `POST /api/companies/:id/agents` (if `paperclip-create-agent` supports it, or via approval flow)
|
||||
- Sets adapter config, prompt template, instructions path
|
||||
4. Sets reporting chains
|
||||
5. Creates projects and workspaces
|
||||
6. Applies governance rules
|
||||
7. Deploys skill files to configured paths
|
||||
8. Returns summary of created resources
|
||||
|
||||
### 7.2 Conflict Resolution
|
||||
|
||||
- **Agent name collision**: Append `-2`, `-3` suffix
|
||||
- **Project name collision**: Prompt buyer to rename or skip
|
||||
- **Adapter mismatch**: Warn if blueprint requires adapter not available locally
|
||||
- **Model availability**: Warn if required model not configured
|
||||
|
||||
---
|
||||
|
||||
## 8. Revenue Model
|
||||
|
||||
| Fee | Amount | Notes |
|
||||
|---|---|---|
|
||||
| Creator revenue share | 90% of sale price | Minus Stripe processing (~2.9% + $0.30) |
|
||||
| Platform fee | 10% of sale price | ClipHub's cut |
|
||||
| Free listings | $0 | No fees for free listings |
|
||||
| Stripe Connect | Standard rates | Handled by Stripe |
|
||||
|
||||
---
|
||||
|
||||
## 9. Technical Architecture
|
||||
|
||||
### 9.1 Stack
|
||||
|
||||
- **Frontend**: Next.js (React), Tailwind CSS, same UI framework as Paperclip
|
||||
- **Backend**: Node.js API (or extend Paperclip server)
|
||||
- **Database**: Postgres (can share Paperclip's DB or separate)
|
||||
- **Payments**: Stripe Connect (marketplace mode)
|
||||
- **Storage**: S3/R2 for listing bundles and images
|
||||
- **Auth**: Shared with Paperclip auth (or OAuth2)
|
||||
|
||||
### 9.2 Integration with Paperclip
|
||||
|
||||
ClipHub can be:
|
||||
- **Option A**: A separate app that calls Paperclip's API to install blueprints
|
||||
- **Option B**: A built-in section of the Paperclip UI (`/marketplace` route)
|
||||
|
||||
Option B is simpler for MVP — adds routes to the existing Paperclip UI and API.
|
||||
|
||||
### 9.3 Bundle Format
|
||||
|
||||
Listing bundles are ZIP/tar archives containing:
|
||||
|
||||
```
|
||||
blueprint/
|
||||
├── manifest.json # Listing metadata + agent configs
|
||||
├── README.md # Documentation
|
||||
├── org-chart.json # Agent hierarchy
|
||||
├── governance.json # Approval rules, budgets
|
||||
├── agents/
|
||||
│ ├── ceo/
|
||||
│ │ ├── prompt.md # Prompt template
|
||||
│ │ ├── AGENTS.md # Instructions
|
||||
│ │ └── skills/ # Skill files
|
||||
│ ├── cto/
|
||||
│ │ ├── prompt.md
|
||||
│ │ ├── AGENTS.md
|
||||
│ │ └── skills/
|
||||
│ └── engineer/
|
||||
│ ├── prompt.md
|
||||
│ ├── AGENTS.md
|
||||
│ └── skills/
|
||||
└── projects/
|
||||
└── default/
|
||||
└── workspace.json # Project workspace config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. MVP Scope
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [ ] Listing schema and CRUD API
|
||||
- [ ] Browse page with filters (type, category, price)
|
||||
- [ ] Listing detail page with org chart visualization
|
||||
- [ ] Creator registration and listing creation wizard
|
||||
- [ ] Free installs only (no payments yet)
|
||||
- [ ] Install flow: blueprint → Paperclip company
|
||||
|
||||
### Phase 2: Payments & Social
|
||||
- [ ] Stripe Connect integration
|
||||
- [ ] Purchase flow
|
||||
- [ ] Review system
|
||||
- [ ] Creator analytics dashboard
|
||||
- [ ] "Export from Paperclip" CLI command
|
||||
|
||||
### Phase 3: Growth
|
||||
- [ ] Search with relevance ranking
|
||||
- [ ] Featured/trending listings
|
||||
- [ ] Creator verification program
|
||||
- [ ] Blueprint versioning and update notifications
|
||||
- [ ] Live demo sandbox
|
||||
- [ ] API for programmatic publishing
|
||||
@@ -5,24 +5,15 @@ summary: Get Paperclip running in minutes
|
||||
|
||||
Get Paperclip running locally in under 5 minutes.
|
||||
|
||||
## Option 1: Docker Compose (Recommended)
|
||||
|
||||
The fastest way to start. No Node.js install needed.
|
||||
## Quick Start (Recommended)
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.quickstart.yml up --build
|
||||
npx paperclipai onboard --yes
|
||||
```
|
||||
|
||||
Open [http://localhost:3100](http://localhost:3100). That's it.
|
||||
This walks you through setup, configures your environment, and gets Paperclip running.
|
||||
|
||||
The Docker image includes Claude Code CLI and Codex CLI pre-installed for local adapter runs. Pass API keys to enable them:
|
||||
|
||||
```sh
|
||||
ANTHROPIC_API_KEY=sk-... OPENAI_API_KEY=sk-... \
|
||||
docker compose -f docker-compose.quickstart.yml up --build
|
||||
```
|
||||
|
||||
## Option 2: Local Development
|
||||
## Local Development
|
||||
|
||||
Prerequisites: Node.js 20+ and pnpm 9+.
|
||||
|
||||
@@ -33,9 +24,9 @@ pnpm dev
|
||||
|
||||
This starts the API server and UI at [http://localhost:3100](http://localhost:3100).
|
||||
|
||||
No Docker or external database required — Paperclip uses an embedded PostgreSQL instance by default.
|
||||
No external database required — Paperclip uses an embedded PostgreSQL instance by default.
|
||||
|
||||
## Option 3: One-Command Bootstrap
|
||||
## One-Command Bootstrap
|
||||
|
||||
```sh
|
||||
pnpm paperclipai run
|
||||
|
||||
14
package.json
14
package.json
@@ -3,8 +3,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-runner.mjs dev",
|
||||
"dev": "node scripts/dev-runner.mjs watch",
|
||||
"dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never node scripts/dev-runner.mjs watch",
|
||||
"dev:once": "node scripts/dev-runner.mjs dev",
|
||||
"dev:server": "pnpm --filter @paperclipai/server dev",
|
||||
"dev:ui": "pnpm --filter @paperclipai/ui dev",
|
||||
"build": "pnpm -r build",
|
||||
@@ -18,13 +19,22 @@
|
||||
"paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts",
|
||||
"build:npm": "./scripts/build-npm.sh",
|
||||
"release": "./scripts/release.sh",
|
||||
"release:preflight": "./scripts/release-preflight.sh",
|
||||
"release:github": "./scripts/create-github-release.sh",
|
||||
"release:rollback": "./scripts/rollback-latest.sh",
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version",
|
||||
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
|
||||
"docs:dev": "cd docs && npx mintlify dev"
|
||||
"docs:dev": "cd docs && npx mintlify dev",
|
||||
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
|
||||
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
|
||||
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
|
||||
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
|
||||
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"esbuild": "^0.27.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# @paperclipai/adapter-utils
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-utils",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.7",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -30,6 +30,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export type {
|
||||
AdapterRuntime,
|
||||
UsageSummary,
|
||||
AdapterBillingType,
|
||||
AdapterRuntimeServiceReport,
|
||||
AdapterExecutionResult,
|
||||
AdapterInvocationMeta,
|
||||
AdapterExecutionContext,
|
||||
@@ -13,6 +14,8 @@ export type {
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterSessionCodec,
|
||||
AdapterModel,
|
||||
HireApprovedPayload,
|
||||
HireApprovedHookResult,
|
||||
ServerAdapterModule,
|
||||
TranscriptEntry,
|
||||
StdoutLineParser,
|
||||
|
||||
@@ -15,6 +15,14 @@ interface RunningProcess {
|
||||
graceSec: number;
|
||||
}
|
||||
|
||||
type ChildProcessWithEvents = ChildProcess & {
|
||||
on(event: "error", listener: (err: Error) => void): ChildProcess;
|
||||
on(
|
||||
event: "close",
|
||||
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
||||
): ChildProcess;
|
||||
};
|
||||
|
||||
export const runningProcesses = new Map<string, RunningProcess>();
|
||||
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
||||
export const MAX_EXCERPT_BYTES = 32 * 1024;
|
||||
@@ -217,7 +225,7 @@ export async function runChildProcess(
|
||||
env: mergedEnv,
|
||||
shell: false,
|
||||
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||
});
|
||||
}) as ChildProcessWithEvents;
|
||||
|
||||
if (opts.stdin != null && child.stdin) {
|
||||
child.stdin.write(opts.stdin);
|
||||
@@ -244,7 +252,7 @@ export async function runChildProcess(
|
||||
}, opts.timeoutSec * 1000)
|
||||
: null;
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
child.stdout?.on("data", (chunk: unknown) => {
|
||||
const text = String(chunk);
|
||||
stdout = appendWithCap(stdout, text);
|
||||
logChain = logChain
|
||||
@@ -252,7 +260,7 @@ export async function runChildProcess(
|
||||
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
child.stderr?.on("data", (chunk: unknown) => {
|
||||
const text = String(chunk);
|
||||
stderr = appendWithCap(stderr, text);
|
||||
logChain = logChain
|
||||
@@ -260,7 +268,7 @@ export async function runChildProcess(
|
||||
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
child.on("error", (err: Error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
const errno = (err as NodeJS.ErrnoException).code;
|
||||
@@ -272,7 +280,7 @@ export async function runChildProcess(
|
||||
reject(new Error(msg));
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
void logChain.finally(() => {
|
||||
|
||||
@@ -32,6 +32,27 @@ export interface UsageSummary {
|
||||
|
||||
export type AdapterBillingType = "api" | "subscription" | "unknown";
|
||||
|
||||
export interface AdapterRuntimeServiceReport {
|
||||
id?: string | null;
|
||||
projectId?: string | null;
|
||||
projectWorkspaceId?: string | null;
|
||||
issueId?: string | null;
|
||||
scopeType?: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||
scopeId?: string | null;
|
||||
serviceName: string;
|
||||
status?: "starting" | "running" | "stopped" | "failed";
|
||||
lifecycle?: "shared" | "ephemeral";
|
||||
reuseKey?: string | null;
|
||||
command?: string | null;
|
||||
cwd?: string | null;
|
||||
port?: number | null;
|
||||
url?: string | null;
|
||||
providerRef?: string | null;
|
||||
ownerAgentId?: string | null;
|
||||
stopPolicy?: Record<string, unknown> | null;
|
||||
healthStatus?: "unknown" | "healthy" | "unhealthy";
|
||||
}
|
||||
|
||||
export interface AdapterExecutionResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
@@ -51,6 +72,7 @@ export interface AdapterExecutionResult {
|
||||
billingType?: AdapterBillingType | null;
|
||||
costUsd?: number | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
runtimeServices?: AdapterRuntimeServiceReport[];
|
||||
summary?: string | null;
|
||||
clearSession?: boolean;
|
||||
}
|
||||
@@ -119,6 +141,27 @@ export interface AdapterEnvironmentTestContext {
|
||||
};
|
||||
}
|
||||
|
||||
/** Payload for the onHireApproved adapter lifecycle hook (e.g. join-request or hire_agent approval). */
|
||||
export interface HireApprovedPayload {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
adapterType: string;
|
||||
/** "join_request" | "approval" */
|
||||
source: "join_request" | "approval";
|
||||
sourceId: string;
|
||||
approvedAt: string;
|
||||
/** Canonical operator-facing message for cloud adapters to show the user. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Result of onHireApproved hook; failures are non-fatal to the approval flow. */
|
||||
export interface HireApprovedHookResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ServerAdapterModule {
|
||||
type: string;
|
||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||
@@ -128,6 +171,14 @@ export interface ServerAdapterModule {
|
||||
models?: AdapterModel[];
|
||||
listModels?: () => Promise<AdapterModel[]>;
|
||||
agentConfigurationDoc?: string;
|
||||
/**
|
||||
* Optional lifecycle hook when an agent is approved/hired (join-request or hire_agent approval).
|
||||
* adapterConfig is the agent's adapter config so the adapter can e.g. send a callback to a configured URL.
|
||||
*/
|
||||
onHireApproved?: (
|
||||
payload: HireApprovedPayload,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) => Promise<HireApprovedHookResult>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -135,8 +186,8 @@ export interface ServerAdapterModule {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TranscriptEntry =
|
||||
| { kind: "assistant"; ts: string; text: string }
|
||||
| { kind: "thinking"; ts: string; text: string }
|
||||
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
|
||||
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
|
||||
| { kind: "user"; ts: string; text: string }
|
||||
| { kind: "tool_call"; ts: string; name: string; input: unknown }
|
||||
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
||||
@@ -179,6 +230,12 @@ export interface CreateConfigValues {
|
||||
envBindings: Record<string, unknown>;
|
||||
url: string;
|
||||
bootstrapPrompt: string;
|
||||
payloadTemplateJson?: string;
|
||||
workspaceStrategyType?: string;
|
||||
workspaceBaseRef?: string;
|
||||
workspaceBranchTemplate?: string;
|
||||
worktreeParentDir?: string;
|
||||
runtimeServicesJson?: string;
|
||||
maxTurnsPerRun: number;
|
||||
heartbeatEnabled: boolean;
|
||||
intervalSec: number;
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
# @paperclipai/adapter-claude-local
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.7
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.6
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.5
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.4
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.3
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-claude-local",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.7",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -32,7 +32,8 @@
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
@@ -44,6 +45,7 @@
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export const label = "Claude Code (local)";
|
||||
|
||||
export const models = [
|
||||
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
|
||||
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
|
||||
];
|
||||
@@ -23,8 +25,13 @@ Core fields:
|
||||
- command (string, optional): defaults to "claude"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||
`;
|
||||
|
||||
@@ -27,10 +27,19 @@ import {
|
||||
isClaudeUnknownSessionError,
|
||||
} from "./parse.js";
|
||||
|
||||
const PAPERCLIP_SKILLS_DIR = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../skills",
|
||||
);
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
|
||||
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
|
||||
@@ -41,11 +50,13 @@ async function buildSkillsDir(): Promise<string> {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-"));
|
||||
const target = path.join(tmp, ".claude", "skills");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
const entries = await fs.readdir(PAPERCLIP_SKILLS_DIR, { withFileTypes: true });
|
||||
const skillsDir = await resolvePaperclipSkillsDir();
|
||||
if (!skillsDir) return tmp;
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
await fs.symlink(
|
||||
path.join(PAPERCLIP_SKILLS_DIR, entry.name),
|
||||
path.join(skillsDir, entry.name),
|
||||
path.join(target, entry.name),
|
||||
);
|
||||
}
|
||||
@@ -104,14 +115,28 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
|
||||
? context.paperclipRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
|
||||
? context.paperclipRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
@@ -172,6 +197,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
@@ -181,9 +209,24 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
|
||||
@@ -50,6 +50,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
@@ -70,6 +82,18 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||
if (v.workspaceStrategyType === "git_worktree") {
|
||||
ac.workspaceStrategy = {
|
||||
type: "git_worktree",
|
||||
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
||||
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
||||
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
||||
};
|
||||
}
|
||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||
ac.workspaceRuntime = runtimeServices;
|
||||
}
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
# @paperclipai/adapter-codex-local
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.7
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.6
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.5
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.4
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Version bump (patch)
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.2.3
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-codex-local",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.7",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
@@ -32,7 +32,8 @@
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
@@ -44,6 +45,7 @@
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex";
|
||||
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
|
||||
|
||||
export const models = [
|
||||
{ id: "gpt-5.4", label: "gpt-5.4" },
|
||||
{ id: DEFAULT_CODEX_LOCAL_MODEL, label: DEFAULT_CODEX_LOCAL_MODEL },
|
||||
{ id: "gpt-5.3-codex-spark", label: "gpt-5.3-codex-spark" },
|
||||
{ id: "gpt-5", label: "gpt-5" },
|
||||
@@ -30,6 +31,8 @@ Core fields:
|
||||
- command (string, optional): defaults to "codex"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
@@ -39,4 +42,5 @@ Notes:
|
||||
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
||||
- Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills.
|
||||
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
||||
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||
`;
|
||||
|
||||
@@ -19,10 +19,11 @@ import {
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||
|
||||
const PAPERCLIP_SKILLS_DIR = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../skills",
|
||||
);
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
|
||||
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
|
||||
];
|
||||
const CODEX_ROLLOUT_NOISE_RE =
|
||||
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
|
||||
|
||||
@@ -66,19 +67,24 @@ function codexHomeDir(): string {
|
||||
return path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
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 ensureCodexSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
|
||||
const sourceExists = await fs
|
||||
.stat(PAPERCLIP_SKILLS_DIR)
|
||||
.then((stats) => stats.isDirectory())
|
||||
.catch(() => false);
|
||||
if (!sourceExists) return;
|
||||
const skillsDir = await resolvePaperclipSkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
const skillsHome = path.join(codexHomeDir(), "skills");
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const entries = await fs.readdir(PAPERCLIP_SKILLS_DIR, { withFileTypes: true });
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(PAPERCLIP_SKILLS_DIR, entry.name);
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) continue;
|
||||
@@ -120,14 +126,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "");
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
|
||||
? context.paperclipRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
|
||||
? context.paperclipRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
@@ -186,6 +206,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
@@ -195,9 +218,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,18 @@ function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
@@ -76,6 +88,18 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
||||
typeof v.dangerouslyBypassSandbox === "boolean"
|
||||
? v.dangerouslyBypassSandbox
|
||||
: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
if (v.workspaceStrategyType === "git_worktree") {
|
||||
ac.workspaceStrategy = {
|
||||
type: "git_worktree",
|
||||
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
||||
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
||||
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
||||
};
|
||||
}
|
||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||
ac.workspaceRuntime = runtimeServices;
|
||||
}
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
|
||||
7
packages/adapters/cursor-local/CHANGELOG.md
Normal file
7
packages/adapters/cursor-local/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# @paperclipai/adapter-cursor-local
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Added initial `cursor` adapter package for local Cursor CLI execution
|
||||
51
packages/adapters/cursor-local/package.json
Normal file
51
packages/adapters/cursor-local/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-cursor-local",
|
||||
"version": "0.2.7",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
317
packages/adapters/cursor-local/src/cli/format-event.ts
Normal file
317
packages/adapters/cursor-local/src/cli/format-event.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import pc from "picocolors";
|
||||
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function printUserMessage(messageRaw: unknown): void {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
if (text) console.log(pc.gray(`user: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return;
|
||||
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) console.log(pc.gray(`user: ${directText}`));
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
if (type !== "output_text" && type !== "text") continue;
|
||||
const text = asString(part.text).trim();
|
||||
if (text) console.log(pc.gray(`user: ${text}`));
|
||||
}
|
||||
}
|
||||
|
||||
function printAssistantMessage(messageRaw: unknown): void {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return;
|
||||
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) console.log(pc.green(`assistant: ${directText}`));
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
|
||||
if (type === "output_text" || type === "text") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const name = asString(part.name, asString(part.tool, "tool"));
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
const input = part.input ?? part.arguments ?? part.args;
|
||||
if (input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(input)));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result") {
|
||||
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||
const contentText =
|
||||
asString(part.output) ||
|
||||
asString(part.text) ||
|
||||
asString(part.result) ||
|
||||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part);
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
if (contentText) console.log((isError ? pc.red : pc.gray)(contentText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printToolCallEventTopLevel(parsed: Record<string, unknown>): void {
|
||||
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||
const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "")));
|
||||
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
||||
if (!toolCall) {
|
||||
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const [toolName] = Object.keys(toolCall);
|
||||
if (!toolName) {
|
||||
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
|
||||
return;
|
||||
}
|
||||
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||
const args = payload.args ?? asRecord(payload.function)?.arguments;
|
||||
const result =
|
||||
payload.result ??
|
||||
payload.output ??
|
||||
payload.error ??
|
||||
asRecord(payload.function)?.result ??
|
||||
asRecord(payload.function)?.output;
|
||||
const isError =
|
||||
parsed.is_error === true ||
|
||||
payload.is_error === true ||
|
||||
subtype === "failed" ||
|
||||
subtype === "error" ||
|
||||
subtype === "cancelled" ||
|
||||
payload.error !== undefined;
|
||||
|
||||
if (subtype === "started" || subtype === "start") {
|
||||
console.log(pc.yellow(`tool_call: ${toolName}${callId ? ` (${callId})` : ""}`));
|
||||
if (args !== undefined) {
|
||||
console.log(pc.gray(stringifyUnknown(args)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||
const header = `tool_result${isError ? " (error)" : ""}${callId ? ` (${callId})` : ""}`;
|
||||
console.log((isError ? pc.red : pc.cyan)(header));
|
||||
if (result !== undefined) {
|
||||
console.log((isError ? pc.red : pc.gray)(stringifyUnknown(result)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`));
|
||||
}
|
||||
|
||||
function printLegacyToolEvent(part: Record<string, unknown>): void {
|
||||
const tool = asString(part.tool, "tool");
|
||||
const callId = asString(part.callID, asString(part.id, ""));
|
||||
const state = asRecord(part.state);
|
||||
const status = asString(state?.status);
|
||||
const input = state?.input;
|
||||
const output = asString(state?.output).replace(/\s+$/, "");
|
||||
const metadata = asRecord(state?.metadata);
|
||||
const exit = asNumber(metadata?.exit, NaN);
|
||||
const isError =
|
||||
status === "failed" ||
|
||||
status === "error" ||
|
||||
status === "cancelled" ||
|
||||
(Number.isFinite(exit) && exit !== 0);
|
||||
|
||||
console.log(pc.yellow(`tool_call: ${tool}${callId ? ` (${callId})` : ""}`));
|
||||
if (input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(input)));
|
||||
}
|
||||
}
|
||||
|
||||
if (status || output) {
|
||||
const summary = [
|
||||
"tool_result",
|
||||
status ? `status=${status}` : "",
|
||||
Number.isFinite(exit) ? `exit=${exit}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
console.log((isError ? pc.red : pc.cyan)(summary));
|
||||
if (output) {
|
||||
console.log((isError ? pc.red : pc.gray)(output));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function printCursorStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = normalizeCursorStreamLine(raw).line;
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(parsed.subtype);
|
||||
if (subtype === "init") {
|
||||
const sessionId =
|
||||
asString(parsed.session_id) ||
|
||||
asString(parsed.sessionId) ||
|
||||
asString(parsed.sessionID);
|
||||
const model = asString(parsed.model);
|
||||
const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
console.log(pc.blue(`Cursor init${details ? ` (${details})` : ""}`));
|
||||
return;
|
||||
}
|
||||
console.log(pc.blue(`system: ${subtype || "event"}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
printAssistantMessage(parsed.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
printUserMessage(parsed.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
printToolCallEventTopLevel(parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const input = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
|
||||
const output = asNumber(usage?.output_tokens, asNumber(usage?.outputTokens));
|
||||
const cached = asNumber(
|
||||
usage?.cached_input_tokens,
|
||||
asNumber(usage?.cachedInputTokens, asNumber(usage?.cache_read_input_tokens)),
|
||||
);
|
||||
const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
|
||||
const subtype = asString(parsed.subtype, "result");
|
||||
const isError = parsed.is_error === true || subtype === "error" || subtype === "failed";
|
||||
|
||||
console.log(pc.blue(`result: subtype=${subtype}`));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
const resultText = asString(parsed.result).trim();
|
||||
if (resultText) console.log((isError ? pc.red : pc.green)(`assistant: ${resultText}`));
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map((value) => stringifyUnknown(value)).filter(Boolean) : [];
|
||||
if (errors.length > 0) console.log(pc.red(`errors: ${errors.join(" | ")}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = asString(parsed.message) || stringifyUnknown(parsed.error ?? parsed.detail) || line;
|
||||
console.log(pc.red(`error: ${message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Compatibility with older stream-json event shapes.
|
||||
if (type === "step_start") {
|
||||
const sessionId = asString(parsed.sessionID);
|
||||
console.log(pc.blue(`step started${sessionId ? ` (session: ${sessionId})` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text);
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
const part = asRecord(parsed.part);
|
||||
if (part) {
|
||||
printLegacyToolEvent(part);
|
||||
} else {
|
||||
console.log(pc.yellow("tool_use"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = asRecord(parsed.part);
|
||||
const tokens = asRecord(part?.tokens);
|
||||
const cache = asRecord(tokens?.cache);
|
||||
const reason = asString(part?.reason, "step_finish");
|
||||
const input = asNumber(tokens?.input);
|
||||
const output = asNumber(tokens?.output);
|
||||
const cached = asNumber(cache?.read);
|
||||
const cost = asNumber(part?.cost);
|
||||
console.log(pc.blue(`step finished: reason=${reason}`));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
}
|
||||
1
packages/adapters/cursor-local/src/cli/index.ts
Normal file
1
packages/adapters/cursor-local/src/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { printCursorStreamEvent } from "./format-event.js";
|
||||
83
packages/adapters/cursor-local/src/index.ts
Normal file
83
packages/adapters/cursor-local/src/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export const type = "cursor";
|
||||
export const label = "Cursor CLI (local)";
|
||||
export const DEFAULT_CURSOR_LOCAL_MODEL = "auto";
|
||||
|
||||
const CURSOR_FALLBACK_MODEL_IDS = [
|
||||
"auto",
|
||||
"composer-1.5",
|
||||
"composer-1",
|
||||
"gpt-5.3-codex-low",
|
||||
"gpt-5.3-codex-low-fast",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.3-codex-fast",
|
||||
"gpt-5.3-codex-high",
|
||||
"gpt-5.3-codex-high-fast",
|
||||
"gpt-5.3-codex-xhigh",
|
||||
"gpt-5.3-codex-xhigh-fast",
|
||||
"gpt-5.3-codex-spark-preview",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-codex-low",
|
||||
"gpt-5.2-codex-low-fast",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.2-codex-fast",
|
||||
"gpt-5.2-codex-high",
|
||||
"gpt-5.2-codex-high-fast",
|
||||
"gpt-5.2-codex-xhigh",
|
||||
"gpt-5.2-codex-xhigh-fast",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-max-high",
|
||||
"gpt-5.2-high",
|
||||
"gpt-5.1-high",
|
||||
"gpt-5.1-codex-mini",
|
||||
"opus-4.6-thinking",
|
||||
"opus-4.6",
|
||||
"opus-4.5",
|
||||
"opus-4.5-thinking",
|
||||
"sonnet-4.6",
|
||||
"sonnet-4.6-thinking",
|
||||
"sonnet-4.5",
|
||||
"sonnet-4.5-thinking",
|
||||
"gemini-3.1-pro",
|
||||
"gemini-3-pro",
|
||||
"gemini-3-flash",
|
||||
"grok",
|
||||
"kimi-k2.5",
|
||||
];
|
||||
|
||||
export const models = CURSOR_FALLBACK_MODEL_IDS.map((id) => ({ id, label: id }));
|
||||
|
||||
export const agentConfigurationDoc = `# cursor agent configuration
|
||||
|
||||
Adapter: cursor
|
||||
|
||||
Use when:
|
||||
- You want Paperclip to run Cursor Agent CLI locally as the agent runtime
|
||||
- You want Cursor chat session resume across heartbeats via --resume
|
||||
- You want structured stream output in run logs via --output-format stream-json
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use openclaw_gateway or http)
|
||||
- You only need one-shot shell commands (use process)
|
||||
- Cursor Agent CLI is not installed on the machine
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- model (string, optional): Cursor model id (for example auto or gpt-5.3-codex)
|
||||
- mode (string, optional): Cursor execution mode passed as --mode (plan|ask). Leave unset for normal autonomous runs.
|
||||
- command (string, optional): defaults to "agent"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- Runs are executed with: agent -p --output-format stream-json ...
|
||||
- Prompts are piped to Cursor via stdin.
|
||||
- Sessions are resumed with --resume when stored session cwd matches current cwd.
|
||||
- Paperclip auto-injects local skills into "~/.cursor/skills" when missing, so Cursor can discover "$paperclip" and related skills on local runs.
|
||||
- Paperclip auto-adds --yolo unless one of --trust/--yolo/-f is already present in extraArgs.
|
||||
`;
|
||||
485
packages/adapters/cursor-local/src/server/execute.ts
Normal file
485
packages/adapters/cursor-local/src/server/execute.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
||||
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
||||
import { hasCursorTrustBypassArg } from "../shared/trust.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"),
|
||||
path.resolve(__moduleDir, "../../../../../skills"),
|
||||
];
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveCursorBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasNonEmptyEnvValue(env, "CURSOR_API_KEY") || hasNonEmptyEnvValue(env, "OPENAI_API_KEY")
|
||||
? "api"
|
||||
: "subscription";
|
||||
}
|
||||
|
||||
function resolveProviderFromModel(model: string): string | null {
|
||||
const trimmed = model.trim().toLowerCase();
|
||||
if (!trimmed) return null;
|
||||
const slash = trimmed.indexOf("/");
|
||||
if (slash > 0) return trimmed.slice(0, slash);
|
||||
if (trimmed.includes("sonnet") || trimmed.includes("claude")) return "anthropic";
|
||||
if (trimmed.startsWith("gpt") || trimmed.startsWith("o")) return "openai";
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeMode(rawMode: string): "plan" | "ask" | null {
|
||||
const mode = rawMode.trim().toLowerCase();
|
||||
if (mode === "plan" || mode === "ask") return mode;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPaperclipEnvNote(env: Record<string, string>): string {
|
||||
const paperclipKeys = Object.keys(env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort();
|
||||
if (paperclipKeys.length === 0) return "";
|
||||
return [
|
||||
"Paperclip runtime note:",
|
||||
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
|
||||
"Do not assume these variables are missing without checking your shell environment.",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function cursorSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".cursor", "skills");
|
||||
}
|
||||
|
||||
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type EnsureCursorSkillsInjectedOptions = {
|
||||
skillsDir?: string | null;
|
||||
skillsHome?: string;
|
||||
linkSkill?: (source: string, target: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export async function ensureCursorSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
options: EnsureCursorSkillsInjectedOptions = {},
|
||||
) {
|
||||
const skillsDir = options.skillsDir ?? await resolvePaperclipSkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
const skillsHome = options.skillsHome ?? cursorSkillsHome();
|
||||
try {
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to prepare Cursor skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target));
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) continue;
|
||||
|
||||
try {
|
||||
await linkSkill(source, target);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Injected Cursor skill "${entry.name}" into ${skillsHome}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to inject Cursor skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
const command = asString(config.command, "agent");
|
||||
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
||||
const mode = normalizeMode(asString(config.mode, ""));
|
||||
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureCursorSkillsInjected(onLog);
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
if (wakeTaskId) {
|
||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
}
|
||||
if (wakeReason) {
|
||||
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
}
|
||||
if (approvalStatus) {
|
||||
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
}
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const billingType = resolveCursorBillingType(env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
const autoTrustEnabled = !hasCursorTrustBypassArg(extraArgs);
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Cursor session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const commandNotes = (() => {
|
||||
const notes: string[] = [];
|
||||
if (autoTrustEnabled) {
|
||||
notes.push("Auto-added --yolo to bypass interactive prompts.");
|
||||
}
|
||||
notes.push("Prompt is piped to Cursor via stdin.");
|
||||
if (!instructionsFilePath) return notes;
|
||||
if (instructionsPrefix.length > 0) {
|
||||
notes.push(
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||
);
|
||||
return notes;
|
||||
}
|
||||
notes.push(
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
);
|
||||
return notes;
|
||||
})();
|
||||
|
||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||
const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`;
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["-p", "--output-format", "stream-json", "--workspace", cwd];
|
||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||
if (model) args.push("--model", model);
|
||||
if (mode) args.push("--mode", mode);
|
||||
if (autoTrustEnabled) args.push("--yolo");
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
return args;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "cursor",
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args,
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
let stdoutLineBuffer = "";
|
||||
const emitNormalizedStdoutLine = async (rawLine: string) => {
|
||||
const normalized = normalizeCursorStreamLine(rawLine);
|
||||
if (!normalized.line) return;
|
||||
await onLog(normalized.stream ?? "stdout", `${normalized.line}\n`);
|
||||
};
|
||||
const flushStdoutChunk = async (chunk: string, finalize = false) => {
|
||||
const combined = `${stdoutLineBuffer}${chunk}`;
|
||||
const lines = combined.split(/\r?\n/);
|
||||
stdoutLineBuffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
await emitNormalizedStdoutLine(line);
|
||||
}
|
||||
|
||||
if (finalize) {
|
||||
const trailing = stdoutLineBuffer.trim();
|
||||
stdoutLineBuffer = "";
|
||||
if (trailing) {
|
||||
await emitNormalizedStdoutLine(trailing);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
stdin: prompt,
|
||||
onLog: async (stream, chunk) => {
|
||||
if (stream !== "stdout") {
|
||||
await onLog(stream, chunk);
|
||||
return;
|
||||
}
|
||||
await flushStdoutChunk(chunk);
|
||||
},
|
||||
});
|
||||
await flushStdoutChunk("", true);
|
||||
|
||||
return {
|
||||
proc,
|
||||
parsed: parseCursorJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const providerFromModel = resolveProviderFromModel(model);
|
||||
|
||||
const toResult = (
|
||||
attempt: {
|
||||
proc: {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
parsed: ReturnType<typeof parseCursorJsonl>;
|
||||
},
|
||||
clearSessionOnMissingSession = false,
|
||||
): AdapterExecutionResult => {
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
stderrLine ||
|
||||
`Cursor exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
(attempt.proc.exitCode ?? 0) === 0
|
||||
? null
|
||||
: fallbackErrorMessage,
|
||||
usage: attempt.parsed.usage,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: providerFromModel,
|
||||
model,
|
||||
billingType,
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
resultJson: {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionId);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isCursorUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Cursor resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true);
|
||||
}
|
||||
|
||||
return toResult(initial);
|
||||
}
|
||||
64
packages/adapters/cursor-local/src/server/index.ts
Normal file
64
packages/adapters/cursor-local/src/server/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export { execute, ensureCursorSkillsInjected } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId =
|
||||
readNonEmptyString(record.sessionId) ??
|
||||
readNonEmptyString(record.session_id) ??
|
||||
readNonEmptyString(record.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return (
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID)
|
||||
);
|
||||
},
|
||||
};
|
||||
162
packages/adapters/cursor-local/src/server/parse.ts
Normal file
162
packages/adapters/cursor-local/src/server/parse.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
||||
|
||||
function asErrorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = parseObject(value);
|
||||
const message =
|
||||
asString(rec.message, "") ||
|
||||
asString(rec.error, "") ||
|
||||
asString(rec.code, "") ||
|
||||
asString(rec.detail, "");
|
||||
if (message) return message;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function collectAssistantText(message: unknown): string[] {
|
||||
if (typeof message === "string") {
|
||||
const trimmed = message.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
const rec = parseObject(message);
|
||||
const direct = asString(rec.text, "").trim();
|
||||
const lines: string[] = direct ? [direct] : [];
|
||||
const content = Array.isArray(rec.content) ? rec.content : [];
|
||||
|
||||
for (const partRaw of content) {
|
||||
const part = parseObject(partRaw);
|
||||
const type = asString(part.type, "").trim();
|
||||
if (type === "output_text" || type === "text") {
|
||||
const text = asString(part.text, "").trim();
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function readSessionId(event: Record<string, unknown>): string | null {
|
||||
return (
|
||||
asString(event.session_id, "").trim() ||
|
||||
asString(event.sessionId, "").trim() ||
|
||||
asString(event.sessionID, "").trim() ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function parseCursorJsonl(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
const messages: string[] = [];
|
||||
let errorMessage: string | null = null;
|
||||
let totalCostUsd = 0;
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = normalizeCursorStreamLine(rawLine).line;
|
||||
if (!line) continue;
|
||||
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const foundSession = readSessionId(event);
|
||||
if (foundSession) sessionId = foundSession;
|
||||
|
||||
const type = asString(event.type, "").trim();
|
||||
|
||||
if (type === "assistant") {
|
||||
messages.push(...collectAssistantText(event.message));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usageObj = parseObject(event.usage);
|
||||
usage.inputTokens += asNumber(
|
||||
usageObj.input_tokens,
|
||||
asNumber(usageObj.inputTokens, 0),
|
||||
);
|
||||
usage.cachedInputTokens += asNumber(
|
||||
usageObj.cached_input_tokens,
|
||||
asNumber(usageObj.cachedInputTokens, asNumber(usageObj.cache_read_input_tokens, 0)),
|
||||
);
|
||||
usage.outputTokens += asNumber(
|
||||
usageObj.output_tokens,
|
||||
asNumber(usageObj.outputTokens, 0),
|
||||
);
|
||||
totalCostUsd += asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, 0)));
|
||||
|
||||
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
|
||||
const resultText = asString(event.result, "").trim();
|
||||
if (resultText && messages.length === 0) {
|
||||
messages.push(resultText);
|
||||
}
|
||||
if (isError) {
|
||||
const resultError = asErrorText(event.error ?? event.message ?? event.result).trim();
|
||||
if (resultError) errorMessage = resultError;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = asErrorText(event.message ?? event.error ?? event.detail).trim();
|
||||
if (message) errorMessage = message;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(event.subtype, "").trim().toLowerCase();
|
||||
if (subtype === "error") {
|
||||
const message = asErrorText(event.message ?? event.error ?? event.detail).trim();
|
||||
if (message) errorMessage = message;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compatibility with older stream-json shapes.
|
||||
if (type === "text") {
|
||||
const part = parseObject(event.part);
|
||||
const text = asString(part.text, "").trim();
|
||||
if (text) messages.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = parseObject(event.part);
|
||||
const tokens = parseObject(part.tokens);
|
||||
const cache = parseObject(tokens.cache);
|
||||
usage.inputTokens += asNumber(tokens.input, 0);
|
||||
usage.cachedInputTokens += asNumber(cache.read, 0);
|
||||
usage.outputTokens += asNumber(tokens.output, 0);
|
||||
totalCostUsd += asNumber(part.cost, 0);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
summary: messages.join("\n\n").trim(),
|
||||
usage,
|
||||
costUsd: totalCostUsd > 0 ? totalCostUsd : null,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCursorUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return /unknown\s+(session|chat)|session\s+.*\s+not\s+found|chat\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|could\s+not\s+resume/i.test(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
210
packages/adapters/cursor-local/src/server/test.ts
Normal file
210
packages/adapters/cursor-local/src/server/test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||
import { parseCursorJsonl } from "./parse.js";
|
||||
import { hasCursorTrustBypassArg } from "../shared/trust.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function isNonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function commandLooksLike(command: string, expected: string): boolean {
|
||||
const base = path.basename(command).toLowerCase();
|
||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||
if (!raw) return null;
|
||||
const clean = raw.replace(/\s+/g, " ").trim();
|
||||
const max = 240;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
||||
}
|
||||
|
||||
const CURSOR_AUTH_REQUIRED_RE =
|
||||
/(?:authentication\s+required|not\s+authenticated|not\s+logged\s+in|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|cursor[_\s-]?api[_\s-]?key|run\s+'?agent\s+login'?\s+first|api(?:[_\s-]?key)?(?:\s+is)?\s+required)/i;
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "agent");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
checks.push({
|
||||
code: "cursor_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "cursor_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "cursor_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "cursor_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
|
||||
const configCursorApiKey = env.CURSOR_API_KEY;
|
||||
const hostCursorApiKey = process.env.CURSOR_API_KEY;
|
||||
if (isNonEmpty(configCursorApiKey) || isNonEmpty(hostCursorApiKey)) {
|
||||
const source = isNonEmpty(configCursorApiKey) ? "adapter config env" : "server environment";
|
||||
checks.push({
|
||||
code: "cursor_api_key_present",
|
||||
level: "info",
|
||||
message: "CURSOR_API_KEY is set for Cursor authentication.",
|
||||
detail: `Detected in ${source}.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "cursor_api_key_missing",
|
||||
level: "warn",
|
||||
message: "CURSOR_API_KEY is not set. Cursor runs may fail until authentication is configured.",
|
||||
hint: "Set CURSOR_API_KEY in adapter env or run `agent login`.",
|
||||
});
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "cursor_cwd_invalid" && check.code !== "cursor_command_unresolvable");
|
||||
if (canRunProbe) {
|
||||
if (!commandLooksLike(command, "agent")) {
|
||||
checks.push({
|
||||
code: "cursor_hello_probe_skipped_custom_command",
|
||||
level: "info",
|
||||
message: "Skipped hello probe because command is not `agent`.",
|
||||
detail: command,
|
||||
hint: "Use the `agent` CLI command to run the automatic installation and auth probe.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
const autoTrustEnabled = !hasCursorTrustBypassArg(extraArgs);
|
||||
const args = ["-p", "--mode", "ask", "--output-format", "json", "--workspace", cwd];
|
||||
if (model) args.push("--model", model);
|
||||
if (autoTrustEnabled) args.push("--yolo");
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push("Respond with hello.");
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`cursor-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
graceSec: 5,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
const parsed = parseCursorJsonl(probe.stdout);
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "cursor_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "Cursor hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, verify `agent -p --mode ask --output-format json \"Respond with hello.\"` manually.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0) {
|
||||
const summary = parsed.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
code: hasHello ? "cursor_hello_probe_passed" : "cursor_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "Cursor hello probe succeeded."
|
||||
: "Cursor probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Try `agent -p --mode ask --output-format json \"Respond with hello.\"` manually to inspect full output.",
|
||||
}),
|
||||
});
|
||||
} else if (CURSOR_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||
checks.push({
|
||||
code: "cursor_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Cursor CLI is installed, but authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `agent login` or configure CURSOR_API_KEY in adapter env/shell, then retry the probe.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "cursor_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "Cursor hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `agent -p --mode ask --output-format json \"Respond with hello.\"` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
16
packages/adapters/cursor-local/src/shared/stream.ts
Normal file
16
packages/adapters/cursor-local/src/shared/stream.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function normalizeCursorStreamLine(rawLine: string): {
|
||||
stream: "stdout" | "stderr" | null;
|
||||
line: string;
|
||||
} {
|
||||
const trimmed = rawLine.trim();
|
||||
if (!trimmed) return { stream: null, line: "" };
|
||||
|
||||
const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*([\[{].*)$/i);
|
||||
if (!prefixed) {
|
||||
return { stream: null, line: trimmed };
|
||||
}
|
||||
|
||||
const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout";
|
||||
const line = (prefixed[2] ?? "").trim();
|
||||
return { stream, line };
|
||||
}
|
||||
9
packages/adapters/cursor-local/src/shared/trust.ts
Normal file
9
packages/adapters/cursor-local/src/shared/trust.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function hasCursorTrustBypassArg(args: readonly string[]): boolean {
|
||||
return args.some(
|
||||
(arg) =>
|
||||
arg === "--trust" ||
|
||||
arg === "--yolo" ||
|
||||
arg === "-f" ||
|
||||
arg.startsWith("--trust="),
|
||||
);
|
||||
}
|
||||
81
packages/adapters/cursor-local/src/ui/build-config.ts
Normal file
81
packages/adapters/cursor-local/src/ui/build-config.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function normalizeMode(value: string): "plan" | "ask" | null {
|
||||
const mode = value.trim().toLowerCase();
|
||||
if (mode === "plan" || mode === "ask") return mode;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildCursorLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
ac.model = v.model || DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
const mode = normalizeMode(v.thinkingEffort);
|
||||
if (mode) ac.mode = mode;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
2
packages/adapters/cursor-local/src/ui/index.ts
Normal file
2
packages/adapters/cursor-local/src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { parseCursorStdoutLine } from "./parse-stdout.js";
|
||||
export { buildCursorLocalConfig } from "./build-config.js";
|
||||
400
packages/adapters/cursor-local/src/ui/parse-stdout.ts
Normal file
400
packages/adapters/cursor-local/src/ui/parse-stdout.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Max chars of stdout/stderr to show in run log for shell tool results. */
|
||||
const SHELL_OUTPUT_TRUNCATE = 2000;
|
||||
|
||||
/**
|
||||
* Format shell tool result for run log: exit code + stdout/stderr (truncated).
|
||||
* If the result is not a shell-shaped object, returns full stringify.
|
||||
*/
|
||||
function formatShellToolResultForLog(result: unknown): string {
|
||||
const obj = asRecord(result);
|
||||
if (!obj) return stringifyUnknown(result);
|
||||
const success = asRecord(obj.success);
|
||||
if (!success) return stringifyUnknown(result);
|
||||
const exitCode = asNumber(success.exitCode, NaN);
|
||||
const stdout = asString(success.stdout).trim();
|
||||
const stderr = asString(success.stderr).trim();
|
||||
const hasShellShape = Number.isFinite(exitCode) || stdout.length > 0 || stderr.length > 0;
|
||||
if (!hasShellShape) return stringifyUnknown(result);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (Number.isFinite(exitCode)) lines.push(`exit ${exitCode}`);
|
||||
if (stdout) {
|
||||
const out = stdout.length > SHELL_OUTPUT_TRUNCATE ? stdout.slice(0, SHELL_OUTPUT_TRUNCATE) + "\n... (truncated)" : stdout;
|
||||
lines.push("<stdout>");
|
||||
lines.push(out);
|
||||
}
|
||||
if (stderr) {
|
||||
const err = stderr.length > SHELL_OUTPUT_TRUNCATE ? stderr.slice(0, SHELL_OUTPUT_TRUNCATE) + "\n... (truncated)" : stderr;
|
||||
lines.push("<stderr>");
|
||||
lines.push(err);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Return compact input for run log when tool is shell/shellToolCall (command only). */
|
||||
function compactShellToolInput(rawInput: unknown, payload?: Record<string, unknown>): unknown {
|
||||
const cmd = asString(payload?.command ?? asRecord(rawInput)?.command);
|
||||
if (cmd) return { command: cmd };
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
function parseUserMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
return text ? [{ kind: "user", ts, text }] : [];
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return [];
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) entries.push({ kind: "user", ts, text: directText });
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
if (type !== "output_text" && type !== "text") continue;
|
||||
const text = asString(part.text).trim();
|
||||
if (text) entries.push({ kind: "user", ts, text });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
return text ? [{ kind: "assistant", ts, text }] : [];
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return [];
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) {
|
||||
entries.push({ kind: "assistant", ts, text: directText });
|
||||
}
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
|
||||
if (type === "output_text" || type === "text") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) entries.push({ kind: "assistant", ts, text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) entries.push({ kind: "thinking", ts, text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const name = asString(part.name, asString(part.tool, "tool"));
|
||||
const rawInput = part.input ?? part.arguments ?? part.args ?? {};
|
||||
const input =
|
||||
name === "shellToolCall" || name === "shell"
|
||||
? compactShellToolInput(rawInput, asRecord(rawInput) ?? undefined)
|
||||
: rawInput;
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name,
|
||||
input,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result") {
|
||||
const toolUseId =
|
||||
asString(part.tool_use_id) ||
|
||||
asString(part.toolUseId) ||
|
||||
asString(part.call_id) ||
|
||||
asString(part.id) ||
|
||||
"tool_result";
|
||||
const rawOutput = part.output ?? part.result ?? part.text;
|
||||
const contentText =
|
||||
typeof rawOutput === "object" && rawOutput !== null
|
||||
? formatShellToolResultForLog(rawOutput)
|
||||
: asString(rawOutput) || stringifyUnknown(rawOutput);
|
||||
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||
entries.push({
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId,
|
||||
content: contentText,
|
||||
isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseCursorToolCallEvent(event: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||
const subtype = asString(event.subtype).trim().toLowerCase();
|
||||
const callId =
|
||||
asString(event.call_id) ||
|
||||
asString(event.callId) ||
|
||||
asString(event.id) ||
|
||||
"tool_call";
|
||||
const toolCall = asRecord(event.tool_call ?? event.toolCall);
|
||||
if (!toolCall) {
|
||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||
}
|
||||
|
||||
const [toolName] = Object.keys(toolCall);
|
||||
if (!toolName) {
|
||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||
}
|
||||
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||
const rawInput = payload.args ?? asRecord(payload.function)?.arguments ?? payload;
|
||||
const isShellTool = toolName === "shellToolCall" || toolName === "shell";
|
||||
const input = isShellTool ? compactShellToolInput(rawInput, payload) : rawInput;
|
||||
|
||||
if (subtype === "started" || subtype === "start") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
input,
|
||||
}];
|
||||
}
|
||||
|
||||
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||
const result =
|
||||
payload.result ??
|
||||
payload.output ??
|
||||
payload.error ??
|
||||
asRecord(payload.function)?.result ??
|
||||
asRecord(payload.function)?.output;
|
||||
const isError =
|
||||
event.is_error === true ||
|
||||
payload.is_error === true ||
|
||||
asString(payload.status).toLowerCase() === "error" ||
|
||||
asString(payload.status).toLowerCase() === "failed" ||
|
||||
asString(payload.status).toLowerCase() === "cancelled" ||
|
||||
payload.error !== undefined;
|
||||
const content =
|
||||
result !== undefined
|
||||
? isShellTool
|
||||
? formatShellToolResultForLog(result)
|
||||
: stringifyUnknown(result)
|
||||
: `${toolName} completed`;
|
||||
return [{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: callId,
|
||||
content,
|
||||
isError,
|
||||
}];
|
||||
}
|
||||
|
||||
return [{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}`,
|
||||
}];
|
||||
}
|
||||
|
||||
export function parseCursorStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const normalized = normalizeCursorStreamLine(line);
|
||||
if (!normalized.line) return [];
|
||||
|
||||
const parsed = asRecord(safeJsonParse(normalized.line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: normalized.line }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(parsed.subtype);
|
||||
if (subtype === "init") {
|
||||
const sessionId =
|
||||
asString(parsed.session_id) ||
|
||||
asString(parsed.sessionId) ||
|
||||
asString(parsed.sessionID);
|
||||
return [{ kind: "init", ts, model: asString(parsed.model, "cursor"), sessionId }];
|
||||
}
|
||||
return [{ kind: "system", ts, text: subtype ? `system: ${subtype}` : "system" }];
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const entries = parseAssistantMessage(parsed.message, ts);
|
||||
return entries.length > 0 ? entries : [{ kind: "assistant", ts, text: asString(parsed.result) }];
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
return parseUserMessage(parsed.message, ts);
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const textFromTopLevel = asString(parsed.text);
|
||||
const textFromDelta = asString(asRecord(parsed.delta)?.text);
|
||||
const text = textFromTopLevel.length > 0 ? textFromTopLevel : textFromDelta;
|
||||
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||
const isDelta = subtype === "delta" || asRecord(parsed.delta) !== null;
|
||||
if (!text.trim()) return [];
|
||||
return [{ kind: "thinking", ts, text: isDelta ? text : text.trim(), ...(isDelta ? { delta: true } : {}) }];
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
return parseCursorToolCallEvent(parsed, ts);
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const inputTokens = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
|
||||
const outputTokens = asNumber(usage?.output_tokens, asNumber(usage?.outputTokens));
|
||||
const cachedTokens = asNumber(
|
||||
usage?.cached_input_tokens,
|
||||
asNumber(usage?.cachedInputTokens, asNumber(usage?.cache_read_input_tokens)),
|
||||
);
|
||||
const subtype = asString(parsed.subtype, "result");
|
||||
const errors = Array.isArray(parsed.errors)
|
||||
? parsed.errors.map((value) => stringifyUnknown(value)).filter(Boolean)
|
||||
: [];
|
||||
const errorText = asString(parsed.error).trim();
|
||||
if (errorText) errors.push(errorText);
|
||||
const isError = parsed.is_error === true || subtype === "error" || subtype === "failed";
|
||||
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: asString(parsed.result),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
|
||||
subtype,
|
||||
isError,
|
||||
errors,
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = asString(parsed.message) || stringifyUnknown(parsed.error ?? parsed.detail) || normalized.line;
|
||||
return [{ kind: "stderr", ts, text: message }];
|
||||
}
|
||||
|
||||
// Compatibility with older stream-json event shapes.
|
||||
if (type === "step_start") {
|
||||
const sessionId = asString(parsed.sessionID);
|
||||
return [{ kind: "system", ts, text: `step started${sessionId ? ` (${sessionId})` : ""}` }];
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (!text) return [];
|
||||
return [{ kind: "assistant", ts, text }];
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
const part = asRecord(parsed.part);
|
||||
const toolUseId = asString(part?.callID, asString(part?.id, "tool_use"));
|
||||
const toolName = asString(part?.tool, "tool");
|
||||
const state = asRecord(part?.state);
|
||||
const input = state?.input ?? {};
|
||||
const output = asString(state?.output).trim();
|
||||
const status = asString(state?.status).trim();
|
||||
const exitCode = asNumber(asRecord(state?.metadata)?.exit, NaN);
|
||||
const isError =
|
||||
status === "failed" ||
|
||||
status === "error" ||
|
||||
status === "cancelled" ||
|
||||
(Number.isFinite(exitCode) && exitCode !== 0);
|
||||
|
||||
const entries: TranscriptEntry[] = [
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
input,
|
||||
},
|
||||
];
|
||||
|
||||
if (status || output) {
|
||||
const lines: string[] = [];
|
||||
if (status) lines.push(`status: ${status}`);
|
||||
if (Number.isFinite(exitCode)) lines.push(`exit: ${exitCode}`);
|
||||
if (output) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push(output);
|
||||
}
|
||||
entries.push({
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId,
|
||||
content: lines.join("\n").trim() || "tool completed",
|
||||
isError,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = asRecord(parsed.part);
|
||||
const tokens = asRecord(part?.tokens);
|
||||
const cache = asRecord(tokens?.cache);
|
||||
const reason = asString(part?.reason);
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: reason,
|
||||
inputTokens: asNumber(tokens?.input),
|
||||
outputTokens: asNumber(tokens?.output),
|
||||
cachedTokens: asNumber(cache?.read),
|
||||
costUsd: asNumber(part?.cost),
|
||||
subtype: reason || "step_finish",
|
||||
isError: reason === "error" || reason === "failed",
|
||||
errors: [],
|
||||
}];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: normalized.line }];
|
||||
}
|
||||
9
packages/adapters/cursor-local/tsconfig.json
Normal file
9
packages/adapters/cursor-local/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
72
packages/adapters/openclaw-gateway/README.md
Normal file
72
packages/adapters/openclaw-gateway/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# OpenClaw Gateway Adapter
|
||||
|
||||
This document describes how `@paperclipai/adapter-openclaw-gateway` invokes OpenClaw over the Gateway protocol.
|
||||
|
||||
## Transport
|
||||
|
||||
This adapter always uses WebSocket gateway transport.
|
||||
|
||||
- URL must be `ws://` or `wss://`
|
||||
- Connect flow follows gateway protocol:
|
||||
1. receive `connect.challenge`
|
||||
2. send `req connect` (protocol/client/auth/device payload)
|
||||
3. send `req agent`
|
||||
4. wait for completion via `req agent.wait`
|
||||
5. stream `event agent` frames into Paperclip logs/transcript parsing
|
||||
|
||||
## Auth Modes
|
||||
|
||||
Gateway credentials can be provided in any of these ways:
|
||||
|
||||
- `authToken` / `token` in adapter config
|
||||
- `headers.x-openclaw-token`
|
||||
- `headers.x-openclaw-auth` (legacy)
|
||||
- `password` (shared password mode)
|
||||
|
||||
When a token is present and `authorization` header is missing, the adapter derives `Authorization: Bearer <token>`.
|
||||
|
||||
## Device Auth
|
||||
|
||||
By default the adapter sends a signed `device` payload in `connect` params.
|
||||
|
||||
- set `disableDeviceAuth=true` to omit device signing
|
||||
- set `devicePrivateKeyPem` to pin a stable signing key
|
||||
- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run
|
||||
- when `autoPairOnFirstConnect` is enabled (default), the adapter handles one initial `pairing required` by calling `device.pair.list` + `device.pair.approve` over shared auth, then retries once.
|
||||
|
||||
## Session Strategy
|
||||
|
||||
The adapter supports the same session routing model as HTTP OpenClaw mode:
|
||||
|
||||
- `sessionKeyStrategy=issue|fixed|run`
|
||||
- `sessionKey` is used when strategy is `fixed`
|
||||
|
||||
Resolved session key is sent as `agent.sessionKey`.
|
||||
|
||||
## Payload Mapping
|
||||
|
||||
The agent request is built as:
|
||||
|
||||
- required fields:
|
||||
- `message` (wake text plus optional `payloadTemplate.message`/`payloadTemplate.text` prefix)
|
||||
- `idempotencyKey` (Paperclip `runId`)
|
||||
- `sessionKey` (resolved strategy)
|
||||
- optional additions:
|
||||
- all `payloadTemplate` fields merged in
|
||||
- `agentId` from config if set and not already in template
|
||||
|
||||
## Timeouts
|
||||
|
||||
- `timeoutSec` controls adapter-level request budget
|
||||
- `waitTimeoutMs` controls `agent.wait.timeoutMs`
|
||||
|
||||
If `agent.wait` returns `timeout`, adapter returns `openclaw_gateway_wait_timeout`.
|
||||
|
||||
## Log Format
|
||||
|
||||
Structured gateway event logs use:
|
||||
|
||||
- `[openclaw-gateway] ...` for lifecycle/system logs
|
||||
- `[openclaw-gateway:event] run=<id> stream=<stream> data=<json>` for `event agent` frames
|
||||
|
||||
UI/CLI parsers consume these lines to render transcript updates.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user