Compare commits

...

54 Commits

Author SHA1 Message Date
Pawla Abdul 82e40fb370 Use fixed .tmp suffix for writeSettings temp file
The mutex already serializes all writes, so the PID-based temp name
is unnecessary. A fixed suffix matches writeStore and avoids orphaned
temp files after a crash (a restarted process picks a new PID).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 00:45:34 +00:00
Pawla Abdul 06d692e87d test(server): add startServer PAPERCLIP_API_URL guard tests
Add integration tests that directly exercise the PAPERCLIP_API_URL
conditional guard in startServer():
- Externally set PAPERCLIP_API_URL is preserved (not overwritten)
- Unset PAPERCLIP_API_URL falls back to host-based URL

Addresses Greptile review comment on PR #3472.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 00:45:34 +00:00
Chris Farhood cb15a87f64 fix(server): remove unreachable ?? fallback after conditional guard 2026-04-13 00:45:34 +00:00
Pawla Abdul 6094081a71 Revert "feat(skills): add dryRun flag for scan prune path"
This reverts commit 13f0fee7d86334291f6faa2794ba67e9c7e90f35.
2026-04-13 00:45:34 +00:00
Pawla Abdul e739a2d130 feat(skills): add dryRun flag for scan prune path
Add a `dryRun` option to the scan-projects endpoint. When true, the
scan identifies which skills would be pruned and which agents would be
affected, but does not delete anything or modify agent configs.

The response now includes:
- `pruned[]`: list of skills that would be (or were) removed, with
  affected agent names
- `dryRun`: boolean echoed back so callers can distinguish preview
  results from live mutations

This lets callers preview destructive prune operations before committing
to them, addressing the review concern about silent deletion of
production data.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 00:45:34 +00:00
Pawla Abdul 3610559cc7 fix(skills): emit warning for all pruned skills, not just agent-attached ones
Previously, skills pruned during re-scan only emitted a warning when
they were attached to agents. Skills with no agent references were
deleted silently. Now every pruned skill emits a warning in the scan
result so the deletion is always visible to the caller.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 00:45:34 +00:00
Pawla Abdul 157729ea95 test(skills): add service-level tests for scan prune path
Cover the pruning logic in scanProjectWorkspaces with integration tests
using embedded Postgres and mocked GitHub HTTP layer:

- skill removed from source → detached from agents → deleted from DB
- skill removed with no agent references → deleted without warnings
- source fetch failure → no pruning, warning emitted

Addresses review feedback on PR #3351 requesting test coverage for the
destructive prune/delete behavior.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-13 00:45:34 +00:00
Chris Farhood 9615c90c4f fix(skills): use agents.update instead of agents.updateAgent
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:45:34 +00:00
Chris Farhood 2e522c9b37 fix(skills): auto-detach agents when pruned skill is in use during repo sync
During a repo sync (scan button), if a skill was removed from the source
and is still assigned to agents, the skill is now automatically detached
from those agents before deletion, rather than leaving it attached.
Manual delete-by-source is unchanged (still blocks if in use).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:45:34 +00:00
Chris Farhood ba44667ae3 fix(skills): handle prune type mismatch by using warnings for agent-attached skills
GitHub/sks_sh pruned skills don't have project/workspace context needed for
the CompanySkillProjectScanConflict/Skipped types. Orphaned skills are
silently deleted; skills still used by agents emit a warning instead of
a conflict entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:45:34 +00:00
Chris Farhood 9f15436e05 test(skills): add scan-projects route test for skipped/conflicts return
Verifies the scan endpoint returns skipped and conflicts arrays in the
response, covering the prune path where removed skills are either
skipped (orphaned) or added to conflicts (still used by agents).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:45:34 +00:00
Chris Farhood 3144df70d6 fix(skills): prune skills removed from GitHub/sks_sh sources on re-scan
When re-scanning existing sources, diff skills in the DB against the
current source manifest. Skills no longer in the source are either:
- Added to conflicts (if still used by agents)
- Deleted via deleteSkill (if orphaned), added to skipped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:45:34 +00:00
Chris Farhood 268806ff1c test(skills): add route test for scan-projects endpoint
Verifies that agents with canCreateAgents permission can call the
scan-projects endpoint and that scanProjectWorkspaces is invoked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:45:34 +00:00
Chris Farhood a77fc31dc1 fix(skills): simplify source locator collection; remove redundant key derivation
- Use Set<string> instead of Map for tracking unique source locators
- Remove dead skillsAtSource destructuring from loop
- Remove redundant deriveCanonicalSkillKey call (already set by readUrlSkillImports)
- Invert slug check to continue guard for cleaner flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:45:33 +00:00
Chris Farhood aed1a886a9 Address Greptile review: trim version, remove redundant fs/promises import, use fixed .tmp suffix 2026-04-12 14:22:05 -04:00
Pawla Abdul ddf36667f9 Auto-reinstall adapter packages missing from disk on reload and boot
Adapters installed with the old --no-save flag are not tracked in
package.json and get pruned when another adapter is installed. This
adds ensurePackageOnDisk() which detects missing packages and
reinstalls them from npm before reload or server boot attempts to
import them, fixing ENOENT errors for previously-pruned adapters.

Fixes FAR-47

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 16:38:19 +00:00
Pawla Abdul e2af316b3e Fix adapter install pruning other plugins by removing --no-save
npm install --no-save does not record dependencies in package.json,
so when a second adapter is installed, npm prunes the first as
extraneous. Removing --no-save ensures all installed adapters are
tracked in the managed package.json and persist across installs.

Fixes FAR-47

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 16:16:44 +00:00
Pawla Abdul 92afa0fb67 Fix adapter plugin conflicts caused by concurrent install/reload/delete operations
Add an async mutex (promise-chain FIFO queue) to serialise all adapter
mutation routes (install, reinstall, reload, delete) so that concurrent
requests cannot race on npm, the adapter-plugins.json store, or the
in-memory adapter registry.

Also switch adapter-plugin-store file writes to atomic write-tmp-then-rename
to prevent partial/corrupted reads from concurrent processes.

Includes the packageName.trim() fix for whitespace-induced npm failures.

9 new tests covering mutex serialisation, error recovery, FIFO ordering,
and atomic store operations.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 16:03:48 +00:00
Chris Farhood 9c7a17e16c Reapply "fix(server): respect externally set PAPERCLIP_API_URL env var"
This reverts commit 119bf6336e.
2026-04-12 09:07:31 -04:00
Chris Farhood 119bf6336e Revert "fix(server): respect externally set PAPERCLIP_API_URL env var"
This reverts commit 6b30178766.
2026-04-12 09:03:29 -04:00
Chris Farhood 6b30178766 fix(server): respect externally set PAPERCLIP_API_URL env var
Previously the server unconditionally overwrote PAPERCLIP_API_URL on
startup, clobbering any value set externally (e.g., via Kubernetes
ConfigMap). Now only sets the default if the env var is not already set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 09:02:42 -04:00
Chris Farhood 8b64cf10ef feat(docker): add hermes-paperclip-adapter to production stage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:24:15 -04:00
Chris Farhood f44ae212f6 Merge branch 'skill-scan-refresh' into master
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 07:34:31 -04:00
Chris Farhood a1343ea4f5 fix(skills): use acceptedSkills instead of listFull; cross-source slug check
- Replace redundant listFull() call with acceptedSkills to avoid extra DB round-trip
- Check slug conflicts against full acceptedSkills list instead of just same-source skills
- Call upsertAcceptedSkill after persisting to keep in-memory list current

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 07:34:26 -04:00
Chris Farhood e454277d10 feat(skills): scan button re-scans existing GitHub/sks_sh sources for new skills
When "Scan project workspaces for skills" runs, now also iterates all
existing GitHub/sks_sh skills and re-fetches their source repos to
detect newly added skills. New skills are upserted automatically.
Skips sources that fail, logged as warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 07:34:26 -04:00
Chris Farhood 4c413429be fix(docker): add passwd package for usermod/groupmod 2026-04-10 21:47:22 -04:00
Chris Farhood 2d05002d73 Merge skill-pat-feature into master 2026-04-10 21:27:02 -04:00
Chris Farhood 772130a573 feat(docker): add jq to production stage alongside other tooling 2026-04-10 21:22:38 -04:00
Chris Farhood edc77da082 fix(skills): delete secret row when PAT is cleared via updateSkillAuth
When updateSkillAuth(null) is called, the underlying secret row was
left orphaned. Now deletes the secret via secretsSvc.remove() before
clearing sourceAuthSecretId from metadata.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 17:34:01 -04:00
Chris Farhood ec4e94a6e7 fix: add companyId filter to metadata update + export CompanySkillUpdateAuth type
- Scope metadata update WHERE clause to companyId for defence-in-depth
- Add CompanySkillUpdateAuth inferred type export to match other schemas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 17:34:01 -04:00
Chris Farhood 0b224f0864 fix(ui): remove dead delete API method and add confirmation for delete-by-source
- Remove duplicate `delete` method (identical to `remove`)
- Route delete-by-source through confirmation dialog with source
  locator displayed and "Remove all from source" button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 17:34:01 -04:00
Chris Farhood 54c5e7cb41 fix(skills): atomic deleteBySource + PAT secret cleanup on skill deletion
- Pre-check all skills for agent usage before deleting any in deleteBySource
  to prevent partial/failed deletions
- Delete (rotate to empty) the skill-pat:<skillId> secret when a skill is
  deleted to prevent orphaned PAT secrets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 17:34:01 -04:00
Chris Farhood 022a0df61a feat(skills): GitHub PAT support for private skill repos + delete by source
- Add optional authToken to skill import for GitHub private repos
- Store PAT as encrypted company secret (skill-pat:{skillId})
- Thread auth token through ghFetch, fetchText, fetchJson, and all GitHub resolution functions
- Add PATCH /companies/:companyId/skills/:skillId/auth for managing PAT per skill
- Add DELETE /companies/:companyId/skills/by-source for bulk deleting skills from a repo
- Preserve sourceAuthSecretId across skill re-imports/updates
- UI: Add PAT input field in import form for GitHub URLs
- UI: Add SkillAuthSection with ShieldCheck icon for viewing/updating/removing PAT
- UI: Add trash icon next to source label for delete-by-source

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 17:34:01 -04:00
Chris Farhood b672ebb540 fix(skills): delete secret row when PAT is cleared via updateSkillAuth
When updateSkillAuth(null) is called, the underlying secret row was
left orphaned. Now deletes the secret via secretsSvc.remove() before
clearing sourceAuthSecretId from metadata.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 17:31:36 -04:00
Chris Farhood 4640417166 feat(docker): add kubectl, kubeseal, uv, nano, vim to production stage
Install custom tooling in the production stage via direct binaries and apt
so it doesn't break the base stage build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 17:37:58 -04:00
Chris Farhood ae0b344915 fix(docker): install gh via direct binary to fix keyring checksum issue 2026-04-09 17:31:29 -04:00
Chris Farhood 26155c2b90 chore(docker): revert to upstream Dockerfile
The fork added build-time tooling (kubectl, kubeseal, uv, nano, vim) that
is not needed inside the container build and was causing repeated build
failures due to URL/checksum drift. These tools belong in the runtime
environment, not the image build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 17:27:14 -04:00
Chris Farhood 99c3289d8e fix(docker): pin kubectl and kubeseal versions, use correct kubeseal URL
- kubectl: pin to v1.32.0 instead of dynamic stable.txt (which was
  returning a version with no matching binary, causing 404)
