simba-skills 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,10 +1,9 @@
1
1
  {
2
2
  "name": "simba-skills",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "AI skills manager - central store with symlink-based distribution across 14+ coding agents",
5
5
  "publishConfig": {
6
- "access": "public",
7
- "provenance": true
6
+ "access": "public"
8
7
  },
9
8
  "type": "module",
10
9
  "license": "MIT",
@@ -0,0 +1,602 @@
1
+ import { defineCommand } from "citty"
2
+ import * as p from "@clack/prompts"
3
+ import simpleGit from "simple-git"
4
+ import * as tar from "tar"
5
+ import { tmpdir } from "node:os"
6
+ import { join, resolve, dirname } from "node:path"
7
+ import { access, mkdir, readFile, rm } from "node:fs/promises"
8
+ import { RegistryStore } from "../core/registry-store"
9
+ import { SkillsStore } from "../core/skills-store"
10
+ import { SnapshotManager } from "../core/snapshot"
11
+ import { AgentRegistry } from "../core/agent-registry"
12
+ import { ConfigStore } from "../core/config-store"
13
+ import { getRegistryPath, getSkillsDir, getSnapshotsDir, getConfigPath, expandPath } from "../utils/paths"
14
+ import { discoverSkills } from "./install"
15
+ import type { ManagedSkill, InstallSource, SkillAssignment } from "../core/types"
16
+
17
+ /** Skills with installSource can be re-fetched from their origin */
18
+ export interface InstallableSkill {
19
+ name: string
20
+ skill: ManagedSkill & { installSource: InstallSource }
21
+ }
22
+
23
+ /** Skills without installSource were adopted locally and can't be auto-fetched */
24
+ export interface AdoptedSkill {
25
+ name: string
26
+ skill: ManagedSkill
27
+ }
28
+
29
+ export interface PartitionedSkills {
30
+ installable: InstallableSkill[]
31
+ adopted: AdoptedSkill[]
32
+ }
33
+
34
+ /** A group of skills from the same repo, to be cloned once */
35
+ export interface RepoGroup {
36
+ repo: string
37
+ protocol: InstallSource["protocol"]
38
+ skills: Array<{ name: string; skillPath: string | undefined }>
39
+ }
40
+
41
+ /** Group installable skills by repo to minimize clones */
42
+ export function groupByRepo(skills: InstallableSkill[]): { remote: RepoGroup[]; local: RepoGroup[] } {
43
+ const groups = new Map<string, RepoGroup>()
44
+
45
+ for (const { name, skill } of skills) {
46
+ const { repo, protocol, skillPath } = skill.installSource
47
+ const existing = groups.get(repo)
48
+ if (existing) {
49
+ existing.skills.push({ name, skillPath })
50
+ } else {
51
+ groups.set(repo, { repo, protocol, skills: [{ name, skillPath }] })
52
+ }
53
+ }
54
+
55
+ const remote: RepoGroup[] = []
56
+ const local: RepoGroup[] = []
57
+
58
+ for (const group of groups.values()) {
59
+ if (group.protocol === "local") {
60
+ local.push(group)
61
+ } else {
62
+ remote.push(group)
63
+ }
64
+ }
65
+
66
+ return { remote, local }
67
+ }
68
+
69
+ /** Build a git clone URL from repo string and protocol */
70
+ export function resolveGitUrl(repo: string, protocol: InstallSource["protocol"], sshOverride: boolean): string {
71
+ const effectiveProtocol = sshOverride ? "ssh" : protocol
72
+ // Already a full URL
73
+ if (repo.includes("://") || repo.startsWith("git@")) {
74
+ return repo
75
+ }
76
+ // GitHub shorthand (user/repo)
77
+ if (effectiveProtocol === "ssh") {
78
+ return `git@github.com:${repo}.git`
79
+ }
80
+ return `https://github.com/${repo}`
81
+ }
82
+
83
+ export interface FetchResult {
84
+ name: string
85
+ status: "fetched" | "linked" | "failed" | "not-found" | "skipped" | "from-backup" | "exists"
86
+ message?: string
87
+ }
88
+
89
+ /**
90
+ * Check if a skill already exists. Returns an "exists" FetchResult to skip,
91
+ * or undefined to proceed. With force: snapshots existing skill and removes it.
92
+ */
93
+ async function checkExisting(
94
+ name: string,
95
+ skillsStore: SkillsStore,
96
+ force: boolean,
97
+ snapshots: SnapshotManager
98
+ ): Promise<FetchResult | undefined> {
99
+ const exists = await skillsStore.hasSkill(name)
100
+ if (!exists) return undefined
101
+
102
+ if (!force) {
103
+ return { name, status: "exists", message: "already exists (use --force to overwrite)" }
104
+ }
105
+
106
+ // --force: snapshot then remove
107
+ const skillPath = skillsStore.getSkillPath(name)
108
+ await snapshots.createSnapshot([skillPath], `bootstrap --force: ${name}`)
109
+ await skillsStore.removeSkill(name)
110
+ return undefined
111
+ }
112
+
113
+ /** Locate a skill within a cloned repo by its skillPath, or discover by name */
114
+ async function locateSkillInClone(
115
+ cloneDir: string,
116
+ skillName: string,
117
+ skillPath: string | undefined
118
+ ): Promise<string | undefined> {
119
+ // If we have an explicit skillPath, resolve it directly
120
+ if (skillPath !== undefined) {
121
+ const resolved = resolve(cloneDir, skillPath)
122
+ try {
123
+ await access(join(resolved, "SKILL.md"))
124
+ return resolved
125
+ } catch {
126
+ return undefined
127
+ }
128
+ }
129
+
130
+ // No skillPath — discover skills in clone and find by name
131
+ const discovered = await discoverSkills(cloneDir)
132
+ const match = discovered.find(s => s.name === skillName)
133
+ return match?.path
134
+ }
135
+
136
+ /** Clone each remote repo group, extract skills, copy to central store */
137
+ export async function fetchRemoteRepos(
138
+ groups: RepoGroup[],
139
+ skillsStore: SkillsStore,
140
+ options: { ssh: boolean; force: boolean; snapshots: SnapshotManager }
141
+ ): Promise<FetchResult[]> {
142
+ const results: FetchResult[] = []
143
+
144
+ for (const group of groups) {
145
+ // Pre-check all skills for existence before cloning
146
+ const pending: Array<{ name: string; skillPath: string | undefined }> = []
147
+ for (const skill of group.skills) {
148
+ const existing = await checkExisting(skill.name, skillsStore, options.force, options.snapshots)
149
+ if (existing !== undefined) {
150
+ results.push(existing)
151
+ } else {
152
+ pending.push(skill)
153
+ }
154
+ }
155
+
156
+ if (pending.length === 0) continue
157
+
158
+ const url = resolveGitUrl(group.repo, group.protocol, options.ssh)
159
+ const tempDir = join(tmpdir(), `simba-bootstrap-${Date.now()}`)
160
+
161
+ try {
162
+ await mkdir(tempDir, { recursive: true })
163
+ const git = simpleGit()
164
+ await git.clone(url, tempDir, ["--depth", "1"])
165
+
166
+ for (const { name, skillPath } of pending) {
167
+ const skillDir = await locateSkillInClone(tempDir, name, skillPath)
168
+ if (skillDir === undefined) {
169
+ results.push({ name, status: "not-found", message: `not found in ${group.repo}` })
170
+ continue
171
+ }
172
+
173
+ await skillsStore.addSkill(name, skillDir)
174
+ results.push({ name, status: "fetched" })
175
+ }
176
+ } catch (err) {
177
+ const message = err instanceof Error ? err.message : String(err)
178
+ for (const { name } of pending) {
179
+ results.push({ name, status: "failed", message: `clone failed: ${message}` })
180
+ }
181
+ } finally {
182
+ await rm(tempDir, { recursive: true, force: true })
183
+ }
184
+ }
185
+
186
+ return results
187
+ }
188
+
189
+ /** Verify local repo paths exist and symlink skills into central store */
190
+ export async function fetchLocalRepos(
191
+ groups: RepoGroup[],
192
+ skillsStore: SkillsStore,
193
+ options: { force: boolean; snapshots: SnapshotManager }
194
+ ): Promise<FetchResult[]> {
195
+ const results: FetchResult[] = []
196
+
197
+ for (const group of groups) {
198
+ const repoPath = group.repo
199
+
200
+ try {
201
+ await access(repoPath)
202
+ } catch {
203
+ for (const { name } of group.skills) {
204
+ results.push({ name, status: "skipped", message: `local path not found: ${repoPath}` })
205
+ }
206
+ continue
207
+ }
208
+
209
+ for (const { name, skillPath } of group.skills) {
210
+ const existing = await checkExisting(name, skillsStore, options.force, options.snapshots)
211
+ if (existing !== undefined) {
212
+ results.push(existing)
213
+ continue
214
+ }
215
+
216
+ const skillDir = await locateSkillInClone(repoPath, name, skillPath)
217
+ if (skillDir === undefined) {
218
+ results.push({ name, status: "not-found", message: `not found in ${repoPath}` })
219
+ continue
220
+ }
221
+
222
+ await skillsStore.linkSkill(name, skillDir)
223
+ results.push({ name, status: "linked" })
224
+ }
225
+ }
226
+
227
+ return results
228
+ }
229
+
230
+ /** Backup archive manifest matching simba backup output */
231
+ interface BackupManifest {
232
+ skills: Record<string, unknown>
233
+ }
234
+
235
+ /** Handle adopted skills: warn by default, restore from backup archive when provided */
236
+ export async function handleAdoptedSkills(
237
+ adopted: AdoptedSkill[],
238
+ skillsStore: SkillsStore,
239
+ backupPath: string | undefined,
240
+ options: { force: boolean; snapshots: SnapshotManager }
241
+ ): Promise<FetchResult[]> {
242
+ if (adopted.length === 0) return []
243
+
244
+ // No backup — warn about each adopted skill
245
+ if (backupPath === undefined) {
246
+ return adopted.map(({ name }) => ({
247
+ name,
248
+ status: "skipped" as const,
249
+ message: "adopted skill — no installSource and no --backup provided",
250
+ }))
251
+ }
252
+
253
+ // Extract backup to temp dir and read manifest
254
+ const tempDir = join(dirname(backupPath), `.simba-bootstrap-${Date.now()}`)
255
+ try {
256
+ await mkdir(tempDir, { recursive: true })
257
+
258
+ try {
259
+ await tar.extract({ file: backupPath, cwd: tempDir })
260
+ } catch (err) {
261
+ const message = err instanceof Error ? err.message : String(err)
262
+ return adopted.map(({ name }) => ({
263
+ name,
264
+ status: "failed" as const,
265
+ message: `backup extraction failed: ${message}`,
266
+ }))
267
+ }
268
+
269
+ let manifest: BackupManifest
270
+ try {
271
+ const manifestRaw = await readFile(join(tempDir, "manifest.json"), "utf-8")
272
+ const parsed: unknown = JSON.parse(manifestRaw)
273
+ if (parsed === null || typeof parsed !== "object" || !("skills" in parsed)) {
274
+ throw new Error("missing 'skills' field")
275
+ }
276
+ manifest = parsed as BackupManifest
277
+ } catch (err) {
278
+ const message = err instanceof Error ? err.message : String(err)
279
+ return adopted.map(({ name }) => ({
280
+ name,
281
+ status: "failed" as const,
282
+ message: `invalid backup manifest: ${message}`,
283
+ }))
284
+ }
285
+
286
+ const results: FetchResult[] = []
287
+ for (const { name } of adopted) {
288
+ if (!(name in manifest.skills)) {
289
+ results.push({
290
+ name,
291
+ status: "skipped",
292
+ message: "adopted skill — not found in backup archive",
293
+ })
294
+ continue
295
+ }
296
+
297
+ const existing = await checkExisting(name, skillsStore, options.force, options.snapshots)
298
+ if (existing !== undefined) {
299
+ results.push(existing)
300
+ continue
301
+ }
302
+
303
+ const sourcePath = join(tempDir, "skills", name)
304
+ try {
305
+ await access(sourcePath)
306
+ } catch {
307
+ results.push({
308
+ name,
309
+ status: "skipped",
310
+ message: "adopted skill — listed in manifest but missing from archive",
311
+ })
312
+ continue
313
+ }
314
+
315
+ await skillsStore.addSkill(name, sourcePath)
316
+ results.push({ name, status: "from-backup" })
317
+ }
318
+
319
+ return results
320
+ } finally {
321
+ await rm(tempDir, { recursive: true, force: true })
322
+ }
323
+ }
324
+
325
+ function hasInstallSource(skill: ManagedSkill): skill is ManagedSkill & { installSource: InstallSource } {
326
+ return skill.installSource !== undefined
327
+ }
328
+
329
+ /** Partition registry skills by whether they have an installSource */
330
+ export function partitionSkills(skills: Record<string, ManagedSkill>): PartitionedSkills {
331
+ const installable: InstallableSkill[] = []
332
+ const adopted: AdoptedSkill[] = []
333
+
334
+ for (const [name, skill] of Object.entries(skills)) {
335
+ if (hasInstallSource(skill)) {
336
+ installable.push({ name, skill })
337
+ } else {
338
+ adopted.push({ name, skill })
339
+ }
340
+ }
341
+
342
+ return { installable, adopted }
343
+ }
344
+
345
+ export interface AssignResult {
346
+ skill: string
347
+ agent: string
348
+ status: "assigned" | "skipped"
349
+ message?: string
350
+ }
351
+
352
+ /** Detect agents and create symlinks for each skill's assignments */
353
+ export async function assignSkillsToAgents(
354
+ registry: { skills: Record<string, ManagedSkill> },
355
+ skillsStore: SkillsStore,
356
+ fetchedSkills: Set<string>,
357
+ config: { agents: Record<string, import("../core/types").Agent> }
358
+ ): Promise<AssignResult[]> {
359
+ const agentRegistry = new AgentRegistry(config.agents)
360
+ const detected = await agentRegistry.detectAgents()
361
+ const results: AssignResult[] = []
362
+
363
+ for (const skillName of fetchedSkills) {
364
+ const skill = registry.skills[skillName]
365
+ if (!skill) continue
366
+
367
+ const assignments = skill.assignments
368
+ for (const [agentId, assignment] of Object.entries(assignments)) {
369
+ const agent = detected[agentId]
370
+ if (!agent?.detected) {
371
+ results.push({ skill: skillName, agent: agentId, status: "skipped", message: "agent not detected" })
372
+ continue
373
+ }
374
+
375
+ const agentSkillsDir = expandPath(agent.globalPath)
376
+ await skillsStore.assignSkill(skillName, agentSkillsDir, assignment)
377
+ results.push({ skill: skillName, agent: agentId, status: "assigned" })
378
+ }
379
+ }
380
+
381
+ return results
382
+ }
383
+
384
+ export default defineCommand({
385
+ meta: { name: "bootstrap", description: "Restore all skills from registry" },
386
+ args: {
387
+ registryPath: {
388
+ type: "positional",
389
+ description: "Path to registry.json (default: ~/.config/simba/registry.json)",
390
+ required: false,
391
+ },
392
+ backup: {
393
+ type: "string",
394
+ description: "Path to backup archive for restoring adopted skills",
395
+ required: false,
396
+ },
397
+ force: {
398
+ type: "boolean",
399
+ description: "Overwrite existing skills (creates snapshot first)",
400
+ default: false,
401
+ },
402
+ ssh: {
403
+ type: "boolean",
404
+ description: "Use SSH for all remote repos",
405
+ default: false,
406
+ },
407
+ dryRun: {
408
+ type: "boolean",
409
+ alias: "n",
410
+ description: "Preview actions without making changes",
411
+ default: false,
412
+ },
413
+ },
414
+ async run({ args }) {
415
+ p.intro("simba bootstrap")
416
+
417
+ const registryPath = args.registryPath || getRegistryPath()
418
+ const registryStore = new RegistryStore(registryPath)
419
+ const registry = await registryStore.load()
420
+
421
+ const skillEntries = Object.entries(registry.skills)
422
+ if (skillEntries.length === 0) {
423
+ p.log.info("Registry is empty — nothing to bootstrap.")
424
+ p.outro("Done")
425
+ return
426
+ }
427
+
428
+ const { installable, adopted } = partitionSkills(registry.skills)
429
+
430
+ p.log.info(
431
+ `Found ${skillEntries.length} skill(s): ${installable.length} installable, ${adopted.length} adopted`
432
+ )
433
+
434
+ const { remote, local } = groupByRepo(installable)
435
+
436
+ if (remote.length > 0) {
437
+ p.log.info(
438
+ `${remote.length} remote repo(s) to clone, ${remote.reduce((n, g) => n + g.skills.length, 0)} skill(s)`
439
+ )
440
+ }
441
+ if (local.length > 0) {
442
+ p.log.info(
443
+ `${local.length} local repo(s) to link, ${local.reduce((n, g) => n + g.skills.length, 0)} skill(s)`
444
+ )
445
+ }
446
+
447
+ // Dry-run: preview all actions without filesystem changes
448
+ if (args.dryRun) {
449
+ if (remote.length > 0) {
450
+ p.log.step("Would clone remote repos:")
451
+ for (const group of remote) {
452
+ const url = resolveGitUrl(group.repo, group.protocol, args.ssh)
453
+ p.log.message(` ${url}`)
454
+ for (const s of group.skills) {
455
+ p.log.message(` → ${s.name}${s.skillPath ? ` (${s.skillPath})` : ""}`)
456
+ }
457
+ }
458
+ }
459
+
460
+ if (local.length > 0) {
461
+ p.log.step("Would link local repos:")
462
+ for (const group of local) {
463
+ p.log.message(` ${group.repo}`)
464
+ for (const s of group.skills) {
465
+ p.log.message(` → ${s.name}${s.skillPath ? ` (${s.skillPath})` : ""}`)
466
+ }
467
+ }
468
+ }
469
+
470
+ if (adopted.length > 0) {
471
+ p.log.step(`Would handle ${adopted.length} adopted skill(s):`)
472
+ for (const { name } of adopted) {
473
+ const action = args.backup ? "restore from backup" : "skip (no --backup)"
474
+ p.log.message(` ${name}: ${action}`)
475
+ }
476
+ }
477
+
478
+ // Preview agent assignments
479
+ const configStore = new ConfigStore(getConfigPath())
480
+ const config = await configStore.load()
481
+ const agentRegistry = new AgentRegistry(config.agents)
482
+ const detected = await agentRegistry.detectAgents()
483
+ const detectedNames = Object.entries(detected)
484
+ .filter(([, a]) => a.detected)
485
+ .map(([id]) => id)
486
+
487
+ if (detectedNames.length > 0) {
488
+ p.log.step(`Would assign to detected agents: ${detectedNames.join(", ")}`)
489
+ const allSkillNames = [...installable.map(s => s.name), ...adopted.map(s => s.name)]
490
+ for (const skillName of allSkillNames) {
491
+ const skill = registry.skills[skillName]
492
+ if (!skill) continue
493
+ for (const [agentId, _assignment] of Object.entries(skill.assignments)) {
494
+ const status = detected[agentId]?.detected ? "symlink" : "skip (not detected)"
495
+ p.log.message(` ${skillName} → ${agentId}: ${status}`)
496
+ }
497
+ }
498
+ }
499
+
500
+ p.outro("Dry run complete — no changes made")
501
+ return
502
+ }
503
+
504
+ const skillsStore = new SkillsStore(getSkillsDir(), registryPath)
505
+ const snapshots = new SnapshotManager(getSnapshotsDir(), 10)
506
+
507
+ const remoteResults = await fetchRemoteRepos(remote, skillsStore, { ssh: args.ssh, force: args.force, snapshots })
508
+ const localResults = await fetchLocalRepos(local, skillsStore, { force: args.force, snapshots })
509
+ const adoptedResults = await handleAdoptedSkills(adopted, skillsStore, args.backup, { force: args.force, snapshots })
510
+ const results = [...remoteResults, ...localResults, ...adoptedResults]
511
+
512
+ // Agent assignment: symlink skills to detected agents
513
+ const successStatuses = new Set<FetchResult["status"]>(["fetched", "linked", "from-backup", "exists"])
514
+ const fetchedSkills = new Set(
515
+ results.filter(r => successStatuses.has(r.status)).map(r => r.name)
516
+ )
517
+
518
+ let assignResults: AssignResult[] = []
519
+ if (fetchedSkills.size > 0) {
520
+ const configStore = new ConfigStore(getConfigPath())
521
+ const config = await configStore.load()
522
+ assignResults = await assignSkillsToAgents(registry, skillsStore, fetchedSkills, config)
523
+ await registryStore.save(registry)
524
+ }
525
+
526
+ // --- Summary output ---
527
+ p.log.step("Summary")
528
+
529
+ // Per-skill status
530
+ for (const r of results) {
531
+ const detail = r.message ? ` — ${r.message}` : ""
532
+ switch (r.status) {
533
+ case "fetched":
534
+ case "linked":
535
+ case "from-backup":
536
+ p.log.success(`${r.name}: ${r.status}${detail}`)
537
+ break
538
+ case "exists":
539
+ case "skipped":
540
+ case "not-found":
541
+ p.log.warn(`${r.name}: ${r.status}${detail}`)
542
+ break
543
+ case "failed":
544
+ p.log.error(`${r.name}: ${r.status}${detail}`)
545
+ break
546
+ }
547
+ }
548
+
549
+ // Agent assignment counts per detected agent
550
+ if (assignResults.length > 0) {
551
+ const perAgent = new Map<string, { assigned: number; skipped: number }>()
552
+ const skippedAgents = new Set<string>()
553
+
554
+ for (const r of assignResults) {
555
+ if (r.status === "skipped") {
556
+ skippedAgents.add(r.agent)
557
+ continue
558
+ }
559
+ const counts = perAgent.get(r.agent) ?? { assigned: 0, skipped: 0 }
560
+ counts.assigned++
561
+ perAgent.set(r.agent, counts)
562
+ }
563
+
564
+ if (perAgent.size > 0) {
565
+ const agentSummaries = [...perAgent.entries()]
566
+ .map(([agent, counts]) => `${agent}: ${counts.assigned} skill(s)`)
567
+ .join(", ")
568
+ p.log.success(`Agent assignments — ${agentSummaries}`)
569
+ }
570
+
571
+ if (skippedAgents.size > 0) {
572
+ p.log.warn(`Skipped agents (not detected): ${[...skippedAgents].join(", ")}`)
573
+ }
574
+ }
575
+
576
+ // Totals
577
+ const counts = { fetched: 0, linked: 0, restored: 0, exists: 0, skipped: 0, failed: 0 }
578
+ for (const r of results) {
579
+ switch (r.status) {
580
+ case "fetched": counts.fetched++; break
581
+ case "linked": counts.linked++; break
582
+ case "from-backup": counts.restored++; break
583
+ case "exists": counts.exists++; break
584
+ case "skipped":
585
+ case "not-found": counts.skipped++; break
586
+ case "failed": counts.failed++; break
587
+ }
588
+ }
589
+
590
+ const parts: string[] = []
591
+ if (counts.fetched > 0) parts.push(`${counts.fetched} fetched`)
592
+ if (counts.linked > 0) parts.push(`${counts.linked} linked`)
593
+ if (counts.restored > 0) parts.push(`${counts.restored} restored`)
594
+ if (counts.exists > 0) parts.push(`${counts.exists} existing`)
595
+ if (counts.skipped > 0) parts.push(`${counts.skipped} skipped`)
596
+ if (counts.failed > 0) parts.push(`${counts.failed} failed`)
597
+
598
+ const hasFailed = counts.failed > 0
599
+ p.outro(`${hasFailed ? "Done (with errors)" : "Done"} — ${parts.join(", ")}`)
600
+ if (hasFailed) process.exit(1)
601
+ },
602
+ })
@@ -282,6 +282,7 @@ export interface InstallOptions {
282
282
  registryPath: string
283
283
  useSSH: boolean
284
284
  skillName?: string // Install specific skill by name, skip selection
285
+ installAll?: boolean // Install all discovered skills without prompts
285
286
  onSelect: (skills: DiscoveredSkill[]) => Promise<string[]>
286
287
  }
287
288
 
@@ -349,7 +350,10 @@ export async function runInstall(options: InstallOptions): Promise<void> {
349
350
 
350
351
  let selected: string[]
351
352
 
352
- if (options.skillName) {
353
+ if (options.installAll) {
354
+ selected = discovered.map(s => s.name)
355
+ console.log(`Installing all ${selected.length} skills...`)
356
+ } else if (options.skillName) {
353
357
  // Direct install of specific skill
354
358
  const skill = discovered.find(s => s.name === options.skillName)
355
359
  if (!skill) {
@@ -405,14 +409,16 @@ export async function runInstall(options: InstallOptions): Promise<void> {
405
409
  renderDiff(comparison.diff, "current", "new")
406
410
  }
407
411
 
408
- const update = await p.confirm({
409
- message: `Update ${name}?`,
410
- initialValue: true,
411
- })
412
+ if (!options.installAll) {
413
+ const update = await p.confirm({
414
+ message: `Update ${name}?`,
415
+ initialValue: true,
416
+ })
412
417
 
413
- if (p.isCancel(update) || !update) {
414
- console.log(` Skipping ${name}`)
415
- continue
418
+ if (p.isCancel(update) || !update) {
419
+ console.log(` Skipping ${name}`)
420
+ continue
421
+ }
416
422
  }
417
423
 
418
424
  // Remove old and add new
@@ -474,6 +480,7 @@ export default defineCommand({
474
480
  source: { type: "positional", description: "GitHub repo (user/repo) or local path", required: true },
475
481
  ssh: { type: "boolean", description: "Use SSH for GitHub repos (for private repos)", default: false },
476
482
  skill: { type: "string", description: "Install specific skill by name (skip selection)", required: false },
483
+ all: { type: "boolean", description: "Install all skills without prompts", default: false },
477
484
  },
478
485
  async run({ args }) {
479
486
  await runInstall({
@@ -482,6 +489,7 @@ export default defineCommand({
482
489
  registryPath: getRegistryPath(),
483
490
  useSSH: args.ssh,
484
491
  skillName: args.skill,
492
+ installAll: args.all,
485
493
  onSelect: async (skills) => {
486
494
  const result = await p.multiselect({
487
495
  message: "Select skills to install:",
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@ import { defineCommand, runMain, showUsage } from "citty"
5
5
  const main = defineCommand({
6
6
  meta: {
7
7
  name: "simba",
8
- version: "0.2.0",
8
+ version: "0.5.0",
9
9
  description: "AI skills manager",
10
10
  },
11
11
  async run({ cmd, rawArgs }) {
@@ -17,6 +17,7 @@ const main = defineCommand({
17
17
  adopt: () => import("./commands/adopt").then((m) => m.default),
18
18
  assign: () => import("./commands/assign").then((m) => m.default),
19
19
  backup: () => import("./commands/backup").then((m) => m.default),
20
+ bootstrap: () => import("./commands/bootstrap").then((m) => m.default),
20
21
  detect: () => import("./commands/detect").then((m) => m.default),
21
22
  doctor: () => import("./commands/doctor").then((m) => m.default),
22
23
  import: () => import("./commands/import").then((m) => m.default),