agent-skillboard 0.2.8 → 0.2.10

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 CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.10 — 2026-07-03
6
+
7
+ ### Fixed
8
+
9
+ - `sudo npm install -g agent-skillboard` now resolves `SUDO_USER` during
10
+ install-time setup so the SkillBoard guidance skill is written to the
11
+ invoking user's agent homes instead of `/root`.
12
+
13
+ ### Changed
14
+
15
+ - Install docs and CLI help now describe both normal global npm installs and
16
+ sudo/system npm installs, including the distinction between agent-home setup
17
+ and npm's executable prefix.
18
+
19
+ ## 0.2.9 — 2026-07-03
20
+
21
+ ### Changed
22
+
23
+ - Lowered the published runtime engine requirement from Node.js `>=20` to
24
+ `>=14.21` after validating CLI smoke on Node 14.21 and 16.20 plus the full
25
+ test suite on Node 18.19.
26
+
27
+ ### Fixed
28
+
29
+ - Replaced runtime use of `node:readline/promises` and
30
+ `String.prototype.replaceAll` so the CLI can load and run on older Node.js
31
+ versions without npm `EBADENGINE` warnings on common system Node installs.
32
+
5
33
  ## 0.2.8 — 2026-07-03
6
34
 
7
35
  ### Added
package/README.md CHANGED
@@ -93,6 +93,12 @@ AI/automation/operator details:
93
93
  npm install -g agent-skillboard
94
94
  ```
95
95
 
96
+ If your system npm requires elevated permissions, `sudo npm install -g
97
+ agent-skillboard` is also supported. In that flow, install-time setup resolves
98
+ `SUDO_USER` and writes the user-level guidance skill under the invoking user's
99
+ agent homes, while the `skillboard` binary still lands in the global prefix used
100
+ by that npm command.
101
+
96
102
  The install-time setup writes a user-level `skillboard` guidance skill under
97
103
  detected agent homes. For Codex, detection includes `CODEX_HOME/skills`,
98
104
  `AGENTS_HOME/skills`, `~/.agents/skills`, and `~/.codex/skills`.
package/docs/install.md CHANGED
@@ -19,12 +19,27 @@ Codex, Claude, OpenCode, and Hermes user skill roots. The setup is best-effort:
19
19
  it never fails the package install, does not edit agent config files, and does
20
20
  not create project policy files.
21
21
 
22
+ The published CLI supports Node.js 14.21 or newer. Node 12 and older are not
23
+ supported without a transpiled bundle because the source uses modern ESM and
24
+ syntax such as nullish coalescing.
25
+
22
26
  AI/automation/operator details:
23
27
 
24
28
  ```bash
25
29
  npm install -g agent-skillboard
