agent-skillboard 0.2.18 → 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.
Files changed (82) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +161 -255
  3. package/bin/postinstall.mjs +2 -1
  4. package/docs/adapters.md +37 -113
  5. package/docs/ai-skill-routing-goal.md +41 -108
  6. package/docs/capabilities.md +17 -104
  7. package/docs/install.md +70 -473
  8. package/docs/policy-model.md +50 -280
  9. package/docs/positioning.md +19 -86
  10. package/docs/profiles.md +21 -153
  11. package/docs/reference.md +133 -362
  12. package/docs/rollout-runbook.md +23 -25
  13. package/docs/routing.md +23 -90
  14. package/docs/user-flow.md +68 -279
  15. package/docs/value-proof.md +23 -181
  16. package/docs/variant-lifecycle.md +47 -67
  17. package/docs/versioning.md +49 -269
  18. package/examples/v2-multi-source.config.yaml +35 -0
  19. package/examples/v2-policy-error.config.yaml +6 -0
  20. package/package.json +1 -1
  21. package/src/advisor/actions.mjs +102 -6
  22. package/src/advisor/application-commands.mjs +10 -9
  23. package/src/advisor/apply-action.mjs +74 -1
  24. package/src/advisor/guidance.mjs +24 -16
  25. package/src/advisor/schema.mjs +17 -6
  26. package/src/advisor/skills.mjs +18 -5
  27. package/src/advisor.mjs +27 -9
  28. package/src/agent-integration-cli.mjs +96 -13
  29. package/src/agent-integration-content.mjs +21 -11
  30. package/src/agent-integration-files.mjs +1 -1
  31. package/src/agent-integration-home.mjs +14 -1
  32. package/src/agent-inventory-platforms.mjs +21 -8
  33. package/src/agent-inventory.mjs +44 -16
  34. package/src/agent-root-registry.mjs +127 -0
  35. package/src/agent-skill-import.mjs +2 -2
  36. package/src/agent-skill-roots.mjs +70 -13
  37. package/src/audit-paths.mjs +42 -0
  38. package/src/brief-cli.mjs +3 -2
  39. package/src/brief-renderer.mjs +1 -0
  40. package/src/cli.mjs +521 -235
  41. package/src/compatibility.mjs +24 -0
  42. package/src/control/can-use-guard.mjs +21 -1
  43. package/src/control/config-write.mjs +32 -2
  44. package/src/control/skill-crud.mjs +5 -0
  45. package/src/control/v2-guard.mjs +175 -0
  46. package/src/control/v2-skill-crud.mjs +32 -0
  47. package/src/control/v2-skill-forget.mjs +38 -0
  48. package/src/control/variant-status.mjs +47 -1
  49. package/src/control.mjs +55 -0
  50. package/src/doctor.mjs +71 -5
  51. package/src/domain/v2-policy.mjs +111 -0
  52. package/src/hook-plan.mjs +33 -3
  53. package/src/impact.mjs +52 -29
  54. package/src/index.mjs +25 -1
  55. package/src/init.mjs +50 -34
  56. package/src/install-health.mjs +177 -0
  57. package/src/inventory-install-units.mjs +63 -0
  58. package/src/inventory-json.mjs +279 -0
  59. package/src/inventory-refresh.mjs +168 -19
  60. package/src/lifecycle-cli.mjs +40 -12
  61. package/src/lifecycle-content.mjs +52 -67
  62. package/src/migration/v1-to-v2.mjs +212 -0
  63. package/src/migration/v2-files.mjs +211 -0
  64. package/src/migration/v2-journal.mjs +169 -0
  65. package/src/migration/v2-projection.mjs +108 -0
  66. package/src/migration/v2-transaction.mjs +205 -0
  67. package/src/policy.mjs +3 -0
  68. package/src/reconcile.mjs +139 -111
  69. package/src/report.mjs +168 -148
  70. package/src/review.mjs +2 -0
  71. package/src/route-advisory.mjs +47 -2
  72. package/src/route-selection.mjs +38 -2
  73. package/src/route.mjs +62 -2
  74. package/src/shared-skill-reconcile.mjs +97 -0
  75. package/src/shared-skill.mjs +325 -0
  76. package/src/source-digest.mjs +42 -0
  77. package/src/source-profiles.mjs +171 -144
  78. package/src/source-verification.mjs +32 -48
  79. package/src/uninstall.mjs +22 -0
  80. package/src/user-state-paths.mjs +19 -0
  81. package/src/user-uninstall.mjs +161 -0
  82. package/src/workspace.mjs +119 -79
