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,251 @@
|
|
|
1
|
+
import { defineCommand } from "citty"
|
|
2
|
+
import { readFile, mkdir, rm } from "node:fs/promises"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import * as p from "@clack/prompts"
|
|
5
|
+
import simpleGit from "simple-git"
|
|
6
|
+
import { tmpdir } from "node:os"
|
|
7
|
+
import { RegistryStore } from "../core/registry-store"
|
|
8
|
+
import { SkillsStore } from "../core/skills-store"
|
|
9
|
+
import { getSkillsDir, getRegistryPath } from "../utils/paths"
|
|
10
|
+
import { compareFiles, renderDiff } from "../utils/diff"
|
|
11
|
+
import { discoverSkills } from "./install"
|
|
12
|
+
import type { ManagedSkill, InstallSource } from "../core/types"
|
|
13
|
+
import matter from "gray-matter"
|
|
14
|
+
|
|
15
|
+
interface SkillUpdate {
|
|
16
|
+
skill: ManagedSkill
|
|
17
|
+
newPath: string
|
|
18
|
+
newContent: string
|
|
19
|
+
existingContent: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RepoGroup {
|
|
23
|
+
repo: string
|
|
24
|
+
protocol: "https" | "ssh"
|
|
25
|
+
skills: ManagedSkill[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function groupByRepo(skills: ManagedSkill[]): RepoGroup[] {
|
|
29
|
+
const groups = new Map<string, RepoGroup>()
|
|
30
|
+
|
|
31
|
+
for (const skill of skills) {
|
|
32
|
+
if (!skill.installSource) continue
|
|
33
|
+
// Skip local skills - they're symlinked and always up to date
|
|
34
|
+
if (skill.installSource.protocol === "local") continue
|
|
35
|
+
|
|
36
|
+
const key = `${skill.installSource.protocol}:${skill.installSource.repo}`
|
|
37
|
+
if (!groups.has(key)) {
|
|
38
|
+
groups.set(key, {
|
|
39
|
+
repo: skill.installSource.repo,
|
|
40
|
+
protocol: skill.installSource.protocol as "https" | "ssh",
|
|
41
|
+
skills: []
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
groups.get(key)!.skills.push(skill)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return Array.from(groups.values())
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UpdateOptions {
|
|
51
|
+
skillsDir: string
|
|
52
|
+
registryPath: string
|
|
53
|
+
onConfirm: (updates: Array<{ name: string; hasChanges: boolean }>) => Promise<string[]>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function runUpdate(options: UpdateOptions): Promise<void> {
|
|
57
|
+
const skillsStore = new SkillsStore(options.skillsDir, options.registryPath)
|
|
58
|
+
const registryStore = new RegistryStore(options.registryPath)
|
|
59
|
+
const registry = await registryStore.load()
|
|
60
|
+
|
|
61
|
+
// Find skills with installSource (excluding local symlinked skills)
|
|
62
|
+
const allSkillsWithSource = Object.values(registry.skills).filter(s => s.installSource)
|
|
63
|
+
const localSkills = allSkillsWithSource.filter(s => s.installSource?.protocol === "local")
|
|
64
|
+
const remoteSkills = allSkillsWithSource.filter(s => s.installSource?.protocol !== "local")
|
|
65
|
+
|
|
66
|
+
if (allSkillsWithSource.length === 0) {
|
|
67
|
+
console.log("No updatable skills found. Skills need installSource to be updated.")
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (localSkills.length > 0) {
|
|
72
|
+
console.log(`Skipping ${localSkills.length} local symlinked skills (always up to date)`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (remoteSkills.length === 0) {
|
|
76
|
+
console.log("No remote skills to update.")
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log(`Found ${remoteSkills.length} remote skills to check`)
|
|
81
|
+
|
|
82
|
+
// Group by repo
|
|
83
|
+
const repoGroups = groupByRepo(remoteSkills)
|
|
84
|
+
const allUpdates: SkillUpdate[] = []
|
|
85
|
+
|
|
86
|
+
for (const group of repoGroups) {
|
|
87
|
+
console.log(`\nChecking ${group.repo}...`)
|
|
88
|
+
|
|
89
|
+
// Clone the repo
|
|
90
|
+
let url: string
|
|
91
|
+
if (group.repo.includes("://") || group.repo.startsWith("git@")) {
|
|
92
|
+
url = group.repo
|
|
93
|
+
} else if (group.protocol === "ssh") {
|
|
94
|
+
url = `git@github.com:${group.repo}.git`
|
|
95
|
+
} else {
|
|
96
|
+
url = `https://github.com/${group.repo}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const tempDir = join(tmpdir(), `simba-update-${Date.now()}`)
|
|
100
|
+
await mkdir(tempDir, { recursive: true })
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const git = simpleGit()
|
|
104
|
+
await git.clone(url, tempDir, ["--depth", "1"])
|
|
105
|
+
|
|
106
|
+
// Discover skills in the cloned repo
|
|
107
|
+
const discovered = await discoverSkills(tempDir)
|
|
108
|
+
const discoveredByPath = new Map(
|
|
109
|
+
discovered.map(s => [s.relativePath, s])
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
// Check each skill for updates
|
|
113
|
+
for (const skill of group.skills) {
|
|
114
|
+
const skillPath = skill.installSource!.skillPath
|
|
115
|
+
const remote = discoveredByPath.get(skillPath)
|
|
116
|
+
|
|
117
|
+
if (!remote) {
|
|
118
|
+
console.log(` ⚠ ${skill.name}: skill path not found in repo (${skillPath})`)
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
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")
|
|
127
|
+
|
|
128
|
+
if (comparison.identical) {
|
|
129
|
+
console.log(` ✓ ${skill.name}: up to date`)
|
|
130
|
+
} else {
|
|
131
|
+
console.log(` ↑ ${skill.name}: update available`)
|
|
132
|
+
allUpdates.push({
|
|
133
|
+
skill,
|
|
134
|
+
newPath: remote.path,
|
|
135
|
+
newContent,
|
|
136
|
+
existingContent
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (allUpdates.length === 0) {
|
|
146
|
+
console.log("\nAll skills are up to date!")
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log(`\n${allUpdates.length} updates available:`)
|
|
151
|
+
|
|
152
|
+
// Show updates and prompt for selection
|
|
153
|
+
const updateInfo = allUpdates.map(u => ({
|
|
154
|
+
name: u.skill.name,
|
|
155
|
+
hasChanges: true
|
|
156
|
+
}))
|
|
157
|
+
|
|
158
|
+
const selectedNames = await options.onConfirm(updateInfo)
|
|
159
|
+
|
|
160
|
+
if (selectedNames.length === 0) {
|
|
161
|
+
console.log("No updates selected.")
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Apply selected updates
|
|
166
|
+
for (const name of selectedNames) {
|
|
167
|
+
const update = allUpdates.find(u => u.skill.name === name)!
|
|
168
|
+
const existingParsed = matter(update.existingContent)
|
|
169
|
+
const newParsed = matter(update.newContent)
|
|
170
|
+
|
|
171
|
+
const existingVersion = existingParsed.data.version as string | undefined
|
|
172
|
+
const newVersion = newParsed.data.version as string | undefined
|
|
173
|
+
|
|
174
|
+
if (existingVersion && newVersion) {
|
|
175
|
+
console.log(`\n${name}: v${existingVersion} → v${newVersion}`)
|
|
176
|
+
} else {
|
|
177
|
+
console.log(`\n${name}: changes detected`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const comparison = compareFiles(update.existingContent, update.newContent, "SKILL.md")
|
|
181
|
+
if (comparison.diff) {
|
|
182
|
+
renderDiff(comparison.diff, "current", "new")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Re-clone to get the files (since we deleted tempDir)
|
|
186
|
+
const source = update.skill.installSource!
|
|
187
|
+
let url: string
|
|
188
|
+
if (source.repo.includes("://") || source.repo.startsWith("git@")) {
|
|
189
|
+
url = source.repo
|
|
190
|
+
} else if (source.protocol === "ssh") {
|
|
191
|
+
url = `git@github.com:${source.repo}.git`
|
|
192
|
+
} else {
|
|
193
|
+
url = `https://github.com/${source.repo}`
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const tempDir = join(tmpdir(), `simba-update-apply-${Date.now()}`)
|
|
197
|
+
await mkdir(tempDir, { recursive: true })
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const git = simpleGit()
|
|
201
|
+
await git.clone(url, tempDir, ["--depth", "1"])
|
|
202
|
+
|
|
203
|
+
const discovered = await discoverSkills(tempDir)
|
|
204
|
+
const remote = discovered.find(s => s.relativePath === source.skillPath)
|
|
205
|
+
|
|
206
|
+
if (!remote) {
|
|
207
|
+
console.log(` Error: Could not find skill at ${source.skillPath}`)
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Remove old and add new
|
|
212
|
+
await rm(join(options.skillsDir, name), { recursive: true })
|
|
213
|
+
await skillsStore.addSkill(name, remote.path)
|
|
214
|
+
|
|
215
|
+
registry.skills[name].installedAt = new Date().toISOString()
|
|
216
|
+
console.log(` Updated: ${name}`)
|
|
217
|
+
} finally {
|
|
218
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await registryStore.save(registry)
|
|
223
|
+
console.log("\nUpdate complete!")
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export default defineCommand({
|
|
227
|
+
meta: { name: "update", description: "Update installed skills from their sources" },
|
|
228
|
+
args: {},
|
|
229
|
+
async run() {
|
|
230
|
+
await runUpdate({
|
|
231
|
+
skillsDir: getSkillsDir(),
|
|
232
|
+
registryPath: getRegistryPath(),
|
|
233
|
+
onConfirm: async (updates) => {
|
|
234
|
+
const result = await p.multiselect({
|
|
235
|
+
message: "Select skills to update:",
|
|
236
|
+
options: updates.map(u => ({
|
|
237
|
+
value: u.name,
|
|
238
|
+
label: u.name,
|
|
239
|
+
})),
|
|
240
|
+
initialValues: updates.map(u => u.name), // Select all by default
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
if (p.isCancel(result)) {
|
|
244
|
+
process.exit(0)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return result as string[]
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
},
|
|
251
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { access, readdir, mkdir, cp, rm } from "node:fs/promises"
|
|
2
|
+
import { join, dirname } from "node:path"
|
|
3
|
+
import { expandPath } from "../utils/paths"
|
|
4
|
+
import { hashTree } from "../utils/hash"
|
|
5
|
+
import type { Agent, SkillInfo } from "./types"
|
|
6
|
+
|
|
7
|
+
export class AgentRegistry {
|
|
8
|
+
constructor(private agents: Record<string, Agent>) {}
|
|
9
|
+
|
|
10
|
+
async detectAgents(): Promise<Record<string, Agent>> {
|
|
11
|
+
const results: Record<string, Agent> = {}
|
|
12
|
+
|
|
13
|
+
for (const [id, agent] of Object.entries(this.agents)) {
|
|
14
|
+
const globalPath = expandPath(agent.globalPath)
|
|
15
|
+
const parentDir = dirname(globalPath)
|
|
16
|
+
|
|
17
|
+
let detected = false
|
|
18
|
+
try {
|
|
19
|
+
await access(parentDir)
|
|
20
|
+
detected = true
|
|
21
|
+
} catch {
|
|
22
|
+
detected = false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
results[id] = { ...agent, detected }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return results
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async listSkills(agentId: string): Promise<SkillInfo[]> {
|
|
32
|
+
const agent = this.agents[agentId]
|
|
33
|
+
if (!agent) throw new Error(`Unknown agent: ${agentId}`)
|
|
34
|
+
|
|
35
|
+
const skillsPath = expandPath(agent.globalPath)
|
|
36
|
+
const skills: SkillInfo[] = []
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const entries = await readdir(skillsPath, { withFileTypes: true })
|
|
40
|
+
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (!entry.isDirectory()) continue
|
|
43
|
+
|
|
44
|
+
const skillPath = join(skillsPath, entry.name)
|
|
45
|
+
const skillMdPath = join(skillPath, "SKILL.md")
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await access(skillMdPath)
|
|
49
|
+
} catch {
|
|
50
|
+
continue // Skip directories without SKILL.md
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { treeHash, files } = await hashTree(skillPath)
|
|
54
|
+
|
|
55
|
+
skills.push({
|
|
56
|
+
name: entry.name,
|
|
57
|
+
treeHash,
|
|
58
|
+
files,
|
|
59
|
+
origin: agentId,
|
|
60
|
+
lastSeen: new Date(),
|
|
61
|
+
agents: [agentId],
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
66
|
+
throw err
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return skills
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async copySkill(
|
|
74
|
+
skillName: string,
|
|
75
|
+
fromAgent: string,
|
|
76
|
+
toAgent: string
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const from = this.agents[fromAgent]
|
|
79
|
+
const to = this.agents[toAgent]
|
|
80
|
+
|
|
81
|
+
if (!from || !to) {
|
|
82
|
+
throw new Error(`Unknown agent: ${fromAgent} or ${toAgent}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sourcePath = join(expandPath(from.globalPath), skillName)
|
|
86
|
+
const destPath = join(expandPath(to.globalPath), skillName)
|
|
87
|
+
|
|
88
|
+
await mkdir(dirname(destPath), { recursive: true })
|
|
89
|
+
await cp(sourcePath, destPath, { recursive: true })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async deleteSkill(skillName: string, agentId: string): Promise<void> {
|
|
93
|
+
const agent = this.agents[agentId]
|
|
94
|
+
if (!agent) throw new Error(`Unknown agent: ${agentId}`)
|
|
95
|
+
|
|
96
|
+
const skillPath = join(expandPath(agent.globalPath), skillName)
|
|
97
|
+
await rm(skillPath, { recursive: true })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getSkillPath(skillName: string, agentId: string): string {
|
|
101
|
+
const agent = this.agents[agentId]
|
|
102
|
+
if (!agent) throw new Error(`Unknown agent: ${agentId}`)
|
|
103
|
+
return join(expandPath(agent.globalPath), skillName)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { parse, stringify } from "smol-toml";
|
|
2
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import type { Config, Agent } from "./types";
|
|
5
|
+
|
|
6
|
+
// Add new agents here: [id, name, shortName, globalPath, projectPath]
|
|
7
|
+
const AGENT_DEFINITIONS: [string, string, string, string, string][] = [
|
|
8
|
+
["claude", "Claude Code", "Claude", "~/.claude/skills", ".claude/skills"],
|
|
9
|
+
["cursor", "Cursor", "Cursor", "~/.cursor/skills", ".cursor/skills"],
|
|
10
|
+
["codex", "Codex", "Codex", "~/.codex/skills", ".codex/skills"],
|
|
11
|
+
["copilot", "GitHub Copilot", "Copilot", "~/.copilot/skills", ".github/skills"],
|
|
12
|
+
["gemini", "Gemini CLI", "Gemini", "~/.gemini/skills", ".gemini/skills"],
|
|
13
|
+
["windsurf", "Windsurf", "Windsurf", "~/.codeium/windsurf/skills", ".windsurf/skills"],
|
|
14
|
+
["amp", "Amp", "Amp", "~/.config/agents/skills", ".agents/skills"],
|
|
15
|
+
["goose", "Goose", "Goose", "~/.config/goose/skills", ".goose/skills"],
|
|
16
|
+
["opencode", "OpenCode", "OpenCode", "~/.config/opencode/skill", ".opencode/skill"],
|
|
17
|
+
["kilo", "Kilo Code", "Kilo", "~/.kilocode/skills", ".kilocode/skills"],
|
|
18
|
+
["roo", "Roo Code", "Roo", "~/.roo/skills", ".roo/skills"],
|
|
19
|
+
["antigravity", "Antigravity", "Antigrav", "~/.gemini/antigravity/skills", ".agent/skills"],
|
|
20
|
+
["clawdbot", "Clawdbot", "Clawdbot", "~/.clawdbot/skills", "skills"],
|
|
21
|
+
["droid", "Droid", "Droid", "~/.factory/skills", ".factory/skills"],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const DEFAULT_AGENTS: Record<string, Agent> = Object.fromEntries(
|
|
25
|
+
AGENT_DEFINITIONS.map(([id, name, shortName, globalPath, projectPath]) => [
|
|
26
|
+
id,
|
|
27
|
+
{ id, name, shortName, globalPath, projectPath, detected: false },
|
|
28
|
+
]),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
function createDefaultConfig(): Config {
|
|
32
|
+
return {
|
|
33
|
+
agents: { ...DEFAULT_AGENTS },
|
|
34
|
+
sync: {
|
|
35
|
+
strategy: "union",
|
|
36
|
+
sourceAgent: "",
|
|
37
|
+
},
|
|
38
|
+
snapshots: {
|
|
39
|
+
maxCount: 10,
|
|
40
|
+
autoSnapshot: true,
|
|
41
|
+
},
|
|
42
|
+
skills: {},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ConfigStore {
|
|
47
|
+
constructor(private configPath: string) {}
|
|
48
|
+
|
|
49
|
+
async load(): Promise<Config> {
|
|
50
|
+
try {
|
|
51
|
+
const content = await readFile(this.configPath, "utf-8");
|
|
52
|
+
const parsed = parse(content) as unknown as Config;
|
|
53
|
+
return this.mergeWithDefaults(parsed);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
56
|
+
return createDefaultConfig();
|
|
57
|
+
}
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async save(config: Config): Promise<void> {
|
|
63
|
+
await mkdir(dirname(this.configPath), { recursive: true });
|
|
64
|
+
const toml = stringify(config as unknown as Record<string, unknown>);
|
|
65
|
+
await writeFile(this.configPath, toml);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private mergeWithDefaults(parsed: Partial<Config>): Config {
|
|
69
|
+
const defaults = createDefaultConfig();
|
|
70
|
+
|
|
71
|
+
// Merge agents and ensure shortName exists for each
|
|
72
|
+
const mergedAgents = { ...defaults.agents };
|
|
73
|
+
for (const [id, agent] of Object.entries(parsed.agents ?? {})) {
|
|
74
|
+
mergedAgents[id] = {
|
|
75
|
+
...defaults.agents[id],
|
|
76
|
+
...agent,
|
|
77
|
+
// Ensure shortName exists, fallback to first word of name or id
|
|
78
|
+
shortName: agent.shortName ?? defaults.agents[id]?.shortName ?? agent.name?.split(" ")[0] ?? id,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
agents: mergedAgents,
|
|
84
|
+
sync: { ...defaults.sync, ...parsed.sync },
|
|
85
|
+
snapshots: { ...defaults.snapshots, ...parsed.snapshots },
|
|
86
|
+
skills: parsed.skills ?? {},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
import type { Registry } from "./types"
|
|
4
|
+
|
|
5
|
+
function createEmptyRegistry(): Registry {
|
|
6
|
+
return { version: 1, skills: {} }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class RegistryStore {
|
|
10
|
+
constructor(private registryPath: string) {}
|
|
11
|
+
|
|
12
|
+
async load(): Promise<Registry> {
|
|
13
|
+
try {
|
|
14
|
+
const content = await readFile(this.registryPath, "utf-8")
|
|
15
|
+
return JSON.parse(content) as Registry
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
18
|
+
return createEmptyRegistry()
|
|
19
|
+
}
|
|
20
|
+
throw err
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async save(registry: Registry): Promise<void> {
|
|
25
|
+
await mkdir(dirname(this.registryPath), { recursive: true })
|
|
26
|
+
await writeFile(this.registryPath, JSON.stringify(registry, null, 2))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Agent, SkillMatrix, SkillStatus } from "./types"
|
|
2
|
+
import type { AgentRegistry } from "./agent-registry"
|
|
3
|
+
|
|
4
|
+
export class SkillManager {
|
|
5
|
+
constructor(
|
|
6
|
+
private registry: AgentRegistry,
|
|
7
|
+
private agents: Record<string, Agent>
|
|
8
|
+
) {}
|
|
9
|
+
|
|
10
|
+
async buildMatrix(): Promise<SkillMatrix[]> {
|
|
11
|
+
const detectedAgents = Object.entries(this.agents).filter(
|
|
12
|
+
([_, agent]) => agent.detected
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// Collect all skills from all agents
|
|
16
|
+
const skillMap = new Map<
|
|
17
|
+
string,
|
|
18
|
+
Map<string, { present: boolean; hash: string | null }>
|
|
19
|
+
>()
|
|
20
|
+
|
|
21
|
+
for (const [agentId] of detectedAgents) {
|
|
22
|
+
const skills = await this.registry.listSkills(agentId)
|
|
23
|
+
|
|
24
|
+
for (const skill of skills) {
|
|
25
|
+
if (!skillMap.has(skill.name)) {
|
|
26
|
+
skillMap.set(skill.name, new Map())
|
|
27
|
+
}
|
|
28
|
+
skillMap.get(skill.name)!.set(agentId, {
|
|
29
|
+
present: true,
|
|
30
|
+
hash: skill.treeHash,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Build matrix with status
|
|
36
|
+
const matrix: SkillMatrix[] = []
|
|
37
|
+
|
|
38
|
+
for (const [skillName, agentHashes] of skillMap) {
|
|
39
|
+
const agents: Record<string, { present: boolean; hash: string | null }> =
|
|
40
|
+
{}
|
|
41
|
+
|
|
42
|
+
// Initialize all agents as not present
|
|
43
|
+
for (const [agentId] of detectedAgents) {
|
|
44
|
+
agents[agentId] = agentHashes.get(agentId) ?? {
|
|
45
|
+
present: false,
|
|
46
|
+
hash: null,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const status = this.computeStatus(agentHashes)
|
|
51
|
+
|
|
52
|
+
matrix.push({ skillName, agents, status })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return matrix.sort((a, b) => a.skillName.localeCompare(b.skillName))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private computeStatus(
|
|
59
|
+
agentHashes: Map<string, { present: boolean; hash: string | null }>
|
|
60
|
+
): SkillStatus {
|
|
61
|
+
const presentAgents = Array.from(agentHashes.entries()).filter(
|
|
62
|
+
([_, v]) => v.present
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if (presentAgents.length === 0) {
|
|
66
|
+
return "missing"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (presentAgents.length === 1) {
|
|
70
|
+
return "unique"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const hashes = new Set(presentAgents.map(([_, v]) => v.hash))
|
|
74
|
+
return hashes.size === 1 ? "synced" : "conflict"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async syncUnique(skillName: string, sourceAgent: string): Promise<string[]> {
|
|
78
|
+
const targetAgents = Object.entries(this.agents)
|
|
79
|
+
.filter(([id, agent]) => agent.detected && id !== sourceAgent)
|
|
80
|
+
.map(([id]) => id)
|
|
81
|
+
|
|
82
|
+
for (const targetAgent of targetAgents) {
|
|
83
|
+
await this.registry.copySkill(skillName, sourceAgent, targetAgent)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return targetAgents
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async resolveConflict(
|
|
90
|
+
skillName: string,
|
|
91
|
+
winnerAgent: string,
|
|
92
|
+
loserAgents: string[]
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
for (const loserAgent of loserAgents) {
|
|
95
|
+
await this.registry.deleteSkill(skillName, loserAgent)
|
|
96
|
+
await this.registry.copySkill(skillName, winnerAgent, loserAgent)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readdir, access, mkdir, cp, rm } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { createSymlink, removeSymlink } from "../utils/symlinks"
|
|
4
|
+
import type { SkillAssignment } from "./types"
|
|
5
|
+
|
|
6
|
+
export class SkillsStore {
|
|
7
|
+
constructor(
|
|
8
|
+
private skillsDir: string,
|
|
9
|
+
private registryPath: string
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
async ensureDir(): Promise<void> {
|
|
13
|
+
await mkdir(this.skillsDir, { recursive: true })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async listSkills(): Promise<string[]> {
|
|
17
|
+
try {
|
|
18
|
+
const entries = await readdir(this.skillsDir, { withFileTypes: true })
|
|
19
|
+
return entries.filter(e => e.isDirectory()).map(e => e.name)
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
24
|
+
throw err
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async hasSkill(name: string): Promise<boolean> {
|
|
29
|
+
try {
|
|
30
|
+
await access(join(this.skillsDir, name))
|
|
31
|
+
return true
|
|
32
|
+
} catch {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async addSkill(name: string, sourcePath: string): Promise<void> {
|
|
38
|
+
await this.ensureDir()
|
|
39
|
+
const destPath = join(this.skillsDir, name)
|
|
40
|
+
await cp(sourcePath, destPath, { recursive: true })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async linkSkill(name: string, sourcePath: string): Promise<void> {
|
|
44
|
+
await this.ensureDir()
|
|
45
|
+
const destPath = join(this.skillsDir, name)
|
|
46
|
+
await createSymlink(sourcePath, destPath)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async removeSkill(name: string): Promise<void> {
|
|
50
|
+
const skillPath = join(this.skillsDir, name)
|
|
51
|
+
await rm(skillPath, { recursive: true })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async assignSkill(
|
|
55
|
+
name: string,
|
|
56
|
+
agentSkillsDir: string,
|
|
57
|
+
assignment: SkillAssignment
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
const sourcePath = join(this.skillsDir, name)
|
|
60
|
+
|
|
61
|
+
if (assignment.type === "directory") {
|
|
62
|
+
const targetPath = join(agentSkillsDir, name)
|
|
63
|
+
await createSymlink(sourcePath, targetPath)
|
|
64
|
+
} else {
|
|
65
|
+
const sourceFile = join(sourcePath, assignment.target!)
|
|
66
|
+
const targetPath = join(agentSkillsDir, `${name}.${assignment.target!.split(".").pop()}`)
|
|
67
|
+
await createSymlink(sourceFile, targetPath)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async unassignSkill(name: string, agentSkillsDir: string): Promise<void> {
|
|
72
|
+
const targetPath = join(agentSkillsDir, name)
|
|
73
|
+
await removeSymlink(targetPath)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getSkillPath(name: string): string {
|
|
77
|
+
return join(this.skillsDir, name)
|
|
78
|
+
}
|
|
79
|
+
}
|