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,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)
|
package/src/tui/.gitkeep
ADDED
|
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
|
+
}
|