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.
@@ -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
+ })