simba-skills 0.1.2
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/README.md +181 -0
- package/package.json +59 -0
- package/src/commands/.gitkeep +0 -0
- package/src/commands/adopt.ts +232 -0
- package/src/commands/assign.ts +110 -0
- package/src/commands/backup.ts +121 -0
- package/src/commands/detect.ts +63 -0
- package/src/commands/doctor.ts +169 -0
- package/src/commands/import.ts +99 -0
- package/src/commands/install.ts +486 -0
- package/src/commands/list.ts +64 -0
- package/src/commands/manage.ts +12 -0
- package/src/commands/migrate.ts +137 -0
- package/src/commands/restore.ts +175 -0
- package/src/commands/snapshots.ts +39 -0
- package/src/commands/status.ts +70 -0
- package/src/commands/sync.ts +108 -0
- package/src/commands/unassign.ts +68 -0
- package/src/commands/undo.ts +57 -0
- package/src/commands/uninstall.ts +134 -0
- package/src/commands/update.ts +251 -0
- package/src/core/agent-registry.ts +105 -0
- package/src/core/config-store.ts +89 -0
- package/src/core/registry-store.ts +28 -0
- package/src/core/skill-manager.ts +99 -0
- package/src/core/skills-store.ts +79 -0
- package/src/core/snapshot.ts +123 -0
- package/src/core/types.ts +75 -0
- package/src/index.ts +33 -0
- package/src/tui/.gitkeep +0 -0
- package/src/tui/matrix.ts +189 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/diff.ts +53 -0
- package/src/utils/hash.ts +47 -0
- package/src/utils/paths.ts +30 -0
- package/src/utils/prompts.ts +85 -0
- package/src/utils/symlinks.ts +34 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import { defineCommand } from "citty"
|
|
2
|
+
import { readdir, access, readFile } from "node:fs/promises"
|
|
3
|
+
import { join, relative, resolve } from "node:path"
|
|
4
|
+
import * as p from "@clack/prompts"
|
|
5
|
+
import matter from "gray-matter"
|
|
6
|
+
import simpleGit from "simple-git"
|
|
7
|
+
import { tmpdir } from "node:os"
|
|
8
|
+
import { mkdir, rm } from "node:fs/promises"
|
|
9
|
+
import { RegistryStore } from "../core/registry-store"
|
|
10
|
+
import { SkillsStore } from "../core/skills-store"
|
|
11
|
+
import { getSkillsDir, getRegistryPath } from "../utils/paths"
|
|
12
|
+
import { compareFiles, renderDiff } from "../utils/diff"
|
|
13
|
+
import type { ManagedSkill, InstallSource } from "../core/types"
|
|
14
|
+
|
|
15
|
+
interface DiscoveredSkill {
|
|
16
|
+
name: string
|
|
17
|
+
path: string
|
|
18
|
+
description?: string
|
|
19
|
+
relativePath?: string // Path relative to repo root, for installSource tracking
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface MarketplacePlugin {
|
|
23
|
+
name: string
|
|
24
|
+
description?: string
|
|
25
|
+
source?: string
|
|
26
|
+
skills?: string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface MarketplaceJson {
|
|
30
|
+
name: string
|
|
31
|
+
plugins?: MarketplacePlugin[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SKILL_DIRS = ["skills", ".claude/skills", ".cursor/skills", ".codex/skills"]
|
|
35
|
+
|
|
36
|
+
interface SubmoduleInfo {
|
|
37
|
+
path: string
|
|
38
|
+
url: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function parseGitmodules(basePath: string): Promise<SubmoduleInfo[]> {
|
|
42
|
+
const gitmodulesPath = join(basePath, ".gitmodules")
|
|
43
|
+
const submodules: SubmoduleInfo[] = []
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const content = await readFile(gitmodulesPath, "utf-8")
|
|
47
|
+
// Parse submodule sections
|
|
48
|
+
const sections = content.split(/\[submodule\s+"[^"]+"\]/)
|
|
49
|
+
|
|
50
|
+
for (const section of sections) {
|
|
51
|
+
if (!section.trim()) continue
|
|
52
|
+
|
|
53
|
+
const pathMatch = section.match(/^\s*path\s*=\s*(.+)$/m)
|
|
54
|
+
const urlMatch = section.match(/^\s*url\s*=\s*(.+)$/m)
|
|
55
|
+
|
|
56
|
+
if (pathMatch && urlMatch) {
|
|
57
|
+
submodules.push({
|
|
58
|
+
path: pathMatch[1].trim(),
|
|
59
|
+
url: urlMatch[1].trim(),
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// No .gitmodules or can't read it
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return submodules
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function cloneSubmodules(basePath: string): Promise<void> {
|
|
71
|
+
const submodules = await parseGitmodules(basePath)
|
|
72
|
+
|
|
73
|
+
for (const sub of submodules) {
|
|
74
|
+
const subPath = join(basePath, sub.path)
|
|
75
|
+
|
|
76
|
+
// Check if already cloned (has content)
|
|
77
|
+
try {
|
|
78
|
+
const entries = await readdir(subPath)
|
|
79
|
+
if (entries.length > 0) continue // Already has content
|
|
80
|
+
} catch {
|
|
81
|
+
// Directory doesn't exist, need to clone
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log(` Cloning submodule: ${sub.path}...`)
|
|
85
|
+
try {
|
|
86
|
+
const git = simpleGit()
|
|
87
|
+
await mkdir(subPath, { recursive: true })
|
|
88
|
+
await rm(subPath, { recursive: true }) // Remove empty dir for clone
|
|
89
|
+
await git.clone(sub.url, subPath, ["--depth", "1"])
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.log(` Warning: Failed to clone ${sub.path}: ${(err as Error).message}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function findMarketplaceFiles(basePath: string): Promise<string[]> {
|
|
97
|
+
const results: string[] = []
|
|
98
|
+
|
|
99
|
+
async function scan(dir: string): Promise<void> {
|
|
100
|
+
try {
|
|
101
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue
|
|
104
|
+
|
|
105
|
+
const fullPath = join(dir, entry.name)
|
|
106
|
+
if (entry.isDirectory()) {
|
|
107
|
+
if (entry.name === ".claude-plugin") {
|
|
108
|
+
const marketplacePath = join(fullPath, "marketplace.json")
|
|
109
|
+
try {
|
|
110
|
+
await access(marketplacePath)
|
|
111
|
+
results.push(marketplacePath)
|
|
112
|
+
} catch {
|
|
113
|
+
// No marketplace.json in this .claude-plugin
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
await scan(fullPath)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// Can't read directory
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await scan(basePath)
|
|
126
|
+
return results
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function scanMarketplaceSkills(basePath: string): Promise<DiscoveredSkill[]> {
|
|
130
|
+
const skills: DiscoveredSkill[] = []
|
|
131
|
+
const marketplaceFiles = await findMarketplaceFiles(basePath)
|
|
132
|
+
|
|
133
|
+
for (const marketplacePath of marketplaceFiles) {
|
|
134
|
+
try {
|
|
135
|
+
const content = await readFile(marketplacePath, "utf-8")
|
|
136
|
+
const marketplace: MarketplaceJson = JSON.parse(content)
|
|
137
|
+
const pluginDir = join(marketplacePath, "..", "..") // Go up from .claude-plugin/marketplace.json
|
|
138
|
+
|
|
139
|
+
for (const plugin of marketplace.plugins || []) {
|
|
140
|
+
for (const skillPath of plugin.skills || []) {
|
|
141
|
+
// Resolve relative path from plugin's base directory
|
|
142
|
+
const resolvedPath = join(pluginDir, skillPath)
|
|
143
|
+
const skillMdPath = join(resolvedPath, "SKILL.md")
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
await access(skillMdPath)
|
|
147
|
+
const skillContent = await readFile(skillMdPath, "utf-8")
|
|
148
|
+
const { data } = matter(skillContent)
|
|
149
|
+
|
|
150
|
+
// Use skill name from frontmatter, or derive from path
|
|
151
|
+
const name = data.name || skillPath.split("/").pop() || skillPath
|
|
152
|
+
|
|
153
|
+
skills.push({
|
|
154
|
+
name,
|
|
155
|
+
path: resolvedPath,
|
|
156
|
+
description: data.description,
|
|
157
|
+
})
|
|
158
|
+
} catch {
|
|
159
|
+
// SKILL.md doesn't exist at this path
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// Can't parse marketplace.json
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return skills
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function scanDirectoryForSkills(skillsPath: string): Promise<DiscoveredSkill[]> {
|
|
172
|
+
const skills: DiscoveredSkill[] = []
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const entries = await readdir(skillsPath, { withFileTypes: true })
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
if (!entry.isDirectory()) continue
|
|
178
|
+
|
|
179
|
+
const skillPath = join(skillsPath, entry.name)
|
|
180
|
+
const skillMdPath = join(skillPath, "SKILL.md")
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
await access(skillMdPath)
|
|
184
|
+
const content = await readFile(skillMdPath, "utf-8")
|
|
185
|
+
const { data } = matter(content)
|
|
186
|
+
|
|
187
|
+
skills.push({
|
|
188
|
+
name: entry.name,
|
|
189
|
+
path: skillPath,
|
|
190
|
+
description: data.description,
|
|
191
|
+
})
|
|
192
|
+
} catch {
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// Directory doesn't exist or can't be read
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return skills
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function discoverSkills(basePath: string): Promise<DiscoveredSkill[]> {
|
|
204
|
+
const skills: DiscoveredSkill[] = []
|
|
205
|
+
const seenNames = new Set<string>()
|
|
206
|
+
|
|
207
|
+
// Scan standard skill directories
|
|
208
|
+
for (const dir of SKILL_DIRS) {
|
|
209
|
+
const found = await scanDirectoryForSkills(join(basePath, dir))
|
|
210
|
+
for (const skill of found) {
|
|
211
|
+
if (!seenNames.has(skill.name)) {
|
|
212
|
+
skills.push(skill)
|
|
213
|
+
seenNames.add(skill.name)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Scan .claude-plugin/marketplace.json for skill paths
|
|
219
|
+
const marketplaceSkills = await scanMarketplaceSkills(basePath)
|
|
220
|
+
for (const skill of marketplaceSkills) {
|
|
221
|
+
if (!seenNames.has(skill.name)) {
|
|
222
|
+
skills.push(skill)
|
|
223
|
+
seenNames.add(skill.name)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Scan submodules for skills
|
|
228
|
+
const submodules = await parseGitmodules(basePath)
|
|
229
|
+
for (const sub of submodules) {
|
|
230
|
+
const submoduleBase = join(basePath, sub.path)
|
|
231
|
+
|
|
232
|
+
// Check if submodule itself is a skill (has SKILL.md at root)
|
|
233
|
+
try {
|
|
234
|
+
const skillMdPath = join(submoduleBase, "SKILL.md")
|
|
235
|
+
await access(skillMdPath)
|
|
236
|
+
const content = await readFile(skillMdPath, "utf-8")
|
|
237
|
+
const { data } = matter(content)
|
|
238
|
+
const name = data.name || sub.path.split("/").pop() || sub.path
|
|
239
|
+
|
|
240
|
+
if (!seenNames.has(name)) {
|
|
241
|
+
skills.push({
|
|
242
|
+
name,
|
|
243
|
+
path: submoduleBase,
|
|
244
|
+
description: data.description,
|
|
245
|
+
})
|
|
246
|
+
seenNames.add(name)
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
// Not a skill at root, check standard dirs within submodule
|
|
250
|
+
for (const dir of SKILL_DIRS) {
|
|
251
|
+
const found = await scanDirectoryForSkills(join(submoduleBase, dir))
|
|
252
|
+
for (const skill of found) {
|
|
253
|
+
if (!seenNames.has(skill.name)) {
|
|
254
|
+
skills.push(skill)
|
|
255
|
+
seenNames.add(skill.name)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Also check submodule for marketplace.json
|
|
261
|
+
const subMarketplaceSkills = await scanMarketplaceSkills(submoduleBase)
|
|
262
|
+
for (const skill of subMarketplaceSkills) {
|
|
263
|
+
if (!seenNames.has(skill.name)) {
|
|
264
|
+
skills.push(skill)
|
|
265
|
+
seenNames.add(skill.name)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Compute relativePath for each skill
|
|
272
|
+
for (const skill of skills) {
|
|
273
|
+
skill.relativePath = "./" + relative(basePath, skill.path)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return skills
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface InstallOptions {
|
|
280
|
+
source: string
|
|
281
|
+
skillsDir: string
|
|
282
|
+
registryPath: string
|
|
283
|
+
useSSH: boolean
|
|
284
|
+
onSelect: (skills: DiscoveredSkill[]) => Promise<string[]>
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function runInstall(options: InstallOptions): Promise<void> {
|
|
288
|
+
const skillsStore = new SkillsStore(options.skillsDir, options.registryPath)
|
|
289
|
+
const registryStore = new RegistryStore(options.registryPath)
|
|
290
|
+
const registry = await registryStore.load()
|
|
291
|
+
|
|
292
|
+
let sourcePath = options.source
|
|
293
|
+
let isTemp = false
|
|
294
|
+
let sourceInfo: { repo: string; protocol: "https" | "ssh" | "local" } | null = null
|
|
295
|
+
|
|
296
|
+
// Check if it's a local path
|
|
297
|
+
const isLocalPath = options.source.startsWith("/") ||
|
|
298
|
+
options.source.startsWith(".") ||
|
|
299
|
+
options.source.startsWith("~")
|
|
300
|
+
|
|
301
|
+
if (isLocalPath) {
|
|
302
|
+
// Resolve to absolute path
|
|
303
|
+
sourcePath = resolve(options.source.replace(/^~/, process.env.HOME || "~"))
|
|
304
|
+
sourceInfo = {
|
|
305
|
+
repo: sourcePath,
|
|
306
|
+
protocol: "local"
|
|
307
|
+
}
|
|
308
|
+
} else if (options.source.includes("/")) {
|
|
309
|
+
// Git URL or GitHub shorthand
|
|
310
|
+
let url: string
|
|
311
|
+
if (options.source.includes("://") || options.source.startsWith("git@")) {
|
|
312
|
+
// Already a full URL
|
|
313
|
+
url = options.source
|
|
314
|
+
} else if (options.useSSH) {
|
|
315
|
+
// GitHub shorthand with SSH
|
|
316
|
+
url = `git@github.com:${options.source}.git`
|
|
317
|
+
} else {
|
|
318
|
+
// GitHub shorthand with HTTPS
|
|
319
|
+
url = `https://github.com/${options.source}`
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Track git info for installSource
|
|
323
|
+
sourceInfo = {
|
|
324
|
+
repo: options.source,
|
|
325
|
+
protocol: options.useSSH ? "ssh" : "https"
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const tempDir = join(tmpdir(), `simba-install-${Date.now()}`)
|
|
329
|
+
await mkdir(tempDir, { recursive: true })
|
|
330
|
+
isTemp = true
|
|
331
|
+
|
|
332
|
+
console.log(`Cloning ${url}...`)
|
|
333
|
+
const git = simpleGit()
|
|
334
|
+
await git.clone(url, tempDir, ["--depth", "1"])
|
|
335
|
+
sourcePath = tempDir
|
|
336
|
+
|
|
337
|
+
// Clone any submodules defined in .gitmodules
|
|
338
|
+
await cloneSubmodules(sourcePath)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const discovered = await discoverSkills(sourcePath)
|
|
343
|
+
|
|
344
|
+
if (discovered.length === 0) {
|
|
345
|
+
console.log("No skills found in source.")
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
console.log(`\nFound ${discovered.length} skills:`)
|
|
350
|
+
for (const skill of discovered) {
|
|
351
|
+
console.log(` * ${skill.name}${skill.description ? ` - ${skill.description}` : ""}`)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const selected = await options.onSelect(discovered)
|
|
355
|
+
|
|
356
|
+
if (selected.length === 0) {
|
|
357
|
+
console.log("No skills selected.")
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
for (const name of selected) {
|
|
362
|
+
const skill = discovered.find(s => s.name === name)!
|
|
363
|
+
const newContent = await readFile(join(skill.path, "SKILL.md"), "utf-8")
|
|
364
|
+
const newParsed = matter(newContent)
|
|
365
|
+
|
|
366
|
+
if (await skillsStore.hasSkill(name)) {
|
|
367
|
+
// Compare with existing
|
|
368
|
+
const existingPath = join(options.skillsDir, name, "SKILL.md")
|
|
369
|
+
const existingContent = await readFile(existingPath, "utf-8")
|
|
370
|
+
const existingParsed = matter(existingContent)
|
|
371
|
+
|
|
372
|
+
const comparison = compareFiles(existingContent, newContent, "SKILL.md")
|
|
373
|
+
|
|
374
|
+
if (comparison.identical) {
|
|
375
|
+
console.log(` Skipping ${name} (identical)`)
|
|
376
|
+
continue
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Show version info if available
|
|
380
|
+
const existingVersion = existingParsed.data.version as string | undefined
|
|
381
|
+
const newVersion = newParsed.data.version as string | undefined
|
|
382
|
+
|
|
383
|
+
if (existingVersion && newVersion) {
|
|
384
|
+
console.log(`\n ${name}: v${existingVersion} → v${newVersion}`)
|
|
385
|
+
} else {
|
|
386
|
+
console.log(`\n ${name}: changes detected`)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (comparison.diff) {
|
|
390
|
+
renderDiff(comparison.diff, "current", "new")
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const update = await p.confirm({
|
|
394
|
+
message: `Update ${name}?`,
|
|
395
|
+
initialValue: true,
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
if (p.isCancel(update) || !update) {
|
|
399
|
+
console.log(` Skipping ${name}`)
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Remove old and add new
|
|
404
|
+
await rm(join(options.skillsDir, name), { recursive: true })
|
|
405
|
+
|
|
406
|
+
if (sourceInfo?.protocol === "local") {
|
|
407
|
+
await skillsStore.linkSkill(name, skill.path)
|
|
408
|
+
} else {
|
|
409
|
+
await skillsStore.addSkill(name, skill.path)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
registry.skills[name].source = `installed:${options.source}`
|
|
413
|
+
registry.skills[name].installedAt = new Date().toISOString()
|
|
414
|
+
if (sourceInfo) {
|
|
415
|
+
registry.skills[name].installSource = {
|
|
416
|
+
repo: sourceInfo.repo,
|
|
417
|
+
protocol: sourceInfo.protocol,
|
|
418
|
+
skillPath: skill.relativePath
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
console.log(` Updated: ${name}`)
|
|
423
|
+
} else {
|
|
424
|
+
if (sourceInfo?.protocol === "local") {
|
|
425
|
+
await skillsStore.linkSkill(name, skill.path)
|
|
426
|
+
} else {
|
|
427
|
+
await skillsStore.addSkill(name, skill.path)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const managedSkill: ManagedSkill = {
|
|
431
|
+
name,
|
|
432
|
+
source: `installed:${options.source}`,
|
|
433
|
+
installedAt: new Date().toISOString(),
|
|
434
|
+
assignments: {},
|
|
435
|
+
installSource: sourceInfo ? {
|
|
436
|
+
repo: sourceInfo.repo,
|
|
437
|
+
protocol: sourceInfo.protocol,
|
|
438
|
+
skillPath: skill.relativePath
|
|
439
|
+
} : undefined
|
|
440
|
+
}
|
|
441
|
+
registry.skills[name] = managedSkill
|
|
442
|
+
|
|
443
|
+
console.log(` Installed: ${name}`)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
await registryStore.save(registry)
|
|
448
|
+
console.log("\nInstallation complete!")
|
|
449
|
+
} finally {
|
|
450
|
+
if (isTemp) {
|
|
451
|
+
await rm(sourcePath, { recursive: true, force: true })
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export default defineCommand({
|
|
457
|
+
meta: { name: "install", description: "Install skills from GitHub or local path" },
|
|
458
|
+
args: {
|
|
459
|
+
source: { type: "positional", description: "GitHub repo (user/repo) or local path", required: true },
|
|
460
|
+
ssh: { type: "boolean", description: "Use SSH for GitHub repos (for private repos)", default: false },
|
|
461
|
+
},
|
|
462
|
+
async run({ args }) {
|
|
463
|
+
await runInstall({
|
|
464
|
+
source: args.source,
|
|
465
|
+
skillsDir: getSkillsDir(),
|
|
466
|
+
registryPath: getRegistryPath(),
|
|
467
|
+
useSSH: args.ssh,
|
|
468
|
+
onSelect: async (skills) => {
|
|
469
|
+
const result = await p.multiselect({
|
|
470
|
+
message: "Select skills to install:",
|
|
471
|
+
options: skills.map(s => ({
|
|
472
|
+
value: s.name,
|
|
473
|
+
label: s.name,
|
|
474
|
+
hint: s.description,
|
|
475
|
+
})),
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
if (p.isCancel(result)) {
|
|
479
|
+
process.exit(0)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return result as string[]
|
|
483
|
+
},
|
|
484
|
+
})
|
|
485
|
+
},
|
|
486
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { defineCommand } from "citty"
|
|
2
|
+
import { RegistryStore } from "../core/registry-store"
|
|
3
|
+
import { ConfigStore } from "../core/config-store"
|
|
4
|
+
import { getRegistryPath, getConfigPath } from "../utils/paths"
|
|
5
|
+
|
|
6
|
+
export interface ListOptions {
|
|
7
|
+
registryPath: string
|
|
8
|
+
agents: Record<string, { name: string }>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SkillInfo {
|
|
12
|
+
name: string
|
|
13
|
+
agentNames: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function listSkills(options: ListOptions): Promise<SkillInfo[]> {
|
|
17
|
+
const registryStore = new RegistryStore(options.registryPath)
|
|
18
|
+
const registry = await registryStore.load()
|
|
19
|
+
|
|
20
|
+
const skills = Object.values(registry.skills)
|
|
21
|
+
|
|
22
|
+
return skills.map((skill) => {
|
|
23
|
+
const assignments = Object.keys(skill.assignments)
|
|
24
|
+
const agentNames = assignments.map((id) => options.agents[id]?.name || id)
|
|
25
|
+
return { name: skill.name, agentNames }
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default defineCommand({
|
|
30
|
+
meta: {
|
|
31
|
+
name: "list",
|
|
32
|
+
description: "List all managed skills",
|
|
33
|
+
},
|
|
34
|
+
async run() {
|
|
35
|
+
const registryStore = new RegistryStore(getRegistryPath())
|
|
36
|
+
const registry = await registryStore.load()
|
|
37
|
+
|
|
38
|
+
const configStore = new ConfigStore(getConfigPath())
|
|
39
|
+
const config = await configStore.load()
|
|
40
|
+
|
|
41
|
+
const skills = Object.values(registry.skills)
|
|
42
|
+
|
|
43
|
+
if (skills.length === 0) {
|
|
44
|
+
console.log("No skills managed. Run 'simba adopt' to get started.")
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log("\nManaged skills:\n")
|
|
49
|
+
|
|
50
|
+
for (const skill of skills) {
|
|
51
|
+
const assignments = Object.keys(skill.assignments)
|
|
52
|
+
const agentNames = assignments.map(id => config.agents[id]?.name || id)
|
|
53
|
+
|
|
54
|
+
console.log(` ${skill.name}`)
|
|
55
|
+
if (agentNames.length > 0) {
|
|
56
|
+
console.log(` └─ ${agentNames.join(", ")}`)
|
|
57
|
+
} else {
|
|
58
|
+
console.log(` └─ (not assigned)`)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(`\nTotal: ${skills.length} skills`)
|
|
63
|
+
},
|
|
64
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineCommand } from "citty"
|
|
2
|
+
import { runMatrixTUI } from "../tui/matrix"
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
meta: {
|
|
6
|
+
name: "manage",
|
|
7
|
+
description: "Open interactive skill management TUI",
|
|
8
|
+
},
|
|
9
|
+
async run() {
|
|
10
|
+
await runMatrixTUI()
|
|
11
|
+
},
|
|
12
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { defineCommand } from "citty"
|
|
2
|
+
import { ConfigStore } from "../core/config-store"
|
|
3
|
+
import { AgentRegistry } from "../core/agent-registry"
|
|
4
|
+
import { SnapshotManager } from "../core/snapshot"
|
|
5
|
+
import { getConfigPath, getSnapshotsDir } from "../utils/paths"
|
|
6
|
+
import { selectAgent } from "../utils/prompts"
|
|
7
|
+
|
|
8
|
+
export interface MigrateOptions {
|
|
9
|
+
from: string
|
|
10
|
+
to: string
|
|
11
|
+
dryRun: boolean
|
|
12
|
+
configPath: string
|
|
13
|
+
snapshotsDir: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function runMigrate(options: MigrateOptions): Promise<void> {
|
|
17
|
+
const configStore = new ConfigStore(options.configPath)
|
|
18
|
+
const config = await configStore.load()
|
|
19
|
+
|
|
20
|
+
const fromAgent = config.agents[options.from]
|
|
21
|
+
const toAgent = config.agents[options.to]
|
|
22
|
+
|
|
23
|
+
if (!fromAgent) {
|
|
24
|
+
console.error(`Unknown agent: ${options.from}`)
|
|
25
|
+
process.exit(1)
|
|
26
|
+
}
|
|
27
|
+
if (!toAgent) {
|
|
28
|
+
console.error(`Unknown agent: ${options.to}`)
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
if (!fromAgent.detected) {
|
|
32
|
+
console.error(`Agent not detected: ${options.from}`)
|
|
33
|
+
process.exit(1)
|
|
34
|
+
}
|
|
35
|
+
if (!toAgent.detected) {
|
|
36
|
+
console.error(`Agent not detected: ${options.to}`)
|
|
37
|
+
process.exit(1)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const registry = new AgentRegistry(config.agents)
|
|
41
|
+
const snapshots = new SnapshotManager(
|
|
42
|
+
options.snapshotsDir,
|
|
43
|
+
config.snapshots.maxCount
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const sourceSkills = await registry.listSkills(options.from)
|
|
47
|
+
const targetSkills = await registry.listSkills(options.to)
|
|
48
|
+
const targetNames = new Set(targetSkills.map((s) => s.name))
|
|
49
|
+
|
|
50
|
+
const toCopy = sourceSkills.filter((s) => !targetNames.has(s.name))
|
|
51
|
+
const skipped = sourceSkills.filter((s) => targetNames.has(s.name))
|
|
52
|
+
|
|
53
|
+
console.log(`\nMigrating from ${fromAgent.name} to ${toAgent.name}`)
|
|
54
|
+
console.log(`\nWill copy: ${toCopy.length} skills`)
|
|
55
|
+
for (const skill of toCopy) {
|
|
56
|
+
console.log(` ${skill.name}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (skipped.length > 0) {
|
|
60
|
+
console.log(`\nSkipping (already exist): ${skipped.length} skills`)
|
|
61
|
+
for (const skill of skipped) {
|
|
62
|
+
console.log(` ${skill.name}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (options.dryRun) {
|
|
67
|
+
console.log("\n(dry run - no changes made)")
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (toCopy.length === 0) {
|
|
72
|
+
console.log("\nNothing to migrate.")
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Create snapshot
|
|
77
|
+
if (config.snapshots.autoSnapshot) {
|
|
78
|
+
const skillPaths = toCopy.map((s) =>
|
|
79
|
+
registry.getSkillPath(s.name, options.from)
|
|
80
|
+
)
|
|
81
|
+
await snapshots.createSnapshot(skillPaths, `migrate-${options.from}-${options.to}`)
|
|
82
|
+
console.log("\nSnapshot created.")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Copy skills
|
|
86
|
+
for (const skill of toCopy) {
|
|
87
|
+
await registry.copySkill(skill.name, options.from, options.to)
|
|
88
|
+
console.log(`Copied: ${skill.name}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log("\nMigration complete!")
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export default defineCommand({
|
|
95
|
+
meta: {
|
|
96
|
+
name: "migrate",
|
|
97
|
+
description: "Copy all skills from one agent to another",
|
|
98
|
+
},
|
|
99
|
+
args: {
|
|
100
|
+
from: {
|
|
101
|
+
type: "string",
|
|
102
|
+
description: "Source agent",
|
|
103
|
+
},
|
|
104
|
+
to: {
|
|
105
|
+
type: "string",
|
|
106
|
+
description: "Target agent",
|
|
107
|
+
},
|
|
108
|
+
dryRun: {
|
|
109
|
+
type: "boolean",
|
|
110
|
+
alias: "n",
|
|
111
|
+
description: "Preview changes without applying",
|
|
112
|
+
default: false,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
async run({ args }) {
|
|
116
|
+
const configStore = new ConfigStore(getConfigPath())
|
|
117
|
+
const config = await configStore.load()
|
|
118
|
+
|
|
119
|
+
let from = args.from
|
|
120
|
+
let to = args.to
|
|
121
|
+
|
|
122
|
+
if (!from) {
|
|
123
|
+
from = await selectAgent(config.agents, "Select source agent")
|
|
124
|
+
}
|
|
125
|
+
if (!to) {
|
|
126
|
+
to = await selectAgent(config.agents, "Select target agent", (a) => a.id !== from)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await runMigrate({
|
|
130
|
+
from,
|
|
131
|
+
to,
|
|
132
|
+
dryRun: args.dryRun,
|
|
133
|
+
configPath: getConfigPath(),
|
|
134
|
+
snapshotsDir: getSnapshotsDir(),
|
|
135
|
+
})
|
|
136
|
+
},
|
|
137
|
+
})
|