feat(skills): import a skill folder or zip, with the tree shown before importing - #17
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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[]inlib/skillBundle.tsbefore anything is uploaded. The backend therefore accepts one shape. Zips are unpacked client-side withjszip, 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/validatefirst 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.envlooks 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
toBase64chunks 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.readEntriesis 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.webkitRelativePathoverfile.name. The latter is just a basename, soscripts/run.pyandreferences/run.pywould collapse into one key.__MACOSX/is discarded. macOS puts resource forks in every zip it makes.Proxy coverage had a gap
skillsProxyCoverage.test.ts(added after the/tenant404) did not catch a deletedimport/index.ts:/api/v1/skills/importis 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