agent-skillboard 0.3.0 → 0.3.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.
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { access, chown, lstat, readFile } from "node:fs/promises";
2
+ import { access, chown, lstat, readFile, readdir } from "node:fs/promises";
3
3
  import { dirname, isAbsolute, relative, resolve } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
 
@@ -53,6 +53,19 @@ export async function applyOwnership(path, ownership) {
53
53
  }
54
54
  }
55
55
 
56
+ export async function applyOwnershipTree(path, ownership) {
57
+ if (ownership === null) return;
58
+ const stats = await pathStats(path);
59
+ if (stats === null || stats.isSymbolicLink()) return;
60
+ await applyOwnership(path, ownership);
61
+ if (!stats.isDirectory()) return;
62
+ const entries = await readdir(path, { withFileTypes: true });
63
+ for (const entry of entries) {
64
+ if (entry.isSymbolicLink()) continue;
65
+ await applyOwnershipTree(resolve(path, entry.name), ownership);
66
+ }
67
+ }
68
+
56
69
  function canApplyProcessOwnership(uid, gid) {
57
70
  if (typeof process.getuid !== "function") {
58
71
  return false;
@@ -1,19 +1,22 @@
1
1
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
2
- import { agentSkillRootCandidates } from "./agent-skill-roots.mjs";
2
+ import { activeAgentSkillRootCandidates } from "./agent-skill-roots.mjs";
3
3
 
4
- export async function defaultScanRoots(home, env) {
4
+ export async function defaultScanRoots(home, env, options = {}) {
5
5
  const codexHome = env.CODEX_HOME ?? join(home, ".codex");
6
6
  const agentRoots = await Promise.all([
7
- agentSkillRootCandidates("codex", home, env),
8
- agentSkillRootCandidates("claude", home, env),
9
- agentSkillRootCandidates("opencode", home, env),
10
- agentSkillRootCandidates("hermes", home, env)
7
+ activeAgentSkillRootCandidates("codex", home, env, options),
8
+ activeAgentSkillRootCandidates("claude", home, env, options),
9
+ activeAgentSkillRootCandidates("opencode", home, env, options),
10
+ activeAgentSkillRootCandidates("hermes", home, env, options)
11
11
  ]);
12
12
  return [
13
- join(codexHome, "skills", ".system"),
14
- join(codexHome, "plugins", "cache"),
15
- join(home, ".agents", "shared-skills"),
16
- ...agentRoots.flat().map((root) => root.skillRoot)
13
+ { path: join(codexHome, "skills", ".system") },
14
+ { path: join(codexHome, "plugins", "cache") },
15
+ { path: join(home, ".agents", "shared-skills") },
16
+ ...agentRoots.flatMap((roots, index) => roots.map((root) => ({
17
+ path: root.skillRoot,
18
+ agent: ["codex", "claude", "opencode", "hermes"][index]
19
+ })))
17
20
  ];
18
21
  }
19
22
 
@@ -110,16 +110,16 @@ export async function discoverAgentSkillInventory(options = {}) {
110
110
  const env = options.env ?? process.env;
111
111
  const home = options.home ?? env.HOME ?? env.USERPROFILE ?? homedir();
112
112
  const detectors = options.detectors ?? agentInventoryDetectors;
113
- const roots = uniquePaths([
114
- ...(await defaultScanRoots(home, env)),
115
- ...readCsv(env.SKILLBOARD_INIT_SCAN_ROOTS),
116
- ...(options.roots ?? [])
113
+ const roots = uniqueRootEntries([
114
+ ...(await defaultScanRoots(home, env, { registeredRoots: options.registeredRoots })),
115
+ ...readCsv(env.SKILLBOARD_INIT_SCAN_ROOTS).map((path) => ({ path })),
116
+ ...(options.roots ?? []).map((path) => ({ path }))
117
117
  ], home);
118
118
  const groups = [];
119
119
  const warnings = [];
120
120
 
121
121
  for (const root of roots) {
122
- const discovered = await discoverRoot(root, home, detectors);
122
+ const discovered = await discoverRoot(root.path, home, detectors, root.agent);
123
123
  groups.push(...discovered.groups);
124
124
  warnings.push(...discovered.warnings);
125
125
  }
@@ -293,13 +293,13 @@ function ensureLocalWorkflow(workflowsMap, target, skills, document) {
293
293
  return true;
294
294
  }
295
295
 
296
- async function discoverRoot(root, home, detectors) {
296
+ async function discoverRoot(root, home, detectors, agent) {
297
297
  const path = resolvePath(root, home);
298
298
  if (!(await exists(path))) {
299
299
  return { groups: [], warnings: [] };
300
300
  }
301
301
  const warnings = [];
302
- const detector = detectors.find((candidate) => {
302
+ const matched = detectors.find((candidate) => {
303
303
  try {
304
304
  return candidate.matches(path);
305
305
  } catch (error) {
@@ -307,6 +307,7 @@ async function discoverRoot(root, home, detectors) {
307
307
  return false;
308
308
  }
309
309
  });
310
+ const detector = agent === undefined ? matched : forcedAgentDetector(agent, matched, detectors);
310
311
  if (detector === undefined) {
311
312
  return { groups: [], warnings };
312
313
  }
@@ -319,6 +320,12 @@ async function discoverRoot(root, home, detectors) {
319
320
  }
320
321
  }
321
322
 
323
+ function forcedAgentDetector(agent, matched, detectors) {
324
+ if (matched?.id === "hermes-profile-skills" && agent === "hermes") return matched;
325
+ const id = `${agent}-user-skills`;
326
+ return detectors.find((candidate) => candidate.id === id) ?? matched;
327
+ }
328
+
322
329
  async function discoverSkillDirectory(root, unit, options) {
323
330
  const files = await findSkillFiles(root, root, options);
324
331
  return files.length === 0 ? [] : [{ unit, root, files }];
@@ -898,21 +905,20 @@ function uniqueStrings(values) {
898
905
  return [...new Set(values.filter((value) => value.trim().length > 0))];
899
906
  }
900
907
 
901
- function uniquePaths(values, home) {
902
- const seen = new Set();
903
- const result = [];
908
+ function uniqueRootEntries(values, home) {
909
+ const byPath = new Map();
904
910
  for (const value of values) {
905
- const trimmed = value.trim();
911
+ const trimmed = value.path.trim();
906
912
  if (trimmed.length === 0) {
907
913
  continue;
908
914
  }
909
915
  const key = resolve(trimmed.startsWith("~/") ? join(home, trimmed.slice(2)) : trimmed);
910
- if (!seen.has(key)) {
911
- seen.add(key);
912
- result.push(trimmed);
916
+ const existing = byPath.get(key);
917
+ if (existing === undefined || existing.agent === undefined && value.agent !== undefined) {
918
+ byPath.set(key, { path: trimmed, ...(value.agent === undefined ? {} : { agent: value.agent }) });
913
919
  }
914
920
  }
915
- return result;
921
+ return [...byPath.values()];
916
922
  }
917
923
 
918
924
  function errorMessage(error) {
@@ -0,0 +1,127 @@
1
+ import { lstat, readFile } from "node:fs/promises";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import { atomicWrite } from "./migration/v2-files.mjs";
4
+
5
+ const FORMAT_VERSION = 1;
6
+ const SUPPORTED_AGENTS = new Set(["codex", "claude", "opencode", "hermes"]);
7
+
8
+ export function agentRootRegistryPath(home) {
9
+ return join(resolve(home), ".skillboard", "agent-roots.json");
10
+ }
11
+
12
+ export async function loadRegisteredAgentRoots(home) {
13
+ const root = resolve(home);
14
+ const path = agentRootRegistryPath(root);
15
+ const stats = await lstat(path).catch(missingOnly);
16
+ if (stats === undefined) return [];
17
+ if (stats.isSymbolicLink() || !stats.isFile()) {
18
+ throw new Error("Agent root registry must be a regular file, not a symbolic link.");
19
+ }
20
+ const value = JSON.parse(await readFile(path, "utf8"));
21
+ if (value?.format_version !== FORMAT_VERSION || !Array.isArray(value.roots)) {
22
+ throw new Error("Invalid agent root registry format.");
23
+ }
24
+ const entries = normalizeEntries(value.roots, root);
25
+ for (const entry of entries) await assertSafeRootPath(root, entry.path);
26
+ return entries;
27
+ }
28
+
29
+ export async function proposedAgentRoot(home, agent, path, cwd = process.cwd()) {
30
+ assertAgent(agent);
31
+ const root = resolve(home);
32
+ const target = resolve(cwd, path);
33
+ const rel = relative(root, target);
34
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
35
+ throw new Error("Registered skill root must remain inside the invoking user's home.");
36
+ }
37
+ await assertSafeRootPath(root, target);
38
+ return { agent, path: target };
39
+ }
40
+
41
+ export async function writeRegisteredAgentRoots(home, entries) {
42
+ const root = resolve(home);
43
+ const normalized = normalizeEntries(entries.map((entry) => ({
44
+ agent: entry.agent,
45
+ path: portablePath(root, entry.path)
46
+ })), root);
47
+ const value = {
48
+ format_version: FORMAT_VERSION,
49
+ roots: normalized.map((entry) => ({ agent: entry.agent, path: portablePath(root, entry.path) }))
50
+ };
51
+ await atomicWrite(agentRootRegistryPath(root), Buffer.from(`${JSON.stringify(value, null, 2)}\n`));
52
+ return normalized;
53
+ }
54
+
55
+ export function mergeRegisteredAgentRoots(entries, proposed) {
56
+ const byKey = new Map();
57
+ const agentByPath = new Map();
58
+ for (const entry of proposed === undefined ? entries : [...entries, proposed]) {
59
+ assertAgent(entry.agent);
60
+ const path = resolve(entry.path);
61
+ const registeredAgent = agentByPath.get(path);
62
+ if (registeredAgent !== undefined && registeredAgent !== entry.agent) {
63
+ throw new Error(`Custom skill root is already registered for agent ${registeredAgent}: ${path}`);
64
+ }
65
+ agentByPath.set(path, entry.agent);
66
+ byKey.set(`${entry.agent}\0${path}`, { agent: entry.agent, path });
67
+ }
68
+ return [...byKey.values()].sort(compareEntries);
69
+ }
70
+
71
+ function normalizeEntries(entries, home) {
72
+ if (!entries.every((entry) => entry !== null && typeof entry === "object")) {
73
+ throw new Error("Invalid agent root registry entry.");
74
+ }
75
+ return mergeRegisteredAgentRoots(entries.map((entry) => {
76
+ assertAgent(entry.agent);
77
+ if (typeof entry.path !== "string" || entry.path.trim() === "") {
78
+ throw new Error("Registered skill root path must be a non-empty string.");
79
+ }
80
+ const path = isAbsolute(entry.path) ? resolve(entry.path) : resolve(home, entry.path);
81
+ const rel = relative(home, path);
82
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
83
+ throw new Error("Registered skill root must remain inside the invoking user's home.");
84
+ }
85
+ return { agent: entry.agent, path };
86
+ }));
87
+ }
88
+
89
+ function portablePath(home, path) {
90
+ return relative(home, resolve(path)).split("\\").join("/");
91
+ }
92
+
93
+ function assertAgent(agent) {
94
+ if (!SUPPORTED_AGENTS.has(agent)) {
95
+ throw new Error(`Unsupported setup agent: ${String(agent)}`);
96
+ }
97
+ }
98
+
99
+ function compareEntries(left, right) {
100
+ return `${left.agent}\0${left.path}`.localeCompare(`${right.agent}\0${right.path}`);
101
+ }
102
+
103
+ async function assertSafeRootPath(home, target) {
104
+ const components = [];
105
+ let current = resolve(target);
106
+ while (current !== home) {
107
+ components.push(current);
108
+ const parent = resolve(current, "..");
109
+ if (parent === current) break;
110
+ current = parent;
111
+ }
112
+ for (const component of components.reverse()) {
113
+ const stats = await lstat(component).catch(missingOnly);
114
+ if (stats === undefined) continue;
115
+ if (stats.isSymbolicLink()) {
116
+ throw new Error("Registered skill root must not traverse a symbolic link.");
117
+ }
118
+ if (!stats.isDirectory()) {
119
+ throw new Error("Registered skill root components must be directories.");
120
+ }
121
+ }
122
+ }
123
+
124
+ function missingOnly(error) {
125
+ if (error?.code === "ENOENT") return undefined;
126
+ throw error;
127
+ }
@@ -1,6 +1,7 @@
1
- import { access, readdir } from "node:fs/promises";
1
+ import { access, readdir, readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
+ import { loadRegisteredAgentRoots } from "./agent-root-registry.mjs";
4
5
 
5
6
  const SUPPORTED_AGENTS = Object.freeze(["codex", "claude", "opencode", "hermes"]);
6
7
 
@@ -9,11 +10,12 @@ export function supportedAgentNames() {
9
10
  }
10
11
 
11
12
  export async function detectedAgentSkillRoots(agent, home = homedir(), env = process.env, options = {}) {
12
- const candidates = await agentSkillRootCandidates(agent, home, env);
13
+ const candidates = await activeAgentSkillRootCandidates(agent, home, env, options);
13
14
  const detected = [];
14
15
  for (const candidate of candidates) {
15
16
  if (
16
17
  candidate.explicit
18
+ || candidate.registered
17
19
  || await exists(candidate.skillRoot)
18
20
  || candidate.detectWhenExists !== undefined && await exists(candidate.detectWhenExists)
19
21
  ) {
@@ -27,7 +29,7 @@ export async function detectedAgentSkillRoots(agent, home = homedir(), env = pro
27
29
  }
28
30
 
29
31
  export async function setupAgentSkillTargets(agent, home = homedir(), env = process.env, options = {}) {
30
- const roots = await detectedAgentSkillRoots(agent, home, env, { includeFallback: options.includeFallback === true });
32
+ const roots = await detectedAgentSkillRoots(agent, home, env, options);
31
33
  return roots.map((root) => ({
32
34
  agent,
33
35
  home: resolve(home),
@@ -42,38 +44,61 @@ export async function preferredAgentSkillRoot(agent, home = homedir(), env = pro
42
44
  return root.skillRoot;
43
45
  }
44
46
 
45
- export async function agentSkillRootCandidates(agent, home = homedir(), env = process.env) {
47
+ export async function activeAgentSkillRootCandidates(agent, home = homedir(), env = process.env, options = {}) {
48
+ const candidates = await agentSkillRootCandidates(agent, home, env, options);
49
+ const hasRegistered = candidates.some((entry) => entry.registered);
50
+ const hasExplicit = candidates.some((entry) => entry.explicit);
51
+ if (!hasRegistered && !hasExplicit) return candidates;
52
+ const active = await Promise.all(candidates.map(async (entry) => ({
53
+ entry,
54
+ keep: entry.registered
55
+ || entry.explicit
56
+ || entry.profile
57
+ || entry.sharedCodex
58
+ || await hasAgentOwnedSkillContent(entry.skillRoot)
59
+ })));
60
+ return active.filter(({ keep }) => keep).map(({ entry }) => entry);
61
+ }
62
+
63
+ export async function agentSkillRootCandidates(agent, home = homedir(), env = process.env, options = {}) {
46
64
  const normalized = normalizeAgent(agent);
47
65
  const xdgConfig = env.XDG_CONFIG_HOME ?? join(home, ".config");
66
+ const registered = (options.registeredRoots ?? await loadRegisteredAgentRoots(home))
67
+ .filter((entry) => entry.agent === normalized)
68
+ .map((entry) => candidate(entry.path, "registered custom skill root", false, false, undefined, { registered: true }));
48
69
  if (normalized === "codex") {
49
70
  const agentsHome = join(home, ".agents");
50
71
  return uniqueCandidates([
72
+ ...registered,
51
73
  env.CODEX_HOME === undefined ? null : candidate(join(env.CODEX_HOME, "skills"), "CODEX_HOME/skills", true, false),
52
74
  env.AGENTS_HOME === undefined ? null : candidate(join(env.AGENTS_HOME, "skills"), "AGENTS_HOME/skills", true, false),
53
- candidate(join(agentsHome, "skills"), "~/.agents/skills", false, false, agentsHome),
54
- candidate(join(home, ".codex", "skills"), "~/.codex/skills", false, true)
75
+ candidate(join(agentsHome, "skills"), "~/.agents/skills", false, false, agentsHome, { sharedCodex: true }),
76
+ candidate(join(home, ".codex", "skills"), "~/.codex/skills", false, true, join(home, ".codex"))
55
77
  ]);
56
78
  }
57
79
  if (normalized === "claude") {
58
80
  return uniqueCandidates([
81
+ ...registered,
59
82
  env.CLAUDE_HOME === undefined ? null : candidate(join(env.CLAUDE_HOME, "skills"), "CLAUDE_HOME/skills", true, false),
60
- candidate(join(home, ".claude", "skills"), "~/.claude/skills", false, true),
83
+ candidate(join(home, ".claude", "skills"), "~/.claude/skills", false, true, join(home, ".claude")),
61
84
  candidate(join(xdgConfig, "claude", "skills"), "XDG claude skills", false, false),
62
85
  candidate(join(home, ".config", "claude", "skills"), "~/.config/claude/skills", false, false)
63
86
  ]);
64
87
  }
65
88
  if (normalized === "opencode") {
66
89
  return uniqueCandidates([
90
+ ...registered,
67
91
  env.OPENCODE_HOME === undefined ? null : candidate(join(env.OPENCODE_HOME, "skills"), "OPENCODE_HOME/skills", true, false),
68
- candidate(join(xdgConfig, "opencode", "skills"), "XDG opencode skills", false, xdgConfig !== join(home, ".config")),
69
- candidate(join(home, ".config", "opencode", "skills"), "~/.config/opencode/skills", false, true),
92
+ candidate(join(xdgConfig, "opencode", "skills"), "XDG opencode skills", false, xdgConfig !== join(home, ".config"), join(xdgConfig, "opencode")),
93
+ candidate(join(home, ".config", "opencode", "skills"), "~/.config/opencode/skills", false, true, join(home, ".config", "opencode")),
70
94
  candidate(join(home, ".opencode", "skills"), "~/.opencode/skills", false, false)
71
95
  ]);
72
96
  }
73
97
  const hermesHome = env.HERMES_HOME ?? join(home, ".hermes");
74
98
  return uniqueCandidates([
99
+ ...registered,
75
100
  env.HERMES_HOME === undefined ? null : candidate(join(env.HERMES_HOME, "skills"), "HERMES_HOME/skills", true, false),
76
- candidate(join(home, ".hermes", "skills"), "~/.hermes/skills", false, true),
101
+ candidate(join(home, ".hermes", "skills"), "~/.hermes/skills", false, true, join(home, ".hermes")),
77
102
  ...(await hermesProfileCandidates(hermesHome))
78
103
  ]);
79
104
  }
@@ -85,8 +110,8 @@ function normalizeAgent(agent) {
85
110
  return agent;
86
111
  }
87
112
 
88
- function candidate(skillRoot, source, explicit, fallback, detectWhenExists) {
89
- return { skillRoot, source, explicit, fallback, detectWhenExists };
113
+ function candidate(skillRoot, source, explicit, fallback, detectWhenExists, metadata = {}) {
114
+ return { skillRoot, source, explicit, fallback, detectWhenExists, ...metadata };
90
115
  }
91
116
 
92
117
  function fallbackCandidate(candidates) {
@@ -112,9 +137,41 @@ async function hermesProfileCandidates(hermesHome) {
112
137
  const entries = await readdir(profilesRoot, { withFileTypes: true }).catch(() => []);
113
138
  return entries
114
139
  .filter((entry) => entry.isDirectory())
115
- .map((entry) => candidate(join(profilesRoot, entry.name, "skills"), `Hermes profile ${entry.name}`, false, false));
140
+ .map((entry) => candidate(
141
+ join(profilesRoot, entry.name, "skills"),
142
+ `Hermes profile ${entry.name}`,
143
+ false,
144
+ false,
145
+ join(profilesRoot, entry.name),
146
+ { profile: true }
147
+ ));
116
148
  }
117
149
 
118
150
  async function exists(path) {
119
151
  return access(path).then(() => true, () => false);
120
152
  }
153
+
154
+ async function hasAgentOwnedSkillContent(skillRoot) {
155
+ const entries = await readdir(skillRoot, { withFileTypes: true }).catch((error) => {
156
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return [];
157
+ throw error;
158
+ });
159
+ for (const entry of entries) {
160
+ if (entry.name === "skillboard" || entry.name === ".system") continue;
161
+ if (!entry.isDirectory()) return true;
162
+ const metadata = await readFile(join(skillRoot, entry.name, ".skillboard-share.json"), "utf8")
163
+ .then((text) => {
164
+ try {
165
+ return JSON.parse(text);
166
+ } catch {
167
+ return null;
168
+ }
169
+ }, (error) => {
170
+ if (error?.code === "ENOENT") return null;
171
+ throw error;
172
+ });
173
+ if (metadata?.version === 1 && metadata.managed_by === "skillboard") continue;
174
+ return true;
175
+ }
176
+ return false;
177
+ }
package/src/cli.mjs CHANGED
@@ -76,7 +76,7 @@ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.u
76
76
  const APPLY_ACTION_VALUE_OPTIONS = new Set(["agent", "workflow", "intent", "dir", "config", "skills", "out", "skillboard-bin"]);
77
77
  const COMMAND_USAGE = new Map([
78
78
  ["skill", ["enable|disable <skill-id> [--dry-run] [--json]", "share|unshare <skill-id> [--dry-run] [--json]", "preference <skill-id> --intent <term>[,<term>] --priority <integer> [--dry-run] [--json]", "forget <skill-id> [--dry-run] [--json]"]],
79
- ["setup", ["setup [--yes] [--agent codex[,claude,opencode,hermes]]"]],
79
+ ["setup", ["setup [--yes] [--agent codex[,claude,opencode,hermes]] [--skill-root <path>]"]],
80
80
  ["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]"]],
81
81
  ["uninstall", ["uninstall --user (--dry-run|--yes) [--json]", "uninstall [--dir <path>] [--dry-run] [--keep-settings] [--purge] [--remove-config|--reset-config] [--remove-reports] [--remove-hooks] [--keep-empty-dirs] [--agent-layer] [--agent codex[,claude,opencode,hermes]]"]],
82
82
  [
@@ -451,7 +451,10 @@ async function doctor(options, stdout) {
451
451
  root: options.get("dir") ?? dirname(state.configPath),
452
452
  configPath: state.configPath,
453
453
  skillsRoot: options.get("skills"),
454
- verifySources: options.get("verify") === "true"
454
+ verifySources: options.get("verify") === "true",
455
+ entrypointPath: process.argv[1],
456
+ env: process.env,
457
+ packageVersion: VERSION
455
458
  });
456
459
  const summary = options.get("summary") === "true";
457
460
  writeOutput(stdout, result, options, () => summary ? renderDoctorSummary(result) : renderDoctor(result));
@@ -1285,7 +1288,7 @@ const VALUE_OPTIONS = new Set([
1285
1288
  "command", "config", "config-file", "dir", "exposure", "from", "harness", "harness-status",
1286
1289
  "hook-projection-version", "install-output", "intent", "invocation", "kind", "mode", "out",
1287
1290
  "owner-install-unit", "path", "priority", "profile", "profile-dirs", "rollback", "rollouts-dir",
1288
- "scan-root", "scope", "skill", "skillboard-bin", "skills", "source", "source-root", "status",
1291
+ "scan-root", "scope", "skill", "skill-root", "skillboard-bin", "skills", "source", "source-root", "status",
1289
1292
  "target-skill", "to", "transaction", "trust-level", "unit", "workflow"
1290
1293
  ]);
1291
1294
 
@@ -1684,6 +1687,7 @@ function renderDoctorSummary(result) {
1684
1687
  const status = result.ok ? result.reviewRequired ? "safe mode, review needed" : "passed" : "needs attention";
1685
1688
  const lines = [
1686
1689
  `SkillBoard doctor: ${status}`,
1690
+ renderInstallation(result.installation),
1687
1691
  `Workspace: ${result.workspace.skills.declared} skills, ${result.workspace.workflows} workflows, ${result.workspace.harnesses} harnesses, ${result.workspace.installUnits.total} install units`,
1688
1692
  `Source audit: ${result.sources.ok ? "passed" : "failed"} (${result.sources.errors.length} errors, ${result.sources.warnings.length} warnings, ${result.sources.blockingWarnings.length} blocking)`,
1689
1693
  `Policy: ${result.policy.ok ? "passed" : "failed"} (${result.policy.errors.length} errors, ${result.policy.warnings.length} warnings)`
@@ -1717,6 +1721,7 @@ function renderDoctor(result) {
1717
1721
  const status = result.ok ? result.reviewRequired ? "safe mode, review needed" : "passed" : "needs attention";
1718
1722
  const lines = [
1719
1723
  `SkillBoard doctor: ${status}`,
1724
+ renderInstallation(result.installation),
1720
1725
  `Root: ${result.root}`,
1721
1726
  `Config: ${result.config.exists ? result.config.valid ? `valid v${result.config.version}` : `invalid (${result.config.error})` : "missing"}`,
1722
1727
  `Bridge: ${bridges}`,
@@ -1743,6 +1748,14 @@ function renderDoctor(result) {
1743
1748
  return lines.join("\n");
1744
1749
  }
1745
1750
 
1751
+ function renderInstallation(installation) {
1752
+ const currentVersion = installation.current.version ?? "unknown";
1753
+ const selected = installation.pathSelected === null
1754
+ ? "PATH selection not found"
1755
+ : `PATH selects ${installation.pathSelected.version ?? "unknown"} at ${installation.pathSelected.path}`;
1756
+ return `Installation: current ${currentVersion} at ${installation.current.entrypoint}; ${selected}; ${installation.installations.length} package installation(s) on PATH`;
1757
+ }
1758
+
1746
1759
  function appendNotInitializedAttachGuidance(lines, result) {
1747
1760
  if (result.mode !== "not-initialized") {
1748
1761
  return;
@@ -1771,6 +1784,7 @@ function helpText() {
1771
1784
  " The package postinstall auto-runs agent-layer guidance setup on install and update.",
1772
1785
  " Under sudo, setup targets SUDO_USER's agent homes while npm still controls the binary prefix.",
1773
1786
  " Run skillboard setup later after adding another supported agent or when install scripts were skipped.",
1787
+ " Run skillboard doctor --summary after updates to detect PATH shadowing or duplicate global installs.",
1774
1788
  " Preview skillboard uninstall --user --dry-run before package removal to clean every managed artifact.",
1775
1789
  "",
1776
1790
  "Core AI/automation operations:",
@@ -1901,7 +1915,7 @@ function initHelpText() {
1901
1915
 
1902
1916
  function setupHelpText() {
1903
1917
  return [
1904
- "Usage: skillboard setup [--yes] [--agent codex[,claude,opencode,hermes]]",
1918
+ "Usage: skillboard setup [--yes] [--agent codex[,claude,opencode,hermes]] [--skill-root <path>]",
1905
1919
  "",
1906
1920
  "Installs or refreshes SkillBoard at the agent layer, not into a project.",
1907
1921
  "A normal global package install already runs this automatically for detected supported agents.",
@@ -1913,6 +1927,8 @@ function setupHelpText() {
1913
1927
  "What changes after confirmation:",
1914
1928
  " Writes a SkillBoard guidance skill into detected user agent skill roots.",
1915
1929
  " Creates ~/skillboard.config.yaml and ~/.skillboard/inventory.json; it writes no project files.",
1930
+ " Reconciles already-shared skills into agents and profiles added after the original share.",
1931
+ " --skill-root registers one custom root for the selected --agent and reuses it on future updates.",
1916
1932
  " Teaches agents to use local skills by default and share only user-selected skills.",
1917
1933
  " Remove this managed guidance later with skillboard uninstall --agent-layer.",
1918
1934
  "",
@@ -1926,6 +1942,7 @@ function setupHelpText() {
1926
1942
  " skillboard setup",
1927
1943
  " skillboard setup --yes",
1928
1944
  " skillboard setup --agent codex,claude,opencode,hermes --yes",
1945
+ " skillboard setup --agent hermes --skill-root ~/.hermes/profiles/work/skills --yes",
1929
1946
  ""
1930
1947
  ].join("\n");
1931
1948
  }
@@ -2007,6 +2024,8 @@ function doctorHelpText() {
2007
2024
  "Usage: skillboard doctor [--dir <path>] [--config <path>] [--skills <dir>] [--verify] [--strict] [--summary] [--json]",
2008
2025
  "",
2009
2026
  "Checks the user-level policy and generated inventory health.",
2027
+ "Also compares the running package and PATH-selected SkillBoard executable.",
2028
+ "Installation discovery reads links and package metadata; it does not execute PATH candidates.",
2010
2029
  "The status command is an alias for doctor.",
2011
2030
  "",
2012
2031
  "Options:",
@@ -2019,6 +2038,7 @@ function doctorHelpText() {
2019
2038
  " --json Print an agent-readable payload.",
2020
2039
  "",
2021
2040
  "Without path overrides, this checks ~/skillboard.config.yaml and ~/.skillboard/inventory.json from any directory.",
2041
+ "Installation warnings are informational and do not change policy health.",
2022
2042
  "It is read-only.",
2023
2043
  ""
2024
2044
  ].join("\n");
package/src/doctor.mjs CHANGED
@@ -4,6 +4,7 @@ import { auditSources } from "./control.mjs";
4
4
  import { hasRuntimeComponents, installUnitSourceClass, isModelSelectableInvocation } from "./domain/source-classes.mjs";
5
5
  import { BRIDGE_END, BRIDGE_START } from "./lifecycle-content.mjs";
6
6
  import { checkPolicy } from "./policy.mjs";
7
+ import { inspectInstallation } from "./install-health.mjs";
7
8
  import { verifySources } from "./source-verification.mjs";
8
9
  import { uninstallProject } from "./uninstall.mjs";
9
10
  import { loadWorkspace } from "./workspace.mjs";
@@ -13,6 +14,11 @@ export async function doctorProject(options = {}) {
13
14
  const configPath = resolveUnderRoot(root, options.configPath ?? "skillboard.config.yaml");
14
15
  const skillsRoot = resolveUnderRoot(root, options.skillsRoot ?? "skills");
15
16
  const bridges = await bridgeStatuses(root);
17
+ const installation = options.installation ?? await inspectInstallation({
18
+ entrypointPath: options.entrypointPath,
19
+ env: options.env,
20
+ packageVersion: options.packageVersion
21
+ });
16
22
  const uninstall = await uninstallProject({
17
23
  root,
18
24
  dryRun: true,
@@ -39,6 +45,7 @@ export async function doctorProject(options = {}) {
39
45
  error: configExists ? null : "skillboard.config.yaml was not found"
40
46
  },
41
47
  bridges,
48
+ installation,
42
49
  workspace: emptyWorkspaceSummary(),
43
50
  inventory: { required: false, ok: true, path: null, errors: [] },
44
51
  policy: { ok: false, errors: [], warnings: [] },
@@ -120,7 +127,7 @@ function finalizeDoctor(result, recommendations) {
120
127
  strictOk,
121
128
  reviewRequired,
122
129
  mode: ok ? reviewRequired ? "safe-mode" : "passed" : result.initialized ? "failed" : "not-initialized",
123
- recommendations: [...new Set(recommendations)],
130
+ recommendations: [...new Set([...recommendations, ...result.installation.warnings])],
124
131
  reviewSummary: reviewSummaryFor(result)
125
132
  };
126
133
  }