26
30
  ```
27
31
 
32
+ If your system npm requires elevated permissions, this is also supported:
33
+
34
+ ```bash
35
+ sudo npm install -g agent-skillboard
36
+ ```
37
+
38
+ Under sudo, the postinstall setup resolves `SUDO_USER` and targets that user's
39
+ agent homes instead of writing guidance under `/root`. The executable prefix is
40
+ still decided by the npm command you run, so use the same npm/Node environment
41
+ you expect to provide `skillboard` on `PATH`.
42
+
28
43
  The install-time setup writes a user-level SkillBoard guidance skill under
29
44
  detected agent homes such as `CODEX_HOME`, `AGENTS_HOME`, `CLAUDE_HOME`,
30
45
  `OPENCODE_HOME`, and `HERMES_HOME`. Codex detection also checks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-skillboard",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "Let AI agents pick and use allowed skills in each workflow.",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -52,7 +52,7 @@
52
52
  "yaml": "^2.8.1"
53
53
  },
54
54
  "engines": {
55
- "node": ">=20"
55
+ "node": ">=14.21"
56
56
  },
57
57
  "license": "MIT",
58
58
  "devDependencies": {
@@ -70,5 +70,5 @@ function emptyApplication(blockedReason) {
70
70
  }
71
71
 
72
72
  function shellQuote(value) {
73
- return /^[A-Za-z0-9_./:=@-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
73
+ return /^[A-Za-z0-9_./:=@-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
74
74
  }
@@ -43,7 +43,7 @@ export function userHermesUnit(path, home) {
43
43
  }
44
44
 
45
45
  export function hermesProfileUnit(path, home) {
46
- const profile = safeSegment(basename(dirname(path))).replaceAll(".", "-");
46
+ const profile = safeSegment(basename(dirname(path))).replace(/\./g, "-");
47
47
  return userSkillUnit(`hermes.profile.${profile}.skills`, path, home);
48
48
  }
49
49
 
@@ -55,7 +55,7 @@ export function customUserUnit(path, home) {
55
55
  }
56
56
 
57
57
  export function isHermesProfileSkillsPath(path) {
58
- const normalized = path.replaceAll("\\", "/");
58
+ const normalized = path.replace(/\\/g, "/");
59
59
  return /\/\.hermes\/profiles\/[^/]+\/skills$/u.test(normalized);
60
60
  }
61
61
 
@@ -72,7 +72,7 @@ export function displayPath(path, home) {
72
72
  return "~";
73
73
  }
74
74
  if (!rel.startsWith("..") && !isAbsolute(rel)) {
75
- return `~/${rel.replaceAll("\\", "/")}`;
75
+ return `~/${rel.replace(/\\/g, "/")}`;
76
76
  }
77
77
  return resolvedPath;
78
78
  }
@@ -242,7 +242,7 @@ function localWorkflowTarget(unit) {
242
242
  const profile = unit.id.slice("hermes.profile.".length, -".skills".length);
243
243
  return { harness: "hermes", workflow: `hermes-${profile}-local-manual` };
244
244
  }
245
- const base = safeSegment(unit?.id ?? "local").replaceAll(".", "-");
245
+ const base = safeSegment(unit?.id ?? "local").replace(/\./g, "-");
246
246
  return { harness: "local", workflow: `${base}-local-manual` };
247
247
  }
248
248
 
@@ -495,7 +495,7 @@ async function findSkillFiles(root, base, options, seen = new Set()) {
495
495
  const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
496
496
  for (const entry of entries) {
497
497
  const path = join(root, entry.name);
498
- const rel = relative(base, path).replaceAll("\\", "/");
498
+ const rel = relative(base, path).replace(/\\/g, "/");
499
499
  if (options.excludeSystem === true && (rel === ".system" || rel.startsWith(".system/"))) {
500
500
  continue;
501
501
  }
@@ -539,7 +539,7 @@ function skillIdFor(unit, root, file, frontmatter) {
539
539
  }
540
540
 
541
541
  function skillPath(root, file) {
542
- return relative(root, dirname(file)).replaceAll("\\", "/");
542
+ return relative(root, dirname(file)).replace(/\\/g, "/");
543
543
  }
544
544
 
545
545
  function skillNode(skill, defaults = {}) {
@@ -266,7 +266,7 @@ function digest(content) {
266
266
  }
267
267
 
268
268
  function resolveRelativeTarget(root, value) {
269
- const relativePath = String(value).replaceAll("\\", "/");
269
+ const relativePath = String(value).replace(/\\/g, "/");
270
270
  if (relativePath.trim() === "" || relativePath.includes("\0")) {
271
271
  throw new Error("skill path must be a non-empty relative path");
272
272
  }
@@ -565,8 +565,8 @@ function workflowSummary(workflow) {
565
565
  }
566
566
 
567
567
  function safeText(value, maxLength = 180) {
568
- const compact = String(value).replaceAll(/\s+/g, " ").trim();
569
- const withoutTicks = compact.replaceAll("`", "'");
568
+ const compact = String(value).replace(/\s+/g, " ").trim();
569
+ const withoutTicks = compact.replace(/`/g, "'");
570
570
  return withoutTicks.length > maxLength ? `${withoutTicks.slice(0, maxLength - 3)}...` : withoutTicks;
571
571
  }
572
572
 
package/src/cli.mjs CHANGED
@@ -1097,7 +1097,7 @@ function formatList(values) {
1097
1097
  }
1098
1098
 
1099
1099
  function shellQuote(value) {
1100
- return /^[A-Za-z0-9_./:=@-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
1100
+ return /^[A-Za-z0-9_./:=@-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
1101
1101
  }
1102
1102
 
1103
1103
  function readCsv(value) {
@@ -1548,7 +1548,9 @@ function helpText() {
1548
1548
  "",
1549
1549
  "After global install:",
1550
1550
  " npm install -g agent-skillboard",
1551
+ " sudo npm install -g agent-skillboard is also supported when system npm requires it.",
1551
1552
  " The package postinstall auto-runs agent-layer guidance setup for detected supported agents.",
1553
+ " Under sudo, setup targets SUDO_USER's agent homes while npm still controls the binary prefix.",
1552
1554
  " Run skillboard setup later after adding another supported agent or when install scripts were skipped.",
1553
1555
  "",
1554
1556
  "AI/automation operations:",
@@ -35,7 +35,7 @@ export function resolveVariantSnapshotFile({ configPath, snapshotPath }) {
35
35
  throw new Error(`snapshot path must stay under ${SNAPSHOT_ROOT}`);
36
36
  }
37
37
  return {
38
- storedPath: relative(configDir, absolutePath).replaceAll("\\", "/"),
38
+ storedPath: relative(configDir, absolutePath).replace(/\\/g, "/"),
39
39
  absolutePath,
40
40
  rootPath: root
41
41
  };
@@ -213,7 +213,7 @@ function normalizeStoredSnapshotPath(value) {
213
213
  if (value.includes("\0")) {
214
214
  throw new Error("snapshot path must not contain null bytes");
215
215
  }
216
- const normalized = value.replaceAll("\\", "/");
216
+ const normalized = value.replace(/\\/g, "/");
217
217
  if (normalized.startsWith("/") || normalized.startsWith("//") || /^[A-Za-z]:\//.test(normalized)) {
218
218
  throw new Error("snapshot path must be relative to the config directory");
219
219
  }
package/src/hook-plan.mjs CHANGED
@@ -143,7 +143,7 @@ function modeToPermissions(mode) {
143
143
  }
144
144
 
145
145
  function shellQuote(value) {
146
- return `'${value.replaceAll("'", "'\\''")}'`;
146
+ return `'${value.replace(/'/g, "'\\''")}'`;
147
147
  }
