simba-skills 0.4.0 → 0.5.1
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 -2
- package/src/commands/bootstrap.ts +602 -0
- package/src/core/config-store.ts +1 -0
- package/src/index.ts +2 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simba-skills",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AI skills manager - central store with symlink-based distribution across
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "AI skills manager - central store with symlink-based distribution across 15+ coding agents",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -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/core/config-store.ts
CHANGED
|
@@ -19,6 +19,7 @@ const AGENT_DEFINITIONS: [string, string, string, string, string][] = [
|
|
|
19
19
|
["antigravity", "Antigravity", "Antigrav", "~/.gemini/antigravity/skills", ".agent/skills"],
|
|
20
20
|
["clawdbot", "Clawdbot", "Clawdbot", "~/.clawdbot/skills", "skills"],
|
|
21
21
|
["droid", "Droid", "Droid", "~/.factory/skills", ".factory/skills"],
|
|
22
|
+
["pi", "pi", "pi", "~/.pi/agent/skills", ".pi/skills"],
|
|
22
23
|
];
|
|
23
24
|
|
|
24
25
|
const DEFAULT_AGENTS: Record<string, Agent> = Object.fromEntries(
|
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),
|