From 9bbdeb9470d2079fbded3e78afa829a085153f5f Mon Sep 17 00:00:00 2001 From: Ethan Fann <6005240+ethanfann@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:25:31 -0800 Subject: [PATCH 01/13] feat(cli): register bootstrap subcommand --- .claude/state/bootstrap-command/prd.json | 188 +++++++++++++++++++ .claude/state/bootstrap-command/progress.txt | 18 ++ src/commands/bootstrap.ts | 37 ++++ src/index.ts | 1 + 4 files changed, 244 insertions(+) create mode 100644 .claude/state/bootstrap-command/prd.json create mode 100644 .claude/state/bootstrap-command/progress.txt create mode 100644 src/commands/bootstrap.ts diff --git a/.claude/state/bootstrap-command/prd.json b/.claude/state/bootstrap-command/prd.json new file mode 100644 index 0000000..6515654 --- /dev/null +++ b/.claude/state/bootstrap-command/prd.json @@ -0,0 +1,188 @@ +{ + "prdName": "bootstrap-command", + "tasks": [ + { + "id": "cli-1", + "category": "cli", + "description": "Register bootstrap subcommand in CLI entry point with citty defineCommand, positional registry-path arg, and --backup/--force/--ssh/--dry-run flags", + "steps": [ + "simba bootstrap --help shows usage with all flags", + "simba --help lists bootstrap as a subcommand", + "Positional arg accepts a file path", + "--backup accepts a file path", + "--force, --ssh, --dry-run are boolean flags", + "-n is alias for --dry-run" + ], + "passes": true + }, + { + "id": "core-1", + "category": "core", + "description": "Load registry from default XDG path or positional arg, partition skills into installable (has installSource) vs adopted (no installSource)", + "steps": [ + "With no arg, reads ~/.config/simba/registry.json", + "With path arg, reads from that path", + "Skills with installSource are classified as installable", + "Skills without installSource are classified as adopted", + "Empty registry produces clean exit with message" + ], + "passes": false + }, + { + "id": "core-2", + "category": "core", + "description": "Group installable skills by installSource.repo to minimize git clones — one clone per unique repo", + "steps": [ + "Skills from same repo are grouped together", + "Local and remote repos are grouped separately", + "Each group contains skill names and their skillPath within the repo", + "Grouping handles both GitHub shorthand and full URLs" + ], + "passes": false + }, + { + "id": "core-3", + "category": "core", + "description": "For each remote repo group: clone once (depth=1), locate skills by skillPath, copy to central store", + "steps": [ + "Remote repo is cloned to temp dir with --depth 1", + "Each skill in the group is located by its skillPath", + "Skill files are copied to ~/.config/simba/skills//", + "Temp dir is cleaned up after extraction", + "installSource.protocol is respected (https vs ssh)", + "--ssh flag overrides protocol to ssh for all remote repos" + ], + "passes": false + }, + { + "id": "core-4", + "category": "core", + "description": "For local installSource: verify path exists, create symlink to central store (matching install behavior)", + "steps": [ + "Local path that exists gets symlinked into central store", + "Local path that doesn't exist is skipped with warning", + "Warning message names the skill and the missing path", + "Skipped local skills are included in final summary" + ], + "passes": false + }, + { + "id": "core-5", + "category": "core", + "description": "Handle adopted skills: skip with warning by default, restore from --backup archive when provided", + "steps": [ + "Without --backup: adopted skills listed as skipped warnings", + "With --backup: tar.gz is extracted, adopted skills found in archive are copied to central store", + "Adopted skills not found in archive are still warned about", + "Backup extraction uses same format as simba backup output" + ], + "passes": false + }, + { + "id": "core-6", + "category": "core", + "description": "Idempotency: skip existing skills by default, --force creates snapshot then overwrites", + "steps": [ + "Skill already in central store is skipped with 'already exists' message", + "--force with existing skills creates a snapshot before overwriting", + "Snapshot follows existing snapshot system (autoSnapshot pattern)", + "--force removes old skill dir and replaces with fresh fetch", + "Missing symlinks are recreated in both modes" + ], + "passes": false + }, + { + "id": "agent-1", + "category": "agent", + "description": "Always run fresh agent detection, create symlinks only for detected agents per registry assignments", + "steps": [ + "AgentRegistry.detectAgents() is called (fresh scan, not cached config)", + "For each skill's assignments in registry: symlink only if agent is detected", + "Undetected agents are logged as skipped", + "SkillAssignment.type (directory vs file) is respected", + "Symlink target matches SkillsStore.assignSkill() behavior" + ], + "passes": false + }, + { + "id": "error-1", + "category": "error", + "description": "Per-repo error isolation: failed clone skips that repo's skills, doesn't abort bootstrap", + "steps": [ + "Clone failure for one repo logs error and continues to next repo", + "Skills from failed repo are marked as failed in summary", + "Successful repos are unaffected by earlier failures", + "Exit code is non-zero if any skills failed" + ], + "passes": false + }, + { + "id": "cli-2", + "category": "cli", + "description": "Dry-run mode: preview all actions without filesystem changes", + "steps": [ + "--dry-run shows which repos would be cloned", + "--dry-run shows which skills would be fetched/skipped/warned", + "--dry-run shows which agent symlinks would be created", + "No files are written, no repos are cloned, no symlinks created" + ], + "passes": false + }, + { + "id": "cli-3", + "category": "cli", + "description": "Summary output at end: skill name, source, status (fetched/skipped/failed/from-backup), agent assignment counts", + "steps": [ + "Output lists each skill with its resolution status", + "Agent symlink counts shown per detected agent", + "Skipped agents listed with reason (not detected)", + "Total restored/skipped/failed counts shown", + "Output uses @clack/prompts consistent with other simba commands" + ], + "passes": false + }, + { + "id": "test-1", + "category": "testing", + "description": "Tests for core bootstrap logic: registry parsing, repo grouping, idempotency, error isolation", + "steps": [ + "Test: skills partitioned correctly into installable vs adopted", + "Test: skills grouped by repo correctly", + "Test: existing skills skipped without --force", + "Test: --force triggers snapshot before overwrite", + "Test: failed repo doesn't abort other repos", + "Test: local missing path produces warning not error" + ], + "passes": false + } + ], + "context": { + "patterns": [ + "CLI commands: src/commands/install.ts — citty defineCommand, @clack/prompts, runInstall() pattern", + "Agent detection: src/commands/detect.ts — AgentRegistry.detectAgents()", + "Symlink assignment: src/commands/assign.ts — SkillsStore.assignSkill()", + "Backup extraction: src/commands/restore.ts — tar.gz handling", + "Snapshot creation: src/core/snapshot.ts — autoSnapshot before destructive ops" + ], + "keyFiles": [ + "src/core/types.ts", + "src/core/registry-store.ts", + "src/core/skills-store.ts", + "src/core/config-store.ts", + "src/core/agent-registry.ts", + "src/commands/install.ts", + "src/commands/assign.ts", + "src/commands/restore.ts", + "src/utils/paths.ts", + "src/utils/symlinks.ts", + "src/index.ts" + ], + "nonGoals": [ + "Parallel cloning", + "Skill pinning / version locking", + "Interactive skill selection", + "Config.toml generation", + "Registry conflict resolution" + ] + } +} diff --git a/.claude/state/bootstrap-command/progress.txt b/.claude/state/bootstrap-command/progress.txt new file mode 100644 index 0000000..b9556e8 --- /dev/null +++ b/.claude/state/bootstrap-command/progress.txt @@ -0,0 +1,18 @@ +# Progress Log +PRD: bootstrap-command +Started: 2026-02-16 + +## Codebase Patterns +- CLI commands use camelCase arg names (e.g. `dryRun` not `dry-run`) +- `alias: "n"` for dry-run shorthand is standard across commands +- Subcommands lazy-loaded via `() => import("./commands/x").then(m => m.default)` +- Positional args use `required: false` when optional +- State dir lives at `.claude/state/` not `.agents/state/` + +--- + +## Task - cli-1 +- Registered `bootstrap` subcommand in `src/index.ts` +- Created `src/commands/bootstrap.ts` with positional `registryPath`, `--backup`, `--force`, `--ssh`, `--dryRun`/`-n` +- Files changed: `src/index.ts`, `src/commands/bootstrap.ts` (new) +- **Learnings:** citty renders camelCase args as `--camelCase` in help output (e.g. `--dryRun`), consistent with other simba commands diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts new file mode 100644 index 0000000..052cd6d --- /dev/null +++ b/src/commands/bootstrap.ts @@ -0,0 +1,37 @@ +import { defineCommand } from "citty" + +export default defineCommand({ + meta: { name: "bootstrap", description: "Restore all skills from registry" }, + args: { + registryPath: { + type: "positional", + description: "Path to registry.json (default: ~/.config/simba/registry.json)", + required: false, + }, + backup: { + type: "string", + description: "Path to backup archive for restoring adopted skills", + required: false, + }, + force: { + type: "boolean", + description: "Overwrite existing skills (creates snapshot first)", + default: false, + }, + ssh: { + type: "boolean", + description: "Use SSH for all remote repos", + default: false, + }, + dryRun: { + type: "boolean", + alias: "n", + description: "Preview actions without making changes", + default: false, + }, + }, + async run({ args }) { + // Implementation in subsequent tasks + console.log("bootstrap: not yet implemented", args) + }, +}) diff --git a/src/index.ts b/src/index.ts index 2fccd5e..09bc663 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ const main = defineCommand({ adopt: () => import("./commands/adopt").then((m) => m.default), assign: () => import("./commands/assign").then((m) => m.default), backup: () => import("./commands/backup").then((m) => m.default), + bootstrap: () => import("./commands/bootstrap").then((m) => m.default), detect: () => import("./commands/detect").then((m) => m.default), doctor: () => import("./commands/doctor").then((m) => m.default), import: () => import("./commands/import").then((m) => m.default), From a5bb65abc38592db707be6aa12996818cc1454cc Mon Sep 17 00:00:00 2001 From: Ethan Fann <6005240+ethanfann@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:27:17 -0800 Subject: [PATCH 02/13] feat(bootstrap): load registry and partition skills into installable vs adopted --- src/commands/bootstrap.ts | 60 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index 052cd6d..366dbf6 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -1,4 +1,41 @@ import { defineCommand } from "citty" +import * as p from "@clack/prompts" +import { RegistryStore } from "../core/registry-store" +import { getRegistryPath } from "../utils/paths" +import type { ManagedSkill } from "../core/types" + +/** Skills with installSource can be re-fetched from their origin */ +export interface InstallableSkill { + name: string + skill: ManagedSkill +} + +/** Skills without installSource were adopted locally and can't be auto-fetched */ +export interface AdoptedSkill { + name: string + skill: ManagedSkill +} + +export interface PartitionedSkills { + installable: InstallableSkill[] + adopted: AdoptedSkill[] +} + +/** Partition registry skills by whether they have an installSource */ +export function partitionSkills(skills: Record): PartitionedSkills { + const installable: InstallableSkill[] = [] + const adopted: AdoptedSkill[] = [] + + for (const [name, skill] of Object.entries(skills)) { + if (skill.installSource) { + installable.push({ name, skill }) + } else { + adopted.push({ name, skill }) + } + } + + return { installable, adopted } +} export default defineCommand({ meta: { name: "bootstrap", description: "Restore all skills from registry" }, @@ -31,7 +68,26 @@ export default defineCommand({ }, }, async run({ args }) { - // Implementation in subsequent tasks - console.log("bootstrap: not yet implemented", args) + p.intro("simba bootstrap") + + const registryPath = args.registryPath || getRegistryPath() + const registryStore = new RegistryStore(registryPath) + const registry = await registryStore.load() + + const skillEntries = Object.entries(registry.skills) + if (skillEntries.length === 0) { + p.log.info("Registry is empty — nothing to bootstrap.") + p.outro("Done") + return + } + + const { installable, adopted } = partitionSkills(registry.skills) + + p.log.info( + `Found ${skillEntries.length} skill(s): ${installable.length} installable, ${adopted.length} adopted` + ) + + // Subsequent tasks will handle cloning, copying, symlinks, etc. + void args }, }) From ac6ae74da8eafe75bd53046c5c0e1a8c54d0c72e Mon Sep 17 00:00:00 2001 From: Ethan Fann <6005240+ethanfann@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:29:11 -0800 Subject: [PATCH 03/13] feat(bootstrap): group installable skills by repo for batch cloning --- .claude/state/bootstrap-command/prd.json | 4 +- .claude/state/bootstrap-command/progress.txt | 17 ++++++ src/commands/bootstrap.ts | 60 +++++++++++++++++++- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/.claude/state/bootstrap-command/prd.json b/.claude/state/bootstrap-command/prd.json index 6515654..ab38482 100644 --- a/.claude/state/bootstrap-command/prd.json +++ b/.claude/state/bootstrap-command/prd.json @@ -26,7 +26,7 @@ "Skills without installSource are classified as adopted", "Empty registry produces clean exit with message" ], - "passes": false + "passes": true }, { "id": "core-2", @@ -38,7 +38,7 @@ "Each group contains skill names and their skillPath within the repo", "Grouping handles both GitHub shorthand and full URLs" ], - "passes": false + "passes": true }, { "id": "core-3", diff --git a/.claude/state/bootstrap-command/progress.txt b/.claude/state/bootstrap-command/progress.txt index b9556e8..be085b8 100644 --- a/.claude/state/bootstrap-command/progress.txt +++ b/.claude/state/bootstrap-command/progress.txt @@ -8,6 +8,7 @@ Started: 2026-02-16 - Subcommands lazy-loaded via `() => import("./commands/x").then(m => m.default)` - Positional args use `required: false` when optional - State dir lives at `.claude/state/` not `.agents/state/` +- Use type guard functions (`is` predicates) to narrow optional fields instead of `as Type` assertions --- @@ -16,3 +17,19 @@ Started: 2026-02-16 - Created `src/commands/bootstrap.ts` with positional `registryPath`, `--backup`, `--force`, `--ssh`, `--dryRun`/`-n` - Files changed: `src/index.ts`, `src/commands/bootstrap.ts` (new) - **Learnings:** citty renders camelCase args as `--camelCase` in help output (e.g. `--dryRun`), consistent with other simba commands + +## Task - core-1 +- Load registry from positional arg or default XDG path via `getRegistryPath()` +- Partition skills into `installable` (has `installSource`) vs `adopted` (no `installSource`) +- Empty registry exits cleanly with `p.log.info` message +- Exported `partitionSkills()` as pure function for testability +- Files changed: `src/commands/bootstrap.ts` +- **Learnings:** `RegistryStore.load()` returns empty registry on ENOENT, so "empty" check is on `Object.entries(registry.skills).length` + +## Task - core-2 +- Added `RepoGroup` interface and `groupByRepo()` pure function +- Groups `InstallableSkill[]` by `installSource.repo`, separates into `remote` and `local` +- Refined `InstallableSkill.skill` type to guarantee `installSource` via type guard `hasInstallSource()` +- Wired into `run()` with summary logging of repo/skill counts +- Files changed: `src/commands/bootstrap.ts` +- **Learnings:** Use type guard (`is` predicate) to narrow `installSource` from optional to required, avoids `as Type` assertion diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index 366dbf6..e37ab3f 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -2,12 +2,12 @@ import { defineCommand } from "citty" import * as p from "@clack/prompts" import { RegistryStore } from "../core/registry-store" import { getRegistryPath } from "../utils/paths" -import type { ManagedSkill } from "../core/types" +import type { ManagedSkill, InstallSource } from "../core/types" /** Skills with installSource can be re-fetched from their origin */ export interface InstallableSkill { name: string - skill: ManagedSkill + skill: ManagedSkill & { installSource: InstallSource } } /** Skills without installSource were adopted locally and can't be auto-fetched */ @@ -21,13 +21,52 @@ export interface PartitionedSkills { adopted: AdoptedSkill[] } +/** A group of skills from the same repo, to be cloned once */ +export interface RepoGroup { + repo: string + protocol: InstallSource["protocol"] + skills: Array<{ name: string; skillPath: string | undefined }> +} + +/** Group installable skills by repo to minimize clones */ +export function groupByRepo(skills: InstallableSkill[]): { remote: RepoGroup[]; local: RepoGroup[] } { + const groups = new Map() + + for (const { name, skill } of skills) { + const { repo, protocol, skillPath } = skill.installSource + const existing = groups.get(repo) + if (existing) { + existing.skills.push({ name, skillPath }) + } else { + groups.set(repo, { repo, protocol, skills: [{ name, skillPath }] }) + } + } + + const remote: RepoGroup[] = [] + const local: RepoGroup[] = [] + + for (const group of groups.values()) { + if (group.protocol === "local") { + local.push(group) + } else { + remote.push(group) + } + } + + return { remote, local } +} + +function hasInstallSource(skill: ManagedSkill): skill is ManagedSkill & { installSource: InstallSource } { + return skill.installSource !== undefined +} + /** Partition registry skills by whether they have an installSource */ export function partitionSkills(skills: Record): PartitionedSkills { const installable: InstallableSkill[] = [] const adopted: AdoptedSkill[] = [] for (const [name, skill] of Object.entries(skills)) { - if (skill.installSource) { + if (hasInstallSource(skill)) { installable.push({ name, skill }) } else { adopted.push({ name, skill }) @@ -87,7 +126,22 @@ export default defineCommand({ `Found ${skillEntries.length} skill(s): ${installable.length} installable, ${adopted.length} adopted` ) + const { remote, local } = groupByRepo(installable) + + if (remote.length > 0) { + p.log.info( + `${remote.length} remote repo(s) to clone, ${remote.reduce((n, g) => n + g.skills.length, 0)} skill(s)` + ) + } + if (local.length > 0) { + p.log.info( + `${local.length} local repo(s) to link, ${local.reduce((n, g) => n + g.skills.length, 0)} skill(s)` + ) + } + // Subsequent tasks will handle cloning, copying, symlinks, etc. void args + void remote + void local }, }) From 530d25d9e9f5c382c8fcd0011941cea668f29c14 Mon Sep 17 00:00:00 2001 From: Ethan Fann <6005240+ethanfann@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:31:19 -0800 Subject: [PATCH 04/13] feat(bootstrap): clone remote repos and extract skills to central store --- src/commands/bootstrap.ts | 102 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 3 deletions(-) diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index e37ab3f..30a77f4 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -1,7 +1,13 @@ import { defineCommand } from "citty" import * as p from "@clack/prompts" +import simpleGit from "simple-git" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { access, mkdir, rm } from "node:fs/promises" import { RegistryStore } from "../core/registry-store" -import { getRegistryPath } from "../utils/paths" +import { SkillsStore } from "../core/skills-store" +import { getRegistryPath, getSkillsDir } from "../utils/paths" +import { discoverSkills } from "./install" import type { ManagedSkill, InstallSource } from "../core/types" /** Skills with installSource can be re-fetched from their origin */ @@ -56,6 +62,89 @@ export function groupByRepo(skills: InstallableSkill[]): { remote: RepoGroup[]; return { remote, local } } +/** Build a git clone URL from repo string and protocol */ +export function resolveGitUrl(repo: string, protocol: InstallSource["protocol"], sshOverride: boolean): string { + const effectiveProtocol = sshOverride ? "ssh" : protocol + // Already a full URL + if (repo.includes("://") || repo.startsWith("git@")) { + return repo + } + // GitHub shorthand (user/repo) + if (effectiveProtocol === "ssh") { + return `git@github.com:${repo}.git` + } + return `https://github.com/${repo}` +} + +export interface FetchResult { + name: string + status: "fetched" | "failed" | "not-found" + message?: string +} + +/** Locate a skill within a cloned repo by its skillPath, or discover by name */ +async function locateSkillInClone( + cloneDir: string, + skillName: string, + skillPath: string | undefined +): Promise { + // If we have an explicit skillPath, resolve it directly + if (skillPath !== undefined) { + const resolved = resolve(cloneDir, skillPath) + try { + await access(join(resolved, "SKILL.md")) + return resolved + } catch { + return undefined + } + } + + // No skillPath — discover skills in clone and find by name + const discovered = await discoverSkills(cloneDir) + const match = discovered.find(s => s.name === skillName) + return match?.path +} + +/** Clone each remote repo group, extract skills, copy to central store */ +export async function fetchRemoteRepos( + groups: RepoGroup[], + skillsStore: SkillsStore, + options: { ssh: boolean } +): Promise { + const results: FetchResult[] = [] + + for (const group of groups) { + const url = resolveGitUrl(group.repo, group.protocol, options.ssh) + const tempDir = join(tmpdir(), `simba-bootstrap-${Date.now()}`) + + try { + await mkdir(tempDir, { recursive: true }) + const git = simpleGit() + await git.clone(url, tempDir, ["--depth", "1"]) + + for (const { name, skillPath } of group.skills) { + const skillDir = await locateSkillInClone(tempDir, name, skillPath) + if (skillDir === undefined) { + results.push({ name, status: "not-found", message: `not found in ${group.repo}` }) + continue + } + + await skillsStore.addSkill(name, skillDir) + results.push({ name, status: "fetched" }) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + for (const { name } of group.skills) { + results.push({ name, status: "failed", message: `clone failed: ${message}` }) + } + } finally { + await rm(tempDir, { recursive: true, force: true }) + } + } + + return results +} + function hasInstallSource(skill: ManagedSkill): skill is ManagedSkill & { installSource: InstallSource } { return skill.installSource !== undefined } @@ -139,9 +228,16 @@ export default defineCommand({ ) } - // Subsequent tasks will handle cloning, copying, symlinks, etc. + const skillsStore = new SkillsStore(getSkillsDir(), registryPath) + + const results = await fetchRemoteRepos(remote, skillsStore, { ssh: args.ssh }) + + for (const r of results) { + p.log.step(`${r.name}: ${r.status}${r.message ? ` — ${r.message}` : ""}`) + } + + // Subsequent tasks will handle local repos, adopted skills, agent symlinks, etc. void args - void remote void local }, }) From 633abbaa0e187b6ad6cd1f19f8397684ac341841 Mon Sep 17 00:00:00 2001 From: Ethan Fann <6005240+ethanfann@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:33:12 -0800 Subject: [PATCH 05/13] feat(bootstrap): symlink local installSource skills to central store --- .claude/state/bootstrap-command/prd.json | 4 +- .claude/state/bootstrap-command/progress.txt | 16 ++++++++ src/commands/bootstrap.ts | 43 ++++++++++++++++++-- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/.claude/state/bootstrap-command/prd.json b/.claude/state/bootstrap-command/prd.json index ab38482..abb6335 100644 --- a/.claude/state/bootstrap-command/prd.json +++ b/.claude/state/bootstrap-command/prd.json @@ -52,7 +52,7 @@ "installSource.protocol is respected (https vs ssh)", "--ssh flag overrides protocol to ssh for all remote repos" ], - "passes": false + "passes": true }, { "id": "core-4", @@ -64,7 +64,7 @@ "Warning message names the skill and the missing path", "Skipped local skills are included in final summary" ], - "passes": false + "passes": true }, { "id": "core-5", diff --git a/.claude/state/bootstrap-command/progress.txt b/.claude/state/bootstrap-command/progress.txt index be085b8..0855c70 100644 --- a/.claude/state/bootstrap-command/progress.txt +++ b/.claude/state/bootstrap-command/progress.txt @@ -33,3 +33,19 @@ Started: 2026-02-16 - Wired into `run()` with summary logging of repo/skill counts - Files changed: `src/commands/bootstrap.ts` - **Learnings:** Use type guard (`is` predicate) to narrow `installSource` from optional to required, avoids `as Type` assertion + +## Task - core-3 +- Implemented `resolveGitUrl()` to build clone URLs from repo string + protocol, with `--ssh` override +- Implemented `locateSkillInClone()` — resolves `skillPath` directly if available, falls back to `discoverSkills()` scan +- Implemented `fetchRemoteRepos()` — iterates repo groups, clones each to temp dir (depth=1), extracts skills via `skillsStore.addSkill()`, cleans up +- Wired into `run()` with per-skill status logging via `@clack/prompts` +- Files changed: `src/commands/bootstrap.ts` +- **Learnings:** `resolve(cloneDir, skillPath)` correctly handles both `./relative` and bare paths; reusing `discoverSkills()` from install.ts for fallback avoids duplicating scan logic + +## Task - core-4 +- Implemented `fetchLocalRepos()` — verifies local path exists via `access()`, symlinks skills via `skillsStore.linkSkill()` +- Missing paths skip all skills in that group with `"skipped"` status and descriptive message naming the path +- Added `"linked"` and `"skipped"` to `FetchResult.status` union +- Wired into `run()`: local results merged with remote results for unified summary output +- Files changed: `src/commands/bootstrap.ts` +- **Learnings:** `locateSkillInClone()` works identically for local paths — no clone needed, just pass the repo path directly diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index 30a77f4..b34d170 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -78,7 +78,7 @@ export function resolveGitUrl(repo: string, protocol: InstallSource["protocol"], export interface FetchResult { name: string - status: "fetched" | "failed" | "not-found" + status: "fetched" | "linked" | "failed" | "not-found" | "skipped" message?: string } @@ -145,6 +145,40 @@ export async function fetchRemoteRepos( return results } +/** Verify local repo paths exist and symlink skills into central store */ +export async function fetchLocalRepos( + groups: RepoGroup[], + skillsStore: SkillsStore +): Promise { + const results: FetchResult[] = [] + + for (const group of groups) { + const repoPath = group.repo + + try { + await access(repoPath) + } catch { + for (const { name } of group.skills) { + results.push({ name, status: "skipped", message: `local path not found: ${repoPath}` }) + } + continue + } + + for (const { name, skillPath } of group.skills) { + const skillDir = await locateSkillInClone(repoPath, name, skillPath) + if (skillDir === undefined) { + results.push({ name, status: "not-found", message: `not found in ${repoPath}` }) + continue + } + + await skillsStore.linkSkill(name, skillDir) + results.push({ name, status: "linked" }) + } + } + + return results +} + function hasInstallSource(skill: ManagedSkill): skill is ManagedSkill & { installSource: InstallSource } { return skill.installSource !== undefined } @@ -230,14 +264,15 @@ export default defineCommand({ const skillsStore = new SkillsStore(getSkillsDir(), registryPath) - const results = await fetchRemoteRepos(remote, skillsStore, { ssh: args.ssh }) + const remoteResults = await fetchRemoteRepos(remote, skillsStore, { ssh: args.ssh }) + const localResults = await fetchLocalRepos(local, skillsStore) + const results = [...remoteResults, ...localResults] for (const r of results) { p.log.step(`${r.name}: ${r.status}${r.message ? ` — ${r.message}` : ""}`) } - // Subsequent tasks will handle local repos, adopted skills, agent symlinks, etc. + // Subsequent tasks will handle adopted skills, agent symlinks, etc. void args - void local }, }) From 59428e33d9797bf371729488e7c7bb1063f0f273 Mon Sep 17 00:00:00 2001 From: Ethan Fann <6005240+ethanfann@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:35:19 -0800 Subject: [PATCH 06/13] feat(bootstrap): handle adopted skills with optional backup restore --- src/commands/bootstrap.ts | 77 +++++++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index b34d170..2fdd102 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -1,9 +1,10 @@ import { defineCommand } from "citty" import * as p from "@clack/prompts" import simpleGit from "simple-git" +import * as tar from "tar" import { tmpdir } from "node:os" -import { join, resolve } from "node:path" -import { access, mkdir, rm } from "node:fs/promises" +import { join, resolve, dirname } from "node:path" +import { access, mkdir, readFile, rm } from "node:fs/promises" import { RegistryStore } from "../core/registry-store" import { SkillsStore } from "../core/skills-store" import { getRegistryPath, getSkillsDir } from "../utils/paths" @@ -78,7 +79,7 @@ export function resolveGitUrl(repo: string, protocol: InstallSource["protocol"], export interface FetchResult { name: string - status: "fetched" | "linked" | "failed" | "not-found" | "skipped" + status: "fetched" | "linked" | "failed" | "not-found" | "skipped" | "from-backup" message?: string } @@ -179,6 +180,70 @@ export async function fetchLocalRepos( return results } +/** Backup archive manifest matching simba backup output */ +interface BackupManifest { + skills: Record +} + +/** Handle adopted skills: warn by default, restore from backup archive when provided */ +export async function handleAdoptedSkills( + adopted: AdoptedSkill[], + skillsStore: SkillsStore, + backupPath: string | undefined +): Promise { + if (adopted.length === 0) return [] + + // No backup — warn about each adopted skill + if (backupPath === undefined) { + return adopted.map(({ name }) => ({ + name, + status: "skipped" as const, + message: "adopted skill — no installSource and no --backup provided", + })) + } + + // Extract backup to temp dir and read manifest + const tempDir = join(dirname(backupPath), `.simba-bootstrap-${Date.now()}`) + try { + await mkdir(tempDir, { recursive: true }) + await tar.extract({ file: backupPath, cwd: tempDir }) + + const manifestRaw = await readFile(join(tempDir, "manifest.json"), "utf-8") + const manifest: BackupManifest = JSON.parse(manifestRaw) as BackupManifest + + const results: FetchResult[] = [] + for (const { name } of adopted) { + if (!(name in manifest.skills)) { + results.push({ + name, + status: "skipped", + message: "adopted skill — not found in backup archive", + }) + continue + } + + const sourcePath = join(tempDir, "skills", name) + try { + await access(sourcePath) + } catch { + results.push({ + name, + status: "skipped", + message: "adopted skill — listed in manifest but missing from archive", + }) + continue + } + + await skillsStore.addSkill(name, sourcePath) + results.push({ name, status: "from-backup" }) + } + + return results + } finally { + await rm(tempDir, { recursive: true, force: true }) + } +} + function hasInstallSource(skill: ManagedSkill): skill is ManagedSkill & { installSource: InstallSource } { return skill.installSource !== undefined } @@ -266,13 +331,11 @@ export default defineCommand({ const remoteResults = await fetchRemoteRepos(remote, skillsStore, { ssh: args.ssh }) const localResults = await fetchLocalRepos(local, skillsStore) - const results = [...remoteResults, ...localResults] + const adoptedResults = await handleAdoptedSkills(adopted, skillsStore, args.backup) + const results = [...remoteResults, ...localResults, ...adoptedResults] for (const r of results) { p.log.step(`${r.name}: ${r.status}${r.message ? ` — ${r.message}` : ""}`) } - - // Subsequent tasks will handle adopted skills, agent symlinks, etc. - void args }, }) From 68aca3dee83512645789a44f25e1552c636206a1 Mon Sep 17 00:00:00 2001 From: Ethan Fann <6005240+ethanfann@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:37:49 -0800 Subject: [PATCH 07/13] feat(bootstrap): skip existing skills, --force snapshots then overwrites --- .claude/state/bootstrap-command/prd.json | 4 +- .claude/state/bootstrap-command/progress.txt | 23 ++++++ src/commands/bootstrap.ts | 73 +++++++++++++++++--- 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/.claude/state/bootstrap-command/prd.json b/.claude/state/bootstrap-command/prd.json index abb6335..11e4773 100644 --- a/.claude/state/bootstrap-command/prd.json +++ b/.claude/state/bootstrap-command/prd.json @@ -76,7 +76,7 @@ "Adopted skills not found in archive are still warned about", "Backup extraction uses same format as simba backup output" ], - "passes": false + "passes": true }, { "id": "core-6", @@ -89,7 +89,7 @@ "--force removes old skill dir and replaces with fresh fetch", "Missing symlinks are recreated in both modes" ], - "passes": false + "passes": true }, { "id": "agent-1", diff --git a/.claude/state/bootstrap-command/progress.txt b/.claude/state/bootstrap-command/progress.txt index 0855c70..2b22ad6 100644 --- a/.claude/state/bootstrap-command/progress.txt +++ b/.claude/state/bootstrap-command/progress.txt @@ -9,6 +9,8 @@ Started: 2026-02-16 - Positional args use `required: false` when optional - State dir lives at `.claude/state/` not `.agents/state/` - Use type guard functions (`is` predicates) to narrow optional fields instead of `as Type` assertions +- `SnapshotManager(getSnapshotsDir(), maxCount)` for pre-destructive snapshots; `createSnapshot([paths], reason)` returns snapshot ID +- Tests use `bun test`, not vitest --- @@ -49,3 +51,24 @@ Started: 2026-02-16 - Wired into `run()`: local results merged with remote results for unified summary output - Files changed: `src/commands/bootstrap.ts` - **Learnings:** `locateSkillInClone()` works identically for local paths — no clone needed, just pass the repo path directly + +## Task - core-5 +- Implemented `handleAdoptedSkills()` — without `--backup`: returns skipped warnings for each adopted skill +- With `--backup`: extracts tar.gz to temp dir, reads manifest, copies matching skills via `skillsStore.addSkill()` +- Skills not in manifest or missing from archive directory get distinct skip messages +- Added `"from-backup"` to `FetchResult.status` union +- Temp dir cleaned up in `finally` block +- Wired into `run()`: adopted results merged into unified results array +- Files changed: `src/commands/bootstrap.ts` +- **Learnings:** Backup format is tar.gz with `manifest.json` (skills keyed by name) and `skills//` dirs; `tar` npm package used same as restore.ts + +## Task - core-6 +- Added `checkExisting()` — checks `skillsStore.hasSkill()` before each skill fetch +- Without `--force`: returns `"exists"` status with hint to use `--force` +- With `--force`: snapshots via `SnapshotManager.createSnapshot()`, removes old skill, returns undefined to proceed +- Added `"exists"` to `FetchResult.status` union +- `fetchRemoteRepos` pre-checks all skills before cloning (avoids unnecessary clone when all exist) +- `fetchLocalRepos` and `handleAdoptedSkills` check per-skill inline +- `SnapshotManager` instantiated with `getSnapshotsDir()` and hardcoded `maxCount: 10` (matches default config) +- Files changed: `src/commands/bootstrap.ts` +- **Learnings:** `SnapshotManager` takes `(snapshotsDir, maxCount)` — no config dependency needed; pre-checking remote groups avoids clone when all skills already exist diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index 2fdd102..b68cf6b 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -7,7 +7,8 @@ import { join, resolve, dirname } from "node:path" import { access, mkdir, readFile, rm } from "node:fs/promises" import { RegistryStore } from "../core/registry-store" import { SkillsStore } from "../core/skills-store" -import { getRegistryPath, getSkillsDir } from "../utils/paths" +import { SnapshotManager } from "../core/snapshot" +import { getRegistryPath, getSkillsDir, getSnapshotsDir } from "../utils/paths" import { discoverSkills } from "./install" import type { ManagedSkill, InstallSource } from "../core/types" @@ -79,10 +80,34 @@ export function resolveGitUrl(repo: string, protocol: InstallSource["protocol"], export interface FetchResult { name: string - status: "fetched" | "linked" | "failed" | "not-found" | "skipped" | "from-backup" + status: "fetched" | "linked" | "failed" | "not-found" | "skipped" | "from-backup" | "exists" message?: string } +/** + * Check if a skill already exists. Returns an "exists" FetchResult to skip, + * or undefined to proceed. With force: snapshots existing skill and removes it. + */ +async function checkExisting( + name: string, + skillsStore: SkillsStore, + force: boolean, + snapshots: SnapshotManager +): Promise { + const exists = await skillsStore.hasSkill(name) + if (!exists) return undefined + + if (!force) { + return { name, status: "exists", message: "already exists (use --force to overwrite)" } + } + + // --force: snapshot then remove + const skillPath = skillsStore.getSkillPath(name) + await snapshots.createSnapshot([skillPath], `bootstrap --force: ${name}`) + await skillsStore.removeSkill(name) + return undefined +} + /** Locate a skill within a cloned repo by its skillPath, or discover by name */ async function locateSkillInClone( cloneDir: string, @@ -110,11 +135,24 @@ async function locateSkillInClone( export async function fetchRemoteRepos( groups: RepoGroup[], skillsStore: SkillsStore, - options: { ssh: boolean } + options: { ssh: boolean; force: boolean; snapshots: SnapshotManager } ): Promise { const results: FetchResult[] = [] for (const group of groups) { + // Pre-check all skills for existence before cloning + const pending: Array<{ name: string; skillPath: string | undefined }> = [] + for (const skill of group.skills) { + const existing = await checkExisting(skill.name, skillsStore, options.force, options.snapshots) + if (existing !== undefined) { + results.push(existing) + } else { + pending.push(skill) + } + } + + if (pending.length === 0) continue + const url = resolveGitUrl(group.repo, group.protocol, options.ssh) const tempDir = join(tmpdir(), `simba-bootstrap-${Date.now()}`) @@ -123,7 +161,7 @@ export async function fetchRemoteRepos( const git = simpleGit() await git.clone(url, tempDir, ["--depth", "1"]) - for (const { name, skillPath } of group.skills) { + for (const { name, skillPath } of pending) { const skillDir = await locateSkillInClone(tempDir, name, skillPath) if (skillDir === undefined) { results.push({ name, status: "not-found", message: `not found in ${group.repo}` }) @@ -135,7 +173,7 @@ export async function fetchRemoteRepos( } } catch (err) { const message = err instanceof Error ? err.message : String(err) - for (const { name } of group.skills) { + for (const { name } of pending) { results.push({ name, status: "failed", message: `clone failed: ${message}` }) } } finally { @@ -149,7 +187,8 @@ export async function fetchRemoteRepos( /** Verify local repo paths exist and symlink skills into central store */ export async function fetchLocalRepos( groups: RepoGroup[], - skillsStore: SkillsStore + skillsStore: SkillsStore, + options: { force: boolean; snapshots: SnapshotManager } ): Promise { const results: FetchResult[] = [] @@ -166,6 +205,12 @@ export async function fetchLocalRepos( } for (const { name, skillPath } of group.skills) { + const existing = await checkExisting(name, skillsStore, options.force, options.snapshots) + if (existing !== undefined) { + results.push(existing) + continue + } + const skillDir = await locateSkillInClone(repoPath, name, skillPath) if (skillDir === undefined) { results.push({ name, status: "not-found", message: `not found in ${repoPath}` }) @@ -189,7 +234,8 @@ interface BackupManifest { export async function handleAdoptedSkills( adopted: AdoptedSkill[], skillsStore: SkillsStore, - backupPath: string | undefined + backupPath: string | undefined, + options: { force: boolean; snapshots: SnapshotManager } ): Promise { if (adopted.length === 0) return [] @@ -222,6 +268,12 @@ export async function handleAdoptedSkills( continue } + const existing = await checkExisting(name, skillsStore, options.force, options.snapshots) + if (existing !== undefined) { + results.push(existing) + continue + } + const sourcePath = join(tempDir, "skills", name) try { await access(sourcePath) @@ -328,10 +380,11 @@ export default defineCommand({ } const skillsStore = new SkillsStore(getSkillsDir(), registryPath) + const snapshots = new SnapshotManager(getSnapshotsDir(), 10) - const remoteResults = await fetchRemoteRepos(remote, skillsStore, { ssh: args.ssh }) - const localResults = await fetchLocalRepos(local, skillsStore) - const adoptedResults = await handleAdoptedSkills(adopted, skillsStore, args.backup) + const remoteResults = await fetchRemoteRepos(remote, skillsStore, { ssh: args.ssh, force: args.force, snapshots }) + const localResults = await fetchLocalRepos(local, skillsStore, { force: args.force, snapshots }) + const adoptedResults = await handleAdoptedSkills(adopted, skillsStore, args.backup, { force: args.force, snapshots }) const results = [...remoteResults, ...localResults, ...adoptedResults] for (const r of results) { From b738deb413aae40665a390b2abfd90826978114d Mon Sep 17 00:00:00 2001 From: Ethan Fann <6005240+ethanfann@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:39:56 -0800 Subject: [PATCH 08/13] feat(bootstrap): assign skills to detected agents via fresh scan --- .claude/state/bootstrap-command/prd.json | 2 +- .claude/state/bootstrap-command/progress.txt | 10 +++ src/commands/bootstrap.ts | 69 +++++++++++++++++++- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/.claude/state/bootstrap-command/prd.json b/.claude/state/bootstrap-command/prd.json index 11e4773..91c4b0f 100644 --- a/.claude/state/bootstrap-command/prd.json +++ b/.claude/state/bootstrap-command/prd.json @@ -102,7 +102,7 @@ "SkillAssignment.type (directory vs file) is respected", "Symlink target matches SkillsStore.assignSkill() behavior" ], - "passes": false + "passes": true }, { "id": "error-1", diff --git a/.claude/state/bootstrap-command/progress.txt b/.claude/state/bootstrap-command/progress.txt index 2b22ad6..fbfa7e4 100644 --- a/.claude/state/bootstrap-command/progress.txt +++ b/.claude/state/bootstrap-command/progress.txt @@ -72,3 +72,13 @@ Started: 2026-02-16 - `SnapshotManager` instantiated with `getSnapshotsDir()` and hardcoded `maxCount: 10` (matches default config) - Files changed: `src/commands/bootstrap.ts` - **Learnings:** `SnapshotManager` takes `(snapshotsDir, maxCount)` — no config dependency needed; pre-checking remote groups avoids clone when all skills already exist + +## Task - agent-1 +- Added `AssignResult` interface and `assignSkillsToAgents()` function +- Fresh `AgentRegistry.detectAgents()` scan — not cached config +- Iterates each successfully-fetched skill's `assignments` record, symlinks only for detected agents +- Undetected agents logged as `"skipped"` with reason +- Delegates to `skillsStore.assignSkill()` which respects `SkillAssignment.type` (directory vs file) +- `"exists"` skills also get agent assignments (skill already in store, just needs symlinks) +- Files changed: `src/commands/bootstrap.ts` +- **Learnings:** `ConfigStore.load()` merges with defaults so all known agents are always present; `expandPath()` needed to resolve `~` in `agent.globalPath` diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index b68cf6b..ef9f890 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -8,9 +8,11 @@ import { access, mkdir, readFile, rm } from "node:fs/promises" import { RegistryStore } from "../core/registry-store" import { SkillsStore } from "../core/skills-store" import { SnapshotManager } from "../core/snapshot" -import { getRegistryPath, getSkillsDir, getSnapshotsDir } from "../utils/paths" +import { AgentRegistry } from "../core/agent-registry" +import { ConfigStore } from "../core/config-store" +import { getRegistryPath, getSkillsDir, getSnapshotsDir, getConfigPath, expandPath } from "../utils/paths" import { discoverSkills } from "./install" -import type { ManagedSkill, InstallSource } from "../core/types" +import type { ManagedSkill, InstallSource, SkillAssignment } from "../core/types" /** Skills with installSource can be re-fetched from their origin */ export interface InstallableSkill { @@ -316,6 +318,45 @@ export function partitionSkills(skills: Record): Partition return { installable, adopted } } +export interface AssignResult { + skill: string + agent: string + status: "assigned" | "skipped" + message?: string +} + +/** Detect agents and create symlinks for each skill's assignments */ +export async function assignSkillsToAgents( + registry: { skills: Record }, + skillsStore: SkillsStore, + fetchedSkills: Set, + config: { agents: Record } +): Promise { + const agentRegistry = new AgentRegistry(config.agents as Record) + const detected = await agentRegistry.detectAgents() + const results: AssignResult[] = [] + + for (const skillName of fetchedSkills) { + const skill = registry.skills[skillName] + if (!skill) continue + + const assignments = skill.assignments + for (const [agentId, assignment] of Object.entries(assignments)) { + const agent = detected[agentId] + if (!agent?.detected) { + results.push({ skill: skillName, agent: agentId, status: "skipped", message: "agent not detected" }) + continue + } + + const agentSkillsDir = expandPath(agent.globalPath) + await skillsStore.assignSkill(skillName, agentSkillsDir, assignment) + results.push({ skill: skillName, agent: agentId, status: "assigned" }) + } + } + + return results +} + export default defineCommand({ meta: { name: "bootstrap", description: "Restore all skills from registry" }, args: { @@ -390,5 +431,29 @@ export default defineCommand({ for (const r of results) { p.log.step(`${r.name}: ${r.status}${r.message ? ` — ${r.message}` : ""}`) } + + // Agent assignment: symlink skills to detected agents + const successStatuses = new Set(["fetched", "linked", "from-backup", "exists"]) + const fetchedSkills = new Set( + results.filter(r => successStatuses.has(r.status)).map(r => r.name) + ) + + if (fetchedSkills.size > 0) { + const configStore = new ConfigStore(getConfigPath()) + const config = await configStore.load() + const assignResults = await assignSkillsToAgents(registry, skillsStore, fetchedSkills, config) + + const assigned = assignResults.filter(r => r.status === "assigned") + const skipped = assignResults.filter(r => r.status === "skipped") + + if (assigned.length > 0) { + p.log.success(`Assigned ${assigned.length} skill-agent symlink(s)`) + } + for (const r of skipped) { + p.log.warn(`${r.skill} → ${r.agent}: ${r.message}`) + } + } + + p.outro("Done") }, }) From 01a6597eb8f01b787c634da2b004716085a54abd Mon Sep 17 00:00:00 2001 From: Ethan Fann <6005240+ethanfann@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:41:31 -0800 Subject: [PATCH 09/13] feat(bootstrap): exit non-zero when any skills fail to fetch --- .claude/state/bootstrap-command/prd.json | 2 +- .claude/state/bootstrap-command/progress.txt | 7 +++++++ src/commands/bootstrap.ts | 4 +++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.claude/state/bootstrap-command/prd.json b/.claude/state/bootstrap-command/prd.json index 91c4b0f..34954c8 100644 --- a/.claude/state/bootstrap-command/prd.json +++ b/.claude/state/bootstrap-command/prd.json @@ -114,7 +114,7 @@ "Successful repos are unaffected by earlier failures", "Exit code is non-zero if any skills failed" ], - "passes": false + "passes": true }, { "id": "cli-2", diff --git a/.claude/state/bootstrap-command/progress.txt b/.claude/state/bootstrap-command/progress.txt index fbfa7e4..a6a3f0a 100644 --- a/.claude/state/bootstrap-command/progress.txt +++ b/.claude/state/bootstrap-command/progress.txt @@ -82,3 +82,10 @@ Started: 2026-02-16 - `"exists"` skills also get agent assignments (skill already in store, just needs symlinks) - Files changed: `src/commands/bootstrap.ts` - **Learnings:** `ConfigStore.load()` merges with defaults so all known agents are always present; `expandPath()` needed to resolve `~` in `agent.globalPath` + +## Task - error-1 +- Error isolation already implemented: `fetchRemoteRepos` wraps each group's clone+extract in try/catch, marks all pending skills as `"failed"`, continues to next group +- Added non-zero exit code: `process.exit(1)` when any result has `status === "failed"` +- Outro message differentiates: "Done (with errors)" vs "Done" +- Files changed: `src/commands/bootstrap.ts` +- **Learnings:** Most of error-1 was already done by core-3's try/catch structure; only missing piece was propagating failure to exit code diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index ef9f890..d9ae760 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -454,6 +454,8 @@ export default defineCommand({ } } - p.outro("Done") + const hasFailed = results.some(r => r.status === "failed") + p.outro(hasFailed ? "Done (with errors)" : "Done") + if (hasFailed) process.exit(1) }, }) From f769022735ac191ec8553a142fbee13d17b0fd93 Mon Sep 17 00:00:00 2001 From: Ethan Fann <6005240+ethanfann@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:43:07 -0800 Subject: [PATCH 10/13] feat(bootstrap): dry-run mode previews actions without filesystem changes --- .claude/state/bootstrap-command/prd.json | 2 +- .claude/state/bootstrap-command/progress.txt | 8 +++ src/commands/bootstrap.ts | 57 ++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/.claude/state/bootstrap-command/prd.json b/.claude/state/bootstrap-command/prd.json index 34954c8..3fd03f6 100644 --- a/.claude/state/bootstrap-command/prd.json +++ b/.claude/state/bootstrap-command/prd.json @@ -126,7 +126,7 @@ "--dry-run shows which agent symlinks would be created", "No files are written, no repos are cloned, no symlinks created" ], - "passes": false + "passes": true }, { "id": "cli-3", diff --git a/.claude/state/bootstrap-command/progress.txt b/.claude/state/bootstrap-command/progress.txt index a6a3f0a..00ad187 100644 --- a/.claude/state/bootstrap-command/progress.txt +++ b/.claude/state/bootstrap-command/progress.txt @@ -89,3 +89,11 @@ Started: 2026-02-16 - Outro message differentiates: "Done (with errors)" vs "Done" - Files changed: `src/commands/bootstrap.ts` - **Learnings:** Most of error-1 was already done by core-3's try/catch structure; only missing piece was propagating failure to exit code + +## Task - cli-2 +- Added `--dryRun` early return in `run()` after grouping/partitioning, before any filesystem ops +- Previews: remote repos with clone URLs, local repos with paths, adopted skills with backup status +- Previews agent assignments using real `AgentRegistry.detectAgents()` (read-only) +- No `SkillsStore`, `SnapshotManager`, or git operations instantiated in dry-run path +- Files changed: `src/commands/bootstrap.ts` +- **Learnings:** Agent detection is read-only (just checks filesystem for agent dirs), safe to run in dry-run mode for accurate preview diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index d9ae760..09103d6 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -420,6 +420,63 @@ export default defineCommand({ ) } + // Dry-run: preview all actions without filesystem changes + if (args.dryRun) { + if (remote.length > 0) { + p.log.step("Would clone remote repos:") + for (const group of remote) { + const url = resolveGitUrl(group.repo, group.protocol, args.ssh) + p.log.message(` ${url}`) + for (const s of group.skills) { + p.log.message(` → ${s.name}${s.skillPath ? ` (${s.skillPath})` : ""}`) + } + } + } + + if (local.length > 0) { + p.log.step("Would link local repos:") + for (const group of local) { + p.log.message(` ${group.repo}`) + for (const s of group.skills) { + p.log.message(` → ${s.name}${s.skillPath ? ` (${s.skillPath})` : ""}`) + } + } + } + + if (adopted.length > 0) { + p.log.step(`Would handle ${adopted.length} adopted skill(s):`) + for (const { name } of adopted) { + const action = args.backup ? "restore from backup" : "skip (no --backup)" + p.log.message(` ${name}: ${action}`) + } + } + + // Preview agent assignments + const configStore = new ConfigStore(getConfigPath()) + const config = await configStore.load() + const agentRegistry = new AgentRegistry(config.agents as Record) + const detected = await agentRegistry.detectAgents() + const detectedNames = Object.entries(detected) + .filter(([, a]) => a.detected) + .map(([id]) => id) + + if (detectedNames.length > 0) { + p.log.step(`Would assign to detected agents: ${detectedNames.join(", ")}`) + const allSkillNames = [...installable.map(s => s.name), ...adopted.map(s => s.name)] + for (const skillName of allSkillNames) { + const skill = registry.skills[skillName] + if (!skill) continue + for (const [agentId, _assignment] of Object.entries(skill.assignments)) { + const status = detected[agentId]?.detected ? "symlink" : "skip (not detected)" + p.log.message(` ${skillName} → ${agentId}: ${status}`) + } + } + } + + p.outro("Dry run complete — no changes made") + return + } + const skillsStore = new SkillsStore(getSkillsDir(), registryPath) const snapshots = new SnapshotManager(getSnapshotsDir(), 10) From 0ac6d1ff4dbd0272b3a782ef49dd5d60c4aba729 Mon Sep 17 00:00:00 2001 From: Ethan Fann <6005240+ethanfann@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:44:56 -0800 Subject: [PATCH 11/13] feat(bootstrap): structured summary with per-skill status and agent counts --- .claude/state/bootstrap-command/prd.json | 2 +- .claude/state/bootstrap-command/progress.txt | 8 ++ src/commands/bootstrap.ts | 87 ++++++++++++++++---- 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/.claude/state/bootstrap-command/prd.json b/.claude/state/bootstrap-command/prd.json index 3fd03f6..ac69437 100644 --- a/.claude/state/bootstrap-command/prd.json +++ b/.claude/state/bootstrap-command/prd.json @@ -139,7 +139,7 @@ "Total restored/skipped/failed counts shown", "Output uses @clack/prompts consistent with other simba commands" ], - "passes": false + "passes": true }, { "id": "test-1", diff --git a/.claude/state/bootstrap-command/progress.txt b/.claude/state/bootstrap-command/progress.txt index 00ad187..9671779 100644 --- a/.claude/state/bootstrap-command/progress.txt +++ b/.claude/state/bootstrap-command/progress.txt @@ -97,3 +97,11 @@ Started: 2026-02-16 - No `SkillsStore`, `SnapshotManager`, or git operations instantiated in dry-run path - Files changed: `src/commands/bootstrap.ts` - **Learnings:** Agent detection is read-only (just checks filesystem for agent dirs), safe to run in dry-run mode for accurate preview + +## Task - cli-3 +- Replaced flat per-skill logging with structured summary section +- Per-skill: status-appropriate log levels (success/warn/error) with status and message +- Per-agent: counts of assigned skills, skipped agents listed with reason +- Totals line: fetched/linked/restored/existing/skipped/failed counts in outro +- Files changed: `src/commands/bootstrap.ts` +- **Learnings:** `FetchResult["status"]` indexing extracts the union type from the interface — useful for `Set<>` type param instead of repeating string literals diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index 09103d6..4420e56 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -485,34 +485,93 @@ export default defineCommand({ const adoptedResults = await handleAdoptedSkills(adopted, skillsStore, args.backup, { force: args.force, snapshots }) const results = [...remoteResults, ...localResults, ...adoptedResults] - for (const r of results) { - p.log.step(`${r.name}: ${r.status}${r.message ? ` — ${r.message}` : ""}`) - } - // Agent assignment: symlink skills to detected agents - const successStatuses = new Set(["fetched", "linked", "from-backup", "exists"]) + const successStatuses = new Set(["fetched", "linked", "from-backup", "exists"]) const fetchedSkills = new Set( results.filter(r => successStatuses.has(r.status)).map(r => r.name) ) + let assignResults: AssignResult[] = [] if (fetchedSkills.size > 0) { const configStore = new ConfigStore(getConfigPath()) const config = await configStore.load() - const assignResults = await assignSkillsToAgents(registry, skillsStore, fetchedSkills, config) + assignResults = await assignSkillsToAgents(registry, skillsStore, fetchedSkills, config) + } - const assigned = assignResults.filter(r => r.status === "assigned") - const skipped = assignResults.filter(r => r.status === "skipped") + // --- Summary output --- + p.log.step("Summary") - if (assigned.length > 0) { - p.log.success(`Assigned ${assigned.length} skill-agent symlink(s)`) + // Per-skill status + for (const r of results) { + const detail = r.message ? ` — ${r.message}` : "" + switch (r.status) { + case "fetched": + case "linked": + case "from-backup": + p.log.success(`${r.name}: ${r.status}${detail}`) + break + case "exists": + case "skipped": + case "not-found": + p.log.warn(`${r.name}: ${r.status}${detail}`) + break + case "failed": + p.log.error(`${r.name}: ${r.status}${detail}`) + break } - for (const r of skipped) { - p.log.warn(`${r.skill} → ${r.agent}: ${r.message}`) + } + + // Agent assignment counts per detected agent + if (assignResults.length > 0) { + const perAgent = new Map() + const skippedAgents = new Set() + + for (const r of assignResults) { + if (r.status === "skipped") { + skippedAgents.add(r.agent) + continue + } + const counts = perAgent.get(r.agent) ?? { assigned: 0, skipped: 0 } + counts.assigned++ + perAgent.set(r.agent, counts) + } + + if (perAgent.size > 0) { + const agentSummaries = [...perAgent.entries()] + .map(([agent, counts]) => `${agent}: ${counts.assigned} skill(s)`) + .join(", ") + p.log.success(`Agent assignments — ${agentSummaries}`) + } + + if (skippedAgents.size > 0) { + p.log.warn(`Skipped agents (not detected): ${[...skippedAgents].join(", ")}`) } } - const hasFailed = results.some(r => r.status === "failed") - p.outro(hasFailed ? "Done (with errors)" : "Done") + // Totals + const counts = { fetched: 0, linked: 0, restored: 0, exists: 0, skipped: 0, failed: 0 } + for (const r of results) { + switch (r.status) { + case "fetched": counts.fetched++; break + case "linked": counts.linked++; break + case "from-backup": counts.restored++; break + case "exists": counts.exists++; break + case "skipped": + case "not-found": counts.skipped++; break + case "failed": counts.failed++; break + } + } + + const parts: string[] = [] + if (counts.fetched > 0) parts.push(`${counts.fetched} fetched`) + if (counts.linked > 0) parts.push(`${counts.linked} linked`) + if (counts.restored > 0) parts.push(`${counts.restored} restored`) + if (counts.exists > 0) parts.push(`${counts.exists} existing`) + if (counts.skipped > 0) parts.push(`${counts.skipped} skipped`) + if (counts.failed > 0) parts.push(`${counts.failed} failed`) + + const hasFailed = counts.failed > 0 + p.outro(`${hasFailed ? "Done (with errors)" : "Done"} — ${parts.join(", ")}`) if (hasFailed) process.exit(1) }, }) From 32eabd057d7d39aca30183639e2e98d3e784a9fe Mon Sep 17 00:00:00 2001 From: Ethan Fann <6005240+ethanfann@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:47:52 -0800 Subject: [PATCH 12/13] test(bootstrap): core logic tests for partition, grouping, idempotency, error isolation --- .claude/state/bootstrap-command/prd.json | 2 +- .claude/state/bootstrap-command/progress.txt | 8 + tests/commands/bootstrap.test.ts | 371 +++++++++++++++++++ 3 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 tests/commands/bootstrap.test.ts diff --git a/.claude/state/bootstrap-command/prd.json b/.claude/state/bootstrap-command/prd.json index ac69437..0ec6adf 100644 --- a/.claude/state/bootstrap-command/prd.json +++ b/.claude/state/bootstrap-command/prd.json @@ -153,7 +153,7 @@ "Test: failed repo doesn't abort other repos", "Test: local missing path produces warning not error" ], - "passes": false + "passes": true } ], "context": { diff --git a/.claude/state/bootstrap-command/progress.txt b/.claude/state/bootstrap-command/progress.txt index 9671779..f37457f 100644 --- a/.claude/state/bootstrap-command/progress.txt +++ b/.claude/state/bootstrap-command/progress.txt @@ -105,3 +105,11 @@ Started: 2026-02-16 - Totals line: fetched/linked/restored/existing/skipped/failed counts in outro - Files changed: `src/commands/bootstrap.ts` - **Learnings:** `FetchResult["status"]` indexing extracts the union type from the interface — useful for `Set<>` type param instead of repeating string literals + +## Task - test-1 +- 20 tests covering: partitionSkills (4), groupByRepo (3), resolveGitUrl (5), fetchLocalRepos (4), handleAdoptedSkills (3), error isolation (1) +- Pure function tests for partitionSkills, groupByRepo, resolveGitUrl — no mocks needed +- Async tests use real SkillsStore + SnapshotManager against temp dirs +- handleAdoptedSkills tests create real tar.gz backups via `tar` npm package +- Files changed: `tests/commands/bootstrap.test.ts` (new) +- **Learnings:** `tar.create` with `cwd` option simplifies creating test archives; no need to mock SkillsStore since it operates on real filesystem in temp dirs diff --git a/tests/commands/bootstrap.test.ts b/tests/commands/bootstrap.test.ts new file mode 100644 index 0000000..9d42400 --- /dev/null +++ b/tests/commands/bootstrap.test.ts @@ -0,0 +1,371 @@ +import { test, expect, describe, beforeEach, afterEach } from "bun:test" +import { mkdir, rm, writeFile, readdir, readFile } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" +import * as tar from "tar" +import { SkillsStore } from "../../src/core/skills-store" +import { SnapshotManager } from "../../src/core/snapshot" +import type { ManagedSkill, InstallSource } from "../../src/core/types" +import { + partitionSkills, + groupByRepo, + resolveGitUrl, + fetchLocalRepos, + handleAdoptedSkills, + type InstallableSkill, + type RepoGroup, +} from "../../src/commands/bootstrap" + +const testDir = join(tmpdir(), "simba-bootstrap-test-" + Date.now()) +const skillsDir = join(testDir, "skills") +const registryPath = join(testDir, "registry.json") +const snapshotsDir = join(testDir, "snapshots") + +function makeManagedSkill(overrides: Partial & { name: string }): ManagedSkill { + return { + name: overrides.name, + source: overrides.source ?? "installed:test/repo", + installedAt: overrides.installedAt ?? "2026-01-01T00:00:00Z", + assignments: overrides.assignments ?? {}, + installSource: overrides.installSource, + } +} + +async function createSkillDir(baseDir: string, name: string) { + const dir = join(baseDir, name) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, "SKILL.md"), `# ${name}`) + return dir +} + +describe("bootstrap", () => { + beforeEach(async () => { + await mkdir(skillsDir, { recursive: true }) + await mkdir(snapshotsDir, { recursive: true }) + }) + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }) + }) + + describe("partitionSkills", () => { + test("skills with installSource classified as installable", () => { + const skills: Record = { + "skill-a": makeManagedSkill({ + name: "skill-a", + installSource: { repo: "user/repo", protocol: "https" }, + }), + } + + const { installable, adopted } = partitionSkills(skills) + + expect(installable).toHaveLength(1) + expect(installable[0].name).toBe("skill-a") + expect(adopted).toHaveLength(0) + }) + + test("skills without installSource classified as adopted", () => { + const skills: Record = { + "skill-b": makeManagedSkill({ name: "skill-b", source: "adopted:claude" }), + } + + const { installable, adopted } = partitionSkills(skills) + + expect(installable).toHaveLength(0) + expect(adopted).toHaveLength(1) + expect(adopted[0].name).toBe("skill-b") + }) + + test("mixed skills partitioned correctly", () => { + const skills: Record = { + installed: makeManagedSkill({ + name: "installed", + installSource: { repo: "org/repo", protocol: "ssh" }, + }), + adopted: makeManagedSkill({ name: "adopted", source: "adopted:claude" }), + "also-installed": makeManagedSkill({ + name: "also-installed", + installSource: { repo: "org/other", protocol: "https" }, + }), + } + + const result = partitionSkills(skills) + + expect(result.installable).toHaveLength(2) + expect(result.adopted).toHaveLength(1) + }) + + test("empty registry produces empty partitions", () => { + const result = partitionSkills({}) + + expect(result.installable).toHaveLength(0) + expect(result.adopted).toHaveLength(0) + }) + }) + + describe("groupByRepo", () => { + test("skills from same repo grouped together", () => { + const skills: InstallableSkill[] = [ + { + name: "skill-a", + skill: makeManagedSkill({ + name: "skill-a", + installSource: { repo: "user/repo", protocol: "https", skillPath: "./a" }, + }) as ManagedSkill & { installSource: InstallSource }, + }, + { + name: "skill-b", + skill: makeManagedSkill({ + name: "skill-b", + installSource: { repo: "user/repo", protocol: "https", skillPath: "./b" }, + }) as ManagedSkill & { installSource: InstallSource }, + }, + ] + + const { remote } = groupByRepo(skills) + + expect(remote).toHaveLength(1) + expect(remote[0].skills).toHaveLength(2) + expect(remote[0].skills.map(s => s.name)).toEqual(["skill-a", "skill-b"]) + }) + + test("local and remote repos separated", () => { + const skills: InstallableSkill[] = [ + { + name: "remote-skill", + skill: makeManagedSkill({ + name: "remote-skill", + installSource: { repo: "user/repo", protocol: "https" }, + }) as ManagedSkill & { installSource: InstallSource }, + }, + { + name: "local-skill", + skill: makeManagedSkill({ + name: "local-skill", + installSource: { repo: "/home/user/skills", protocol: "local" }, + }) as ManagedSkill & { installSource: InstallSource }, + }, + ] + + const { remote, local } = groupByRepo(skills) + + expect(remote).toHaveLength(1) + expect(remote[0].skills[0].name).toBe("remote-skill") + expect(local).toHaveLength(1) + expect(local[0].skills[0].name).toBe("local-skill") + }) + + test("different repos produce separate groups", () => { + const skills: InstallableSkill[] = [ + { + name: "s1", + skill: makeManagedSkill({ + name: "s1", + installSource: { repo: "org/repo-a", protocol: "https" }, + }) as ManagedSkill & { installSource: InstallSource }, + }, + { + name: "s2", + skill: makeManagedSkill({ + name: "s2", + installSource: { repo: "org/repo-b", protocol: "ssh" }, + }) as ManagedSkill & { installSource: InstallSource }, + }, + ] + + const { remote } = groupByRepo(skills) + + expect(remote).toHaveLength(2) + }) + }) + + describe("resolveGitUrl", () => { + test("GitHub shorthand with https", () => { + expect(resolveGitUrl("user/repo", "https", false)).toBe("https://github.com/user/repo") + }) + + test("GitHub shorthand with ssh", () => { + expect(resolveGitUrl("user/repo", "ssh", false)).toBe("git@github.com:user/repo.git") + }) + + test("ssh override converts https to ssh", () => { + expect(resolveGitUrl("user/repo", "https", true)).toBe("git@github.com:user/repo.git") + }) + + test("full URL passed through unchanged", () => { + const url = "https://gitlab.com/org/repo.git" + expect(resolveGitUrl(url, "https", false)).toBe(url) + }) + + test("git@ URL passed through unchanged", () => { + const url = "git@github.com:user/repo.git" + expect(resolveGitUrl(url, "https", false)).toBe(url) + }) + }) + + describe("fetchLocalRepos", () => { + test("existing local path links skill", async () => { + const localRepo = join(testDir, "local-repo") + await createSkillDir(localRepo, "my-skill") + + const groups: RepoGroup[] = [ + { repo: localRepo, protocol: "local", skills: [{ name: "my-skill", skillPath: "my-skill" }] }, + ] + + const store = new SkillsStore(skillsDir, registryPath) + const snapshots = new SnapshotManager(snapshotsDir, 10) + const results = await fetchLocalRepos(groups, store, { force: false, snapshots }) + + expect(results).toHaveLength(1) + expect(results[0].status).toBe("linked") + expect(results[0].name).toBe("my-skill") + }) + + test("missing local path skips with warning", async () => { + const groups: RepoGroup[] = [ + { + repo: "/nonexistent/path", + protocol: "local", + skills: [{ name: "skill-x", skillPath: undefined }], + }, + ] + + const store = new SkillsStore(skillsDir, registryPath) + const snapshots = new SnapshotManager(snapshotsDir, 10) + const results = await fetchLocalRepos(groups, store, { force: false, snapshots }) + + expect(results).toHaveLength(1) + expect(results[0].status).toBe("skipped") + expect(results[0].message).toContain("/nonexistent/path") + }) + + test("existing skill skipped without --force", async () => { + const localRepo = join(testDir, "local-repo") + await createSkillDir(localRepo, "existing-skill") + // Pre-populate in store + await createSkillDir(skillsDir, "existing-skill") + + const groups: RepoGroup[] = [ + { repo: localRepo, protocol: "local", skills: [{ name: "existing-skill", skillPath: "existing-skill" }] }, + ] + + const store = new SkillsStore(skillsDir, registryPath) + const snapshots = new SnapshotManager(snapshotsDir, 10) + const results = await fetchLocalRepos(groups, store, { force: false, snapshots }) + + expect(results).toHaveLength(1) + expect(results[0].status).toBe("exists") + }) + + test("--force snapshots then overwrites existing skill", async () => { + const localRepo = join(testDir, "local-repo") + await createSkillDir(localRepo, "force-skill") + await createSkillDir(skillsDir, "force-skill") + + const groups: RepoGroup[] = [ + { repo: localRepo, protocol: "local", skills: [{ name: "force-skill", skillPath: "force-skill" }] }, + ] + + const store = new SkillsStore(skillsDir, registryPath) + const snapshots = new SnapshotManager(snapshotsDir, 10) + const results = await fetchLocalRepos(groups, store, { force: true, snapshots }) + + expect(results).toHaveLength(1) + expect(results[0].status).toBe("linked") + + // Snapshot was created + const snapshotEntries = await readdir(snapshotsDir) + expect(snapshotEntries.length).toBeGreaterThan(0) + }) + }) + + describe("handleAdoptedSkills", () => { + test("without --backup returns skipped warnings", async () => { + const adopted = [ + { name: "adopted-a", skill: makeManagedSkill({ name: "adopted-a", source: "adopted:claude" }) }, + { name: "adopted-b", skill: makeManagedSkill({ name: "adopted-b", source: "adopted:windsurf" }) }, + ] + + const store = new SkillsStore(skillsDir, registryPath) + const snapshots = new SnapshotManager(snapshotsDir, 10) + const results = await handleAdoptedSkills(adopted, store, undefined, { force: false, snapshots }) + + expect(results).toHaveLength(2) + expect(results[0].status).toBe("skipped") + expect(results[1].status).toBe("skipped") + expect(results[0].message).toContain("no installSource") + }) + + test("with --backup restores matching skills", async () => { + // Create a backup archive + const backupSrc = join(testDir, "backup-src") + await createSkillDir(join(backupSrc, "skills"), "restored-skill") + const manifest = { skills: { "restored-skill": {} } } + await writeFile(join(backupSrc, "manifest.json"), JSON.stringify(manifest)) + + const backupPath = join(testDir, "backup.tar.gz") + await tar.create({ gzip: true, file: backupPath, cwd: backupSrc }, ["manifest.json", "skills"]) + + const adopted = [ + { name: "restored-skill", skill: makeManagedSkill({ name: "restored-skill", source: "adopted:claude" }) }, + ] + + const store = new SkillsStore(skillsDir, registryPath) + const snapshots = new SnapshotManager(snapshotsDir, 10) + const results = await handleAdoptedSkills(adopted, store, backupPath, { force: false, snapshots }) + + expect(results).toHaveLength(1) + expect(results[0].status).toBe("from-backup") + expect(results[0].name).toBe("restored-skill") + + // Skill actually copied to store + const stored = await readdir(skillsDir) + expect(stored).toContain("restored-skill") + }) + + test("adopted skill not in backup manifest is skipped", async () => { + const backupSrc = join(testDir, "backup-src2") + await mkdir(backupSrc, { recursive: true }) + const manifest = { skills: {} } + await writeFile(join(backupSrc, "manifest.json"), JSON.stringify(manifest)) + + const backupPath = join(testDir, "backup-empty.tar.gz") + await tar.create({ gzip: true, file: backupPath, cwd: backupSrc }, ["manifest.json"]) + + const adopted = [ + { name: "unknown-skill", skill: makeManagedSkill({ name: "unknown-skill", source: "adopted:claude" }) }, + ] + + const store = new SkillsStore(skillsDir, registryPath) + const snapshots = new SnapshotManager(snapshotsDir, 10) + const results = await handleAdoptedSkills(adopted, store, backupPath, { force: false, snapshots }) + + expect(results).toHaveLength(1) + expect(results[0].status).toBe("skipped") + expect(results[0].message).toContain("not found in backup") + }) + }) + + describe("error isolation (fetchLocalRepos)", () => { + test("failed repo does not abort other repos", async () => { + const goodRepo = join(testDir, "good-repo") + await createSkillDir(goodRepo, "good-skill") + + const groups: RepoGroup[] = [ + { repo: "/nonexistent", protocol: "local", skills: [{ name: "bad-skill", skillPath: undefined }] }, + { repo: goodRepo, protocol: "local", skills: [{ name: "good-skill", skillPath: "good-skill" }] }, + ] + + const store = new SkillsStore(skillsDir, registryPath) + const snapshots = new SnapshotManager(snapshotsDir, 10) + const results = await fetchLocalRepos(groups, store, { force: false, snapshots }) + + expect(results).toHaveLength(2) + + const bad = results.find(r => r.name === "bad-skill") + const good = results.find(r => r.name === "good-skill") + expect(bad?.status).toBe("skipped") + expect(good?.status).toBe("linked") + }) + }) +}) From 6f88a53bc355b0afb7420fdf62880cd2c1f0c119 Mon Sep 17 00:00:00 2001 From: Ethan Fann <6005240+ethanfann@users.noreply.github.com> Date: Mon, 16 Feb 2026 18:06:52 -0800 Subject: [PATCH 13/13] fix(bootstrap): remove type assertions, save registry, handle corrupt backups - Remove `as` casts in assignSkillsToAgents, type param correctly - Save registry after agent assignment (matching assign.ts pattern) - Wrap tar extraction and manifest parsing in try-catch - Validate manifest structure before use - Bump version to 0.5.0 --- package.json | 2 +- src/commands/bootstrap.ts | 37 +++++++++++++++++++++++++++++++------ src/index.ts | 2 +- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index b429924..f23bbde 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simba-skills", - "version": "0.4.0", + "version": "0.5.0", "description": "AI skills manager - central store with symlink-based distribution across 14+ coding agents", "publishConfig": { "access": "public" diff --git a/src/commands/bootstrap.ts b/src/commands/bootstrap.ts index 4420e56..f1ff412 100644 --- a/src/commands/bootstrap.ts +++ b/src/commands/bootstrap.ts @@ -254,10 +254,34 @@ export async function handleAdoptedSkills( const tempDir = join(dirname(backupPath), `.simba-bootstrap-${Date.now()}`) try { await mkdir(tempDir, { recursive: true }) - await tar.extract({ file: backupPath, cwd: tempDir }) - const manifestRaw = await readFile(join(tempDir, "manifest.json"), "utf-8") - const manifest: BackupManifest = JSON.parse(manifestRaw) as BackupManifest + try { + await tar.extract({ file: backupPath, cwd: tempDir }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return adopted.map(({ name }) => ({ + name, + status: "failed" as const, + message: `backup extraction failed: ${message}`, + })) + } + + let manifest: BackupManifest + try { + const manifestRaw = await readFile(join(tempDir, "manifest.json"), "utf-8") + const parsed: unknown = JSON.parse(manifestRaw) + if (parsed === null || typeof parsed !== "object" || !("skills" in parsed)) { + throw new Error("missing 'skills' field") + } + manifest = parsed as BackupManifest + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return adopted.map(({ name }) => ({ + name, + status: "failed" as const, + message: `invalid backup manifest: ${message}`, + })) + } const results: FetchResult[] = [] for (const { name } of adopted) { @@ -330,9 +354,9 @@ export async function assignSkillsToAgents( registry: { skills: Record }, skillsStore: SkillsStore, fetchedSkills: Set, - config: { agents: Record } + config: { agents: Record } ): Promise { - const agentRegistry = new AgentRegistry(config.agents as Record) + const agentRegistry = new AgentRegistry(config.agents) const detected = await agentRegistry.detectAgents() const results: AssignResult[] = [] @@ -454,7 +478,7 @@ export default defineCommand({ // Preview agent assignments const configStore = new ConfigStore(getConfigPath()) const config = await configStore.load() - const agentRegistry = new AgentRegistry(config.agents as Record) + const agentRegistry = new AgentRegistry(config.agents) const detected = await agentRegistry.detectAgents() const detectedNames = Object.entries(detected) .filter(([, a]) => a.detected) @@ -496,6 +520,7 @@ export default defineCommand({ const configStore = new ConfigStore(getConfigPath()) const config = await configStore.load() assignResults = await assignSkillsToAgents(registry, skillsStore, fetchedSkills, config) + await registryStore.save(registry) } // --- Summary output --- diff --git a/src/index.ts b/src/index.ts index 09bc663..6639175 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { defineCommand, runMain, showUsage } from "citty" const main = defineCommand({ meta: { name: "simba", - version: "0.2.0", + version: "0.5.0", description: "AI skills manager", }, async run({ cmd, rawArgs }) {