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,123 @@
1
+ import { mkdir, cp, readdir, writeFile, readFile, rm } from "node:fs/promises"
2
+ import { join, basename, dirname } from "node:path"
3
+
4
+ export interface SnapshotManifest {
5
+ id: string
6
+ reason: string
7
+ created: string
8
+ skills: string[]
9
+ }
10
+
11
+ export class SnapshotManager {
12
+ constructor(
13
+ private snapshotsDir: string,
14
+ private maxCount: number
15
+ ) {}
16
+
17
+ async createSnapshot(skillPaths: string[], reason: string): Promise<string> {
18
+ const id = this.generateId()
19
+ const snapshotDir = join(this.snapshotsDir, id)
20
+ const skillsBackupDir = join(snapshotDir, "skills")
21
+
22
+ await mkdir(skillsBackupDir, { recursive: true })
23
+
24
+ const skillNames: string[] = []
25
+
26
+ for (const skillPath of skillPaths) {
27
+ const skillName = basename(skillPath)
28
+ skillNames.push(skillName)
29
+ await cp(skillPath, join(skillsBackupDir, skillName), { recursive: true })
30
+ }
31
+
32
+ const manifest: SnapshotManifest = {
33
+ id,
34
+ reason,
35
+ created: new Date().toISOString(),
36
+ skills: skillNames,
37
+ }
38
+
39
+ await writeFile(
40
+ join(snapshotDir, "manifest.json"),
41
+ JSON.stringify(manifest, null, 2)
42
+ )
43
+
44
+ await this.pruneOldSnapshots()
45
+
46
+ return id
47
+ }
48
+
49
+ async listSnapshots(): Promise<SnapshotManifest[]> {
50
+ try {
51
+ const entries = await readdir(this.snapshotsDir, { withFileTypes: true })
52
+ const manifests: SnapshotManifest[] = []
53
+
54
+ for (const entry of entries) {
55
+ if (!entry.isDirectory()) continue
56
+
57
+ try {
58
+ const manifestPath = join(this.snapshotsDir, entry.name, "manifest.json")
59
+ const content = await readFile(manifestPath, "utf-8")
60
+ manifests.push(JSON.parse(content))
61
+ } catch {
62
+ continue
63
+ }
64
+ }
65
+
66
+ return manifests.sort(
67
+ (a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()
68
+ )
69
+ } catch (err) {
70
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
71
+ return []
72
+ }
73
+ throw err
74
+ }
75
+ }
76
+
77
+ async restore(snapshotId: string, targetDir: string): Promise<void> {
78
+ const snapshotDir = join(this.snapshotsDir, snapshotId)
79
+ const skillsBackupDir = join(snapshotDir, "skills")
80
+ const manifestPath = join(snapshotDir, "manifest.json")
81
+
82
+ const manifest: SnapshotManifest = JSON.parse(
83
+ await readFile(manifestPath, "utf-8")
84
+ )
85
+
86
+ for (const skillName of manifest.skills) {
87
+ const sourcePath = join(skillsBackupDir, skillName)
88
+ const targetPath = join(targetDir, skillName)
89
+
90
+ // Ensure parent exists, remove old version if present
91
+ await mkdir(dirname(targetPath), { recursive: true })
92
+ try {
93
+ await rm(targetPath, { recursive: true })
94
+ } catch {
95
+ // Ignore if doesn't exist
96
+ }
97
+
98
+ await cp(sourcePath, targetPath, { recursive: true })
99
+ }
100
+ }
101
+
102
+ async getLatestSnapshot(): Promise<SnapshotManifest | null> {
103
+ const list = await this.listSnapshots()
104
+ return list[0] ?? null
105
+ }
106
+
107
+ private generateId(): string {
108
+ const now = new Date()
109
+ // Include milliseconds for uniqueness when creating multiple snapshots quickly
110
+ return now.toISOString().replace(/[:.]/g, "-").slice(0, 23)
111
+ }
112
+
113
+ private async pruneOldSnapshots(): Promise<void> {
114
+ const list = await this.listSnapshots()
115
+
116
+ if (list.length <= this.maxCount) return
117
+
118
+ const toDelete = list.slice(this.maxCount)
119
+ for (const snapshot of toDelete) {
120
+ await rm(join(this.snapshotsDir, snapshot.id), { recursive: true })
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,75 @@
1
+ // Core types for simba
2
+
3
+ export interface Agent {
4
+ id: string
5
+ name: string
6
+ shortName: string // Short display name for matrix views (max 8 chars)
7
+ globalPath: string
8
+ projectPath: string
9
+ detected: boolean
10
+ }
11
+
12
+ export interface SkillFile {
13
+ path: string
14
+ hash: string
15
+ }
16
+
17
+ export interface SkillInfo {
18
+ name: string
19
+ treeHash: string
20
+ files: SkillFile[]
21
+ origin: string
22
+ lastSeen: Date
23
+ agents: string[]
24
+ }
25
+
26
+ export interface SyncConfig {
27
+ strategy: "union" | "source"
28
+ sourceAgent: string
29
+ }
30
+
31
+ export interface SnapshotConfig {
32
+ maxCount: number
33
+ autoSnapshot: boolean
34
+ }
35
+
36
+ export interface Config {
37
+ agents: Record<string, Agent>
38
+ sync: SyncConfig
39
+ snapshots: SnapshotConfig
40
+ skills: Record<string, SkillInfo>
41
+ }
42
+
43
+ export type SkillStatus = "synced" | "conflict" | "unique" | "missing"
44
+
45
+ export interface SkillMatrix {
46
+ skillName: string
47
+ agents: Record<string, { present: boolean; hash: string | null }>
48
+ status: SkillStatus
49
+ }
50
+
51
+ // Registry types for skill management
52
+
53
+ export interface SkillAssignment {
54
+ type: "directory" | "file"
55
+ target?: string // For file type, which file to symlink (e.g., "rule.mdc")
56
+ }
57
+
58
+ export interface InstallSource {
59
+ repo: string // GitHub shorthand "user/repo", full URL, or absolute local path
60
+ protocol: "https" | "ssh" | "local"
61
+ skillPath?: string // Path within repo, e.g., "./better-auth/create-auth"
62
+ }
63
+
64
+ export interface ManagedSkill {
65
+ name: string
66
+ source: string // "adopted:claude", "installed:vercel-labs/agent-skills", etc.
67
+ installedAt: string // ISO date
68
+ assignments: Record<string, SkillAssignment> // agentId -> assignment
69
+ installSource?: InstallSource // Present for installed skills, powers updates
70
+ }
71
+
72
+ export interface Registry {
73
+ version: 1
74
+ skills: Record<string, ManagedSkill>
75
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { defineCommand, runMain } from "citty"
4
+
5
+ const main = defineCommand({
6
+ meta: {
7
+ name: "simba",
8
+ version: "0.2.0",
9
+ description: "AI skills manager",
10
+ },
11
+ subCommands: {
12
+ adopt: () => import("./commands/adopt").then((m) => m.default),
13
+ assign: () => import("./commands/assign").then((m) => m.default),
14
+ backup: () => import("./commands/backup").then((m) => m.default),
15
+ detect: () => import("./commands/detect").then((m) => m.default),
16
+ doctor: () => import("./commands/doctor").then((m) => m.default),
17
+ import: () => import("./commands/import").then((m) => m.default),
18
+ install: () => import("./commands/install").then((m) => m.default),
19
+ list: () => import("./commands/list").then((m) => m.default),
20
+ manage: () => import("./commands/manage").then((m) => m.default),
21
+ migrate: () => import("./commands/migrate").then((m) => m.default),
22
+ restore: () => import("./commands/restore").then((m) => m.default),
23
+ snapshots: () => import("./commands/snapshots").then((m) => m.default),
24
+ status: () => import("./commands/status").then((m) => m.default),
25
+ sync: () => import("./commands/sync").then((m) => m.default),
26
+ unassign: () => import("./commands/unassign").then((m) => m.default),
27
+ uninstall: () => import("./commands/uninstall").then((m) => m.default),
28
+ undo: () => import("./commands/undo").then((m) => m.default),
29
+ update: () => import("./commands/update").then((m) => m.default),
30
+ },
31
+ })
32
+
33
+ runMain(main)
File without changes
@@ -0,0 +1,189 @@
1
+ import termkit from "terminal-kit"
2
+ import { RegistryStore } from "../core/registry-store"
3
+ import { SkillsStore } from "../core/skills-store"
4
+ import { ConfigStore } from "../core/config-store"
5
+ import { AgentRegistry } from "../core/agent-registry"
6
+ import { getRegistryPath, getSkillsDir, getConfigPath, expandPath } from "../utils/paths"
7
+ import type { Agent, Registry } from "../core/types"
8
+
9
+ // Cast to any to access showCursor (missing from @types/terminal-kit)
10
+ const term = termkit.terminal as typeof termkit.terminal & { showCursor: () => void }
11
+
12
+ interface MatrixState {
13
+ skills: string[]
14
+ agents: Agent[]
15
+ registry: Registry
16
+ cursorRow: number
17
+ cursorCol: number
18
+ }
19
+
20
+ export async function runMatrixTUI(): Promise<void> {
21
+ const registryStore = new RegistryStore(getRegistryPath())
22
+ const registry = await registryStore.load()
23
+
24
+ const configStore = new ConfigStore(getConfigPath())
25
+ const config = await configStore.load()
26
+
27
+ const skillsStore = new SkillsStore(getSkillsDir(), getRegistryPath())
28
+
29
+ // Run fresh detection instead of relying on stale config
30
+ const agentRegistry = new AgentRegistry(config.agents)
31
+ const detected = await agentRegistry.detectAgents()
32
+ const detectedAgents = Object.values(detected).filter(a => a.detected)
33
+ const skills = Object.keys(registry.skills)
34
+
35
+ if (skills.length === 0) {
36
+ term.yellow("\nNo skills managed yet. Run 'simba adopt' first.\n")
37
+ process.exit(0)
38
+ }
39
+
40
+ const state: MatrixState = {
41
+ skills,
42
+ agents: detectedAgents,
43
+ registry,
44
+ cursorRow: 0,
45
+ cursorCol: 0,
46
+ }
47
+
48
+ term.clear()
49
+ term.hideCursor()
50
+
51
+ const render = () => {
52
+ term.moveTo(1, 1)
53
+ term.eraseLine()
54
+ term.bold.cyan("Simba - Skills Manager")
55
+ term(" ")
56
+ term.dim("[?] Help\n\n")
57
+
58
+ // Header row
59
+ term(" ")
60
+ for (let i = 0; i < state.agents.length; i++) {
61
+ const agent = state.agents[i]
62
+ const name = agent.name.slice(0, 8).padEnd(10)
63
+ if (i === state.cursorCol && state.cursorRow === -1) {
64
+ term.bgWhite.black(name)
65
+ } else {
66
+ term.bold(name)
67
+ }
68
+ }
69
+ term("\n")
70
+ term("─".repeat(21 + state.agents.length * 10) + "\n")
71
+
72
+ // Skill rows
73
+ for (let row = 0; row < state.skills.length; row++) {
74
+ const skillName = state.skills[row]
75
+ const skill = state.registry.skills[skillName]
76
+ const displayName = skillName.slice(0, 18).padEnd(20)
77
+
78
+ if (row === state.cursorRow) {
79
+ term.bgWhite.black(displayName)
80
+ } else {
81
+ term(displayName)
82
+ }
83
+ term(" ")
84
+
85
+ for (let col = 0; col < state.agents.length; col++) {
86
+ const agent = state.agents[col]
87
+ const isAssigned = !!skill.assignments[agent.id]
88
+ const symbol = isAssigned ? "●" : "○"
89
+
90
+ const isCursor = row === state.cursorRow && col === state.cursorCol
91
+
92
+ if (isCursor) {
93
+ term.bgYellow.black(` ${symbol} `.padEnd(10))
94
+ } else if (isAssigned) {
95
+ term.green(` ${symbol} `.padEnd(10))
96
+ } else {
97
+ term.dim(` ${symbol} `.padEnd(10))
98
+ }
99
+ }
100
+ term("\n")
101
+ }
102
+
103
+ term("\n")
104
+ term("─".repeat(21 + state.agents.length * 10) + "\n")
105
+ term.dim("[Space] Toggle [a] Assign all [n] None [q] Quit\n")
106
+ }
107
+
108
+ const toggle = async () => {
109
+ const skillName = state.skills[state.cursorRow]
110
+ const agent = state.agents[state.cursorCol]
111
+ const skill = state.registry.skills[skillName]
112
+
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
+
121
+ await registryStore.save(state.registry)
122
+ }
123
+
124
+ const assignAll = async () => {
125
+ const skillName = state.skills[state.cursorRow]
126
+ const skill = state.registry.skills[skillName]
127
+
128
+ for (const agent of state.agents) {
129
+ if (!skill.assignments[agent.id]) {
130
+ await skillsStore.assignSkill(skillName, expandPath(agent.globalPath), { type: "directory" })
131
+ skill.assignments[agent.id] = { type: "directory" }
132
+ }
133
+ }
134
+
135
+ await registryStore.save(state.registry)
136
+ }
137
+
138
+ const unassignAll = async () => {
139
+ const skillName = state.skills[state.cursorRow]
140
+ const skill = state.registry.skills[skillName]
141
+
142
+ for (const agent of state.agents) {
143
+ if (skill.assignments[agent.id]) {
144
+ await skillsStore.unassignSkill(skillName, expandPath(agent.globalPath))
145
+ delete skill.assignments[agent.id]
146
+ }
147
+ }
148
+
149
+ await registryStore.save(state.registry)
150
+ }
151
+
152
+ render()
153
+
154
+ term.grabInput(true)
155
+
156
+ term.on("key", async (key: string) => {
157
+ switch (key) {
158
+ case "UP":
159
+ state.cursorRow = Math.max(0, state.cursorRow - 1)
160
+ break
161
+ case "DOWN":
162
+ state.cursorRow = Math.min(state.skills.length - 1, state.cursorRow + 1)
163
+ break
164
+ case "LEFT":
165
+ state.cursorCol = Math.max(0, state.cursorCol - 1)
166
+ break
167
+ case "RIGHT":
168
+ state.cursorCol = Math.min(state.agents.length - 1, state.cursorCol + 1)
169
+ break
170
+ case " ":
171
+ await toggle()
172
+ break
173
+ case "a":
174
+ await assignAll()
175
+ break
176
+ case "n":
177
+ await unassignAll()
178
+ break
179
+ case "q":
180
+ case "CTRL_C":
181
+ term.clear()
182
+ term.showCursor()
183
+ term.grabInput(false)
184
+ process.exit(0)
185
+ }
186
+
187
+ render()
188
+ })
189
+ }
File without changes
@@ -0,0 +1,53 @@
1
+ import { parseDiffFromFile, type FileDiffMetadata } from "@pierre/diffs"
2
+ import terminalKit from "terminal-kit"
3
+
4
+ const term = terminalKit.terminal
5
+
6
+ export interface DiffResult {
7
+ identical: boolean
8
+ diff?: FileDiffMetadata
9
+ }
10
+
11
+ export function compareFiles(oldContent: string, newContent: string, filename: string = "file"): DiffResult {
12
+ if (oldContent === newContent) {
13
+ return { identical: true }
14
+ }
15
+
16
+ const diff = parseDiffFromFile(
17
+ { name: filename, contents: oldContent },
18
+ { name: filename, contents: newContent }
19
+ )
20
+
21
+ return { identical: false, diff }
22
+ }
23
+
24
+ export function renderDiff(diff: FileDiffMetadata, leftLabel: string, rightLabel: string): void {
25
+ term.bold(`\n Comparing: `).defaultColor(`${leftLabel} vs ${rightLabel}\n\n`)
26
+
27
+ for (const hunk of diff.hunks) {
28
+ // Show hunk header
29
+ term.gray(` @@ -${hunk.deletionStart},${hunk.deletionLines} +${hunk.additionStart},${hunk.additionLines} @@\n`)
30
+
31
+ for (const content of hunk.hunkContent) {
32
+ if (content.type === "context") {
33
+ for (const line of content.lines) {
34
+ term.gray(` ${line.trimEnd()}\n`)
35
+ }
36
+ } else if (content.type === "change") {
37
+ for (const line of content.deletions) {
38
+ term.red(` - ${line.trimEnd()}\n`)
39
+ }
40
+ for (const line of content.additions) {
41
+ term.green(` + ${line.trimEnd()}\n`)
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ term("\n")
48
+ }
49
+
50
+ export function renderIdenticalMessage(skillName: string): void {
51
+ term.yellow(`\n "${skillName}" has identical content in all agents.\n`)
52
+ term.gray(" Choosing the first agent as source.\n\n")
53
+ }
@@ -0,0 +1,47 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises"
2
+ import { join, relative } from "node:path"
3
+ import { createHash } from "node:crypto"
4
+ import type { SkillFile } from "../core/types"
5
+
6
+ export async function hashFile(filePath: string): Promise<string> {
7
+ const content = await readFile(filePath)
8
+ return createHash("sha256").update(content).digest("hex")
9
+ }
10
+
11
+ export async function hashTree(
12
+ dirPath: string
13
+ ): Promise<{ treeHash: string; files: SkillFile[] }> {
14
+ const files: SkillFile[] = []
15
+ await collectFiles(dirPath, dirPath, files)
16
+
17
+ // Sort for deterministic ordering
18
+ files.sort((a, b) => a.path.localeCompare(b.path))
19
+
20
+ // Git-style: hash of "path:hash\n" entries
21
+ const treeContent = files.map((f) => `${f.path}:${f.hash}`).join("\n")
22
+ const treeHash = createHash("sha256").update(treeContent).digest("hex")
23
+
24
+ return { treeHash, files }
25
+ }
26
+
27
+ async function collectFiles(
28
+ basePath: string,
29
+ currentPath: string,
30
+ files: SkillFile[]
31
+ ): Promise<void> {
32
+ const entries = await readdir(currentPath, { withFileTypes: true })
33
+
34
+ for (const entry of entries) {
35
+ const fullPath = join(currentPath, entry.name)
36
+
37
+ if (entry.isDirectory()) {
38
+ await collectFiles(basePath, fullPath, files)
39
+ } else if (entry.isFile()) {
40
+ const hash = await hashFile(fullPath)
41
+ files.push({
42
+ path: relative(basePath, fullPath),
43
+ hash,
44
+ })
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,30 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+
4
+ export function expandPath(path: string): string {
5
+ if (path.startsWith("~/")) {
6
+ return join(homedir(), path.slice(2))
7
+ }
8
+ return path
9
+ }
10
+
11
+ export function getConfigDir(): string {
12
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
13
+ return join(xdgConfig, "simba")
14
+ }
15
+
16
+ export function getConfigPath(): string {
17
+ return join(getConfigDir(), "config.toml")
18
+ }
19
+
20
+ export function getSnapshotsDir(): string {
21
+ return join(getConfigDir(), "snapshots")
22
+ }
23
+
24
+ export function getSkillsDir(): string {
25
+ return join(getConfigDir(), "skills")
26
+ }
27
+
28
+ export function getRegistryPath(): string {
29
+ return join(getConfigDir(), "registry.json")
30
+ }
@@ -0,0 +1,85 @@
1
+ import * as p from "@clack/prompts"
2
+ import type { Agent } from "../core/types"
3
+
4
+ export function isCancel(value: unknown): value is symbol {
5
+ return p.isCancel(value)
6
+ }
7
+
8
+ export function cancel(message = "Operation cancelled."): never {
9
+ p.cancel(message)
10
+ process.exit(0)
11
+ }
12
+
13
+ export async function selectAgent(
14
+ agents: Record<string, Agent>,
15
+ message: string,
16
+ filter?: (agent: Agent) => boolean
17
+ ): Promise<string> {
18
+ const options = Object.entries(agents)
19
+ .filter(([_, a]) => a.detected && (!filter || filter(a)))
20
+ .map(([id, a]) => ({ value: id, label: a.name }))
21
+
22
+ if (options.length === 0) {
23
+ p.log.error("No agents available.")
24
+ process.exit(1)
25
+ }
26
+
27
+ const result = await p.select({ message, options })
28
+ if (isCancel(result)) cancel()
29
+ return result as string
30
+ }
31
+
32
+ export async function selectMultipleAgents(
33
+ agents: Record<string, Agent>,
34
+ message: string,
35
+ exclude?: string[]
36
+ ): Promise<string[]> {
37
+ const options = Object.entries(agents)
38
+ .filter(([id, a]) => a.detected && !exclude?.includes(id))
39
+ .map(([id, a]) => ({ value: id, label: a.name }))
40
+
41
+ if (options.length === 0) {
42
+ p.log.error("No agents available.")
43
+ process.exit(1)
44
+ }
45
+
46
+ const result = await p.multiselect({ message, options, required: true })
47
+ if (isCancel(result)) cancel()
48
+ return result as string[]
49
+ }
50
+
51
+ export async function selectFromList<T extends string>(
52
+ message: string,
53
+ options: { value: T; label: string; hint?: string }[]
54
+ ): Promise<T> {
55
+ if (options.length === 0) {
56
+ p.log.error("No options available.")
57
+ process.exit(1)
58
+ }
59
+
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ const result = await p.select({ message, options: options as any })
62
+ if (isCancel(result)) cancel()
63
+ return result as T
64
+ }
65
+
66
+ export async function inputText(
67
+ message: string,
68
+ options?: { placeholder?: string; defaultValue?: string }
69
+ ): Promise<string> {
70
+ const result = await p.text({
71
+ message,
72
+ placeholder: options?.placeholder,
73
+ defaultValue: options?.defaultValue,
74
+ })
75
+ if (isCancel(result)) cancel()
76
+ return result as string
77
+ }
78
+
79
+ export async function confirm(message: string, initial = false): Promise<boolean> {
80
+ const result = await p.confirm({ message, initialValue: initial })
81
+ if (isCancel(result)) cancel()
82
+ return result as boolean
83
+ }
84
+
85
+ export { p }
@@ -0,0 +1,34 @@
1
+ import { symlink, unlink, readlink, lstat, mkdir } from "node:fs/promises"
2
+ import { dirname } from "node:path"
3
+
4
+ export async function createSymlink(source: string, target: string): Promise<void> {
5
+ await mkdir(dirname(target), { recursive: true })
6
+ await symlink(source, target)
7
+ }
8
+
9
+ export async function isSymlink(path: string): Promise<boolean> {
10
+ try {
11
+ const stat = await lstat(path)
12
+ return stat.isSymbolicLink()
13
+ } catch {
14
+ return false
15
+ }
16
+ }
17
+
18
+ export async function removeSymlink(path: string): Promise<void> {
19
+ try {
20
+ await unlink(path)
21
+ } catch (err) {
22
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
23
+ throw err
24
+ }
25
+ }
26
+ }
27
+
28
+ export async function getSymlinkTarget(path: string): Promise<string | null> {
29
+ try {
30
+ return await readlink(path)
31
+ } catch {
32
+ return null
33
+ }
34
+ }