simba-skills 0.1.2 → 0.3.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/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Simba
2
2
 
3
+ <p align="center">
4
+ <img src="assets/simba.jpg" alt="Simba the cat" width="600">
5
+ </p>
6
+
3
7
  [![npm version](https://img.shields.io/npm/v/simba-skills)](https://www.npmjs.com/package/simba-skills)
4
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
9
 
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "simba-skills",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "AI skills manager - central store with symlink-based distribution across 14+ coding agents",
5
5
  "publishConfig": {
6
- "access": "public"
6
+ "access": "public",
7
+ "provenance": true
7
8
  },
8
9
  "type": "module",
9
10
  "license": "MIT",
@@ -30,7 +31,7 @@
30
31
  },
31
32
  "files": [
32
33
  "src",
33
- "!src/**/*.test.ts"
34
+ "!assets"
34
35
  ],
35
36
  "scripts": {
36
37
  "dev": "bun run src/index.ts",
@@ -70,9 +70,12 @@ export async function runAdopt(options: AdoptOptions): Promise<void> {
70
70
  }
71
71
 
72
72
  const toAdopt: Array<{ name: string; skill: DiscoveredSkill }> = []
73
+ const toTakeover: Array<{ name: string; skills: DiscoveredSkill[] }> = []
74
+
73
75
  for (const [name, skills] of byName) {
74
76
  if (await skillsStore.hasSkill(name)) {
75
- console.log(` Skipping ${name} (already in store)`)
77
+ // Already in store - take over rogue copies
78
+ toTakeover.push({ name, skills })
76
79
  continue
77
80
  }
78
81
 
@@ -86,12 +89,14 @@ export async function runAdopt(options: AdoptOptions): Promise<void> {
86
89
  }
87
90
  }
88
91
 
89
- if (toAdopt.length === 0) {
92
+ if (toAdopt.length === 0 && toTakeover.length === 0) {
90
93
  console.log("\nNo new skills to adopt.")
91
94
  return
92
95
  }
93
96
 
94
- console.log(`\nAdopting ${toAdopt.length} skills...`)
97
+ if (toAdopt.length > 0) {
98
+ console.log(`\nAdopting ${toAdopt.length} skills...`)
99
+ }
95
100
 
96
101
  if (options.dryRun) {
97
102
  for (const { name, skill } of toAdopt) {
@@ -117,6 +122,33 @@ export async function runAdopt(options: AdoptOptions): Promise<void> {
117
122
  console.log(` Adopted: ${name} (from ${skill.agentId})`)
118
123
  }
119
124
 
125
+ // Take over rogue copies (skill already in store, but real dirs exist at agents)
126
+ if (toTakeover.length > 0) {
127
+ console.log(`\nTaking over ${toTakeover.length} rogue skills...`)
128
+
129
+ if (!options.dryRun) {
130
+ for (const { name, skills } of toTakeover) {
131
+ for (const skill of skills) {
132
+ await rm(skill.path, { recursive: true })
133
+ await createSymlink(join(options.skillsDir, name), skill.path)
134
+
135
+ // Update registry assignment
136
+ if (!registry.skills[name].assignments[skill.agentId]) {
137
+ registry.skills[name].assignments[skill.agentId] = { type: "directory" }
138
+ }
139
+
140
+ console.log(` Replaced: ${name} (${skill.agentId})`)
141
+ }
142
+ }
143
+ } else {
144
+ for (const { name, skills } of toTakeover) {
145
+ for (const skill of skills) {
146
+ console.log(` Would replace: ${name} (${skill.agentId})`)
147
+ }
148
+ }
149
+ }
150
+ }
151
+
120
152
  await registryStore.save(registry)
121
153
  console.log("\nAdoption complete!")
122
154
  }
@@ -1,5 +1,5 @@
1
1
  import { defineCommand } from "citty"
2
- import { access } from "node:fs/promises"
2
+ import { access, rm } from "node:fs/promises"
3
3
  import { join } from "node:path"
4
4
  import * as p from "@clack/prompts"
5
5
  import { ConfigStore } from "../core/config-store"
@@ -164,6 +164,17 @@ export default defineCommand({
164
164
  console.log(`Fixed: ${broken.skill} (${broken.agent})`)
165
165
  }
166
166
 
167
+ for (const rogue of results.rogue) {
168
+ const agent = detected[rogue.agent]
169
+ if (!agent) continue
170
+
171
+ // Delete rogue directory/file and replace with symlink
172
+ await rm(rogue.path, { recursive: true })
173
+ const expectedTarget = join(getSkillsDir(), rogue.skill)
174
+ await createSymlink(expectedTarget, rogue.path)
175
+ console.log(`Fixed rogue: ${rogue.skill} (${rogue.agent})`)
176
+ }
177
+
167
178
  console.log("\nRepairs complete!")
168
179
  },
169
180
  })
@@ -281,6 +281,7 @@ export interface InstallOptions {
281
281
  skillsDir: string
282
282
  registryPath: string
283
283
  useSSH: boolean
284
+ skillName?: string // Install specific skill by name, skip selection
284
285
  onSelect: (skills: DiscoveredSkill[]) => Promise<string[]>
285
286
  }
286
287
 
@@ -346,16 +347,30 @@ export async function runInstall(options: InstallOptions): Promise<void> {
346
347
  return
347
348
  }
348
349
 
349
- console.log(`\nFound ${discovered.length} skills:`)
350
- for (const skill of discovered) {
351
- console.log(` * ${skill.name}${skill.description ? ` - ${skill.description}` : ""}`)
352
- }
350
+ let selected: string[]
353
351
 
354
- const selected = await options.onSelect(discovered)
352
+ if (options.skillName) {
353
+ // Direct install of specific skill
354
+ const skill = discovered.find(s => s.name === options.skillName)
355
+ if (!skill) {
356
+ console.log(`Skill "${options.skillName}" not found in source.`)
357
+ console.log(`Available skills: ${discovered.map(s => s.name).join(", ")}`)
358
+ return
359
+ }
360
+ selected = [options.skillName]
361
+ console.log(`Installing skill: ${options.skillName}`)
362
+ } else {
363
+ console.log(`\nFound ${discovered.length} skills:`)
364
+ for (const skill of discovered) {
365
+ console.log(` * ${skill.name}${skill.description ? ` - ${skill.description}` : ""}`)
366
+ }
355
367
 
356
- if (selected.length === 0) {
357
- console.log("No skills selected.")
358
- return
368
+ selected = await options.onSelect(discovered)
369
+
370
+ if (selected.length === 0) {
371
+ console.log("No skills selected.")
372
+ return
373
+ }
359
374
  }
360
375
 
361
376
  for (const name of selected) {
@@ -458,6 +473,7 @@ export default defineCommand({
458
473
  args: {
459
474
  source: { type: "positional", description: "GitHub repo (user/repo) or local path", required: true },
460
475
  ssh: { type: "boolean", description: "Use SSH for GitHub repos (for private repos)", default: false },
476
+ skill: { type: "string", description: "Install specific skill by name (skip selection)", required: false },
461
477
  },
462
478
  async run({ args }) {
463
479
  await runInstall({
@@ -465,6 +481,7 @@ export default defineCommand({
465
481
  skillsDir: getSkillsDir(),
466
482
  registryPath: getRegistryPath(),
467
483
  useSSH: args.ssh,
484
+ skillName: args.skill,
468
485
  onSelect: async (skills) => {
469
486
  const result = await p.multiselect({
470
487
  message: "Select skills to install:",
@@ -1,9 +1,10 @@
1
1
  import { defineCommand } from "citty"
2
- import { readFile, mkdir, rm } from "node:fs/promises"
3
- import { join } from "node:path"
2
+ import { readFile, readdir, mkdir, rm } from "node:fs/promises"
3
+ import { join, relative } from "node:path"
4
4
  import * as p from "@clack/prompts"
5
5
  import simpleGit from "simple-git"
6
6
  import { tmpdir } from "node:os"
7
+ import { createHash } from "node:crypto"
7
8
  import { RegistryStore } from "../core/registry-store"
8
9
  import { SkillsStore } from "../core/skills-store"
9
10
  import { getSkillsDir, getRegistryPath } from "../utils/paths"
@@ -12,6 +13,50 @@ import { discoverSkills } from "./install"
12
13
  import type { ManagedSkill, InstallSource } from "../core/types"
13
14
  import matter from "gray-matter"
14
15
 
16
+ /**
17
+ * Recursively get all files in a directory (sorted for deterministic hashing)
18
+ */
19
+ async function getAllFiles(dir: string): Promise<string[]> {
20
+ const files: string[] = []
21
+
22
+ async function scan(currentDir: string): Promise<void> {
23
+ const entries = await readdir(currentDir, { withFileTypes: true })
24
+ for (const entry of entries) {
25
+ const fullPath = join(currentDir, entry.name)
26
+ if (entry.isDirectory()) {
27
+ await scan(fullPath)
28
+ } else {
29
+ files.push(relative(dir, fullPath))
30
+ }
31
+ }
32
+ }
33
+
34
+ await scan(dir)
35
+ return files.sort()
36
+ }
37
+
38
+ /**
39
+ * Compute a content hash for an entire skill directory.
40
+ * Hash includes: sorted file paths + their contents
41
+ */
42
+ async function hashSkillDir(dir: string): Promise<string> {
43
+ const files = await getAllFiles(dir)
44
+ const hash = createHash("sha256")
45
+
46
+ for (const file of files) {
47
+ // Include file path in hash (detects renames/additions/deletions)
48
+ hash.update(file)
49
+ hash.update("\0")
50
+
51
+ // Include file content
52
+ const content = await readFile(join(dir, file))
53
+ hash.update(content)
54
+ hash.update("\0")
55
+ }
56
+
57
+ return hash.digest("hex")
58
+ }
59
+
15
60
  interface SkillUpdate {
16
61
  skill: ManagedSkill
17
62
  newPath: string
@@ -119,16 +164,23 @@ export async function runUpdate(options: UpdateOptions): Promise<void> {
119
164
  continue
120
165
  }
121
166
 
122
- const existingPath = join(options.skillsDir, skill.name, "SKILL.md")
123
- const existingContent = await readFile(existingPath, "utf-8")
124
- const newContent = await readFile(join(remote.path, "SKILL.md"), "utf-8")
125
-
126
- const comparison = compareFiles(existingContent, newContent, "SKILL.md")
167
+ const localDir = join(options.skillsDir, skill.name)
168
+
169
+ // Hash entire directories to detect any file changes
170
+ const [localHash, remoteHash] = await Promise.all([
171
+ hashSkillDir(localDir),
172
+ hashSkillDir(remote.path)
173
+ ])
127
174
 
128
- if (comparison.identical) {
175
+ if (localHash === remoteHash) {
129
176
  console.log(` ✓ ${skill.name}: up to date`)
130
177
  } else {
131
178
  console.log(` ↑ ${skill.name}: update available`)
179
+
180
+ // Still read SKILL.md for version display purposes
181
+ const existingContent = await readFile(join(localDir, "SKILL.md"), "utf-8")
182
+ const newContent = await readFile(join(remote.path, "SKILL.md"), "utf-8")
183
+
132
184
  allUpdates.push({
133
185
  skill,
134
186
  newPath: remote.path,
@@ -13,7 +13,7 @@ const AGENT_DEFINITIONS: [string, string, string, string, string][] = [
13
13
  ["windsurf", "Windsurf", "Windsurf", "~/.codeium/windsurf/skills", ".windsurf/skills"],
14
14
  ["amp", "Amp", "Amp", "~/.config/agents/skills", ".agents/skills"],
15
15
  ["goose", "Goose", "Goose", "~/.config/goose/skills", ".goose/skills"],
16
- ["opencode", "OpenCode", "OpenCode", "~/.config/opencode/skill", ".opencode/skill"],
16
+ ["opencode", "OpenCode", "OpenCode", "~/.config/opencode/skills", ".opencode/skills"],
17
17
  ["kilo", "Kilo Code", "Kilo", "~/.kilocode/skills", ".kilocode/skills"],
18
18
  ["roo", "Roo Code", "Roo", "~/.roo/skills", ".roo/skills"],
19
19
  ["antigravity", "Antigravity", "Antigrav", "~/.gemini/antigravity/skills", ".agent/skills"],
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { defineCommand, runMain } from "citty"
3
+ import { defineCommand, runMain, showUsage } from "citty"
4
4
 
5
5
  const main = defineCommand({
6
6
  meta: {
@@ -8,6 +8,11 @@ const main = defineCommand({
8
8
  version: "0.2.0",
9
9
  description: "AI skills manager",
10
10
  },
11
+ async run({ cmd, rawArgs }) {
12
+ if (rawArgs.length === 0) {
13
+ await showUsage(cmd)
14
+ }
15
+ },
11
16
  subCommands: {
12
17
  adopt: () => import("./commands/adopt").then((m) => m.default),
13
18
  assign: () => import("./commands/assign").then((m) => m.default),
package/src/tui/matrix.ts CHANGED
@@ -6,8 +6,7 @@ import { AgentRegistry } from "../core/agent-registry"
6
6
  import { getRegistryPath, getSkillsDir, getConfigPath, expandPath } from "../utils/paths"
7
7
  import type { Agent, Registry } from "../core/types"
8
8
 
9
- // Cast to any to access showCursor (missing from @types/terminal-kit)
10
- const term = termkit.terminal as typeof termkit.terminal & { showCursor: () => void }
9
+ const term = termkit.terminal
11
10
 
12
11
  interface MatrixState {
13
12
  skills: string[]
@@ -102,7 +101,7 @@ export async function runMatrixTUI(): Promise<void> {
102
101
 
103
102
  term("\n")
104
103
  term("─".repeat(21 + state.agents.length * 10) + "\n")
105
- term.dim("[Space] Toggle [a] Assign all [n] None [q] Quit\n")
104
+ term.dim("[Space] Toggle [a] Assign all [n] None [Enter] Confirm [q] Cancel\n")
106
105
  }
107
106
 
108
107
  const toggle = async () => {
@@ -110,29 +109,43 @@ export async function runMatrixTUI(): Promise<void> {
110
109
  const agent = state.agents[state.cursorCol]
111
110
  const skill = state.registry.skills[skillName]
112
111
 
113
- if (skill.assignments[agent.id]) {
114
- await skillsStore.unassignSkill(skillName, expandPath(agent.globalPath))
115
- delete skill.assignments[agent.id]
116
- } else {
117
- await skillsStore.assignSkill(skillName, expandPath(agent.globalPath), { type: "directory" })
118
- skill.assignments[agent.id] = { type: "directory" }
112
+ try {
113
+ if (skill.assignments[agent.id]) {
114
+ await skillsStore.unassignSkill(skillName, expandPath(agent.globalPath))
115
+ delete skill.assignments[agent.id]
116
+ } else {
117
+ await skillsStore.assignSkill(skillName, expandPath(agent.globalPath), { type: "directory" })
118
+ skill.assignments[agent.id] = { type: "directory" }
119
+ }
120
+ await registryStore.save(state.registry)
121
+ } catch (err) {
122
+ term.moveTo(1, state.skills.length + 8)
123
+ term.yellow(`Error: ${(err as Error).message}\n`)
119
124
  }
120
-
121
- await registryStore.save(state.registry)
122
125
  }
123
126
 
124
127
  const assignAll = async () => {
125
128
  const skillName = state.skills[state.cursorRow]
126
129
  const skill = state.registry.skills[skillName]
130
+ const errors: string[] = []
127
131
 
128
132
  for (const agent of state.agents) {
129
133
  if (!skill.assignments[agent.id]) {
130
- await skillsStore.assignSkill(skillName, expandPath(agent.globalPath), { type: "directory" })
131
- skill.assignments[agent.id] = { type: "directory" }
134
+ try {
135
+ await skillsStore.assignSkill(skillName, expandPath(agent.globalPath), { type: "directory" })
136
+ skill.assignments[agent.id] = { type: "directory" }
137
+ } catch (err) {
138
+ errors.push(`${agent.name}: ${(err as Error).message}`)
139
+ }
132
140
  }
133
141
  }
134
142
 
135
143
  await registryStore.save(state.registry)
144
+
145
+ if (errors.length > 0) {
146
+ term.moveTo(1, state.skills.length + 8)
147
+ term.yellow(`Errors:\n${errors.join("\n")}\n`)
148
+ }
136
149
  }
137
150
 
138
151
  const unassignAll = async () => {
@@ -176,11 +189,18 @@ export async function runMatrixTUI(): Promise<void> {
176
189
  case "n":
177
190
  await unassignAll()
178
191
  break
192
+ case "ENTER":
193
+ term.clear()
194
+ term.hideCursor(false)
195
+ term.grabInput(false)
196
+ term.green("Changes saved.\n")
197
+ process.exit(0)
179
198
  case "q":
180
199
  case "CTRL_C":
181
200
  term.clear()
182
- term.showCursor()
201
+ term.hideCursor(false)
183
202
  term.grabInput(false)
203
+ term.yellow("Cancelled.\n")
184
204
  process.exit(0)
185
205
  }
186
206
 
@@ -3,7 +3,29 @@ import { dirname } from "node:path"
3
3
 
4
4
  export async function createSymlink(source: string, target: string): Promise<void> {
5
5
  await mkdir(dirname(target), { recursive: true })
6
- await symlink(source, target)
6
+ try {
7
+ await symlink(source, target)
8
+ } catch (err) {
9
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") {
10
+ // Already exists - check if it's a symlink pointing to the right place
11
+ const existing = await getSymlinkTarget(target)
12
+ if (existing === source) return // Already correct symlink
13
+
14
+ // Check what we're dealing with
15
+ const stat = await lstat(target)
16
+ if (stat.isSymbolicLink()) {
17
+ await unlink(target)
18
+ } else if (stat.isDirectory()) {
19
+ // Real directory - don't delete, throw error
20
+ throw new Error(`Cannot create symlink: ${target} is a directory (not managed by simba)`)
21
+ } else {
22
+ await unlink(target)
23
+ }
24
+ await symlink(source, target)
25
+ } else {
26
+ throw err
27
+ }
28
+ }
7
29
  }
8
30
 
9
31
  export async function isSymlink(path: string): Promise<boolean> {
@@ -17,7 +39,15 @@ export async function isSymlink(path: string): Promise<boolean> {
17
39
 
18
40
  export async function removeSymlink(path: string): Promise<void> {
19
41
  try {
20
- await unlink(path)
42
+ const stat = await lstat(path)
43
+ if (stat.isSymbolicLink()) {
44
+ await unlink(path)
45
+ } else if (stat.isDirectory()) {
46
+ // Real directory - don't delete, it's not managed by simba
47
+ throw new Error(`Cannot remove: ${path} is a directory (not managed by simba)`)
48
+ } else {
49
+ await unlink(path)
50
+ }
21
51
  } catch (err) {
22
52
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
23
53
  throw err