@wirecat/cli-base 0.58.0 → 0.59.0

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
@@ -8,6 +8,20 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
8
8
  Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
9
9
  [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).
10
10
 
11
+ ## 0.59.0 — 26.10.2026
12
+
13
+ ### Added
14
+
15
+ - **`skill install --for hermes` gives Hermes Agent the skill, and `skill status` reports what Hermes reads.** It writes
16
+ the `~/.agents/skills` copy, then adds that folder to Hermes's `skills.external_dirs` through Hermes's own
17
+ `hermes config set`, keeping every folder already listed. When Hermes is not on PATH or refuses, it copies the file
18
+ into `$HERMES_HOME/skills/<app>/` and says why; with neither `hermes` on PATH nor a Hermes home folder it fails
19
+ with `not_found` and writes nothing. `--for all` stays Claude Code and `~/.agents/skills`: it is the
20
+ default, and a bare `skill install` must not change another program's settings. `skill status --json` gains
21
+ `hermes: { path, installed, state, via }` beside `targets`, which keeps its two entries. It runs
22
+ `hermes config get` only when `HERMES_HOME` (else `~/.hermes`) exists, so a test with a temporary `HOME` starts no
23
+ Hermes unless its environment sets `HERMES_HOME`.
24
+
11
25
  ## 0.58.0 — 25.10.2026
12
26
 
13
27
  ### Changed — may break callers
@@ -35,6 +35,53 @@ export interface SkillTargetStatus {
35
35
  }
36
36
  /** Each target's installed copy against this CLI's version; reads only, and an unreadable file is missing. */
37
37
  export declare const skillStatus: (app: SkillApp, env?: Env) => SkillTargetStatus[];
38
+ export interface ProgramResult {
39
+ status: number | null;
40
+ error?: Error;
41
+ stdout: string;
42
+ }
43
+ /** Runs a program with no shell, found and started with the `env` given, never `process.env`. */
44
+ export type RunProgram = (file: string, args: readonly string[], env: Env) => ProgramResult;
45
+ /** How Hermes reads the skill: the `~/.agents/skills` folder listed in its `skills.external_dirs`, or a copy of its own. */
46
+ export type HermesRoute = "external_dirs" | "copy";
47
+ /** `HERMES_HOME`, else the folder Hermes itself defaults to. */
48
+ export declare const hermesHome: (env: Env, platform?: NodeJS.Platform) => string;
49
+ export declare const hermesCopyPath: (appName: string, env: Env) => string;
50
+ export interface HermesInstall {
51
+ via: HermesRoute;
52
+ /** The file Hermes reads. */
53
+ path: string;
54
+ /** Why the copy was written instead, when it was. */
55
+ reason?: string;
56
+ /** An earlier copy this CLI wrote, taken out because Hermes refuses a skill name it finds twice. */
57
+ removed?: string;
58
+ }
59
+ /**
60
+ * Writes the skill to `~/.agents/skills`, then has Hermes read that folder by adding it to `skills.external_dirs`
61
+ * with Hermes's own `config set`, keeping every folder already listed. When Hermes is not on PATH, cannot be asked,
62
+ * or does not keep the folder, the skill is copied into `$HERMES_HOME/skills` instead — but only when that home exists:
63
+ * with neither the program nor its home, Hermes is not installed, and nothing is written.
64
+ */
65
+ export declare const installHermesSkill: (app: SkillApp, skill: URL, { env, run }?: {
66
+ env?: Env;
67
+ run?: RunProgram;
68
+ }) => {
69
+ written: string[];
70
+ hermes: HermesInstall;
71
+ };
72
+ export interface HermesSkillStatus extends Omit<SkillTargetStatus, "target"> {
73
+ /** How Hermes reads the copy reported; `null` when it reads none. */
74
+ via: HermesRoute | null;
75
+ }
76
+ /**
77
+ * The copy Hermes reads: its own `skills/<app>` first, as Hermes scans it first, else the `~/.agents/skills` one when
78
+ * `skills.external_dirs` lists that folder. Asks Hermes through `config get` when its home exists, so a host calls it
79
+ * only when asked.
80
+ */
81
+ export declare const hermesSkillStatus: (app: SkillApp, { env, run }?: {
82
+ env?: Env;
83
+ run?: RunProgram;
84
+ }) => HermesSkillStatus;
38
85
  /** What the command writes to and where home is, for this invocation — the host's own output resolution. */