- kubeseal: fix URL to use versioned tarball (v0.36.6) instead of
  /latest which had no unversioned asset, causing 404
- also removed wget (no longer needed after removing keyring/apt)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 17:25:57 -04:00
Chris Farhood 8dff385086 fix(docker): pin kubectl and kubeseal versions, use correct kubeseal URL
- kubectl: pin to v1.32.0 instead of dynamic stable.txt (which was
  returning a version with no matching binary, causing 404)
- kubeseal: fix URL to use versioned tarball (v0.36.6) instead of
  /latest which had no unversioned asset, causing 404
- also removed wget (no longer needed after removing keyring/apt)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 17:22:00 -04:00
Chris Farhood 002c470ee7 fix(docker): install gh via direct binary instead of keyring/apt
The GitHub CLI keyring approach requires a hardcoded SHA256 checksum
that drifts as the keyring file is updated upstream, causing build
failures. Replace with direct binary tarball download which is simpler
and has no checksum drift issue.

Also removed wget (only needed for keyring download).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 17:17:55 -04:00
Chris Farhood 21411b80b2 fix(docker): install gh via direct binary instead of keyring/apt
The GitHub CLI keyring approach requires a hardcoded SHA256 checksum
that drifts as the keyring file is updated upstream, causing build
failures. Replace with direct binary tarball download which is simpler
and has no checksum drift issue.

Also removed wget (only needed for keyring download).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 17:14:51 -04:00
Chris Farhood 7d55b8d9d0 fix(docker): update GitHub CLI keyring SHA256 checksum
The hardcoded checksum was out of date, causing sha256sum verification
to fail and abort the build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 17:11:02 -04:00
Chris Farhood b8133d6a35 fix(docker): add wget to apt-get install
wget is called immediately after apt-get install but was not included
in the package list, causing the build to fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 17:07:25 -04:00
Chris Farhood 9175a8ee85 Merge branch 'skill-pat-feature' 2026-04-09 16:21:43 -04:00
Chris Farhood 1956ccd7b5 fix: add companyId filter to metadata update + export CompanySkillUpdateAuth type
- Scope metadata update WHERE clause to companyId for defence-in-depth
- Add CompanySkillUpdateAuth inferred type export to match other schemas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 16:03:33 -04:00
Chris Farhood e3c172a06f fix(ui): remove dead delete API method and add confirmation for delete-by-source
- Remove duplicate `delete` method (identical to `remove`)
- Route delete-by-source through confirmation dialog with source
  locator displayed and "Remove all from source" button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 16:02:05 -04:00
Chris Farhood 89909db27c fix(skills): atomic deleteBySource + PAT secret cleanup on skill deletion
- Pre-check all skills for agent usage before deleting any in deleteBySource
  to prevent partial/failed deletions
- Delete (rotate to empty) the skill-pat:<skillId> secret when a skill is
  deleted to prevent orphaned PAT secrets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 15:58:30 -04:00
