diff --git a/.claude/state/bootstrap-command/prd.json b/.claude/state/bootstrap-command/prd.json new file mode 100644 index 0000000..0ec6adf --- /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": true + }, + { + "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": true + }, + { + "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": true + }, + { + "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": true + }, + { + "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": true + }, + { + "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": true + }, + { + "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": true + }, + { + "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": true + }, + { + "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": true + }, + { + "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": true + }, + { + "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": true + } + ], + "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..f37457f --- /dev/null +++ b/.claude/state/bootstrap-command/progress.txt @@ -0,0 +1,115 @@ +# 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/` +- 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 + +--- + +## 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 + +## 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 + +## 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 + +## 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 + +## 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` + +## 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 + +## 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 + +## 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 + +## 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/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 new file mode 100644 index 0000000..f1ff412 --- /dev/null +++ b/src/commands/bootstrap.ts @@ -0,0 +1,602 @@ +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, 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 { SnapshotManager } from "../core/snapshot" +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, SkillAssignment } from "../core/types" + +/** Skills with installSource can be re-fetched from their origin */ +export interface InstallableSkill { + name: string + skill: ManagedSkill & { installSource: InstallSource } +} + +/** 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[] +} + +/** 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 } +} + +/** 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" | "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, + 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; 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()}`) + + try { + await mkdir(tempDir, { recursive: true }) + const git = simpleGit() + await git.clone(url, tempDir, ["--depth", "1"]) + + 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}` }) + 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 pending) { + results.push({ name, status: "failed", message: `clone failed: ${message}` }) + } + } finally { + await rm(tempDir, { recursive: true, force: true }) + } + } + + return results +} + +/** Verify local repo paths exist and symlink skills into central store */ +export async function fetchLocalRepos( + groups: RepoGroup[], + skillsStore: SkillsStore, + options: { force: boolean; snapshots: SnapshotManager } +): 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 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}` }) + continue + } + + await skillsStore.linkSkill(name, skillDir) + results.push({ name, status: "linked" }) + } + } + + 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, + options: { force: boolean; snapshots: SnapshotManager } +): 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 }) + + 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) { + if (!(name in manifest.skills)) { + results.push({ + name, + status: "skipped", + message: "adopted skill — not found in backup archive", + }) + 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) + } 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 +} + +/** 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 (hasInstallSource(skill)) { + installable.push({ name, skill }) + } else { + adopted.push({ name, skill }) + } + } + + 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) + 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: { + 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 }) { + 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` + ) + + 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)` + ) + } + + // 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) + 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) + + 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] + + // 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) + ) + + let assignResults: AssignResult[] = [] + if (fetchedSkills.size > 0) { + const configStore = new ConfigStore(getConfigPath()) + const config = await configStore.load() + assignResults = await assignSkillsToAgents(registry, skillsStore, fetchedSkills, config) + await registryStore.save(registry) + } + + // --- Summary output --- + p.log.step("Summary") + + // 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 + } + } + + // 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(", ")}`) + } + } + + // 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) + }, +}) diff --git a/src/index.ts b/src/index.ts index 2fccd5e..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 }) { @@ -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), 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") + }) + }) +})