39
86
  export type SkillEnvironment = (command: Command) => {
40
87
  renderer: Renderer;
@@ -5,12 +5,14 @@
5
5
  * Nothing here prints by itself. The command writes through the renderer and streams the host hands
6
6
  * it, and the hint is a string the host puts on stderr — never stdout, which an agent parses.
7
7
  */
8
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
- import { dirname, join } from "node:path";
10
+ import { dirname, join, resolve } from "node:path";
11
11
  import { Command, Option } from "commander";
12
+ import crossSpawn from "cross-spawn";
12
13
  import { annotate } from "../commands/index.js";
13
14
  import { CliError } from "../errors.js";
15
+ import { executableEnvironment, executableOnPath } from "../executable.js";
14
16
  import { CHECK_EVERY_MS, isNewer, readNoticeState, writeUpdateState } from "../update/index.js";
15
17
  export const SKILL_TARGETS = ["claude", "agents"];
16
18
  const homeOf = (env) => env.HOME || env.USERPROFILE || homedir();
@@ -91,18 +93,133 @@ export const installSkill = (app, skill, { targets = SKILL_TARGETS, env = proces
91
93
  /** Each target's installed copy against this CLI's version; reads only, and an unreadable file is missing. */
92
94
  export const skillStatus = (app, env = process.env) => SKILL_TARGETS.map((target) => {
93
95
  const path = skillPath(target, app.appName, env);
96
+ return { target, ...(copyStatus(app, path) ?? { path, installed: null, state: "missing" }) };
97
+ });
98
+ const copyStatus = (app, path) => {
94
99
  let content;
95
100
  try {
96
101
  content = readFileSync(path, "utf8");
97
102
  }
98
103
  catch {
99
- return { target, path, installed: null, state: "missing" };
104
+ return undefined;
100
105
  }
101
106
  const installed = skillVersion(content);
102
107
  if (installed === undefined)
103
- return { target, path, installed: null, state: "unversioned" };
104
- return { target, path, installed, state: isNewer(app.version, installed) ? "stale" : "current" };
105
- });
108
+ return { path, installed: null, state: "unversioned" };
109
+ return { path, installed, state: isNewer(app.version, installed) ? "stale" : "current" };
110
+ };
111
+ const runProgram = (file, args, env) => {
112
+ const result = crossSpawn.sync(executableOnPath(file, env), [...args], {
113
+ encoding: "utf8",
114
+ timeout: 30_000,
115
+ maxBuffer: 256_000,
116
+ env: executableEnvironment(env),
117
+ });
118
+ return { status: result.status, ...(result.error ? { error: result.error } : {}), stdout: result.stdout ?? "" };
119
+ };
120
+ /** `HERMES_HOME`, else the folder Hermes itself defaults to. */
121
+ export const hermesHome = (env, platform = process.platform) => {
122
+ const set = env.HERMES_HOME?.trim();
123
+ if (set)
124
+ return set;
125
+ if (platform === "win32")
126
+ return join(env.LOCALAPPDATA || join(homeOf(env), "AppData", "Local"), "hermes");
127
+ return join(homeOf(env), ".hermes");
128
+ };
129
+ export const hermesCopyPath = (appName, env) => join(hermesHome(env), "skills", appName, "SKILL.md");
130
+ const EXTERNAL_DIRS = "skills.external_dirs";
131
+ /**
132
+ * The folders Hermes lists in `skills.external_dirs`, as its own `config get` answers, or `undefined` when Hermes
133
+ * cannot be asked or the setting is neither a list nor one folder — a value this code must never overwrite.
134
+ */
135
+ const externalDirs = (env, run) => {
136
+ const answer = run("hermes", ["config", "get", EXTERNAL_DIRS, "--json"], env);
137
+ if (answer.error || answer.status !== 0)
138
+ return undefined;
139
+ let value;
140
+ try {
141
+ value = JSON.parse(answer.stdout);
142
+ }
143
+ catch {
144
+ return undefined;
145
+ }
146
+ if (value === null)
147
+ return [];
148
+ if (typeof value === "string")
149
+ return [value];
150
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : undefined;
151
+ };
152
+ /** Hermes expands `~` and environment variables in each entry before it resolves the path. */
153
+ const expanded = (entry, env) => resolve(entry
154
+ .replace(/^~(?=$|[\\/])/, homeOf(env))
155
+ .replace(/\$\{(\w+)\}|\$(\w+)/g, (whole, braced, bare) => env[braced ?? bare ?? ""] ?? whole));
156
+ const lists = (dirs, folder, env) => dirs.some((entry) => expanded(entry, env) === resolve(folder));
157
+ /** Our own earlier copy, alone in its folder: anything else there was put by someone else and stays. */
158
+ const removeOwnCopy = (appName, env) => {
159
+ const path = hermesCopyPath(appName, env);
160
+ try {
161
+ if (readdirSync(dirname(path)).join() !== "SKILL.md")
162
+ return undefined;
163
+ if (skillName(readFileSync(path, "utf8")) !== appName)
164
+ return undefined;
165
+ rmSync(path);
166
+ rmdirSync(dirname(path));
167
+ return path;
168
+ }
169
+ catch {
170
+ return undefined;
171
+ }
172
+ };
173
+ /** Has Hermes list `folder` in `skills.external_dirs`, keeping the folders there; why not, when it does not. */
174
+ const readFolder = (folder, env, run) => {
175
+ const dirs = externalDirs(env, run);
176
+ if (dirs === undefined)
177
+ return "Hermes could not be asked for skills.external_dirs";
178
+ if (lists(dirs, folder, env))
179
+ return undefined;
180
+ const set = run("hermes", ["config", "set", EXTERNAL_DIRS, JSON.stringify([...dirs, folder])], env);
181
+ if (set.error || set.status !== 0)
182
+ return "hermes config set failed";
183
+ const after = externalDirs(env, run);
184
+ return after !== undefined && lists(after, folder, env) ? undefined : "Hermes did not keep the folder";
185
+ };
186
+ /**
187
+ * Writes the skill to `~/.agents/skills`, then has Hermes read that folder by adding it to `skills.external_dirs`
188
+ * with Hermes's own `config set`, keeping every folder already listed. When Hermes is not on PATH, cannot be asked,
189
+ * or does not keep the folder, the skill is copied into `$HERMES_HOME/skills` instead — but only when that home exists:
190
+ * with neither the program nor its home, Hermes is not installed, and nothing is written.
191
+ */
192
+ export const installHermesSkill = (app, skill, { env = process.env, run = runProgram } = {}) => {
193
+ if (!existsSync(hermesHome(env)) && run("hermes", ["--version"], env).error)
194
+ throw new CliError("not_found", "Hermes is not installed here; install Hermes first, or set HERMES_HOME");
195
+ const [shared] = installSkill(app, skill, { targets: ["agents"], env });
196
+ const reason = readFolder(dirname(dirname(shared)), env, run);
197
+ if (reason === undefined) {
198
+ const removed = removeOwnCopy(app.appName, env);
199
+ return { written: [shared], hermes: { via: "external_dirs", path: shared, ...(removed ? { removed } : {}) } };
200
+ }
201
+ const copy = hermesCopyPath(app.appName, env);
202
+ mkdirSync(dirname(copy), { recursive: true });
203
+ writeFileSync(copy, readFileSync(shared));
204
+ return { written: [shared, copy], hermes: { via: "copy", path: copy, reason } };
205
+ };
206
+ /**
207
+ * The copy Hermes reads: its own `skills/<app>` first, as Hermes scans it first, else the `~/.agents/skills` one when
208
+ * `skills.external_dirs` lists that folder. Asks Hermes through `config get` when its home exists, so a host calls it
209
+ * only when asked.
210
+ */
211
+ export const hermesSkillStatus = (app, { env = process.env, run = runProgram } = {}) => {
212
+ const own = copyStatus(app, hermesCopyPath(app.appName, env));
213
+ if (own)
214
+ return { ...own, via: "copy" };
215
+ const shared = skillPath("agents", app.appName, env);
216
+ // Asked with no home of its own, Hermes creates one: a status that only reads never starts it there.
217
+ const dirs = existsSync(hermesHome(env)) ? externalDirs(env, run) : undefined;
218
+ const read = dirs !== undefined && lists(dirs, dirname(dirname(shared)), env) ? copyStatus(app, shared) : undefined;
219
+ if (read)
220
+ return { ...read, via: "external_dirs" };
221
+ return { path: shared, installed: null, state: "missing", via: null };
222
+ };
106
223
  /**
107
224
  * `more`: skills a library ships for every CLI built on it, by name — `skill show <name>` prints one with
108
225
  * `{{command}}` turned into this CLI's command. They are printed, not installed: two CLIs would install
@@ -159,10 +276,22 @@ export const skillCommand = (app, skill, environment, more = {}) => {
159
276
  });
160
277
  annotate(command.command("install"), { effects: { remote: "none", local: "write" } })
161
278
  .summary("install the tool's instructions for AI agents")
162
- .description(`write SKILL.md to ~/.claude/skills/${app.appName}/ (Claude Code) and ~/.agents/skills/${app.appName}/ (Codex, Gemini CLI)`)
163
- .addOption(new Option("--for <agents>", "which agents to install for").choices(["claude", "agents", "all"]).default("all"))
279
+ .description(`write SKILL.md to ~/.claude/skills/${app.appName}/ (Claude Code) and ~/.agents/skills/${app.appName}/ (Codex, Gemini CLI, OpenClaw); --for hermes also has Hermes read ~/.agents/skills, which changes Hermes's own settings, so all leaves it out`)
280
+ .addOption(new Option("--for <agents>", "which agents to install for")
281
+ .choices(["claude", "agents", "hermes", "all"])
282
+ .default("all"))
164
283
  .action(function ({ for: chosen }) {
165
284
  const { renderer, env } = environment(this);
285
+ if (chosen === "hermes") {
286
+ const { written, hermes } = installHermesSkill(app, skill, env ? { env } : {});
287
+ renderer.result({ name: app.appName, version: app.version, written, hermes });
288
+ renderer.success(hermes.via === "external_dirs"
289
+ ? `${app.appName} ${app.version} skill installed: ${hermes.path}; Hermes reads ${dirname(dirname(hermes.path))} (skills.external_dirs)`
290
+ : `${app.appName} ${app.version} skill installed: ${hermes.path}, a copy for Hermes (${hermes.reason})`);
291
+ if (hermes.removed)
292
+ renderer.note(`removed the earlier copy Hermes would also have found: ${hermes.removed}`);
293
+ return;
294
+ }
166
295
  const targets = chosen === "all" ? SKILL_TARGETS : [chosen];
167
296
  const written = installSkill(app, skill, { targets, ...(env ? { env } : {}) });
168
297
  renderer.result({ name: app.appName, version: app.version, written });
@@ -173,11 +302,14 @@ export const skillCommand = (app, skill, environment, more = {}) => {
173
302
  examples: [`${app.command} skill status`, `${app.command} skill status --json`],
174
303
  })
175
304
  .summary("whether the installed instructions for AI agents match this version")
176
- .description(`for each place \`${app.command} skill install\` writes to: the path, the version installed there, and whether it is current, stale, missing or unversioned`)
305
+ .description(`for each place \`${app.command} skill install\` writes to, and for the copy Hermes reads: the path, the version installed there, and whether it is current, stale, missing or unversioned`)
177
306
  .action(function () {
178
307
  const { renderer, env } = environment(this);
308
+ // Hermes is its own key, not a third target: it is no folder `skill install` writes, and a caller that
309
+ // expects the two targets keeps working.
179
310
  const targets = skillStatus(app, env ?? process.env);
180
- renderer.result({ name: app.appName, version: app.version, targets });
311
+ const hermes = hermesSkillStatus(app, env ? { env } : {});
312
+ renderer.result({ name: app.appName, version: app.version, targets, hermes });
181
313
  });
182
314
  return command;
183
315
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",