Chris Farhood d9341795b0 feat(skills): GitHub PAT support for private skill repos + delete by source
- Add optional authToken to skill import for GitHub private repos
- Store PAT as encrypted company secret (skill-pat:{skillId})
- Thread auth token through ghFetch, fetchText, fetchJson, and all GitHub resolution functions
- Add PATCH /companies/:companyId/skills/:skillId/auth for managing PAT per skill
- Add DELETE /companies/:companyId/skills/by-source for bulk deleting skills from a repo
- Preserve sourceAuthSecretId across skill re-imports/updates
- UI: Add PAT input field in import form for GitHub URLs
- UI: Add SkillAuthSection with ShieldCheck icon for viewing/updating/removing PAT
- UI: Add trash icon next to source label for delete-by-source

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 15:53:45 -04:00
Chris Farhood ef7e6be8bb Restore e2e and release-smoke workflows 2026-04-09 15:13:13 -04:00
Flea Flicker 3674cef645 fix(ci): update org references from cpfarhood to farhoodliquor
Update runner name and GHCR image path in build workflow to reflect
the repo transfer from cpfarhood/paperclip to farhoodliquor/paperclip.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 15:10:36 -04:00
Chris Farhood 296d051bd5 chore(docker): pre-install @ai-sdk/anthropic in opencode config dir
Required by the custom minimax provider in opencode.json which uses
@ai-sdk/anthropic to hit minimax's Anthropic-compatible API endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:10:34 -04:00
Chris Farhood 45892739a5 chore(docker): add vim and nano to base image
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:10:28 -04:00
Chris Farhood 857e9e4f01 Remove upstream workflows not relevant to our fork
Keep only build.yml (Docker build + push to GHCR).
Removed: docker.yml, e2e.yml, pr.yml, refresh-lockfile.yml,
release.yml, release-smoke.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:10:23 -04:00
Chris Farhood fa03b5944e Add our tooling to Dockerfile, restore build workflow
- Expand base apt: jq, procps, python3, python3-pip, gh
- Install kubectl, uv/uvx, kubeseal binaries
- Add @google/gemini-cli to production agent installs
- Use pnpm-lock.yaml* wildcard + --no-frozen-lockfile (lockfile policy)
- Restore build.yml targeting runners-cpfarhood

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 15:10:21 -04:00
19 changed files with 1394 additions and 549 deletions
@@ -1,55 +1,53 @@
name: Docker
name: Build & Push
on:
push:
branches:
- "master"
tags:
- "v*"
branches: [master]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
build:
runs-on: runners-farhoodliquor
timeout-minutes: 30
concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
images: ghcr.io/farhoodliquor/paperclip
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest
type=sha,prefix=
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
-261
View File
@@ -1,261 +0,0 @@
name: Release
on:
push:
branches:
- master
workflow_dispatch:
inputs:
source_ref:
description: Commit SHA, branch, or tag to publish as stable
required: true
type: string
default: master
stable_date:
description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable.
required: false
type: string
dry_run:
description: Preview the stable release without publishing
required: true
type: boolean
default: false
concurrency:
group: release-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: false
jobs:
verify_canary:
if: github.event_name == 'push'
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 --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
publish_canary:
if: github.event_name == 'push'
needs: verify_canary
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-canary
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 --no-frozen-lockfile
- name: Restore tracked install-time changes
run: git checkout -- pnpm-lock.yaml
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Publish canary
env:
GITHUB_ACTIONS: "true"
run: ./scripts/release.sh canary --skip-verify
- name: Push canary tag
run: |
tag="$(git tag --points-at HEAD | grep '^canary/v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no canary tag points at HEAD after release." >&2
exit 1
fi
git push origin "refs/tags/${tag}"
verify_stable:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
preview_stable:
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Dry-run stable release
env:
GITHUB_ACTIONS: "true"
run: |
args=(stable --skip-verify --dry-run)
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
./scripts/release.sh "${args[@]}"
publish_stable:
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-stable
permissions:
contents: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Restore tracked install-time changes
run: git checkout -- pnpm-lock.yaml
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Publish stable
env:
GITHUB_ACTIONS: "true"
run: |
args=(stable --skip-verify)
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
./scripts/release.sh "${args[@]}"
- name: Push stable tag
run: |
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no stable tag points at HEAD after release." >&2
exit 1
fi
git push origin "refs/tags/${tag}"
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
PUBLISH_REMOTE: origin
run: |
version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')"
if [ -z "$version" ]; then
echo "Error: no v* tag points at HEAD after stable release." >&2
exit 1
fi
./scripts/create-github-release.sh "$version"
+16 -10
View File
@@ -2,16 +2,11 @@ FROM node:lts-trixie-slim AS base
ARG USER_UID=1000
ARG USER_GID=1000
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 \
&& mkdir -p -m 755 /etc/apt/keyrings \
&& wget -nv -O/etc/apt/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& echo "20e0125d6f6e077a9ad46f03371bc26d90b04939fb95170f5a1905099cc6bcc0 /etc/apt/keyrings/githubcli-archive-keyring.gpg" | sha256sum -c - \
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& mkdir -p -m 755 /etc/apt/sources.list.d \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends gh \
&& apt-get install -y --no-install-recommends ca-certificates gosu curl git wget ripgrep python3 passwd \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://github.com/cli/cli/releases/download/v2.89.0/gh_2.89.0_linux_amd64.tar.gz | tar -xzf - -C /tmp \
&& mv /tmp/gh_2.89.0_linux_amd64/bin/gh /usr/local/bin/ \
&& rm -rf /tmp/gh_* \
&& corepack enable
# Modify the existing node user/group to have the specified UID/GID to match host user
@@ -55,7 +50,18 @@ ARG USER_UID=1000
ARG USER_GID=1000
WORKDIR /app
COPY --chown=node:node --from=build /app /app
RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai \
RUN apt-get update \
&& apt-get install -y --no-install-recommends jq nano vim \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://dl.k8s.io/release/v1.32.0/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \
&& chmod +x /usr/local/bin/kubectl \
&& curl -fsSL https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.36.6/kubeseal-0.36.6-linux-amd64.tar.gz | tar -xzf - -C /tmp \
&& mv /tmp/kubeseal /usr/local/bin/kubeseal \
&& rm -rf /tmp/kubeseal /tmp/LICENSE /tmp/README.md \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& mv /root/.local/bin/uv /usr/local/bin/uv \
&& mv /root/.local/bin/uvx /usr/local/bin/uvx \
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai hermes-paperclip-adapter \
&& mkdir -p /paperclip \
&& chown node:node /paperclip
+1
View File
@@ -557,6 +557,7 @@ export {
companySkillDetailSchema,
companySkillUpdateStatusSchema,
companySkillImportSchema,
companySkillUpdateAuthSchema,
companySkillProjectScanRequestSchema,
companySkillProjectScanSkippedSchema,
companySkillProjectScanConflictSchema,
@@ -66,6 +66,11 @@ export const companySkillUpdateStatusSchema = z.object({
export const companySkillImportSchema = z.object({
source: z.string().min(1),
authToken: z.string().min(1).optional(),
});
export const companySkillUpdateAuthSchema = z.object({
authToken: z.string().min(1).nullable(),
});
export const companySkillProjectScanRequestSchema = z.object({
@@ -133,3 +138,4 @@ export type CompanySkillImport = z.infer<typeof companySkillImportSchema>;
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
export type CompanySkillUpdateAuth = z.infer<typeof companySkillUpdateAuthSchema>;
+2
View File
@@ -44,6 +44,7 @@ export {
companySkillDetailSchema,
companySkillUpdateStatusSchema,
companySkillImportSchema,
companySkillUpdateAuthSchema,
companySkillProjectScanRequestSchema,
companySkillProjectScanSkippedSchema,
companySkillProjectScanConflictSchema,
@@ -55,6 +56,7 @@ export {
type CompanySkillProjectScan,
type CompanySkillCreate,
type CompanySkillFileUpdate,
type CompanySkillUpdateAuth,
} from "./company-skill.js";
export {
agentSkillStateSchema,
@@ -0,0 +1,192 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("hermes-paperclip-adapter/server", () => ({
execute: vi.fn(),
testEnvironment: vi.fn(),
sessionCodec: vi.fn(),
listSkills: vi.fn(async () => []),
syncSkills: vi.fn(async () => []),
detectModel: vi.fn(),
}));
vi.mock("hermes-paperclip-adapter", () => ({
agentConfigurationDoc: vi.fn(),
models: [],
}));
import { withAdapterMutex } from "../routes/adapters.js";
// ---------------------------------------------------------------------------
// withAdapterMutex — serialisation tests
// ---------------------------------------------------------------------------
describe("withAdapterMutex", () => {
it("serialises concurrent calls so they do not overlap", async () => {
const log: string[] = [];
const taskA = withAdapterMutex(async () => {
log.push("A-start");
await new Promise((r) => setTimeout(r, 50));
log.push("A-end");
return "a";
});
const taskB = withAdapterMutex(async () => {
log.push("B-start");
await new Promise((r) => setTimeout(r, 10));
log.push("B-end");
return "b";
});
const [resultA, resultB] = await Promise.all([taskA, taskB]);
expect(resultA).toBe("a");
expect(resultB).toBe("b");
// A must fully complete before B starts (FIFO serialisation)
expect(log).toEqual(["A-start", "A-end", "B-start", "B-end"]);
});
it("a failed task does not block subsequent tasks", async () => {
const failing = withAdapterMutex(async () => {
throw new Error("boom");
});
await expect(failing).rejects.toThrow("boom");
const ok = await withAdapterMutex(async () => "ok");
expect(ok).toBe("ok");
});
it("preserves FIFO order for many concurrent callers", async () => {
const order: number[] = [];
const tasks = Array.from({ length: 5 }, (_, i) =>
withAdapterMutex(async () => {
order.push(i);
}),
);
await Promise.all(tasks);
expect(order).toEqual([0, 1, 2, 3, 4]);
});
it("returns the value from the inner function", async () => {
const result = await withAdapterMutex(async () => ({ answer: 42 }));
expect(result).toEqual({ answer: 42 });
});
it("propagates errors from the inner function", async () => {
await expect(
withAdapterMutex(async () => {
throw new TypeError("bad input");
}),
).rejects.toThrow("bad input");
});
});
// ---------------------------------------------------------------------------
// adapter-plugin-store — atomic write tests
// ---------------------------------------------------------------------------
describe("adapter-plugin-store atomic writes", () => {
// We test the store in an isolated temp directory by stubbing HOME
const originalHome = process.env.HOME;
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "adapter-store-test-"));
process.env.HOME = tmpDir;
// Force the store module to pick up the new HOME by resetting its cache.
// We re-import it fresh for each test.
});
afterEach(() => {
process.env.HOME = originalHome;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("writeStore creates the file atomically via rename", async () => {
// Dynamically import the store so it uses the stubbed HOME
vi.resetModules();
const store = await import("../services/adapter-plugin-store.js");
const record = {
packageName: "test-adapter",
type: "test_type",
installedAt: new Date().toISOString(),
};
store.addAdapterPlugin(record);
// The store file should exist and contain the record
const storePath = path.join(tmpDir, ".paperclip", "adapter-plugins.json");
expect(fs.existsSync(storePath)).toBe(true);
const contents = JSON.parse(fs.readFileSync(storePath, "utf-8"));
expect(contents).toHaveLength(1);
expect(contents[0].packageName).toBe("test-adapter");
// No lingering temp files
const dir = path.dirname(storePath);
const tmpFiles = fs.readdirSync(dir).filter((f) => f.endsWith(".tmp"));
expect(tmpFiles).toHaveLength(0);
});
it("addAdapterPlugin is idempotent for the same type", async () => {
vi.resetModules();
const store = await import("../services/adapter-plugin-store.js");
const record1 = {
packageName: "pkg-a",
type: "my_adapter",
version: "1.0.0",
installedAt: new Date().toISOString(),
};
const record2 = {
packageName: "pkg-a",
type: "my_adapter",
version: "2.0.0",
installedAt: new Date().toISOString(),
};
store.addAdapterPlugin(record1);
store.addAdapterPlugin(record2);
const plugins = store.listAdapterPlugins();
expect(plugins).toHaveLength(1);
expect(plugins[0].version).toBe("2.0.0");
});
it("removeAdapterPlugin removes the correct record", async () => {
vi.resetModules();
const store = await import("../services/adapter-plugin-store.js");
store.addAdapterPlugin({
packageName: "a",
type: "type_a",
installedAt: new Date().toISOString(),
});
store.addAdapterPlugin({
packageName: "b",
type: "type_b",
installedAt: new Date().toISOString(),
});
expect(store.listAdapterPlugins()).toHaveLength(2);
const removed = store.removeAdapterPlugin("type_a");
expect(removed).toBe(true);
expect(store.listAdapterPlugins()).toHaveLength(1);
expect(store.listAdapterPlugins()[0].type).toBe("type_b");
});
it("removeAdapterPlugin returns false for unknown type", async () => {
vi.resetModules();
const store = await import("../services/adapter-plugin-store.js");
expect(store.removeAdapterPlugin("nonexistent")).toBe(false);
});
});
@@ -0,0 +1,285 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
agents,
companies,
companySkills,
createDb,
projects,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
// Mock the adapter layer so usage() never tries to call real adapters
vi.mock("../adapters/index.js", () => ({
findActiveServerAdapter: () => null,
}));
// Mock ghFetch so readUrlSkillImports never makes real HTTP calls.
// gitHubApiBase and resolveRawGitHubUrl are pure functions — pass them through.
const mockGhFetch = vi.fn();
vi.mock("../services/github-fetch.js", async (importOriginal) => {
const original = await importOriginal<typeof import("../services/github-fetch.js")>();
return {
...original,
ghFetch: (...args: unknown[]) => mockGhFetch(...args),
};
});
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping company-skills prune tests: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("scanProjectWorkspaces prune path", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const companyId = randomUUID();
const agentId = randomUUID();
const skillKeepId = randomUUID();
const skillPruneId = randomUUID();
const projectId = randomUUID();
const sourceLocator = "https://github.com/test-org/test-skills";
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-skills-prune-");
db = createDb(tempDb.connectionString);
}, 20_000);
beforeEach(async () => {
// Seed company
await db.insert(companies).values({
id: companyId,
name: "Test Co",
issuePrefix: `T${Date.now().toString(36).slice(-4).toUpperCase()}`,
});
// Seed project (no workspaces — local scan phase will find nothing)
await db.insert(projects).values({
id: projectId,
companyId,
name: "Test Project",
});
// Seed agent with both skills in desired config
await db.insert(agents).values({
id: agentId,
companyId,
name: "Builder",
adapterType: "claude_local",
adapterConfig: {
paperclipSkillSync: {
desiredSkills: [
`test-org/test-skills/keep-skill`,
`test-org/test-skills/prune-skill`,
],
},
},
});
// Seed two GitHub-sourced skills from the same repo
await db.insert(companySkills).values([
{
id: skillKeepId,
companyId,
key: "test-org/test-skills/keep-skill",
slug: "keep-skill",
name: "Keep Skill",
markdown: "# Keep Skill",
sourceType: "github",
sourceLocator,
sourceRef: "abc123",
metadata: { sourceKind: "github", owner: "test-org", repo: "test-skills", ref: "abc123", trackingRef: "main" },
},
{
id: skillPruneId,
companyId,
key: "test-org/test-skills/prune-skill",
slug: "prune-skill",
name: "Prune Skill",
markdown: "# Prune Skill",
sourceType: "github",
sourceLocator,
sourceRef: "abc123",
metadata: { sourceKind: "github", owner: "test-org", repo: "test-skills", ref: "abc123", trackingRef: "main" },
},
]);
});
afterEach(async () => {
mockGhFetch.mockReset();
await db.delete(companySkills).where(eq(companySkills.companyId, companyId));
await db.delete(projects).where(eq(projects.companyId, companyId));
await db.delete(agents).where(eq(agents.companyId, companyId));
await db.delete(companies).where(eq(companies.id, companyId));
});
afterAll(async () => {
await tempDb?.cleanup();
});
/**
* Helper: configure mockGhFetch to simulate a GitHub repo that contains
* only the specified skill slugs (each with a SKILL.md in its directory).
*/
function stubGitHubSource(slugs: string[]) {
const sha = "deadbeef".repeat(5);
const tree = slugs.map((slug) => ({
path: `${slug}/SKILL.md`,
type: "blob",
}));
mockGhFetch.mockImplementation(async (url: string, init?: RequestInit) => {
const u = url;
// GET /repos/{owner}/{repo} → default branch
if (u.match(/\/repos\/test-org\/test-skills$/)) {
return new Response(JSON.stringify({ default_branch: "main" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
// GET /repos/{owner}/{repo}/commits/{ref} → pinned SHA
if (u.includes("/commits/")) {
return new Response(JSON.stringify({ sha }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
// GET /repos/{owner}/{repo}/git/trees/{sha}?recursive=1 → file tree
if (u.includes("/git/trees/")) {
return new Response(JSON.stringify({ tree }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
// GET raw content for SKILL.md files
if (u.includes("raw.githubusercontent.com") || u.includes("/raw/")) {
const match = u.match(/\/([^/]+)\/SKILL\.md$/);
const slug = match?.[1] ?? "unknown";
const markdown = [
"---",
`name: ${slug}`,
`slug: ${slug}`,
"---",
"",
`# ${slug}`,
"",
].join("\n");
return new Response(markdown, {
status: 200,
headers: { "content-type": "text/plain" },
});
}
// Default: 404
return new Response("Not Found", { status: 404 });
});
}
it("prunes a skill removed from the source, detaches it from agents, and emits a warning", async () => {
// GitHub returns only "keep-skill" — "prune-skill" is no longer in the repo
stubGitHubSource(["keep-skill"]);
const { companySkillService } = await import("../services/company-skills.js");
const svc = companySkillService(db);
const result = await svc.scanProjectWorkspaces(companyId, {});
// The pruned skill should be deleted from the database
const remaining = await db
.select()
.from(companySkills)
.where(eq(companySkills.companyId, companyId));
const remainingSlugs = remaining.map((row) => row.slug);
expect(remainingSlugs).toContain("keep-skill");
expect(remainingSlugs).not.toContain("prune-skill");
// The agent's desired skills should no longer include the pruned skill key
const [agentRow] = await db
.select()
.from(agents)
.where(eq(agents.id, agentId));
const config = agentRow!.adapterConfig as Record<string, unknown>;
const syncConfig = config.paperclipSkillSync as Record<string, unknown>;
const desiredSkills = syncConfig.desiredSkills as string[];
expect(desiredSkills).not.toContain("test-org/test-skills/prune-skill");
expect(desiredSkills).toContain("test-org/test-skills/keep-skill");
// A warning should be emitted about the detached skill
expect(result.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining("prune-skill"),
expect.stringContaining("Builder"),
]),
);
});
it("deletes a pruned skill even when no agents reference it", async () => {
// Remove the prune skill from the agent's desired skills before scanning
await db.update(agents).set({
adapterConfig: {
paperclipSkillSync: {
desiredSkills: ["test-org/test-skills/keep-skill"],
},
},
}).where(eq(agents.id, agentId));
stubGitHubSource(["keep-skill"]);
const { companySkillService } = await import("../services/company-skills.js");
const svc = companySkillService(db);
const result = await svc.scanProjectWorkspaces(companyId, {});
const remaining = await db
.select()
.from(companySkills)
.where(eq(companySkills.companyId, companyId));
const remainingSlugs = remaining.map((r) => r.slug);
expect(remainingSlugs).toContain("keep-skill");
expect(remainingSlugs).not.toContain("prune-skill");
// A deletion warning should still be emitted (no detachment, but deletion is confirmed)
const deleteWarnings = result.warnings.filter((w) => w.includes("prune-skill") && w.includes("deleted"));
expect(deleteWarnings).toHaveLength(1);
// No agent-detachment warning since no agents used it
const detachWarnings = result.warnings.filter((w) => w.includes("prune-skill") && w.includes("detached"));
expect(detachWarnings).toHaveLength(0);
});
it("skips pruning when the source fetch fails and emits a warning", async () => {
// Simulate GitHub being down
mockGhFetch.mockRejectedValue(new Error("network error"));
const { companySkillService } = await import("../services/company-skills.js");
const svc = companySkillService(db);
const result = await svc.scanProjectWorkspaces(companyId, {});
// Both seeded skills should still exist (bundled skills may also be present)
const remaining = await db
.select()
.from(companySkills)
.where(eq(companySkills.companyId, companyId));
const remainingSlugs = remaining.map((r) => r.slug);
expect(remainingSlugs).toContain("keep-skill");
expect(remainingSlugs).toContain("prune-skill");
// A warning should mention the failed source
expect(result.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining("Could not re-scan source"),
]),
);
});
});
@@ -14,6 +14,7 @@ const mockAccessService = vi.hoisted(() => ({
const mockCompanySkillService = vi.hoisted(() => ({
importFromSource: vi.fn(),
deleteSkill: vi.fn(),
scanProjectWorkspaces: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
@@ -69,6 +70,16 @@ describe("company skill mutation permissions", () => {
slug: "find-skills",
name: "Find Skills",
});
mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({
scannedProjects: 1,
scannedWorkspaces: 2,
discovered: [],
imported: [],
updated: [],
skipped: [],
conflicts: [],
warnings: [],
});
mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(false);
@@ -89,6 +100,7 @@ describe("company skill mutation permissions", () => {
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
"company-1",
"https://github.com/vercel-labs/agent-browser",
undefined,
);
});
@@ -246,6 +258,61 @@ describe("company skill mutation permissions", () => {
expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled();
});
it("allows agents with canCreateAgents to scan project workspaces", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
permissions: { canCreateAgents: true },
});
const res = await request(await createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
runId: "run-1",
}))
.post("/api/companies/company-1/skills/scan-projects")
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockCompanySkillService.scanProjectWorkspaces).toHaveBeenCalledWith("company-1", {});
});
it("returns warnings from scan when removed skills are still used by agents", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
permissions: { canCreateAgents: true },
});
mockCompanySkillService.scanProjectWorkspaces.mockResolvedValueOnce({
scannedProjects: 1,
scannedWorkspaces: 1,
discovered: [],
imported: [],
updated: [],
skipped: [],
conflicts: [],
warnings: [
'Skill "ghost-skill" was removed from https://github.com/vercel-labs/agent-browser and detached from Builder.',
],
});
const res = await request(await createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
runId: "run-1",
}))
.post("/api/companies/company-1/skills/scan-projects")
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
warnings: [expect.stringContaining("was removed from")],
});
});
it("allows agents with canCreateAgents to mutate company skills", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
@@ -266,6 +333,7 @@ describe("company skill mutation permissions", () => {
expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith(
"company-1",
"https://github.com/vercel-labs/agent-browser",
undefined,
);
});
@@ -171,3 +171,27 @@ describe("startServer feedback export wiring", () => {
});
});
});
describe("startServer PAPERCLIP_API_URL handling", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.BETTER_AUTH_SECRET = "test-secret";
delete process.env.PAPERCLIP_API_URL;
});
it("uses the externally set PAPERCLIP_API_URL when provided", async () => {
process.env.PAPERCLIP_API_URL = "http://custom-api:3100";
const started = await startServer();
expect(started.apiUrl).toBe("http://custom-api:3100");
expect(process.env.PAPERCLIP_API_URL).toBe("http://custom-api:3100");
});
it("falls back to host-based URL when PAPERCLIP_API_URL is not set", async () => {
const started = await startServer();
expect(started.apiUrl).toBe("http://127.0.0.1:3210");
expect(process.env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:3210");
});
});
+35
View File
@@ -9,11 +9,15 @@
* adapter-utils, never registry.ts.
*/
import { execFile } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import type { ServerAdapterModule } from "./types.js";
import { logger } from "../middleware/logger.js";
const execFileAsync = promisify(execFile);
import {
listAdapterPlugins,
getAdapterPluginsDir,
@@ -187,8 +191,37 @@ export async function loadExternalAdapterPackage(
return adapterModule;
}
/**
* Ensure an npm-sourced adapter package exists on disk.
* If the package directory is missing (e.g. pruned by a prior --no-save install),
* reinstall it from the registry so reload/boot can proceed.
*/
async function ensurePackageOnDisk(record: AdapterPluginRecord): Promise<void> {
if (record.localPath) return; // local-path adapters are managed externally
const packageDir = resolvePackageDir(record);
const pkgJsonPath = path.join(packageDir, "package.json");
if (fs.existsSync(pkgJsonPath)) return; // already on disk
const pluginsDir = getAdapterPluginsDir();
const spec = record.version
? `${record.packageName}@${record.version}`
: record.packageName;
logger.info(
{ packageName: record.packageName, type: record.type, spec },
"Adapter package missing from disk — reinstalling from npm",
);
await execFileAsync("npm", ["install", spec], {
cwd: pluginsDir,
timeout: 120_000,
});
}
async function loadFromRecord(record: AdapterPluginRecord): Promise<ServerAdapterModule | null> {
try {
await ensurePackageOnDisk(record);
return await loadExternalAdapterPackage(record.packageName, record.localPath);
} catch (err) {
logger.warn(
@@ -209,6 +242,8 @@ export async function reloadExternalAdapter(
const record = getAdapterPluginByType(type);
if (!record) return null;
await ensurePackageOnDisk(record);
const packageDir = resolvePackageDir(record);
const entryPoint = resolvePackageEntryPoint(packageDir);
const modulePath = path.resolve(packageDir, entryPoint);
+4 -2
View File
@@ -553,7 +553,9 @@ export async function startServer(): Promise<StartedServer> {
: runtimeListenHost;
process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost;
process.env.PAPERCLIP_LISTEN_PORT = String(listenPort);
process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`;
if (!process.env.PAPERCLIP_API_URL) {
process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`;
}
setupLiveEventsWebSocketServer(server, db as any, {
deploymentMode: config.deploymentMode,
@@ -765,7 +767,7 @@ export async function startServer(): Promise<StartedServer> {
server,
host: config.host,
listenPort,
apiUrl: process.env.PAPERCLIP_API_URL ?? `http://${runtimeApiHost}:${listenPort}`,
apiUrl: process.env.PAPERCLIP_API_URL!,
databaseUrl: activeDatabaseConnectionString,
};
}
+224 -197
View File
@@ -46,6 +46,26 @@ import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
const execFileAsync = promisify(execFile);
// ---------------------------------------------------------------------------
// Concurrency control — serialise all install / reinstall / reload / delete
// operations so that concurrent requests cannot race on npm, the plugin store
// JSON file, or the in-memory adapter registry.
// ---------------------------------------------------------------------------
let mutexQueue: Promise<void> = Promise.resolve();
/**
* Enqueue `fn` behind any in-flight adapter mutation. Only one mutation runs
* at a time; the rest wait in FIFO order.
*/
export function withAdapterMutex<T>(fn: () => Promise<T>): Promise<T> {
const ticket = mutexQueue.then(fn, fn); // always run `fn` after the queue settles
// Swallow rejections on the queue itself so a failed operation doesn't
// permanently block subsequent ones.
mutexQueue = ticket.then(() => {}, () => {});
return ticket;
}
// ---------------------------------------------------------------------------
// Request / Response types
// ---------------------------------------------------------------------------
@@ -211,8 +231,8 @@ export function adapterRoutes() {
// Strip version suffix if the UI sends "pkg@1.2.3" instead of separating it
// e.g. "@henkey/hermes-paperclip-adapter@0.3.0" → packageName + version
let canonicalName = packageName;
let explicitVersion = version;
let canonicalName = packageName.trim();
let explicitVersion = typeof version === "string" ? version.trim() : version;
const versionSuffix = packageName.match(/@(\d+\.\d+\.\d+.*)$/);
if (versionSuffix) {
// For scoped packages: "@scope/name@1.2.3" → "@scope/name" + "1.2.3"
@@ -224,103 +244,104 @@ export function adapterRoutes() {
}
}
try {
let installedVersion: string | undefined;
let moduleLocalPath: string | undefined;
await withAdapterMutex(async () => {
try {
let installedVersion: string | undefined;
let moduleLocalPath: string | undefined;
if (!isLocalPath) {
// npm install into the managed directory
const pluginsDir = getAdapterPluginsDir();
const spec = explicitVersion ? `${canonicalName}@${explicitVersion}` : canonicalName;
if (!isLocalPath) {
// npm install into the managed directory
const pluginsDir = getAdapterPluginsDir();
const spec = explicitVersion ? `${canonicalName}@${explicitVersion}` : canonicalName;
logger.info({ spec, pluginsDir }, "Installing adapter package via npm");
logger.info({ spec, pluginsDir }, "Installing adapter package via npm");
await execFileAsync("npm", ["install", "--no-save", spec], {
cwd: pluginsDir,
timeout: 120_000,
});
await execFileAsync("npm", ["install", spec], {
cwd: pluginsDir,
timeout: 120_000,
});
// Read installed version from package.json
try {
const pkgJsonPath = path.join(pluginsDir, "node_modules", canonicalName, "package.json");
const pkgContent = await import("node:fs/promises");
const pkgRaw = await pkgContent.readFile(pkgJsonPath, "utf-8");
const pkg = JSON.parse(pkgRaw);
const v = pkg.version;
installedVersion =
typeof v === "string" && v.trim().length > 0 ? v.trim() : explicitVersion;
} catch {
installedVersion = explicitVersion;
}
} else {
// Local path — normalize (e.g., Windows → WSL) and use the resolved path
moduleLocalPath = path.resolve(await normalizeLocalPath(packageName));
try {
const pkgRaw = await readFile(path.join(moduleLocalPath, "package.json"), "utf-8");
const v = JSON.parse(pkgRaw).version;
if (typeof v === "string" && v.trim().length > 0) {
installedVersion = v.trim();
// Read installed version from package.json
try {
const pkgJsonPath = path.join(pluginsDir, "node_modules", canonicalName, "package.json");
const pkgRaw = await readFile(pkgJsonPath, "utf-8");
const pkg = JSON.parse(pkgRaw);
const v = pkg.version;
installedVersion =
typeof v === "string" && v.trim().length > 0 ? v.trim() : explicitVersion;
} catch {
installedVersion = explicitVersion;
}
} catch {
// leave installedVersion undefined if package.json is missing
} else {
// Local path — normalize (e.g., Windows → WSL) and use the resolved path
moduleLocalPath = path.resolve(await normalizeLocalPath(packageName));
try {
const pkgRaw = await readFile(path.join(moduleLocalPath, "package.json"), "utf-8");
const v = JSON.parse(pkgRaw).version;
if (typeof v === "string" && v.trim().length > 0) {
installedVersion = v.trim();
}
} catch {
// leave installedVersion undefined if package.json is missing
}
}
// Load and register the adapter (use canonicalName for path resolution)
const adapterModule = await loadExternalAdapterPackage(canonicalName, moduleLocalPath);
// Check if this type conflicts with a built-in adapter
if (BUILTIN_ADAPTER_TYPES.has(adapterModule.type)) {
res.status(409).json({
error: `Adapter type "${adapterModule.type}" is a built-in adapter and cannot be overwritten.`,
});
return;
}
// Check if already registered (indicates a reinstall/update)
const existing = findServerAdapter(adapterModule.type);
const isReinstall = existing !== null;
if (existing) {
unregisterServerAdapter(adapterModule.type);
logger.info({ type: adapterModule.type }, "Unregistered existing adapter for replacement");
}
// Register the new adapter
registerWithSessionManagement(adapterModule);
// Persist the record (use canonicalName without version suffix)
const record: AdapterPluginRecord = {
packageName: canonicalName,
localPath: moduleLocalPath,
version: installedVersion ?? explicitVersion,
type: adapterModule.type,
installedAt: new Date().toISOString(),
};
addAdapterPlugin(record);
logger.info(
{ type: adapterModule.type, packageName: canonicalName },
"External adapter installed and registered",
);
res.status(201).json({
type: adapterModule.type,
packageName: canonicalName,
version: installedVersion ?? explicitVersion,
installedAt: record.installedAt,
requiresRestart: isReinstall,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, packageName }, "Failed to install external adapter");
// Distinguish npm errors from load errors
if (message.includes("npm") || message.includes("ERR!")) {
res.status(500).json({ error: `npm install failed: ${message}` });
} else {
res.status(500).json({ error: `Failed to install adapter: ${message}` });
}
}
// Load and register the adapter (use canonicalName for path resolution)
const adapterModule = await loadExternalAdapterPackage(canonicalName, moduleLocalPath);
// Check if this type conflicts with a built-in adapter
if (BUILTIN_ADAPTER_TYPES.has(adapterModule.type)) {
res.status(409).json({
error: `Adapter type "${adapterModule.type}" is a built-in adapter and cannot be overwritten.`,
});
return;
}
// Check if already registered (indicates a reinstall/update)
const existing = findServerAdapter(adapterModule.type);
const isReinstall = existing !== null;
if (existing) {
unregisterServerAdapter(adapterModule.type);
logger.info({ type: adapterModule.type }, "Unregistered existing adapter for replacement");
}
// Register the new adapter
registerWithSessionManagement(adapterModule);
// Persist the record (use canonicalName without version suffix)
const record: AdapterPluginRecord = {
packageName: canonicalName,
localPath: moduleLocalPath,
version: installedVersion ?? explicitVersion,
type: adapterModule.type,
installedAt: new Date().toISOString(),
};
addAdapterPlugin(record);
logger.info(
{ type: adapterModule.type, packageName: canonicalName },
"External adapter installed and registered",
);
res.status(201).json({
type: adapterModule.type,
packageName: canonicalName,
version: installedVersion ?? explicitVersion,
installedAt: record.installedAt,
requiresRestart: isReinstall,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, packageName }, "Failed to install external adapter");
// Distinguish npm errors from load errors
if (message.includes("npm") || message.includes("ERR!")) {
res.status(500).json({ error: `npm install failed: ${message}` });
} else {
res.status(500).json({ error: `Failed to install adapter: ${message}` });
}
}
});
});
/**
@@ -412,53 +433,55 @@ export function adapterRoutes() {
return;
}
// Check that the adapter exists in the registry
const existing = findServerAdapter(adapterType);
if (!existing) {
res.status(404).json({
error: `Adapter "${adapterType}" is not registered.`,
});
return;
}
// Check that it's an external adapter
const externalRecord = getAdapterPluginByType(adapterType);
if (!externalRecord) {
res.status(404).json({
error: `Adapter "${adapterType}" is not an externally installed adapter.`,
});
return;
}
// If installed via npm (has packageName but no localPath), run npm uninstall
if (externalRecord.packageName && !externalRecord.localPath) {
try {
const pluginsDir = getAdapterPluginsDir();
await execFileAsync("npm", ["uninstall", externalRecord.packageName], {
cwd: pluginsDir,
timeout: 60_000,
await withAdapterMutex(async () => {
// Check that the adapter exists in the registry
const existing = findServerAdapter(adapterType);
if (!existing) {
res.status(404).json({
error: `Adapter "${adapterType}" is not registered.`,
});
logger.info(
{ type: adapterType, packageName: externalRecord.packageName },
"npm uninstall completed for external adapter",
);
} catch (err) {
logger.warn(
{ err, type: adapterType, packageName: externalRecord.packageName },
"npm uninstall failed for external adapter; continuing with unregister",
);
return;
}
}
// Unregister from the runtime registry
unregisterServerAdapter(adapterType);
// Check that it's an external adapter
const externalRecord = getAdapterPluginByType(adapterType);
if (!externalRecord) {
res.status(404).json({
error: `Adapter "${adapterType}" is not an externally installed adapter.`,
});
return;
}
// Remove from the persistent store
removeAdapterPlugin(adapterType);
// If installed via npm (has packageName but no localPath), run npm uninstall
if (externalRecord.packageName && !externalRecord.localPath) {
try {
const pluginsDir = getAdapterPluginsDir();
await execFileAsync("npm", ["uninstall", externalRecord.packageName], {
cwd: pluginsDir,
timeout: 60_000,
});
logger.info(
{ type: adapterType, packageName: externalRecord.packageName },
"npm uninstall completed for external adapter",
);
} catch (err) {
logger.warn(
{ err, type: adapterType, packageName: externalRecord.packageName },
"npm uninstall failed for external adapter; continuing with unregister",
);
}
}
logger.info({ type: adapterType }, "External adapter unregistered and removed");
// Unregister from the runtime registry
unregisterServerAdapter(adapterType);
res.json({ type: adapterType, removed: true });
// Remove from the persistent store
removeAdapterPlugin(adapterType);
logger.info({ type: adapterType }, "External adapter unregistered and removed");
res.json({ type: adapterType, removed: true });
});
});
/**
@@ -480,39 +503,41 @@ export function adapterRoutes() {
return;
}
// Reload the adapter module (busts ESM cache, re-imports)
try {
const newModule = await reloadExternalAdapter(type);
await withAdapterMutex(async () => {
// Reload the adapter module (busts ESM cache, re-imports)
try {
const newModule = await reloadExternalAdapter(type);
// Not found in the external adapter store
if (!newModule) {
res.status(404).json({ error: `Adapter "${type}" is not an externally installed adapter.` });
return;
}
// Swap in the reloaded module
unregisterServerAdapter(type);
registerWithSessionManagement(newModule);
configSchemaCache.delete(type);
// Sync store.version from package.json (store may be missing version for local installs).
const record = getAdapterPluginByType(type);
let newVersion: string | undefined;
if (record) {
newVersion = readAdapterPackageVersionFromDisk(record);
if (newVersion) {
addAdapterPlugin({ ...record, version: newVersion });
// Not found in the external adapter store
if (!newModule) {
res.status(404).json({ error: `Adapter "${type}" is not an externally installed adapter.` });
return;
}
// Swap in the reloaded module
unregisterServerAdapter(type);
registerWithSessionManagement(newModule);
configSchemaCache.delete(type);
// Sync store.version from package.json (store may be missing version for local installs).
const record = getAdapterPluginByType(type);
let newVersion: string | undefined;
if (record) {
newVersion = readAdapterPackageVersionFromDisk(record);
if (newVersion) {
addAdapterPlugin({ ...record, version: newVersion });
}
}
logger.info({ type, version: newVersion }, "External adapter reloaded at runtime");
res.json({ type, version: newVersion, reloaded: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, type }, "Failed to reload external adapter");
res.status(500).json({ error: `Failed to reload adapter: ${message}` });
}
logger.info({ type, version: newVersion }, "External adapter reloaded at runtime");
res.json({ type, version: newVersion, reloaded: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, type }, "Failed to reload external adapter");
res.status(500).json({ error: `Failed to reload adapter: ${message}` });
}
});
});
// ── POST /api/adapters/:type/reinstall ──────────────────────────────────
@@ -542,45 +567,47 @@ export function adapterRoutes() {
return;
}
try {
const pluginsDir = getAdapterPluginsDir();
await withAdapterMutex(async () => {
try {
const pluginsDir = getAdapterPluginsDir();
logger.info({ type, packageName: record.packageName }, "Reinstalling adapter package via npm");
logger.info({ type, packageName: record.packageName }, "Reinstalling adapter package via npm");
await execFileAsync("npm", ["install", "--no-save", record.packageName], {
cwd: pluginsDir,
timeout: 120_000,
});
await execFileAsync("npm", ["install", record.packageName.trim()], {
cwd: pluginsDir,
timeout: 120_000,
});
// Reload the freshly installed adapter
const newModule = await reloadExternalAdapter(type);
if (!newModule) {
res.status(500).json({ error: "npm install succeeded but adapter reload failed." });
return;
}
unregisterServerAdapter(type);
registerWithSessionManagement(newModule);
configSchemaCache.delete(type);
// Sync store version from disk
let newVersion: string | undefined;
const updatedRecord = getAdapterPluginByType(type);
if (updatedRecord) {
newVersion = readAdapterPackageVersionFromDisk(updatedRecord);
if (newVersion) {
addAdapterPlugin({ ...updatedRecord, version: newVersion });
// Reload the freshly installed adapter
const newModule = await reloadExternalAdapter(type);
if (!newModule) {
res.status(500).json({ error: "npm install succeeded but adapter reload failed." });
return;
}
unregisterServerAdapter(type);
registerWithSessionManagement(newModule);
configSchemaCache.delete(type);
// Sync store version from disk
let newVersion: string | undefined;
const updatedRecord = getAdapterPluginByType(type);
if (updatedRecord) {
newVersion = readAdapterPackageVersionFromDisk(updatedRecord);
if (newVersion) {
addAdapterPlugin({ ...updatedRecord, version: newVersion });
}
}
logger.info({ type, version: newVersion }, "Adapter reinstalled from npm");
res.json({ type, version: newVersion, reinstalled: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, type }, "Failed to reinstall adapter");
res.status(500).json({ error: `Reinstall failed: ${message}` });
}
logger.info({ type, version: newVersion }, "Adapter reinstalled from npm");
res.json({ type, version: newVersion, reinstalled: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error({ err, type }, "Failed to reinstall adapter");
res.status(500).json({ error: `Reinstall failed: ${message}` });
}
});
});
// ── GET /api/adapters/:type/config-schema ────────────────────────────────
+66 -1
View File
@@ -4,6 +4,7 @@ import {
companySkillCreateSchema,
companySkillFileUpdateSchema,
companySkillImportSchema,
companySkillUpdateAuthSchema,
companySkillProjectScanRequestSchema,
} from "@paperclipai/shared";
import { trackSkillImported } from "@paperclipai/shared/telemetry";
@@ -194,7 +195,8 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
const source = String(req.body.source ?? "");
const result = await svc.importFromSource(companyId, source);
const authToken = typeof req.body.authToken === "string" ? req.body.authToken.trim() : undefined;
const result = await svc.importFromSource(companyId, source, authToken || undefined);
const actor = getActorInfo(req);
await logActivity(db, {
@@ -260,6 +262,36 @@ export function companySkillRoutes(db: Db) {
},
);
router.delete("/companies/:companyId/skills/by-source", async (req, res) => {
const companyId = req.params.companyId as string;
const sourceLocator = String(req.query.source ?? "").trim();
if (!sourceLocator) {
res.status(400).json({ error: "source query parameter is required" });
return;
}
await assertCanMutateCompanySkills(req, companyId);
const deleted = await svc.deleteBySource(companyId, sourceLocator);
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "company.skills_source_deleted",
entityType: "company",
entityId: companyId,
details: {
sourceLocator,
deletedCount: deleted.length,
deletedSlugs: deleted.map((s) => s.slug),
},
});
res.json({ deleted });
});
router.delete("/companies/:companyId/skills/:skillId", async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
@@ -318,5 +350,38 @@ export function companySkillRoutes(db: Db) {
res.json(result);
});
router.patch(
"/companies/:companyId/skills/:skillId/auth",
validate(companySkillUpdateAuthSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
const authToken = req.body.authToken as string | null;
const result = await svc.updateSkillAuth(companyId, skillId, authToken);
if (!result) {
res.status(404).json({ error: "Skill not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: authToken ? "company.skill_auth_updated" : "company.skill_auth_removed",
entityType: "company_skill",
entityId: result.id,
details: {
slug: result.slug,
},
});
res.json(result);
},
);
return router;
}
+9 -2
View File
@@ -86,7 +86,12 @@ function readStore(): AdapterPluginRecord[] {
function writeStore(records: AdapterPluginRecord[]): void {
ensureDirs();
fs.writeFileSync(ADAPTER_PLUGINS_STORE_PATH, JSON.stringify(records, null, 2), "utf-8");
// Atomic write: write to a temp file in the same directory then rename.
// rename() is atomic on POSIX when source and target are on the same
// filesystem, preventing partial/corrupted reads from concurrent processes.
const tmpPath = `${ADAPTER_PLUGINS_STORE_PATH}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(records, null, 2), "utf-8");
fs.renameSync(tmpPath, ADAPTER_PLUGINS_STORE_PATH);
storeCache = records;
}
@@ -106,7 +111,9 @@ function readSettings(): AdapterSettings {
function writeSettings(settings: AdapterSettings): void {
ensureDirs();
fs.writeFileSync(ADAPTER_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8");
const tmpPath = `${ADAPTER_SETTINGS_PATH}.tmp`;
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2), "utf-8");
fs.renameSync(tmpPath, ADAPTER_SETTINGS_PATH);
settingsCache = settings;
}
+235 -18
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { and, asc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companySkills } from "@paperclipai/db";
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
import type {
CompanySkill,
@@ -471,20 +471,20 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record<string, un
};
}
async function fetchText(url: string) {
const response = await ghFetch(url);
async function fetchText(url: string, authToken?: string) {
const response = await ghFetch(url, undefined, authToken);
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
}
async function fetchJson<T>(url: string): Promise<T> {
async function fetchJson<T>(url: string, authToken?: string): Promise<T> {
const response = await ghFetch(url, {
headers: {
accept: "application/vnd.github+json",
},
});
}, authToken);
if (!response.ok) {
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
}
@@ -492,16 +492,18 @@ async function fetchJson<T>(url: string): Promise<T> {
}
async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string) {
async function resolveGitHubDefaultBranch(owner: string, repo: string, apiBase: string, authToken?: string) {
const response = await fetchJson<{ default_branch?: string }>(
`${apiBase}/repos/${owner}/${repo}`,
authToken,
);
return asString(response.default_branch) ?? "main";
}
async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string) {
async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, apiBase: string, authToken?: string) {
const response = await fetchJson<{ sha?: string }>(
`${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
authToken,
);
const sha = asString(response.sha);
if (!sha) {
@@ -538,7 +540,7 @@ function parseGitHubSourceUrl(rawUrl: string) {
return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
}
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>) {
async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourceUrl>, authToken?: string) {
const apiBase = gitHubApiBase(parsed.hostname);
if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) {
return {
@@ -549,8 +551,8 @@ async function resolveGitHubPinnedRef(parsed: ReturnType<typeof parseGitHubSourc
const trackingRef = parsed.explicitRef
? parsed.ref
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase);
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase);
: await resolveGitHubDefaultBranch(parsed.owner, parsed.repo, apiBase, authToken);
const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase, authToken);
return { pinnedRef, trackingRef };
}
@@ -981,6 +983,7 @@ async function readUrlSkillImports(
companyId: string,
sourceUrl: string,
requestedSkillSlug: string | null = null,
authToken?: string,
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
const url = sourceUrl.trim();
const warnings: string[] = [];
@@ -995,10 +998,11 @@ async function readUrlSkillImports(
if (looksLikeRepoUrl) {
const parsed = parseGitHubSourceUrl(url);
const apiBase = gitHubApiBase(parsed.hostname);
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed);
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed, authToken);
let ref = pinnedRef;
const tree = await fetchJson<{ tree?: Array<{ path: string; type: string }> }>(
`${apiBase}/repos/${parsed.owner}/${parsed.repo}/git/trees/${ref}?recursive=1`,
authToken,
).catch(() => {
throw unprocessable(`Failed to read GitHub tree for ${url}`);
});
@@ -1025,7 +1029,7 @@ async function readUrlSkillImports(
const skills: ImportedSkill[] = [];
for (const relativeSkillPath of skillPaths) {
const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath;
const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath));
const markdown = await fetchText(resolveRawGitHubUrl(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath), authToken);
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
const skillDir = path.posix.dirname(relativeSkillPath);
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
@@ -1087,7 +1091,7 @@ async function readUrlSkillImports(
}
if (url.startsWith("http://") || url.startsWith("https://")) {
const markdown = await fetchText(url);
const markdown = await fetchText(url, authToken);
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
const urlObj = new URL(url);
const fileName = path.posix.basename(urlObj.pathname);
@@ -1459,6 +1463,22 @@ export function companySkillService(db: Db) {
const projects = projectService(db);
const secretsSvc = secretService(db);
/** Resolve the GitHub auth token from a skill's metadata, if stored. */
async function resolveSkillAuthToken(
companyId: string,
skill: { metadata: Record<string, unknown> | null },
): Promise<string | undefined> {
const meta = skill.metadata;
if (!meta) return undefined;
const secretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId.trim() : "";
if (!secretId) return undefined;
try {
return await secretsSvc.resolveSecretValue(companyId, secretId, "latest");
} catch {
return undefined;
}
}
async function ensureBundledSkills(companyId: string) {
for (const skillsRoot of resolveBundledSkillsRoot()) {
const stats = await fs.stat(skillsRoot).catch(() => null);
@@ -1656,7 +1676,8 @@ export function companySkillService(db: Db) {
const hostname = asString(metadata.hostname) || "github.com";
const apiBase = gitHubApiBase(hostname);
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase);
const authToken = await resolveSkillAuthToken(companyId, skill);
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase, authToken);
return {
supported: true,
reason: null,
@@ -1700,8 +1721,9 @@ export function companySkillService(db: Db) {
if (!owner || !repo) {
throw unprocessable("Skill source metadata is incomplete.");
}
const authToken = await resolveSkillAuthToken(companyId, skill);
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath));
content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath), authToken);
} else if (skill.sourceType === "url") {
if (normalizedPath !== "SKILL.md") {
throw notFound("This skill source only exposes SKILL.md");
@@ -1818,7 +1840,8 @@ export function companySkillService(db: Db) {
throw unprocessable("Skill source locator is missing.");
}
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug);
const authToken = await resolveSkillAuthToken(companyId, skill);
const result = await readUrlSkillImports(companyId, skill.sourceLocator, skill.slug, authToken);
const matching = result.skills.find((entry) => entry.key === skill.key) ?? result.skills[0] ?? null;
if (!matching) {
throw unprocessable(`Skill ${skill.key} could not be re-imported from its source.`);
@@ -1993,6 +2016,64 @@ export function companySkillService(db: Db) {
}
}
// Re-scan GitHub/sks_sh sources to pick up newly added skills and prune removed ones
const sourceLocators = new Set<string>();
for (const skill of acceptedSkills) {
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") continue;
const locator = skill.sourceLocator ?? "";
if (locator) sourceLocators.add(locator);
}
for (const sourceLocator of sourceLocators) {
try {
const result = await readUrlSkillImports(companyId, sourceLocator, null);
const currentSlugs = new Set(result.skills.map((s) => s.slug));
// Upsert any new skills found in the source
for (const nextSkill of result.skills) {
if (acceptedSkills.some((s) => s.slug === nextSkill.slug)) continue;
const persisted = (await upsertImportedSkills(companyId, [nextSkill]))[0];
if (persisted) {
imported.push(persisted);
upsertAcceptedSkill(persisted);
}
}
// Prune skills that are no longer in the source
const skillsAtSource = acceptedSkills.filter((s) => s.sourceLocator === sourceLocator);
for (const skill of skillsAtSource) {
if (currentSlugs.has(skill.slug)) continue;
const usedByAgents = await usage(companyId, skill.key);
if (usedByAgents.length > 0) {
// Detach the skill from all agents that have it, then delete
for (const agent of usedByAgents) {
const fullAgent = await agents.getById(agent.id);
if (!fullAgent) continue;
const currentConfig = (fullAgent.adapterConfig ?? {}) as Record<string, unknown>;
const preference = readPaperclipSkillSyncPreference(currentConfig);
if (preference.desiredSkills.includes(skill.key)) {
const updatedConfig = writePaperclipSkillSyncPreference(
currentConfig,
preference.desiredSkills.filter((k) => k !== skill.key),
);
await agents.update(fullAgent.id, { adapterConfig: updatedConfig });
}
}
warnings.push(
`Skill "${skill.slug}" was removed from ${sourceLocator} and detached from ${usedByAgents.map((a) => a.name).join(", ")}.`,
);
} else {
warnings.push(
`Skill "${skill.slug}" was removed from ${sourceLocator} and deleted.`,
);
}
await deleteSkill(companyId, skill.id);
}
} catch {
// Best-effort: don't fail the whole scan if one source fails
warnings.push(`Could not re-scan source ${sourceLocator} — skipping.`);
}
}
return {
scannedProjects: scannedProjectIds.size,
scannedWorkspaces: scanTargets.length,
@@ -2230,6 +2311,10 @@ export function companySkillService(db: Db) {
const metadata = {
...(skill.metadata ?? {}),
skillKey: skill.key,
// Preserve auth secret reference across re-imports/updates
...(existing?.metadata && typeof (existing.metadata as Record<string, unknown>).sourceAuthSecretId === "string"
? { sourceAuthSecretId: (existing.metadata as Record<string, unknown>).sourceAuthSecretId }
: {}),
};
const values = {
companyId,
@@ -2265,7 +2350,7 @@ export function companySkillService(db: Db) {
return out;
}
async function importFromSource(companyId: string, source: string): Promise<CompanySkillImportResult> {
async function importFromSource(companyId: string, source: string, authToken?: string): Promise<CompanySkillImportResult> {
await ensureSkillInventoryCurrent(companyId);
const parsed = parseSkillImportSourceInput(source);
const local = !/^https?:\/\//i.test(parsed.resolvedSource);
@@ -2275,7 +2360,7 @@ export function companySkillService(db: Db) {
.filter((skill) => !parsed.requestedSkillSlug || skill.slug === parsed.requestedSkillSlug),
warnings: parsed.warnings,
}
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug)
: await readUrlSkillImports(companyId, parsed.resolvedSource, parsed.requestedSkillSlug, authToken)
.then((result) => ({
skills: result.skills,
warnings: [...parsed.warnings, ...result.warnings],
@@ -2302,6 +2387,35 @@ export function companySkillService(db: Db) {
}
}
const imported = await upsertImportedSkills(companyId, filteredSkills);
// Store the auth token as an encrypted company secret and link to imported skills
if (authToken && imported.length > 0) {
for (const skill of imported) {
const secretName = `skill-pat:${skill.id}`;
let secretId: string;
const existing = await secretsSvc.getByName(companyId, secretName);
if (existing) {
await secretsSvc.rotate(existing.id, { value: authToken });
secretId = existing.id;
} else {
const created = await secretsSvc.create(companyId, {
name: secretName,
provider: "local_encrypted",
value: authToken,
description: `GitHub PAT for skill ${skill.slug}`,
});
secretId = created.id;
}
// Store the secret ID in skill metadata
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
meta.sourceAuthSecretId = secretId;
await db
.update(companySkills)
.set({ metadata: meta, updatedAt: new Date() })
.where(and(eq(companySkills.id, skill.id), eq(companySkills.companyId, companyId)));
}
}
return { imported, warnings };
}
@@ -2341,9 +2455,110 @@ export function companySkillService(db: Db) {
// Clean up materialized runtime files
await fs.rm(resolveRuntimeSkillMaterializedPath(companyId, skill), { recursive: true, force: true });
// Delete associated PAT secret if present
const meta = skill.metadata as Record<string, unknown> | null;
const secretId = typeof meta?.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
if (secretId) {
try {
await secretsSvc.remove(secretId);
} catch {
// Best-effort: don't fail the skill deletion if secret cleanup fails
}
}
return skill;
}
async function updateSkillAuth(
companyId: string,
skillId: string,
authToken: string | null,
): Promise<CompanySkill | null> {
const skill = await getById(skillId);
if (!skill || skill.companyId !== companyId) return null;
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
const existingSecretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
if (authToken) {
// Set or update the PAT
const secretName = `skill-pat:${skill.id}`;
let secretId: string;
// Check metadata reference first, then fall back to name lookup
// (metadata ref may have been lost during a skill update/re-import)
const existingSecret = existingSecretId
? await secretsSvc.getById(existingSecretId)
: await secretsSvc.getByName(companyId, secretName);
if (existingSecret) {
await secretsSvc.rotate(existingSecret.id, { value: authToken });
secretId = existingSecret.id;
} else {
const created = await secretsSvc.create(companyId, {
name: secretName,
provider: "local_encrypted",
value: authToken,
description: `GitHub PAT for skill ${skill.slug}`,
});
secretId = created.id;
}
meta.sourceAuthSecretId = secretId;
} else {
// Clear the PAT — delete the secret row to avoid orphaned secrets
if (existingSecretId) {
try {
await secretsSvc.remove(existingSecretId);
} catch {
// Best-effort: don't fail the metadata update if secret deletion fails
}
}
delete meta.sourceAuthSecretId;
}
const [updated] = await db
.update(companySkills)
.set({ metadata: meta, updatedAt: new Date() })
.where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)))
.returning();
return updated ? toCompanySkill(updated) : null;
}
async function deleteBySource(companyId: string, sourceLocator: string): Promise<CompanySkill[]> {
const rows = await db
.select()
.from(companySkills)
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.sourceLocator, sourceLocator)));
if (rows.length === 0) return [];
// Pre-check all skills for agent usage before deleting any (atomicity)
const skills = rows.map(toCompanySkill);
for (const skill of skills) {
const usedByAgents = await usage(companyId, skill.key);
if (usedByAgents.length > 0) {
const agentNames = usedByAgents.map((agent) => agent.name).sort((left, right) => left.localeCompare(right));
throw unprocessable(
`Cannot delete skills from "${sourceLocator}" because skill "${skill.name}" is still used by ${agentNames.join(", ")}. Detach it from those agents first.`,
{
skillId: skill.id,
skillKey: skill.key,
usedByAgents: usedByAgents.map((agent) => ({
id: agent.id,
name: agent.name,
urlKey: agent.urlKey,
adapterType: agent.adapterType,
})),
},
);
}
}
const deleted: CompanySkill[] = [];
for (const row of rows) {
const result = await deleteSkill(companyId, row.id);
if (result) deleted.push(result);
}
return deleted;
}
return {
list,
listFull,
@@ -2359,7 +2574,9 @@ export function companySkillService(db: Db) {
updateFile,
createLocalSkill,
deleteSkill,
deleteBySource,
importFromSource,
updateSkillAuth,
scanProjectWorkspaces,
importPackageFiles,
installUpdate,
+6 -2
View File
@@ -16,9 +16,13 @@ export function resolveRawGitHubUrl(hostname: string, owner: string, repo: strin
: `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`;
}
export async function ghFetch(url: string, init?: RequestInit): Promise<Response> {
export async function ghFetch(url: string, init?: RequestInit, authToken?: string): Promise<Response> {
const headers = new Headers(init?.headers);
if (authToken) {
headers.set("Authorization", `Bearer ${authToken}`);
}
try {
return await fetch(url, init);
return await fetch(url, { ...init, headers });
} catch {
throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`);
}
+15 -6
View File
@@ -36,10 +36,23 @@ export const companySkillsApi = {
`/companies/${encodeURIComponent(companyId)}/skills`,
payload,
),
importFromSource: (companyId: string, source: string) =>
importFromSource: (companyId: string, source: string, authToken?: string) =>
api.post<CompanySkillImportResult>(
`/companies/${encodeURIComponent(companyId)}/skills/import`,
{ source },
{ source, ...(authToken ? { authToken } : {}) },
),
updateAuth: (companyId: string, skillId: string, authToken: string | null) =>
api.patch<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/auth`,
{ authToken },
),
remove: (companyId: string, skillId: string) =>
api.delete<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
),
removeBySource: (companyId: string, sourceLocator: string) =>
api.delete<{ deleted: CompanySkill[] }>(
`/companies/${encodeURIComponent(companyId)}/skills/by-source?source=${encodeURIComponent(sourceLocator)}`,
),
scanProjects: (companyId: string, payload: CompanySkillProjectScanRequest = {}) =>
api.post<CompanySkillProjectScanResult>(
@@ -51,8 +64,4 @@ export const companySkillsApi = {
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/install-update`,
{},
),
delete: (companyId: string, skillId: string) =>
api.delete<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
),
};
+187 -29
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type SVGProps } from "react";
import { Link, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CompanySkill,
CompanySkillCreateRequest,
CompanySkillDetail,
CompanySkillFileDetail,
@@ -52,6 +53,7 @@ import {
RefreshCw,
Save,
Search,
ShieldCheck,
Trash2,
} from "lucide-react";
@@ -487,6 +489,103 @@ function SkillList({
);
}
function SkillAuthSection({
companyId,
skillId,
hasAuth,
}: {
companyId: string;
skillId: string;
hasAuth: boolean;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [editing, setEditing] = useState(false);
const [token, setToken] = useState("");
const updateAuth = useMutation({
mutationFn: (authToken: string | null) =>
companySkillsApi.updateAuth(companyId, skillId, authToken),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(companyId, skillId) });
setEditing(false);
setToken("");
pushToast({ tone: "success", title: "Auth updated" });
},
onError: (error) => {
pushToast({
tone: "error",
title: "Failed to update auth",
body: error instanceof Error ? error.message : "Unknown error",
});
},
});
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Auth</span>
{!editing ? (
<>
{hasAuth ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(true)}
>
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" />
PAT configured
</Button>
<button
className="inline-flex items-center text-muted-foreground/50 hover:text-destructive transition-colors"
onClick={() => updateAuth.mutate(null)}
disabled={updateAuth.isPending}
title="Remove PAT"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(true)}
>
Add PAT
</Button>
)}
</>
) : (
<>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="GitHub Personal Access Token"
className="flex-1 min-w-[200px] rounded-md border border-border px-2 py-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50"
autoComplete="off"
autoFocus
/>
<Button
size="sm"
onClick={() => updateAuth.mutate(token.trim())}
disabled={!token.trim() || updateAuth.isPending}
>
{updateAuth.isPending ? "Saving..." : "Save"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setEditing(false); setToken(""); }}
>
Cancel
</Button>
</>
)}
</div>
);
}
function SkillPane({
loading,
detail,
@@ -525,7 +624,7 @@ function SkillPane({
checkUpdatesPending: boolean;
onInstallUpdate: () => void;
installUpdatePending: boolean;
onDelete: () => void;
onDelete: (sourceLocator?: string | null) => void;
deletePending: boolean;
onSave: () => void;
savePending: boolean;
@@ -572,7 +671,7 @@ function SkillPane({
<Button
variant="ghost"
size="sm"
onClick={onDelete}
onClick={() => onDelete()}
disabled={deletePending}
title={removeDisabledReason ?? undefined}
>
@@ -612,8 +711,23 @@ function SkillPane({
) : (
<span className="truncate">{source.label}</span>
)}
<button
className="inline-flex items-center text-muted-foreground/50 hover:text-destructive transition-colors"
onClick={() => onDelete(detail.sourceLocator)}
disabled={deletePending}
title={detail.sourceLocator ? "Remove this source and all its skills" : "Remove this skill"}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
</div>
{(detail.sourceType === "github" || detail.sourceType === "skills_sh") && (
<SkillAuthSection
companyId={detail.companyId}
skillId={detail.id}
hasAuth={Boolean((detail.metadata as Record<string, unknown> | null)?.sourceAuthSecretId)}
/>
)}
{detail.sourceType === "github" && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Pin</span>
@@ -762,6 +876,7 @@ export function CompanySkills() {
const { pushToast } = useToast();
const [skillFilter, setSkillFilter] = useState("");
const [source, setSource] = useState("");
const [importAuthToken, setImportAuthToken] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false);
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
@@ -775,6 +890,7 @@ export function CompanySkills() {
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteTargetSkillId, setDeleteTargetSkillId] = useState<string | null>(null);
const [deleteTargetDetail, setDeleteTargetDetail] = useState<CompanySkillDetail | null>(null);
const [deleteTargetSourceLocator, setDeleteTargetSourceLocator] = useState<string | null>(null);
const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]);
const routeSkillId = parsedRoute.skillId;
const selectedPath = parsedRoute.filePath;
@@ -883,11 +999,24 @@ export function CompanySkills() {
if (!open) {
setDeleteTargetSkillId(null);
setDeleteTargetDetail(null);
setDeleteTargetSourceLocator(null);
}
}
function handleDeleteSkill(sourceLocator?: string | null) {
if (sourceLocator) {
setDeleteTargetSourceLocator(sourceLocator);
setDeleteTargetSkillId(null);
setDeleteTargetDetail(null);
setDeleteOpen(true);
} else {
openDeleteDialog();
}
}
const importSkill = useMutation({
mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource),
mutationFn: ({ importSource, authToken }: { importSource: string; authToken?: string }) =>
companySkillsApi.importFromSource(selectedCompanyId!, importSource, authToken),
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
if (result.imported[0]) navigate(skillRoute(result.imported[0].id));
@@ -900,6 +1029,7 @@ export function CompanySkills() {
pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] });
}
setSource("");
setImportAuthToken("");
},
onError: (error) => {
pushToast({
@@ -1026,8 +1156,13 @@ export function CompanySkills() {
});
const deleteSkill = useMutation({
mutationFn: () => companySkillsApi.delete(selectedCompanyId!, deleteTargetSkillId!),
onSuccess: async (skill) => {
mutationFn: (sourceLocator?: string | null): Promise<{ deleted: CompanySkill[] }> => {
if (sourceLocator) {
return companySkillsApi.removeBySource(selectedCompanyId!, sourceLocator);
}
return companySkillsApi.remove(selectedCompanyId!, deleteTargetSkillId!).then((skill) => ({ deleted: [skill] }));
},
onSuccess: async (result) => {
closeDeleteDialog(false);
setDisplayedDetail(null);
setDisplayedFile(null);
@@ -1048,10 +1183,10 @@ export function CompanySkills() {
type: "active",
});
navigate("/skills", { replace: true });
const count = result.deleted.length;
pushToast({
tone: "success",
title: "Skill removed",
body: `${skill.name} was removed from the company skill library.`,
title: `${count} skill${count === 1 ? "" : "s"} removed`,
});
},
onError: (error) => {
@@ -1073,7 +1208,8 @@ export function CompanySkills() {
setEmptySourceHelpOpen(true);
return;
}
importSkill.mutate(trimmedSource);
const token = importAuthToken.trim() || undefined;
importSkill.mutate({ importSource: trimmedSource, authToken: token });
}
return (
@@ -1081,30 +1217,40 @@ export function CompanySkills() {
<Dialog open={deleteOpen} onOpenChange={closeDeleteDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Remove skill</DialogTitle>
<DialogTitle>{deleteTargetSourceLocator ? "Remove skills from source" : "Remove skill"}</DialogTitle>
<DialogDescription>
Remove this skill from the company library. If any agents still use it, removal will be blocked until it is detached.
{deleteTargetSourceLocator
? `All skills imported from this source will be permanently removed from the company library.`
: "Remove this skill from the company library. If any agents still use it, removal will be blocked until it is detached."}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
<p>
{deleteTargetDetail
? `You are about to remove ${deleteTargetDetail.name}.`
: "You are about to remove this skill."}
</p>
{deleteTargetDetail?.usedByAgents?.length ? (
<div className="rounded-md border border-border px-3 py-3 text-muted-foreground">
Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}.
</div>
) : null}
{(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? (
<p className="text-muted-foreground">
Detach this skill from all agents to enable removal.
{deleteTargetSourceLocator ? (
<p className="rounded-md border border-destructive/50 bg-destructive/5 px-3 py-2 font-mono text-xs break-all">
{deleteTargetSourceLocator}
</p>
) : null}
) : (
<>
<p>
{deleteTargetDetail
? `You are about to remove ${deleteTargetDetail.name}.`
: "You are about to remove this skill."}
</p>
{deleteTargetDetail?.usedByAgents?.length ? (
<div className="rounded-md border border-border px-3 py-3 text-muted-foreground">
Currently used by {deleteTargetDetail.usedByAgents.map((agent) => agent.name).join(", ")}.
</div>
) : null}
{(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? (
<p className="text-muted-foreground">
Detach this skill from all agents to enable removal.
</p>
) : null}
</>
)}
</div>
<DialogFooter>
{(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 ? (
{(deleteTargetDetail?.usedByAgents.length ?? 0) > 0 && !deleteTargetSourceLocator ? (
<Button variant="ghost" onClick={() => closeDeleteDialog(false)}>
Close
</Button>
@@ -1115,10 +1261,10 @@ export function CompanySkills() {
</Button>
<Button
variant="destructive"
onClick={() => deleteSkill.mutate()}
disabled={deleteSkill.isPending || !deleteTargetSkillId}
onClick={() => deleteSkill.mutate(deleteTargetSourceLocator ?? undefined)}
disabled={deleteSkill.isPending || (!deleteTargetSkillId && !deleteTargetSourceLocator)}
>
{deleteSkill.isPending ? "Removing..." : "Remove skill"}
{deleteSkill.isPending ? "Removing..." : deleteTargetSourceLocator ? "Remove all from source" : "Remove skill"}
</Button>
</>
)}
@@ -1220,6 +1366,18 @@ export function CompanySkills() {
{importSkill.isPending ? <RefreshCw className="h-4 w-4 animate-spin" /> : "Add"}
</Button>
</div>
{source.trim().length > 0 && /github\.com|^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+/.test(source.trim()) && (
<div className="mt-1 flex items-center gap-2 border-b border-border pb-2">
<input
type="password"
value={importAuthToken}
onChange={(event) => setImportAuthToken(event.target.value)}
placeholder="GitHub PAT (optional, for private repos)"
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autoComplete="off"
/>
</div>
)}
{scanStatusMessage && (
<p className="mt-3 text-xs text-muted-foreground">
{scanStatusMessage}
@@ -1284,7 +1442,7 @@ export function CompanySkills() {
checkUpdatesPending={updateStatusQuery.isFetching}
onInstallUpdate={() => installUpdate.mutate()}
installUpdatePending={installUpdate.isPending}
onDelete={openDeleteDialog}
onDelete={handleDeleteSkill}
deletePending={deleteSkill.isPending}
onSave={() => saveFile.mutate()}
savePending={saveFile.isPending}