148
148
 
149
149
  function safeHookFilePart(value) {
@@ -315,9 +315,9 @@ function normalizePath(value, root) {
315
315
 
316
316
  function displayPath(path, root) {
317
317
  if (!isAbsolute(path)) {
318
- return path.replaceAll("\\", "/");
318
+ return path.replace(/\\/g, "/");
319
319
  }
320
- const rel = relative(root, path).replaceAll("\\", "/");
320
+ const rel = relative(root, path).replace(/\\/g, "/");
321
321
  return rel.startsWith("..") ? path : rel;
322
322
  }
323
323
 
@@ -1,12 +1,15 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { execFile } from "node:child_process";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
3
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
- import { createInterface } from "node:readline/promises";
4
+ import { createInterface } from "node:readline";
5
+ import { promisify } from "node:util";
4
6
  import { setupAgentSkillTargets, supportedAgentNames } from "./agent-skill-roots.mjs";
5
7
  import { initProject } from "./init.mjs";
6
8
  import { uninstallProject } from "./uninstall.mjs";
7
9
 
8
10
  const AGENT_INTEGRATION_START = "<!-- BEGIN SKILLBOARD AGENT INTEGRATION -->";
9
11
  const AGENT_INTEGRATION_END = "<!-- END SKILLBOARD AGENT INTEGRATION -->";
12
+ const execFileAsync = promisify(execFile);
10
13
 
11
14
  export async function runInitCommand(options, stdout, runtime = defaultRuntime()) {
12
15
  const root = resolve(options.get("dir") ?? ".");
@@ -180,13 +183,19 @@ async function promptForSetup(runtime) {
180
183
  output: runtime.stdout
181
184
  });
182
185
  try {
183
- const answer = await rl.question("Install SkillBoard agent integration now? [y/N] ");
186
+ const answer = await question(rl, "Install SkillBoard agent integration now? [y/N] ");
184
187
  return /^(y|yes)$/i.test(answer.trim());
185
188
  } finally {
186
189
  rl.close();
187
190
  }
188
191
  }
189
192
 
193
+ function question(rl, prompt) {
194
+ return new Promise((resolve) => {
195
+ rl.question(prompt, resolve);
196
+ });
197
+ }
198
+
190
199
  function formatList(values) {
191
200
  return values.length === 0 ? "none" : values.map((value) => `\`${value}\``).join(", ");
192
201
  }
@@ -212,7 +221,7 @@ function defaultRuntime() {
212
221
 
213
222
  async function agentSetupTargets(options, runtime) {
214
223
  const env = runtime.env ?? process.env;
215
- const home = env.HOME ?? env.USERPROFILE;
224
+ const home = await resolveSetupHome(env, runtime);
216
225
  const requested = readCsv(options.get("agent"));
217
226
  const supported = new Set(supportedAgentNames());
218
227
  const names = requested.length === 0 ? supportedAgentNames() : requested;
@@ -226,6 +235,79 @@ async function agentSetupTargets(options, runtime) {
226
235
  return targets;
227
236
  }
228
237
 
238
+ async function resolveSetupHome(env, runtime) {
239
+ const explicit = nonEmpty(env.SKILLBOARD_SETUP_HOME);
240
+ if (explicit !== null) {
241
+ return explicit;
242
+ }
243
+ if (shouldUseSudoUserHome(env)) {
244
+ const sudoHome = nonEmpty(env.SUDO_HOME)
245
+ ?? await passwdHome(env.SUDO_USER, runtime.passwdPath ?? "/etc/passwd")
246
+ ?? await getentHome(env.SUDO_USER, env)
247
+ ?? await conventionalUserHome(env.SUDO_USER);
248
+ if (sudoHome !== null) {
249
+ return sudoHome;
250
+ }
251
+ }
252
+ return env.HOME ?? env.USERPROFILE;
253
+ }
254
+
255
+ function shouldUseSudoUserHome(env) {
256
+ const sudoUser = nonEmpty(env.SUDO_USER);
257
+ return process.platform !== "win32"
258
+ && sudoUser !== null
259
+ && sudoUser !== "root"
260
+ && (
261
+ env.SUDO_UID !== undefined
262
+ || env.SUDO_GID !== undefined
263
+ || env.USER === "root"
264
+ || env.LOGNAME === "root"
265
+ || env.HOME === "/root"
266
+ );
267
+ }
268
+
269
+ async function passwdHome(user, passwdPath) {
270
+ const text = await readFile(passwdPath, "utf8").catch(() => "");
271
+ for (const line of text.split("\n")) {
272
+ if (line.startsWith(`${user}:`)) {
273
+ return nonEmpty(line.split(":")[5]);
274
+ }
275
+ }
276
+ return null;
277
+ }
278
+
279
+ async function getentHome(user, env) {
280
+ try {
281
+ const { stdout } = await execFileAsync("getent", ["passwd", user], {
282
+ env,
283
+ timeout: 1000
284
+ });
285
+ return nonEmpty(stdout.trim().split(":")[5]);
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+
291
+ async function conventionalUserHome(user) {
292
+ const candidates = process.platform === "darwin"
293
+ ? [`/Users/${user}`]
294
+ : [`/home/${user}`];
295
+ for (const candidate of candidates) {
296
+ if (await exists(candidate)) {
297
+ return candidate;
298
+ }
299
+ }
300
+ return null;
301
+ }
302
+
303
+ async function exists(path) {
304
+ return access(path).then(() => true, () => false);
305
+ }
306
+
307
+ function nonEmpty(value) {
308
+ return value === undefined || value.trim() === "" ? null : value;
309
+ }
310
+
229
311
  async function installAgentIntegration(targets) {
230
312
  const created = [];
231
313
  const updated = [];
@@ -299,7 +381,7 @@ ${AGENT_INTEGRATION_END}
299
381
 
300
382
  function commandPrefix(runtime) {
301
383
  const entrypoint = runtime.entrypointPath ?? "";
302
- const normalized = entrypoint.replaceAll("\\", "/");
384
+ const normalized = entrypoint.replace(/\\/g, "/");
303
385
  if (normalized.includes("/_npx/")) {
304
386
  const packageSpec = runtime.packageSpec ?? "agent-skillboard";
305
387
  return `npx --yes --package ${shellQuote(packageSpec)} skillboard`;
@@ -314,7 +396,7 @@ function isSourceTreeEntrypoint(entrypoint) {
314
396
  if (entrypoint === "") {
315
397
  return false;
316
398
  }
317
- const normalized = entrypoint.replaceAll("\\", "/");
399
+ const normalized = entrypoint.replace(/\\/g, "/");
318
400
  return (normalized === "bin/skillboard.mjs" || normalized.endsWith("/bin/skillboard.mjs"))
319
401
  && !normalized.includes("/node_modules/")
320
402
  && !normalized.includes("/_npx/")
@@ -323,7 +405,7 @@ function isSourceTreeEntrypoint(entrypoint) {
323
405
 
324
406
  function sourceTreeEntrypoint(entrypoint, cwd) {
325
407
  const absoluteEntrypoint = isAbsolute(entrypoint) ? entrypoint : resolve(cwd, entrypoint);
326
- const relativeEntrypoint = relative(cwd, absoluteEntrypoint).replaceAll("\\", "/");
408
+ const relativeEntrypoint = relative(cwd, absoluteEntrypoint).replace(/\\/g, "/");
327
409
  if (!relativeEntrypoint.startsWith("../") && relativeEntrypoint !== ".." && !isAbsolute(relativeEntrypoint)) {
328
410
  return shellQuote(relativeEntrypoint);
329
411
  }
@@ -334,5 +416,5 @@ function shellQuote(value) {
334
416
  if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) {
335
417
  return value;
336
418
  }
337
- return `'${value.replaceAll("'", "'\\''")}'`;
419
+ return `'${value.replace(/'/g, "'\\''")}'`;
338
420
  }
@@ -5,7 +5,7 @@ export function normalizeSkillPath(value, label = "skill path") {
5
5
  if (value.includes("\0")) {
6
6
  throw new Error(`${label} must not contain null bytes`);
7
7
  }
8
- const normalized = value.replaceAll("\\", "/");
8
+ const normalized = value.replace(/\\/g, "/");
9
9
  if (normalized.startsWith("/") || normalized.startsWith("//") || /^[A-Za-z]:\//.test(normalized)) {
10
10
  throw new Error(`${label} must be relative to the skills root`);
11
11
  }
@@ -164,7 +164,7 @@ function resolveUnderRoot(root, path) {
164
164
  }
165
165
 
166
166
  function relativePath(root, path) {
167
- const rel = relative(root, path).replaceAll("\\", "/");
167
+ const rel = relative(root, path).replace(/\\/g, "/");
168
168
  return rel.startsWith("..") ? path : rel;
169
169
  }
170
170
 
@@ -14,7 +14,7 @@ export async function importSource(options) {
14
14
  const matchedFiles = files
15
15
  .map((file) => ({
16
16
  file,
17
- relativeFile: relative(sourceRoot, file).replaceAll("\\", "/")
17
+ relativeFile: relative(sourceRoot, file).replace(/\\/g, "/")
18
18
  }))
19
19
  .filter((entry) => matchesAnyProfilePattern(entry.relativeFile, profile.skillPaths))
20
20
  .sort((left, right) => left.relativeFile.localeCompare(right.relativeFile));
@@ -238,9 +238,9 @@ function matchingPathRule(file, profile) {
238
238
  function patternToRegExp(pattern) {
239
239
  const escaped = pattern
240
240
  .replace(/[.+^${}()|[\]\\]/g, "\\$&")
241
- .replaceAll("**", "\0")
242
- .replaceAll("*", "[^/]*")
243
- .replaceAll("\0", ".*");
241
+ .split("**").join("\0")
242
+ .split("*").join("[^/]*")
243
+ .split("\0").join(".*");
244
244
  return new RegExp(`^${escaped}$`);
245
245
  }
246
246
 
@@ -165,7 +165,7 @@ export async function sourceDigest(path) {
165
165
 
166
166
  async function addPathDigest(hash, root, path) {
167
167
  const stats = await lstat(path);
168
- const rel = relative(root, path).replaceAll("\\", "/") || ".";
168
+ const rel = relative(root, path).replace(/\\/g, "/") || ".";
169
169
  if (stats.isSymbolicLink()) {
170
170
  hash.update(`symlink\0${rel}\0${await readlink(path)}\n`);
171
171
  return;
package/src/workspace.mjs CHANGED
@@ -51,7 +51,7 @@ async function discoverInstalledSkills(skillsRoot, declaredSkills, options = {})
51
51
  const skillFiles = await findSkillFiles(skillsRoot);
52
52
  for (const file of skillFiles) {
53
53
  const frontmatter = parseSkillFrontmatter(await readFile(file, "utf8"));
54
- const path = relative(skillsRoot, file).replaceAll("\\", "/").replace(/\/SKILL\.md$/, "");
54
+ const path = relative(skillsRoot, file).replace(/\\/g, "/").replace(/\/SKILL\.md$/, "");
55
55
  const declared = declaredSkills.find((skill) => skill.path === path);
56
56
  appendInstalledSkill(installed, installedKeys, {
57
57
  id: declared?.id ?? frontmatter.name ?? path,