@@ -1,20 +1,29 @@
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("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)
10
11
  ]);
11
12
  return [
12
- join(codexHome, "skills", ".system"),
13
- join(codexHome, "plugins", "cache"),
14
- ...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
+ })))
15
20
  ];
16
21
  }
17
22
 
23
+ export function sharedUserUnit(path, home) {
24
+ return userSkillUnit("shared.user-skills", path, home);
25
+ }
26
+
18
27
  export function systemCodexUnit(path, home) {
19
28
  return {
20
29
  id: "codex.system-skills",
@@ -38,6 +47,10 @@ export function userClaudeUnit(path, home) {
38
47
  return userSkillUnit("claude.user-skills", path, home);
39
48
  }
40
49
 
50
+ export function userOpenCodeUnit(path, home) {
51
+ return userSkillUnit("opencode.user-skills", path, home);
52
+ }
53
+
41
54
  export function userHermesUnit(path, home) {
42
55
  return userSkillUnit("hermes.user-skills", path, home);
43
56
  }
@@ -9,14 +9,25 @@ import {
9
9
  hermesProfileUnit,
10
10
  isHermesProfileSkillsPath,
11
11
  safeSegment,
12
+ sharedUserUnit,
12
13
  systemCodexUnit,
13
14
  userClaudeUnit,
14
15
  userCodexUnit,
15
- userHermesUnit
16
+ userHermesUnit,
17
+ userOpenCodeUnit
16
18
  } from "./agent-inventory-platforms.mjs";
17
19
  import { readString, requireRecord } from "./config-helpers.mjs";
18
20
 
19
21
  export const agentInventoryDetectors = Object.freeze([
22
+ {
23
+ id: "shared-user-skills",
24
+ matches(path) {
25
+ return path.endsWith("/.agents/shared-skills") || path.endsWith("\\.agents\\shared-skills");
26
+ },
27
+ async discover(path, home) {
28
+ return await discoverSkillDirectory(path, sharedUserUnit(path, home), { excludeSystem: true });
29
+ }
30
+ },
20
31
  {
21
32
  id: "codex-plugin-cache",
22
33
  matches(path) {
@@ -56,6 +67,16 @@ export const agentInventoryDetectors = Object.freeze([
56
67
  return await discoverSkillDirectory(path, userClaudeUnit(path, home), { excludeSystem: true });
57
68
  }
58
69
  },
70
+ {
71
+ id: "opencode-user-skills",
72
+ matches(path) {
73
+ const normalized = path.replace(/\\/g, "/");
74
+ return normalized.endsWith("/.config/opencode/skills") || normalized.endsWith("/.opencode/skills");
75
+ },
76
+ async discover(path, home) {
77
+ return await discoverSkillDirectory(path, userOpenCodeUnit(path, home), { excludeSystem: true });
78
+ }
79
+ },
59
80
  {
60
81
  id: "hermes-user-skills",
61
82
  matches(path) {
@@ -89,16 +110,16 @@ export async function discoverAgentSkillInventory(options = {}) {
89
110
  const env = options.env ?? process.env;
90
111
  const home = options.home ?? env.HOME ?? env.USERPROFILE ?? homedir();
91
112
  const detectors = options.detectors ?? agentInventoryDetectors;
92
- const roots = uniquePaths([
93
- ...(await defaultScanRoots(home, env)),
94
- ...readCsv(env.SKILLBOARD_INIT_SCAN_ROOTS),
95
- ...(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 }))
96
117
  ], home);
97
118
  const groups = [];
98
119
  const warnings = [];
99
120
 
100
121
  for (const root of roots) {
101
- const discovered = await discoverRoot(root, home, detectors);
122
+ const discovered = await discoverRoot(root.path, home, detectors, root.agent);
102
123
  groups.push(...discovered.groups);
103
124
  warnings.push(...discovered.warnings);
104
125
  }
@@ -272,13 +293,13 @@ function ensureLocalWorkflow(workflowsMap, target, skills, document) {
272
293
  return true;
273
294
  }
274
295
 
275
- async function discoverRoot(root, home, detectors) {
296
+ async function discoverRoot(root, home, detectors, agent) {
276
297
  const path = resolvePath(root, home);
277
298
  if (!(await exists(path))) {
278
299
  return { groups: [], warnings: [] };
279
300
  }
280
301
  const warnings = [];
281
- const detector = detectors.find((candidate) => {
302
+ const matched = detectors.find((candidate) => {
282
303
  try {
283
304
  return candidate.matches(path);
284
305
  } catch (error) {
@@ -286,6 +307,7 @@ async function discoverRoot(root, home, detectors) {
286
307
  return false;
287
308
  }
288
309
  });
310
+ const detector = agent === undefined ? matched : forcedAgentDetector(agent, matched, detectors);
289
311
  if (detector === undefined) {
290
312
  return { groups: [], warnings };
291
313
  }
@@ -298,6 +320,12 @@ async function discoverRoot(root, home, detectors) {
298
320
  }
299
321
  }
300
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
+
301
329
  async function discoverSkillDirectory(root, unit, options) {
302
330
  const files = await findSkillFiles(root, root, options);
303
331
  return files.length === 0 ? [] : [{ unit, root, files }];
@@ -416,6 +444,7 @@ async function inventoryFromGroups(groups, home, initialWarnings = []) {
416
444
  const skill = {
417
445
  id,
418
446
  path: sourceAlias.path,
447
+ skillFile: resolve(file),
419
448
  status: "quarantined",
420
449
  invocation: "blocked",
421
450
  exposure: "exported",
@@ -876,21 +905,20 @@ function uniqueStrings(values) {
876
905
  return [...new Set(values.filter((value) => value.trim().length > 0))];
877
906
  }
878
907
 
879
- function uniquePaths(values, home) {
880
- const seen = new Set();
881
- const result = [];
908
+ function uniqueRootEntries(values, home) {
909
+ const byPath = new Map();
882
910
  for (const value of values) {
883
- const trimmed = value.trim();
911
+ const trimmed = value.path.trim();
884
912
  if (trimmed.length === 0) {
885
913
  continue;
886
914
  }
887
915
  const key = resolve(trimmed.startsWith("~/") ? join(home, trimmed.slice(2)) : trimmed);
888
- if (!seen.has(key)) {
889
- seen.add(key);
890
- 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 }) });
891
919
  }
892
920
  }
893
- return result;
921
+ return [...byPath.values()];
894
922
  }
895
923
 
896
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
+ }
@@ -157,7 +157,7 @@ async function findSourceSkill(options) {
157
157
  throw new Error(`Source skill not found: ${options.skill} under ${root}`);
158
158
  }
159
159
 
160
- async function findSourceSkillInRoots(options) {
160
+ export async function findSourceSkillInRoots(options) {
161
161
  const misses = [];
162
162
  for (const root of options.roots) {
163
163
  try {
@@ -200,7 +200,7 @@ function parseSkillFrontmatter(text) {
200
200
  return parsed !== null && typeof parsed === "object" ? parsed : {};
201
201
  }
202
202
 
203
- function analyzeAgentCompatibility(content, options) {
203
+ export function analyzeAgentCompatibility(content, options) {
204
204
  const reasons = [];
205
205
  for (const [agent, markers] of Object.entries(AGENT_MARKERS)) {
206
206
  if (agent === options.targetAgent) {
@@ -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
+ }
@@ -0,0 +1,42 @@
1
+ import { homedir } from "node:os";
2
+ import { basename, isAbsolute, relative, resolve } from "node:path";
3
+
4
+ export function expandPortablePath(value, paths) {
5
+ if (value === "${PROJECT}") return paths.rootDir;
6
+ if (value.startsWith("${PROJECT}/")) return resolve(paths.rootDir, value.slice("${PROJECT}/".length));
7
+ if (value === "${HOME}") return homedir();
8
+ if (value.startsWith("${HOME}/")) return resolve(homedir(), value.slice("${HOME}/".length));
9
+ if (value === "<external>" || value.startsWith("<external>/")) return null;
10
+ return value;
11
+ }
12
+
13
+ export function portableObservation(value, options) {
14
+ if (value.startsWith("${PROJECT}") || value.startsWith("${HOME}") || value.startsWith("<external>")) return value;
15
+ if (value.startsWith("~/")) return `\${HOME}/${value.slice(2)}`;
16
+ return isAbsolute(value) ? auditPath(value, options) : value;
17
+ }
18
+
19
+ export function auditPath(path, options) {
20
+ const project = relative(options.rootDir, path);
21
+ if (!project.startsWith("..") && !isAbsolute(project)) {
22
+ return project === "" ? "${PROJECT}" : `\${PROJECT}/${project.replace(/\\/g, "/")}`;
23
+ }
24
+ const home = relative(homedir(), path);
25
+ if (!home.startsWith("..") && !isAbsolute(home)) {
26
+ return home === "" ? "${HOME}" : `\${HOME}/${home.replace(/\\/g, "/")}`;
27
+ }
28
+ return `<external>/${basename(path)}`;
29
+ }
30
+
31
+ export function redactPathError(error, options) {
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ return replaceText(
34
+ replaceText(replaceText(message, options.rootDir, "${PROJECT}"), options.configDir, "${PROJECT}"),
35
+ homedir(),
36
+ "${HOME}"
37
+ );
38
+ }
39
+
40
+ function replaceText(value, search, replacement) {
41
+ return value.split(search).join(replacement);
42
+ }
package/src/brief-cli.mjs CHANGED
@@ -5,11 +5,12 @@ import { renderSkillBrief } from "./brief-renderer.mjs";
5
5
  export async function runBriefCommand(options, stdout, paths) {
6
6
  const json = options.get("json") === "true";
7
7
  const result = await buildSkillBrief({
8
- root: briefRoot(options),
8
+ root: briefRoot(options) ?? dirname(paths.configPath),
9
9
  configPath: paths.configPath,
10
10
  skillsRoot: paths.skillsRoot,
11
11
  workflow: options.get("workflow"),
12
12
  intent: options.get("intent"),
13
+ agent: options.get("agent"),
13
14
  includeActions: options.get("include-actions") === "true" || !json
14
15
  });
15
16
  writeBriefOutput(stdout, result, options);
@@ -22,7 +23,7 @@ function briefRoot(options) {
22
23
  return dir;
23
24
  }
24
25
  const config = options.get("config");
25
- return config !== undefined && isAbsolute(config) ? dirname(config) : ".";
26
+ return config !== undefined && isAbsolute(config) ? dirname(config) : undefined;
26
27
  }
27
28
 
28
29
  function briefExitCode(result) {
@@ -47,6 +47,7 @@ export function renderSkillBrief(brief, options = {}) {
47
47
  `Workflow: ${workflowSummary(brief.workflow)}`,
48
48
  ""
49
49
  ];
50
+ if (brief.compatibility?.notice !== undefined) lines.push(brief.compatibility.notice, "");
50
51
  if (brief.error !== undefined) {
51
52
  lines.push(`Error: ${safeText(brief.error.message)}`, "");
52
53
  }