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 +2 -3
- package/src/commands/bootstrap.ts +602 -0
- package/src/commands/install.ts +16 -8
- package/src/index.ts +2 -1
package/package.json
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simba-skills",
|
|
3
|
-
"version": "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
|
+
})
|
package/src/commands/install.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
+
if (!options.installAll) {
|
|
413
|
+
const update = await p.confirm({
|
|
414
|
+
message: `Update ${name}?`,
|
|
415
|
+
initialValue: true,
|
|
416
|
+
})
|
|
412
417
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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.
|
|
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),
|