Skip to content

feat(skills): import a skill folder or zip, with the tree shown before importing - #17

Merged
squarezw merged 1 commit into
mainfrom
feat/skill-import-bundle
Aug 18, 2026
Merged

feat(skills): import a skill folder or zip, with the tree shown before importing#17
squarezw merged 1 commit into
mainfrom
feat/skill-import-bundle

Conversation

@squarezw

Copy link
Copy Markdown
Owner

Pairs with ragent-service#84.

Creating a skill meant typing everything by hand. The new-skill page has no assets panel either — every asset endpoint needs a skill id that does not exist yet — so even after saving you had to upload files one at a time.

One file tree, two entry points

Dropping a folder, dropping a zip, and picking either from a file dialog all normalize to the same BundleFile[] in lib/skillBundle.ts before anything is uploaded. The backend therefore accepts one shape. Zips are unpacked client-side with jszip, already a dependency.

Keeping them separate would eventually grow bugs that exist on only one side — "zip imports fine but the folder doesn't" — with nothing in the code to suggest why.

Validate, look, then import

The dialog calls /import/validate first and renders the whole tree with each file marked: red blocks the import, grey is skipped by rule, green shows the detected kind. Only then does the confirm button call /import.

Skipped files are still listed rather than filtered out. Silently dropping .git/, __pycache__ or a real .env looks identical to losing them in upload, and the user has no way to tell which happened.

Directory status rolls up from its descendants, because the tree is collapsible: without roll-up a red file three levels down hides behind a folder that looks fine.

Details that only fail on real input

  • toBase64 chunks the array. String.fromCharCode(...bytes) on a whole file throws "Maximum call stack size exceeded" — but only past a few hundred KB, so it passes every small test and breaks on the first real skill.
  • readEntries is called in a loop. It returns at most 100 entries per call; reading once silently drops files from any larger directory, and drops exactly the ones sorted last.
  • webkitRelativePath over file.name. The latter is just a basename, so scripts/run.py and references/run.py would collapse into one key.
  • __MACOSX/ is discarded. macOS puts resource forks in every zip it makes.
  • Proxy body limit raised to 160mb. Next's 1mb default would 413 before the request ever reached the backend, so the backend's own error would never be seen. Both proxies use the same limit — differing limits would produce "validated fine, import 413s" on large bundles only.

Proxy coverage had a gap

skillsProxyCoverage.test.ts (added after the /tenant 404) did not catch a deleted import/index.ts: /api/v1/skills/import is a single segment, so any [id].ts-shaped proxy "matches" it. That proxy forwards /skills/{id} though, so POSTing an id of "import" only produces a confusing error. Added explicit existence assertions for both import proxies — verified by deleting each file in turn.

Tests

16 new (8 bundle normalization, 8 tree building), 285 passing, tsc non-TS5097 errors unchanged at 112.

Rendering is not verified in a browser: the skills pages need an authenticated session. The end-to-end path was exercised against a running local stack with a real 7-file WorkBuddy export, which imported correctly.

🤖 Generated with Claude Code

…e importing

Creating a skill meant typing everything by hand — there was no way to bring in
a directory exported from another platform. The new-skill page has no assets
panel either, because every asset endpoint needs a skill id that does not exist
yet, so even after saving you had to upload files one at a time.

## One file tree, two entry points

Dropping a folder, dropping a zip, and picking either from a file dialog all
normalize to the same `BundleFile[]` in `lib/skillBundle.ts` before anything is
uploaded. The backend therefore accepts one shape.

Keeping them separate would eventually grow bugs that exist on only one side —
"zip imports fine but the folder doesn't" — with nothing in the code to
suggest why.

## Validate, look, then import

The dialog calls `/import/validate` first and renders the whole tree with each
file marked: red blocks the import, grey is skipped by rule, and green shows
the detected kind. Only then does the confirm button call `/import`.

Skipped files are still listed rather than filtered out. Silently dropping
`.git/`, `__pycache__` or a real `.env` looks identical to losing them in
upload, and the user has no way to tell which happened.

Directory status rolls up from its descendants (`skillImportTree.ts`), because
the tree is collapsible: without roll-up a red file three levels down is
hidden behind a folder that looks fine.

## Details that only fail on real input

- **`toBase64` chunks the array.** `String.fromCharCode(...bytes)` on a whole
  file throws "Maximum call stack size exceeded" — but only past a few hundred
  KB, so it passes every small test and breaks on the first real skill.
- **`readEntries` is called in a loop.** It returns at most 100 entries per
  call; reading once silently drops files from any directory larger than that,
  and drops exactly the ones sorted last.
- **`webkitRelativePath` over `file.name`.** The latter is just a basename, so
  `scripts/run.py` and `references/run.py` would collapse into one key.
- **`__MACOSX/` is discarded.** macOS puts resource forks in every zip it
  makes; they are not skill content and would litter the tree with `._x` files.
- **Proxy body limit raised to 160mb.** Next's 1mb default would 413 before the
  request ever reached the backend, so the backend's own error would never be
  seen. Both proxies use the same limit — differing limits would produce
  "validated fine, import 413s" on large bundles only.

## Proxy coverage had a gap

`skillsProxyCoverage.test.ts` (added after the `/tenant` 404) did not catch a
deleted `import/index.ts`: `/api/v1/skills/import` is a single segment, so any
`[id].ts`-shaped proxy "matches" it. That proxy forwards `/skills/{id}` though,
so POSTing an id of "import" only produces a confusing error. Added an explicit
existence assertion for both import proxies rather than relying on the wildcard
coincidence — verified by deleting each file in turn.

## Tests

16 new (8 bundle normalization, 8 tree building), 285 passing, tsc non-TS5097
errors unchanged at 112.

Rendering is not verified in a browser: the skills pages need an authenticated
session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@squarezw
squarezw merged commit 04138c4 into main Aug 18, 2026
1 check passed
@squarezw
squarezw deleted the feat/skill-import-bundle branch August 18, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant