agent-skillboard 0.2.7 → 0.2.8
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/CHANGELOG.md +20 -0
- package/README.md +46 -24
- package/bin/postinstall.mjs +61 -0
- package/docs/install.md +60 -25
- package/docs/reference.md +43 -0
- package/docs/user-flow.md +11 -0
- package/package.json +2 -1
- package/src/advisor/actions.mjs +1 -0
- package/src/agent-inventory-platforms.mjs +7 -14
- package/src/agent-inventory.mjs +4 -1
- package/src/agent-skill-import.mjs +291 -0
- package/src/agent-skill-roots.mjs +114 -0
- package/src/cli.mjs +134 -19
- package/src/control/skill-crud.mjs +1 -0
- package/src/doctor.mjs +1 -1
- package/src/lifecycle-cli.mjs +159 -5
- package/src/lifecycle-content.mjs +1 -1
- package/src/uninstall.mjs +1 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import YAML from "yaml";
|
|
6
|
+
import { detectedAgentSkillRoots, preferredAgentSkillRoot, supportedAgentNames } from "./agent-skill-roots.mjs";
|
|
7
|
+
|
|
8
|
+
const AGENT_MARKERS = Object.freeze({
|
|
9
|
+
codex: [/CODEX_HOME/i, /(^|[/\\])\.codex([/\\]|$)/i, /~\/\.codex\b/i, /\bCodex\b/i],
|
|
10
|
+
claude: [/CLAUDE_HOME/i, /(^|[/\\])\.claude([/\\]|$)/i, /~\/\.claude\b/i, /\bClaude\b/i, /\bCLAUDE\.md\b/],
|
|
11
|
+
opencode: [/OPENCODE_HOME/i, /(^|[/\\])opencode([/\\]|$)/i, /~\/\.config\/opencode\b/i, /\bOpenCode\b/i],
|
|
12
|
+
hermes: [/HERMES_HOME/i, /(^|[/\\])\.hermes([/\\]|$)/i, /~\/\.hermes\b/i, /\bHermes\b/i]
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export async function importAgentSkill(options = {}) {
|
|
16
|
+
const env = options.env ?? process.env;
|
|
17
|
+
const home = env.HOME ?? env.USERPROFILE ?? homedir();
|
|
18
|
+
const sourceAgent = requireAgent(options.from, "--from");
|
|
19
|
+
const targetAgent = requireAgent(options.to, "--to");
|
|
20
|
+
const sourceSkill = requireNonEmpty(options.skill, "--skill");
|
|
21
|
+
const sourceRoots = (await detectedAgentSkillRoots(sourceAgent, home, env, { includeFallback: true }))
|
|
22
|
+
.map((root) => root.skillRoot);
|
|
23
|
+
const targetRoot = await agentSkillRoot(targetAgent, home, env);
|
|
24
|
+
const source = await findSourceSkillInRoots({ roots: sourceRoots, skill: sourceSkill });
|
|
25
|
+
const sourceContent = await readFile(source.skillFile, "utf8");
|
|
26
|
+
const targetSkill = options.targetSkill ?? sourceSkill;
|
|
27
|
+
const targetDir = resolveRelativeTarget(targetRoot, targetSkill);
|
|
28
|
+
const targetSkillFile = join(targetDir, "SKILL.md");
|
|
29
|
+
const provenanceFile = join(targetDir, ".skillboard-import.json");
|
|
30
|
+
const compatibility = analyzeAgentCompatibility(sourceContent, { sourceAgent, targetAgent });
|
|
31
|
+
const adaptedFile = options.adaptedFile;
|
|
32
|
+
const dryRun = options.dryRun === true;
|
|
33
|
+
const yes = options.yes === true;
|
|
34
|
+
const replace = options.replace === true;
|
|
35
|
+
const mode = adaptedFile === undefined ? "copy" : "adapted";
|
|
36
|
+
const next = nextSteps({ sourceAgent, targetAgent, sourceSkill, targetSkill });
|
|
37
|
+
|
|
38
|
+
if (!compatibility.compatible && adaptedFile === undefined) {
|
|
39
|
+
return {
|
|
40
|
+
status: "needs-adaptation",
|
|
41
|
+
mode: "adaptation-required",
|
|
42
|
+
changed: false,
|
|
43
|
+
source: sourceSummary(sourceAgent, sourceSkill, source),
|
|
44
|
+
target: targetSummary(targetAgent, targetSkill, targetSkillFile),
|
|
45
|
+
compatibility,
|
|
46
|
+
next
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const content = adaptedFile === undefined ? sourceContent : await readFile(adaptedFile, "utf8");
|
|
51
|
+
const targetExists = await exists(targetSkillFile);
|
|
52
|
+
const writeRequired = !dryRun && yes;
|
|
53
|
+
const status = dryRun || !yes ? "ready" : "installed";
|
|
54
|
+
|
|
55
|
+
if (targetExists && !replace) {
|
|
56
|
+
throw new Error(`Target skill already exists: ${targetSkillFile}; pass --replace to overwrite`);
|
|
57
|
+
}
|
|
58
|
+
if (writeRequired) {
|
|
59
|
+
await mkdir(targetDir, { recursive: true });
|
|
60
|
+
await writeFile(targetSkillFile, content, "utf8");
|
|
61
|
+
await writeFile(provenanceFile, `${JSON.stringify(provenance({
|
|
62
|
+
sourceAgent,
|
|
63
|
+
sourceSkill,
|
|
64
|
+
source,
|
|
65
|
+
sourceContent,
|
|
66
|
+
targetAgent,
|
|
67
|
+
targetSkill,
|
|
68
|
+
targetSkillFile,
|
|
69
|
+
targetContent: content,
|
|
70
|
+
mode,
|
|
71
|
+
compatibility
|
|
72
|
+
}), null, 2)}\n`, "utf8");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
status,
|
|
77
|
+
mode,
|
|
78
|
+
changed: writeRequired,
|
|
79
|
+
source: sourceSummary(sourceAgent, sourceSkill, source),
|
|
80
|
+
target: targetSummary(targetAgent, targetSkill, targetSkillFile),
|
|
81
|
+
compatibility,
|
|
82
|
+
next: status === "ready" ? { ...next, apply: `${next.apply} --yes` } : null
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function renderImportAgentSkill(result) {
|
|
87
|
+
if (result.status === "needs-adaptation") {
|
|
88
|
+
return [
|
|
89
|
+
"Skill requires adaptation before import.",
|
|
90
|
+
`Source: ${result.source.agent}:${result.source.skill} (${result.source.path})`,
|
|
91
|
+
`Target: ${result.target.agent}:${result.target.skill} (${result.target.path})`,
|
|
92
|
+
`Reasons: ${result.compatibility.reasons.join("; ")}`,
|
|
93
|
+
`Next: ${result.next.adaptedFileOption}`,
|
|
94
|
+
""
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
return [
|
|
98
|
+
`${result.status === "installed" ? "Imported" : "Ready to import"} skill: ${result.source.agent}:${result.source.skill} -> ${result.target.agent}:${result.target.skill}`,
|
|
99
|
+
`Mode: ${result.mode}`,
|
|
100
|
+
`Target: ${result.target.path}`,
|
|
101
|
+
result.status === "ready" ? "Re-run with --yes to write the target skill." : "Provenance: .skillboard-import.json",
|
|
102
|
+
""
|
|
103
|
+
].join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function agentSkillRoot(agent, home = homedir(), env = process.env) {
|
|
107
|
+
requireKnownAgent(agent);
|
|
108
|
+
return await preferredAgentSkillRoot(agent, home, env);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function supportedImportAgents() {
|
|
112
|
+
return supportedAgentNames();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function requireAgent(value, optionName) {
|
|
116
|
+
const agent = requireNonEmpty(value, optionName);
|
|
117
|
+
if (!supportedAgentNames().includes(agent)) {
|
|
118
|
+
throw new Error(`Unsupported ${optionName} agent: ${agent}`);
|
|
119
|
+
}
|
|
120
|
+
return agent;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function requireKnownAgent(agent) {
|
|
124
|
+
if (!supportedAgentNames().includes(agent)) {
|
|
125
|
+
throw new Error(`Unsupported agent: ${agent}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function requireNonEmpty(value, optionName) {
|
|
130
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
131
|
+
throw new Error(`${optionName} is required`);
|
|
132
|
+
}
|
|
133
|
+
return value.trim();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function findSourceSkill(options) {
|
|
137
|
+
const root = resolve(options.root);
|
|
138
|
+
const directDir = resolveRelativeTarget(root, options.skill);
|
|
139
|
+
const directFile = join(directDir, "SKILL.md");
|
|
140
|
+
if (await exists(directFile)) {
|
|
141
|
+
return { skillDir: directDir, skillFile: directFile, matchedBy: "path" };
|
|
142
|
+
}
|
|
143
|
+
const matches = [];
|
|
144
|
+
for (const file of await findSkillFiles(root)) {
|
|
145
|
+
const text = await readFile(file, "utf8").catch(() => "");
|
|
146
|
+
const frontmatter = parseSkillFrontmatter(text);
|
|
147
|
+
if (frontmatter.name === options.skill) {
|
|
148
|
+
matches.push({ skillDir: dirname(file), skillFile: file, matchedBy: "frontmatter" });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (matches.length === 1) {
|
|
152
|
+
return matches[0];
|
|
153
|
+
}
|
|
154
|
+
if (matches.length > 1) {
|
|
155
|
+
throw new Error(`Multiple source skills matched ${options.skill}; pass a directory name instead`);
|
|
156
|
+
}
|
|
157
|
+
throw new Error(`Source skill not found: ${options.skill} under ${root}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function findSourceSkillInRoots(options) {
|
|
161
|
+
const misses = [];
|
|
162
|
+
for (const root of options.roots) {
|
|
163
|
+
try {
|
|
164
|
+
return await findSourceSkill({ root, skill: options.skill });
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
167
|
+
if (!message.startsWith(`Source skill not found: ${options.skill} under `)) {
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
misses.push(root);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw new Error(`Source skill not found: ${options.skill} under ${misses.join(", ")}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function findSkillFiles(root) {
|
|
177
|
+
const files = [];
|
|
178
|
+
const pending = [resolve(root)];
|
|
179
|
+
while (pending.length > 0) {
|
|
180
|
+
const current = pending.shift();
|
|
181
|
+
const entries = await readdir(current, { withFileTypes: true }).catch(() => []);
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
const path = join(current, entry.name);
|
|
184
|
+
if (entry.isDirectory()) {
|
|
185
|
+
pending.push(path);
|
|
186
|
+
} else if (entry.isFile() && entry.name === "SKILL.md") {
|
|
187
|
+
files.push(path);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return files;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseSkillFrontmatter(text) {
|
|
195
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
196
|
+
if (match === null) {
|
|
197
|
+
return {};
|
|
198
|
+
}
|
|
199
|
+
const parsed = YAML.parse(match[1]);
|
|
200
|
+
return parsed !== null && typeof parsed === "object" ? parsed : {};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function analyzeAgentCompatibility(content, options) {
|
|
204
|
+
const reasons = [];
|
|
205
|
+
for (const [agent, markers] of Object.entries(AGENT_MARKERS)) {
|
|
206
|
+
if (agent === options.targetAgent) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const matched = markers.filter((marker) => marker.test(content)).map((marker) => marker.source);
|
|
210
|
+
if (matched.length > 0) {
|
|
211
|
+
reasons.push(`source mentions ${agent}-specific runtime markers: ${matched.join(", ")}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
compatible: reasons.length === 0,
|
|
216
|
+
sourceAgent: options.sourceAgent,
|
|
217
|
+
targetAgent: options.targetAgent,
|
|
218
|
+
reasons
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function nextSteps(options) {
|
|
223
|
+
const base = `skillboard import-skill --from ${options.sourceAgent} --to ${options.targetAgent} --skill ${options.sourceSkill} --target-skill ${options.targetSkill}`;
|
|
224
|
+
return {
|
|
225
|
+
askUser: "Ask whether to adapt the skill for the target agent before changing the skill body.",
|
|
226
|
+
adaptedFileOption: `${base} --adapted-file <adapted-skill.md> --yes`,
|
|
227
|
+
apply: base
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function provenance(options) {
|
|
232
|
+
return {
|
|
233
|
+
version: 1,
|
|
234
|
+
mode: options.mode,
|
|
235
|
+
source: {
|
|
236
|
+
agent: options.sourceAgent,
|
|
237
|
+
skill: options.sourceSkill,
|
|
238
|
+
path: options.source.skillFile,
|
|
239
|
+
digest: digest(options.sourceContent)
|
|
240
|
+
},
|
|
241
|
+
target: {
|
|
242
|
+
agent: options.targetAgent,
|
|
243
|
+
skill: options.targetSkill,
|
|
244
|
+
path: options.targetSkillFile,
|
|
245
|
+
digest: digest(options.targetContent)
|
|
246
|
+
},
|
|
247
|
+
compatibility: options.compatibility
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function sourceSummary(agent, skill, source) {
|
|
252
|
+
return {
|
|
253
|
+
agent,
|
|
254
|
+
skill,
|
|
255
|
+
path: source.skillFile,
|
|
256
|
+
matchedBy: source.matchedBy
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function targetSummary(agent, skill, path) {
|
|
261
|
+
return { agent, skill, path };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function digest(content) {
|
|
265
|
+
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function resolveRelativeTarget(root, value) {
|
|
269
|
+
const relativePath = String(value).replaceAll("\\", "/");
|
|
270
|
+
if (relativePath.trim() === "" || relativePath.includes("\0")) {
|
|
271
|
+
throw new Error("skill path must be a non-empty relative path");
|
|
272
|
+
}
|
|
273
|
+
if (relativePath.startsWith("/") || /^[A-Za-z]:\//.test(relativePath)) {
|
|
274
|
+
throw new Error("skill path must be relative");
|
|
275
|
+
}
|
|
276
|
+
const absolute = resolve(root, relativePath);
|
|
277
|
+
assertInside(resolve(root), absolute, "skill path");
|
|
278
|
+
return absolute;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function assertInside(root, candidate, label) {
|
|
282
|
+
const rel = relative(root, candidate);
|
|
283
|
+
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
throw new Error(`${label} must stay under ${root}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function exists(path) {
|
|
290
|
+
return access(path).then(() => true, () => false);
|
|
291
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { access, readdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
const SUPPORTED_AGENTS = Object.freeze(["codex", "claude", "opencode", "hermes"]);
|
|
6
|
+
|
|
7
|
+
export function supportedAgentNames() {
|
|
8
|
+
return [...SUPPORTED_AGENTS];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function detectedAgentSkillRoots(agent, home = homedir(), env = process.env, options = {}) {
|
|
12
|
+
const candidates = await agentSkillRootCandidates(agent, home, env);
|
|
13
|
+
const detected = [];
|
|
14
|
+
for (const candidate of candidates) {
|
|
15
|
+
if (candidate.explicit || await exists(candidate.skillRoot)) {
|
|
16
|
+
detected.push(candidate);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (detected.length === 0 && options.includeFallback === true) {
|
|
20
|
+
detected.push(fallbackCandidate(candidates));
|
|
21
|
+
}
|
|
22
|
+
return uniqueCandidates(detected);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function setupAgentSkillTargets(agent, home = homedir(), env = process.env, options = {}) {
|
|
26
|
+
const roots = await detectedAgentSkillRoots(agent, home, env, { includeFallback: options.includeFallback === true });
|
|
27
|
+
return roots.map((root) => ({
|
|
28
|
+
agent,
|
|
29
|
+
skillPath: join(root.skillRoot, "skillboard", "SKILL.md"),
|
|
30
|
+
root: root.skillRoot,
|
|
31
|
+
source: root.source
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function preferredAgentSkillRoot(agent, home = homedir(), env = process.env) {
|
|
36
|
+
const [root] = await detectedAgentSkillRoots(agent, home, env, { includeFallback: true });
|
|
37
|
+
return root.skillRoot;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function agentSkillRootCandidates(agent, home = homedir(), env = process.env) {
|
|
41
|
+
const normalized = normalizeAgent(agent);
|
|
42
|
+
const xdgConfig = env.XDG_CONFIG_HOME ?? join(home, ".config");
|
|
43
|
+
if (normalized === "codex") {
|
|
44
|
+
return uniqueCandidates([
|
|
45
|
+
env.CODEX_HOME === undefined ? null : candidate(join(env.CODEX_HOME, "skills"), "CODEX_HOME/skills", true, false),
|
|
46
|
+
env.AGENTS_HOME === undefined ? null : candidate(join(env.AGENTS_HOME, "skills"), "AGENTS_HOME/skills", true, false),
|
|
47
|
+
candidate(join(home, ".agents", "skills"), "~/.agents/skills", false, false),
|
|
48
|
+
candidate(join(home, ".codex", "skills"), "~/.codex/skills", false, true)
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
if (normalized === "claude") {
|
|
52
|
+
return uniqueCandidates([
|
|
53
|
+
env.CLAUDE_HOME === undefined ? null : candidate(join(env.CLAUDE_HOME, "skills"), "CLAUDE_HOME/skills", true, false),
|
|
54
|
+
candidate(join(home, ".claude", "skills"), "~/.claude/skills", false, true),
|
|
55
|
+
candidate(join(xdgConfig, "claude", "skills"), "XDG claude skills", false, false),
|
|
56
|
+
candidate(join(home, ".config", "claude", "skills"), "~/.config/claude/skills", false, false)
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
if (normalized === "opencode") {
|
|
60
|
+
return uniqueCandidates([
|
|
61
|
+
env.OPENCODE_HOME === undefined ? null : candidate(join(env.OPENCODE_HOME, "skills"), "OPENCODE_HOME/skills", true, false),
|
|
62
|
+
candidate(join(xdgConfig, "opencode", "skills"), "XDG opencode skills", false, xdgConfig !== join(home, ".config")),
|
|
63
|
+
candidate(join(home, ".config", "opencode", "skills"), "~/.config/opencode/skills", false, true),
|
|
64
|
+
candidate(join(home, ".opencode", "skills"), "~/.opencode/skills", false, false)
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
const hermesHome = env.HERMES_HOME ?? join(home, ".hermes");
|
|
68
|
+
return uniqueCandidates([
|
|
69
|
+
env.HERMES_HOME === undefined ? null : candidate(join(env.HERMES_HOME, "skills"), "HERMES_HOME/skills", true, false),
|
|
70
|
+
candidate(join(home, ".hermes", "skills"), "~/.hermes/skills", false, true),
|
|
71
|
+
...(await hermesProfileCandidates(hermesHome))
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeAgent(agent) {
|
|
76
|
+
if (!SUPPORTED_AGENTS.includes(agent)) {
|
|
77
|
+
throw new Error(`Unsupported agent: ${agent}`);
|
|
78
|
+
}
|
|
79
|
+
return agent;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function candidate(skillRoot, source, explicit, fallback) {
|
|
83
|
+
return { skillRoot, source, explicit, fallback };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fallbackCandidate(candidates) {
|
|
87
|
+
return candidates.find((candidate) => candidate.fallback) ?? candidates[0];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function uniqueCandidates(candidates) {
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
const unique = [];
|
|
93
|
+
for (const candidate of candidates.filter(Boolean)) {
|
|
94
|
+
const key = resolve(candidate.skillRoot);
|
|
95
|
+
if (seen.has(key)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
seen.add(key);
|
|
99
|
+
unique.push({ ...candidate, skillRoot: key });
|
|
100
|
+
}
|
|
101
|
+
return unique;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function hermesProfileCandidates(hermesHome) {
|
|
105
|
+
const profilesRoot = join(hermesHome, "profiles");
|
|
106
|
+
const entries = await readdir(profilesRoot, { withFileTypes: true }).catch(() => []);
|
|
107
|
+
return entries
|
|
108
|
+
.filter((entry) => entry.isDirectory())
|
|
109
|
+
.map((entry) => candidate(join(profilesRoot, entry.name, "skills"), `Hermes profile ${entry.name}`, false, false));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function exists(path) {
|
|
113
|
+
return access(path).then(() => true, () => false);
|
|
114
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// allow: SIZE_OK - legacy CLI dispatcher split is deferred from the 0.2.7 release gate.
|
|
1
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import { readFileSync } from "node:fs";
|
|
3
4
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
@@ -51,16 +52,19 @@ import {
|
|
|
51
52
|
} from "./index.mjs";
|
|
52
53
|
// SIZE_OK: src/cli.mjs is pre-existing command-router debt; brief behavior delegates to src/brief-cli.mjs and hook planning delegates through src/hook-plan.mjs until a broader router split.
|
|
53
54
|
import { ApplyActionError, applyActionErrorPayload, applyAdvisorAction } from "./advisor/apply-action.mjs";
|
|
55
|
+
import { importAgentSkill, renderImportAgentSkill } from "./agent-skill-import.mjs";
|
|
54
56
|
import { runBriefCommand } from "./brief-cli.mjs";
|
|
55
57
|
import { renderSkillBrief } from "./brief-renderer.mjs";
|
|
56
58
|
import { planGuardHookInstall } from "./control.mjs";
|
|
57
59
|
import { writeCheckedConfig } from "./control/config-write.mjs";
|
|
58
|
-
import { runInitCommand, runUninstallCommand } from "./lifecycle-cli.mjs";
|
|
60
|
+
import { runInitCommand, runSetupCommand, runUninstallCommand } from "./lifecycle-cli.mjs";
|
|
59
61
|
|
|
60
62
|
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
61
63
|
|
|
62
64
|
const APPLY_ACTION_VALUE_OPTIONS = new Set(["workflow", "dir", "config", "skills", "out", "skillboard-bin"]);
|
|
63
65
|
const COMMAND_USAGE = new Map([
|
|
66
|
+
["setup", ["setup [--yes] [--agent codex[,claude,opencode,hermes]]"]],
|
|
67
|
+
["import-skill", ["import-skill --from <agent> --to <agent> --skill <id-or-dir> [--target-skill <id-or-dir>] [--adapted-file <path>] [--dry-run] [--yes] [--replace] [--json]"]],
|
|
64
68
|
["uninstall", ["uninstall [--dir <path>] [--dry-run] [--purge] [--remove-config|--reset-config] [--remove-reports] [--remove-hooks] [--keep-empty-dirs]"]],
|
|
65
69
|
[
|
|
66
70
|
"inventory",
|
|
@@ -94,19 +98,19 @@ const COMMAND_USAGE = new Map([
|
|
|
94
98
|
]);
|
|
95
99
|
|
|
96
100
|
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
97
|
-
process.exitCode = await main(process.argv.slice(2), process.stdout, process.stderr);
|
|
101
|
+
process.exitCode = await main(process.argv.slice(2), process.stdout, process.stderr, process.stdin);
|
|
98
102
|
}
|
|
99
103
|
|
|
100
|
-
export async function main(argv, stdout, stderr) {
|
|
101
|
-
try {
|
|
102
|
-
return await run(argv, stdout, stderr);
|
|
103
|
-
} catch (error) {
|
|
104
|
-
stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
104
|
+
export async function main(argv, stdout, stderr, stdin = process.stdin) {
|
|
105
|
+
try {
|
|
106
|
+
return await run(argv, stdout, stderr, stdin);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
105
109
|
return Number.isInteger(error?.exitCode) ? error.exitCode : 1;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function run(argv, stdout, stderr) {
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function run(argv, stdout, stderr, stdin) {
|
|
110
114
|
const command = argv[0] ?? "help";
|
|
111
115
|
const commandArgs = argv.slice(1);
|
|
112
116
|
const options = parseOptions(commandArgs);
|
|
@@ -116,6 +120,17 @@ async function run(argv, stdout, stderr) {
|
|
|
116
120
|
return 0;
|
|
117
121
|
}
|
|
118
122
|
switch (command) {
|
|
123
|
+
case "setup":
|
|
124
|
+
return await runSetupCommand(options, stdout, {
|
|
125
|
+
cwd: process.cwd(),
|
|
126
|
+
env: process.env,
|
|
127
|
+
entrypointPath: process.argv[1],
|
|
128
|
+
packageSpec: process.env.npm_config_package,
|
|
129
|
+
stdin,
|
|
130
|
+
stdout
|
|
131
|
+
});
|
|
132
|
+
case "import-skill":
|
|
133
|
+
return await importSkillCommand(options, stdout);
|
|
119
134
|
case "init":
|
|
120
135
|
return await runInitCommand(options, stdout);
|
|
121
136
|
case "uninstall":
|
|
@@ -209,7 +224,7 @@ function selectedHelpText(command, args, options) {
|
|
|
209
224
|
return null;
|
|
210
225
|
}
|
|
211
226
|
|
|
212
|
-
async function importProfile(options, stdout) {
|
|
227
|
+
async function importProfile(options, stdout) {
|
|
213
228
|
const profileRef = options.get("profile");
|
|
214
229
|
const sourceRoot = options.get("source-root");
|
|
215
230
|
if (profileRef === undefined || sourceRoot === undefined) {
|
|
@@ -256,6 +271,22 @@ async function mergeImport(options, imported, stdout) {
|
|
|
256
271
|
return 0;
|
|
257
272
|
}
|
|
258
273
|
|
|
274
|
+
async function importSkillCommand(options, stdout) {
|
|
275
|
+
const result = await importAgentSkill({
|
|
276
|
+
from: options.get("from"),
|
|
277
|
+
to: options.get("to"),
|
|
278
|
+
skill: options.get("skill"),
|
|
279
|
+
targetSkill: options.get("target-skill"),
|
|
280
|
+
adaptedFile: options.get("adapted-file"),
|
|
281
|
+
dryRun: options.get("dry-run") === "true",
|
|
282
|
+
yes: options.get("yes") === "true",
|
|
283
|
+
replace: options.get("replace") === "true",
|
|
284
|
+
env: process.env
|
|
285
|
+
});
|
|
286
|
+
writeOutput(stdout, result, options, () => renderImportAgentSkill(result));
|
|
287
|
+
return result.status === "needs-adaptation" ? 2 : 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
259
290
|
async function inventory(argv, options, stdout) {
|
|
260
291
|
const args = positionalArgs(argv);
|
|
261
292
|
if (args[0] === "detect") {
|
|
@@ -1061,14 +1092,18 @@ function parseOptions(args) {
|
|
|
1061
1092
|
return options;
|
|
1062
1093
|
}
|
|
1063
1094
|
|
|
1064
|
-
function formatList(values) {
|
|
1065
|
-
return values.length === 0 ? "none" : values.map((value) => `\`${value}\``).join(", ");
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1095
|
+
function formatList(values) {
|
|
1096
|
+
return values.length === 0 ? "none" : values.map((value) => `\`${value}\``).join(", ");
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function shellQuote(value) {
|
|
1100
|
+
return /^[A-Za-z0-9_./:=@-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1068
1103
|
function readCsv(value) {
|
|
1069
|
-
if (value === undefined || value.trim() === "") {
|
|
1070
|
-
return [];
|
|
1071
|
-
}
|
|
1104
|
+
if (value === undefined || value.trim() === "") {
|
|
1105
|
+
return [];
|
|
1106
|
+
}
|
|
1072
1107
|
return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
1073
1108
|
}
|
|
1074
1109
|
|
|
@@ -1446,6 +1481,7 @@ function renderDoctorSummary(result) {
|
|
|
1446
1481
|
lines.push(`- ${concern}`);
|
|
1447
1482
|
}
|
|
1448
1483
|
}
|
|
1484
|
+
appendNotInitializedAttachGuidance(lines, result);
|
|
1449
1485
|
if (result.recommendations.length > 0) {
|
|
1450
1486
|
lines.push("Recommendations:");
|
|
1451
1487
|
for (const recommendation of result.recommendations.slice(0, 3)) {
|
|
@@ -1477,6 +1513,7 @@ function renderDoctor(result) {
|
|
|
1477
1513
|
`Runtime extension units: ${formatList(result.workspace.installUnits.runtimeExtensions)}`,
|
|
1478
1514
|
`Uninstall dry run: remove ${result.uninstall.removed.length}, update ${result.uninstall.updated.length}, preserve ${result.uninstall.preserved.length}`
|
|
1479
1515
|
];
|
|
1516
|
+
appendNotInitializedAttachGuidance(lines, result);
|
|
1480
1517
|
if (result.recommendations.length > 0) {
|
|
1481
1518
|
lines.push("Recommendations:");
|
|
1482
1519
|
for (const recommendation of result.recommendations) {
|
|
@@ -1487,6 +1524,16 @@ function renderDoctor(result) {
|
|
|
1487
1524
|
return lines.join("\n");
|
|
1488
1525
|
}
|
|
1489
1526
|
|
|
1527
|
+
function appendNotInitializedAttachGuidance(lines, result) {
|
|
1528
|
+
if (result.mode !== "not-initialized") {
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
lines.push("SkillBoard state:");
|
|
1532
|
+
lines.push("- No local SkillBoard policy file was found in this directory.");
|
|
1533
|
+
lines.push("- That is OK: project management belongs to the agent/workspace layer.");
|
|
1534
|
+
lines.push("- Global install normally runs agent setup automatically; run skillboard setup later only after adding another supported agent or if install scripts were skipped.");
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1490
1537
|
function renderCounts(counts) {
|
|
1491
1538
|
const entries = Object.entries(counts).filter(([, count]) => count > 0);
|
|
1492
1539
|
return entries.length === 0
|
|
@@ -1499,7 +1546,14 @@ function helpText() {
|
|
|
1499
1546
|
"SkillBoard - AI-mediated workflow-scoped skill policy",
|
|
1500
1547
|
"Version: see skillboard --version",
|
|
1501
1548
|
"",
|
|
1549
|
+
"After global install:",
|
|
1550
|
+
" npm install -g agent-skillboard",
|
|
1551
|
+
" The package postinstall auto-runs agent-layer guidance setup for detected supported agents.",
|
|
1552
|
+
" Run skillboard setup later after adding another supported agent or when install scripts were skipped.",
|
|
1553
|
+
"",
|
|
1502
1554
|
"AI/automation operations:",
|
|
1555
|
+
" setup [--yes] [--agent codex[,claude,opencode,hermes]]",
|
|
1556
|
+
" import-skill --from <agent> --to <agent> --skill <id-or-dir> [--target-skill <id-or-dir>] [--adapted-file <path>] [--dry-run] [--yes] [--replace] [--json]",
|
|
1503
1557
|
" init [--dir <path>] [--scan-root <dir>[,<dir>]] [--no-scan-installed]",
|
|
1504
1558
|
" uninstall [--dir <path>] [--dry-run] [--purge] [--remove-config|--reset-config] [--remove-reports] [--remove-hooks] [--keep-empty-dirs]",
|
|
1505
1559
|
" inventory refresh [--dir <path>] [--config <path>] [--scan-root <dir>[,<dir>]] [--dry-run] [--json]",
|
|
@@ -1560,6 +1614,12 @@ function commandHelpText(command) {
|
|
|
1560
1614
|
if (command === "init") {
|
|
1561
1615
|
return initHelpText();
|
|
1562
1616
|
}
|
|
1617
|
+
if (command === "setup") {
|
|
1618
|
+
return setupHelpText();
|
|
1619
|
+
}
|
|
1620
|
+
if (command === "import-skill") {
|
|
1621
|
+
return importSkillHelpText();
|
|
1622
|
+
}
|
|
1563
1623
|
if (command === "uninstall") {
|
|
1564
1624
|
return uninstallHelpText();
|
|
1565
1625
|
}
|
|
@@ -1613,6 +1673,61 @@ function initHelpText() {
|
|
|
1613
1673
|
].join("\n");
|
|
1614
1674
|
}
|
|
1615
1675
|
|
|
1676
|
+
function setupHelpText() {
|
|
1677
|
+
return [
|
|
1678
|
+
"Usage: skillboard setup [--yes] [--agent codex[,claude,opencode,hermes]]",
|
|
1679
|
+
"",
|
|
1680
|
+
"Installs or refreshes SkillBoard at the agent layer, not into a project.",
|
|
1681
|
+
"A normal global package install already runs this automatically for detected supported agents.",
|
|
1682
|
+
"Use setup later after adding another supported agent, enabling a new agent home, or skipping install scripts.",
|
|
1683
|
+
"Without --yes, setup explains the user agent skill files it will write and asks before installing when run in a TTY.",
|
|
1684
|
+
"In non-interactive automation, rerun with --yes after choosing the target agents.",
|
|
1685
|
+
"",
|
|
1686
|
+
"What changes after confirmation:",
|
|
1687
|
+
" Writes a SkillBoard guidance skill into detected user agent skill roots.",
|
|
1688
|
+
" Does not write skillboard.config.yaml, .skillboard/, AGENTS.md, or CLAUDE.md in projects.",
|
|
1689
|
+
" Teaches agents to use installed skills by default and resolve overlap by workflow priority.",
|
|
1690
|
+
"",
|
|
1691
|
+
"Supported agent homes:",
|
|
1692
|
+
" codex: CODEX_HOME, AGENTS_HOME, ~/.agents, or ~/.codex",
|
|
1693
|
+
" claude: CLAUDE_HOME or ~/.claude",
|
|
1694
|
+
" opencode: OPENCODE_HOME or ~/.config/opencode",
|
|
1695
|
+
" hermes: HERMES_HOME or ~/.hermes",
|
|
1696
|
+
"",
|
|
1697
|
+
"Examples:",
|
|
1698
|
+
" skillboard setup",
|
|
1699
|
+
" skillboard setup --yes",
|
|
1700
|
+
" skillboard setup --agent codex,claude,opencode,hermes --yes",
|
|
1701
|
+
""
|
|
1702
|
+
].join("\n");
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
function importSkillHelpText() {
|
|
1706
|
+
return [
|
|
1707
|
+
"Usage: skillboard import-skill --from <agent> --to <agent> --skill <id-or-dir> [--target-skill <id-or-dir>] [--adapted-file <path>] [--dry-run] [--yes] [--replace] [--json]",
|
|
1708
|
+
"",
|
|
1709
|
+
"Copies a user skill from one agent skill root into another agent skill root.",
|
|
1710
|
+
"This is an agent-layer operation. It does not initialize, attach, or modify a project.",
|
|
1711
|
+
"",
|
|
1712
|
+
"Supported agents:",
|
|
1713
|
+
" codex: CODEX_HOME, AGENTS_HOME, ~/.agents, or ~/.codex",
|
|
1714
|
+
" claude: CLAUDE_HOME or ~/.claude",
|
|
1715
|
+
" opencode: OPENCODE_HOME or ~/.config/opencode",
|
|
1716
|
+
" hermes: HERMES_HOME or ~/.hermes",
|
|
1717
|
+
"",
|
|
1718
|
+
"AI use:",
|
|
1719
|
+
" Run this when a user asks to reuse a skill from another agent.",
|
|
1720
|
+
" If the source looks compatible, rerun with --yes to install it as-is.",
|
|
1721
|
+
" If the result status is needs-adaptation, explain the reasons and ask before changing the skill for the target agent.",
|
|
1722
|
+
" After user approval, write the adapted SKILL.md body yourself and pass it with --adapted-file <path> --yes.",
|
|
1723
|
+
"",
|
|
1724
|
+
"Examples:",
|
|
1725
|
+
" skillboard import-skill --from codex --to opencode --skill test-first --json",
|
|
1726
|
+
" skillboard import-skill --from codex --to opencode --skill codex-hook --target-skill opencode-hook --adapted-file /tmp/opencode-hook.SKILL.md --yes --json",
|
|
1727
|
+
""
|
|
1728
|
+
].join("\n");
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1616
1731
|
function uninstallHelpText() {
|
|
1617
1732
|
return [
|
|
1618
1733
|
"Usage: skillboard uninstall [--dir <path>] [--dry-run] [--purge] [--remove-config|--reset-config] [--remove-reports] [--remove-hooks] [--keep-empty-dirs]",
|
package/src/doctor.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export async function doctorProject(options = {}) {
|
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
if (!configExists) {
|
|
48
|
-
return finalizeDoctor(base, ["run skillboard
|
|
48
|
+
return finalizeDoctor(base, ["run skillboard setup once per user/agent install if agents should use SkillBoard for skill priority"]);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
let workspace;
|