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.
- package/CHANGELOG.md +62 -0
- package/README.md +52 -2
- package/bin/postinstall.mjs +2 -1
- package/docs/ai-skill-routing-goal.md +12 -0
- package/docs/install.md +60 -3
- package/docs/policy-model.md +7 -0
- package/docs/reference.md +25 -1
- package/docs/user-flow.md +18 -1
- package/docs/value-proof.md +2 -1
- package/docs/versioning.md +21 -3
- package/package.json +1 -1
- package/src/agent-integration-cli.mjs +97 -40
- package/src/agent-integration-command.mjs +38 -0
- package/src/agent-integration-home.mjs +14 -1
- package/src/agent-inventory-platforms.mjs +13 -10
- package/src/agent-inventory.mjs +21 -15
- package/src/agent-root-registry.mjs +127 -0
- package/src/agent-skill-roots.mjs +70 -13
- package/src/cli.mjs +24 -4
- package/src/doctor.mjs +8 -1
- package/src/install-health.mjs +177 -0
- package/src/inventory-refresh.mjs +7 -2
- package/src/migration/automatic-v2.mjs +14 -0
- package/src/migration/v2-projection.mjs +2 -1
- package/src/migration/v2-transaction.mjs +4 -0
- package/src/setup-policy-migration.mjs +43 -0
- package/src/shared-skill-reconcile.mjs +97 -0
- package/src/shared-skill.mjs +40 -16
- package/src/user-uninstall.mjs +18 -3
|
@@ -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 {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.
|
|
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
|
|
package/src/agent-inventory.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
902
|
-
const
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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(
|
|
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
|
}
|