agent-skillboard 0.3.0 → 0.3.1

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,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
  }
@@ -0,0 +1,177 @@
1
+ import { constants } from "node:fs";
2
+ import { access, lstat, readFile, realpath } from "node:fs/promises";
3
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
4
+
5
+ const PACKAGE_NAME = "agent-skillboard";
6
+ const POSIX_COMMANDS = ["skillboard", "agent-skillboard"];
7
+ const WINDOWS_COMMANDS = [
8
+ "skillboard.cmd",
9
+ "skillboard.exe",
10
+ "skillboard",
11
+ "agent-skillboard.cmd",
12
+ "agent-skillboard.exe",
13
+ "agent-skillboard"
14
+ ];
15
+
16
+ export async function inspectInstallation(options = {}) {
17
+ const platform = options.platform ?? process.platform;
18
+ const pathDelimiter = options.pathDelimiter ?? delimiter;
19
+ const entrypoint = resolve(options.entrypointPath ?? process.argv[1] ?? "bin/skillboard.mjs");
20
+ const currentRealPath = await resolvedPath(entrypoint);
21
+ const currentPackage = await packageForEntrypoint(currentRealPath);
22
+ const pathCandidates = await findPathCandidates(options.env?.PATH ?? "", {
23
+ pathDelimiter,
24
+ platform,
25
+ currentRealPath,
26
+ currentPackageRoot: currentPackage?.root ?? null
27
+ });
28
+ const pathSelected = pathCandidates.find((candidate) => isPrimaryCommand(candidate.path, platform)) ?? null;
29
+ const installations = uniqueInstallations(pathCandidates);
30
+ const duplicateInstallations = installations.length > 1;
31
+ const shadowed = pathSelected !== null && !pathSelected.current;
32
+ const current = {
33
+ version: options.packageVersion ?? currentPackage?.version ?? null,
34
+ entrypoint,
35
+ realPath: currentRealPath,
36
+ packageRoot: currentPackage?.root ?? null
37
+ };
38
+ const warnings = installWarnings({ current, pathSelected, installations, duplicateInstallations, shadowed });
39
+ return {
40
+ current,
41
+ pathSelected,
42
+ pathCandidates,
43
+ installations,
44
+ duplicateInstallations,
45
+ shadowed,
46
+ warnings
47
+ };
48
+ }
49
+
50
+ async function findPathCandidates(pathValue, options) {
51
+ const commands = options.platform === "win32" ? WINDOWS_COMMANDS : POSIX_COMMANDS;
52
+ const directories = pathValue.split(options.pathDelimiter).filter((entry) => entry.trim() !== "");
53
+ const candidates = [];
54
+ const observedPaths = new Set();
55
+ for (const directory of directories) {
56
+ for (const command of commands) {
57
+ const path = resolve(directory, command);
58
+ if (observedPaths.has(path) || !await isExecutable(path, options.platform)) continue;
59
+ observedPaths.add(path);
60
+ candidates.push(await inspectCandidate(path, options));
61
+ }
62
+ }
63
+ return candidates;
64
+ }
65
+
66
+ async function inspectCandidate(path, options) {
67
+ const realPath = await resolvedPath(path);
68
+ const packageMetadata = await packageForCandidate(path, realPath);
69
+ const packageRoot = packageMetadata?.root ?? null;
70
+ return {
71
+ path,
72
+ realPath,
73
+ packageRoot,
74
+ version: packageMetadata?.version ?? null,
75
+ current: realPath === options.currentRealPath
76
+ || (packageRoot !== null && packageRoot === options.currentPackageRoot)
77
+ };
78
+ }
79
+
80
+ async function packageForCandidate(path, realPath) {
81
+ const fromTarget = await packageForEntrypoint(realPath);
82
+ if (fromTarget !== null) return fromTarget;
83
+ const commandDirectory = dirname(path);
84
+ for (const root of [
85
+ join(commandDirectory, "node_modules", PACKAGE_NAME),
86
+ resolve(commandDirectory, "..", PACKAGE_NAME)
87
+ ]) {
88
+ const metadata = await readPackage(root);
89
+ if (metadata !== null) return metadata;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ async function packageForEntrypoint(path) {
95
+ let directory = dirname(path);
96
+ for (let depth = 0; depth < 6; depth += 1) {
97
+ const metadata = await readPackage(directory);
98
+ if (metadata !== null) return metadata;
99
+ const parent = dirname(directory);
100
+ if (parent === directory) break;
101
+ directory = parent;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ async function readPackage(root) {
107
+ try {
108
+ const parsed = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
109
+ if (parsed?.name !== PACKAGE_NAME || typeof parsed.version !== "string") return null;
110
+ return { root: await realpath(root), version: parsed.version };
111
+ } catch (error) {
112
+ if (error?.code === "ENOENT" || error instanceof SyntaxError) return null;
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ async function isExecutable(path, platform) {
118
+ try {
119
+ const stats = await lstat(path);
120
+ if (!stats.isFile() && !stats.isSymbolicLink()) return false;
121
+ await access(path, platform === "win32" ? constants.F_OK : constants.X_OK);
122
+ return true;
123
+ } catch (error) {
124
+ if (["EACCES", "ELOOP", "ENOENT", "ENOTDIR"].includes(error?.code)) return false;
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ async function resolvedPath(path) {
130
+ try {
131
+ return await realpath(path);
132
+ } catch (error) {
133
+ if (error?.code === "ENOENT") return resolve(path);
134
+ throw error;
135
+ }
136
+ }
137
+
138
+ function uniqueInstallations(candidates) {
139
+ const installations = [];
140
+ const observed = new Set();
141
+ for (const candidate of candidates) {
142
+ if (candidate.packageRoot === null || observed.has(candidate.packageRoot)) continue;
143
+ observed.add(candidate.packageRoot);
144
+ installations.push(candidate);
145
+ }
146
+ return installations;
147
+ }
148
+
149
+ function isPrimaryCommand(path, platform) {
150
+ const command = basename(path).toLowerCase();
151
+ return platform === "win32"
152
+ ? ["skillboard", "skillboard.cmd", "skillboard.exe"].includes(command)
153
+ : command === "skillboard";
154
+ }
155
+
156
+ function installWarnings(result) {
157
+ const warnings = [];
158
+ if (result.shadowed) {
159
+ warnings.push(
160
+ `PATH selects SkillBoard ${displayVersion(result.pathSelected.version)} at ${result.pathSelected.path} instead of this ${displayVersion(result.current.version)} invocation at ${result.current.entrypoint}.`
161
+ );
162
+ }
163
+ if (result.duplicateInstallations) {
164
+ warnings.push(
165
+ `Multiple SkillBoard installations were found on PATH: ${result.installations.map(displayInstallation).join(", ")}. Keep one npm prefix active and uninstall stale copies with the npm installation that owns them.`
166
+ );
167
+ }
168
+ return warnings;
169
+ }
170
+
171
+ function displayVersion(version) {
172
+ return version === null ? "with unknown version" : `version ${version}`;
173
+ }
174
+
175
+ function displayInstallation(candidate) {
176
+ return `${candidate.version ?? "unknown"} at ${candidate.path}`;
177
+ }
@@ -29,7 +29,8 @@ async function refreshLocked(options) {
29
29
  const inventory = options.inventory ?? await discoverAgentSkillInventory({
30
30
  roots: options.roots,
31
31
  home: options.home,
32
- env: options.env
32
+ env: options.env,
33
+ registeredRoots: options.registeredRoots
33
34
  });
34
35
  const configVersion = YAML.parse(current)?.version;
35
36
  const generatedInventory = configVersion === 2
@@ -43,9 +44,12 @@ async function refreshLocked(options) {
43
44
  const previousInventoryText = inventoryText === null
44
45
  ? null
45
46
  : await readFile(inventoryPath, "utf8").catch((error) => error?.code === "ENOENT" ? "" : Promise.reject(error));
46
- const merged = configVersion === 2
47
+ const projected = configVersion === 2
47
48
  ? mergeV2InventoryPolicy(current, inventory)
48
49
  : mergeAgentSkillInventory(current, inventory);
50
+ const merged = configVersion === 1 && options.preserveLegacyPolicy === true
51
+ ? { ...projected, text: current }
52
+ : projected;
49
53
  const plan = textChangePlan(current, merged.text);
50
54
  const dryRun = options.dryRun === true;
51
55