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.
@@ -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]",
@@ -1,3 +1,4 @@
1
+ // allow: SIZE_OK - control command split is deferred from the 0.2.7 release gate.
1
2
  import {
2
3
  EXPOSURE_VALUES,
3
4
  INVOCATION_VALUES,
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 init"]);
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;