Add Claude Config file manager (shared skills/agents/hooks UI) #5
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lz/agent-nexus#5
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Goal
Add an in-UI file manager for the shared Claude config (skills, agents, commands, hooks, output-styles,
settings.json,CLAUDE.md). Operators edit these from the Nexus web UI; workers pick them up at boot via symlinks into/root/.claude/.Depends on #4 (SvelteKit migration) — do not start until that ships and is merged.
Why
Today the
claude-sessionDocker volume only holds OAuth state (credentials.json,account.json). Operators have no way to ship shared skills / agents / hooks to the fleet — they'd have to bake them into the worker image or sync them out-of-band. This feature gives every worker the same~/.claude/static-config subset, managed from one place.Architectural choices (already decided — don't relitigate)
claude-sessionvolume: keepcredentials.json+account.jsonat the root; add/session/config/for the shareable subset only. The full~/.claude/cannot be mounted directly because it mixes shareable static config with per-worker runtime state (projects/,sessions/,shell-snapshots/,history.jsonl,file-history/,telemetry/,plugins/,ide/,backups/,cache/,downloads/,.last-cleanup,mcp-needs-auth-cache.json) that MUST stay local per worker. The sibling file~/.claude.jsonalso stays local — it's built per-container byworker/entrypoint.sh:113-127.skills/,agents/,commands/,hooks/,output-styles/(so any file claude writes inside e.g.skills/foo/flows back to the volume). Copysettings.jsonandCLAUDE.md(claude-code may rewrite settings.json; breaking symlinks across tools is a known footgun). Document tradeoff: UI edits tosettings.json/CLAUDE.mdonly take effect after worker restart.claude-sessioninto the Nexus container at/shared-config, declared indocker-compose.yml. Nexus uses plainfs/promisesagainst/shared-config/config/. Do NOT use the throwaway-container pattern fromnexus/src/lib/server/workers/session-probe.tsfor file ops — ~200ms per op × dozens of files would make the UI unusable. This is a declarative bind set in compose; the docker-proxy allowlist doesn't see it (no API call involved), so no proxy change is needed.@codemirror/*packages (not thecodemirrorumbrella, which is ~135KB gz and defeats tree-shaking). Targeted imports land around 95–110KB gz with markdown + JSON + shell modes. Lazy-load via dynamicimport()inside the editor component so the initial bundle stays small.Hard constraints (don't skip)
volume-fs.ts: reject.., leading dots, backslashes, null bytes, control chars; cap depth ≤ 8 and total length ≤ 512; whitelist segments via/^[A-Za-z0-9._-][A-Za-z0-9._ -]*$/;path.resolvethen assert prefix of the base directory. Uselstat(notstat) on every read and refuseS_ISLNK— a malicious worker can plant a symlink inside the volume.allowBinary: trueflag is passed (default false). Encode contents as base64 over the wire to avoid JSON-escaping issues./api/claude-config/*route requires an unlocked session. With the path-prefix gate inhooks.server.ts(added in #4) this is automatic — just don't put any of these routes in the public allowlist.Co-Authored-By: Claudeline in commits. Standing operator instruction.claude-sessionas a plain named volume (claude-session: {}) in the top-levelvolumes:block. Do NOT useexternal: true— fresh installs would fail because today the volume is created lazily byprobeClaudeSession(nexus/src/lib/server/workers/session-probe.ts:34). Also remove thatdocker.createVolumefallback in this PR so compose owns the lifecycle.Scope
Backend
nexus/src/lib/server/claude-config/:volume-fs.ts—listTree(),readFile(rel),writeFile(rel, contentB64),mkdir(rel),unlink(rel),rename(from, to). All operate underSHARED_CONFIG_DIR(new env var, default/shared-config/config). All paths go throughsafeJoin().volume-fs.test.ts— vitest covering each traversal case + size cap + symlink-refusal (create a symlink fixture).nexus/src/routes/api/claude-config/:GET /api/claude-config/treeGET /api/claude-config/file?path=…PUT /api/claude-config/filebody{ path, contentBase64 }POST /api/claude-config/mkdirbody{ path }POST /api/claude-config/renamebody{ from, to }DELETE /api/claude-config/path?path=…SHARED_CONFIG_DIRtosrc/lib/server/config.ts.Frontend
nexus/src/routes/claude-config/+page.svelte— split-pane layout.nexus/src/lib/views/claude-config/:FileTree.svelte— recursive{#each}over tree data;expanded: Set<string>andselected: string | nullrunes.FileEditor.svelte— on mount, dynamic-imports CodeMirror 6 (await import('@codemirror/view')etc.) and creates anEditorViewbound to a<div bind:this={hostEl}>.nexus/src/routes/+layout.svelte, labeled "Claude Config", positioned between Workspaces and Providers.nexus/src/lib/api-client.ts— addclaudeConfignamespace with one method per route. Usebtoa(unescape(encodeURIComponent(content)))for the write path.nexus/src/app.css:.cfg-split { display: grid; grid-template-columns: 260px 1fr; },.cfg-tree,.cfg-tree .dir,.cfg-editor. Reuse existing palette tokens (var(--muted),var(--bg-2)).Worker / compose
worker/entrypoint.shafter the credentials install (~line 54):docker-compose.yml: addclaude-session:/shared-configto thenexusservice'svolumes:list. Declareclaude-session: {}in the top-levelvolumes:block. Volume is still also bound into workers at/sessionbynexus/src/lib/server/workers/service.ts(unchanged code path).docker.createVolumefallback innexus/src/lib/server/workers/session-probe.ts(compose owns the lifecycle now).Deps
nexus/package.json:codemirror,@codemirror/state,@codemirror/view,@codemirror/lang-markdown,@codemirror/lang-json,@codemirror/legacy-modes. Do NOT install thecodemirrorumbrella alone.Docs
AGENTS.mdfact #3: amend to mention/session/config/subtree alongside the existing two-file auth state.worker/README.md: note the symlink seeding in the mount table area (line 41).Verification (issue is not done until all pass)
pnpm testgreen; newvolume-fs.test.tscovers each traversal case + size cap + symlink-refusal.pnpm buildclean. Inspect the build output: CodeMirror should land in a route-specific chunk (only loaded when visiting/claude-config), not in the entry chunk.docker compose up -d --build. Unlock vault → open "Claude Config" → createskills/test/SKILL.mdwith a one-line body in the CodeMirror editor → Save.ls /root/.claude/skills/test/SKILL.mdresolves;catshows the content.docker compose restart nexus→ tree still shows the file (volume survived).docker exec <worker> readlink /root/.claude/skillsreturns/session/config/skills.settings.jsonin UI → restart worker → new content present in/root/.claude/settings.json. Verify it's a regular file, not a symlink (stat -c '%F' /root/.claude/settings.json→regular file).PUT /api/claude-config/filewithpath: "../../etc/passwd"→ 400.Gotchas
external: trueand removing thecreateVolumefallback. This is the highest-likelihood regression — one-line miss breaksdocker compose upon a clean clone.'codemirror'(the meta-package) defeats tree-shaking. Use granular@codemirror/*packages.References
nexus/src/lib/server/workers/session-probe.tsworker/entrypoint.shdocker-compose.yml