@wirecat/cli-base 0.58.0 → 0.60.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 +43 -0
- package/dist/skill/index.d.ts +47 -0
- package/dist/skill/index.js +142 -10
- package/docs/standards/ci.md +6 -2
- package/docs/standards/kit-surface.md +9 -2
- package/docs/standards/releases.md +6 -5
- package/docs/standards/style.md +2 -1
- package/docs/standards/toolchain.md +19 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,49 @@ 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.60.0 — 26.10.2026
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`node-ci.yml` takes `test-shards` (2 to 8) to run `test:coverage` on that many runners.** The `check` job then
|
|
16
|
+
skips the tests; `test (i/n)` jobs each run one Vitest shard with the blob reporter, and a `coverage` job merges
|
|
17
|
+
them and enforces the coverage floor of the repository's Vitest config. Each shard runs with the floor at zero,
|
|
18
|
+
because one shard alone covers a fraction of the code. `coverage` fails, rather than being skipped, when a shard
|
|
19
|
+
fails, and when fewer reports than shards arrive. A repository that requires its tests to pass adds
|
|
20
|
+
`<job> / ci / coverage` to its required checks. With `cli: true`, `test:matrix:check` moves from `check` to
|
|
21
|
+
`coverage` and reads the shards' argv logs joined. Default 1: the tests stay in `check`, as before.
|
|
22
|
+
- **Before 1.0.0, a release with a breaking change raises the minor version**
|
|
23
|
+
([Releases](docs/standards/releases.md#wc-rel-7--versions-are-xyz-before-100-a-breaking-change-raises-the-minor)).
|
|
24
|
+
A section with an entry under "Changed — may break callers" or "Removed", or a new feature, raises the minor; only
|
|
25
|
+
fixes and document changes raise the patch. npm's `^0.x.y` takes every patch of that minor, so a patch must not
|
|
26
|
+
break anyone. The rule used to allow a breaking change in a patch release.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **Handbook corrections from the cli-template 0.1.8 audit.**
|
|
31
|
+
[Kit surface](docs/standards/kit-surface.md) lists every cli-base and cli-core export the template uses, in `src/`
|
|
32
|
+
and `scripts/`, including `privateFiles` and `profileFree`, and the config files it extends.
|
|
33
|
+
[Toolchain](docs/standards/toolchain.md): the `engines` example is `^22.16.0 || >=24`, as cli-core and cli-template
|
|
34
|
+
pin it; `tsconfig.test.json` extends the shared test config last and sets no `exclude` of its own, since the shared
|
|
35
|
+
file already sets `"exclude": []`; the spell check covers `README.md` and every user page in `docs/`.
|
|
36
|
+
[Style](docs/standards/style.md): a cast may stay only if it was on `main` on 17.10.2026, when the rule against
|
|
37
|
+
casts came in. [CI](docs/standards/ci.md) says where an audit finds the shared workflows CI runs: at the commit
|
|
38
|
+
pinned in the repository's workflow files, not in the npm package.
|
|
39
|
+
|
|
40
|
+
## 0.59.0 — 26.10.2026
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- **`skill install --for hermes` gives Hermes Agent the skill, and `skill status` reports what Hermes reads.** It writes
|
|
45
|
+
the `~/.agents/skills` copy, then adds that folder to Hermes's `skills.external_dirs` through Hermes's own
|
|
46
|
+
`hermes config set`, keeping every folder already listed. When Hermes is not on PATH or refuses, it copies the file
|
|
47
|
+
into `$HERMES_HOME/skills/<app>/` and says why; with neither `hermes` on PATH nor a Hermes home folder it fails
|
|
48
|
+
with `not_found` and writes nothing. `--for all` stays Claude Code and `~/.agents/skills`: it is the
|
|
49
|
+
default, and a bare `skill install` must not change another program's settings. `skill status --json` gains
|
|
50
|
+
`hermes: { path, installed, state, via }` beside `targets`, which keeps its two entries. It runs
|
|
51
|
+
`hermes config get` only when `HERMES_HOME` (else `~/.hermes`) exists, so a test with a temporary `HOME` starts no
|
|
52
|
+
Hermes unless its environment sets `HERMES_HOME`.
|
|
53
|
+
|
|
11
54
|
## 0.58.0 — 25.10.2026
|
|
12
55
|
|
|
13
56
|
### Changed — may break callers
|
package/dist/skill/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/skill/index.js
CHANGED
|
@@ -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
|
|
104
|
+
return undefined;
|
|
100
105
|
}
|
|
101
106
|
const installed = skillVersion(content);
|
|
102
107
|
if (installed === undefined)
|
|
103
|
-
return {
|
|
104
|
-
return {
|
|
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")
|
|
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
|
-
|
|
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/docs/standards/ci.md
CHANGED
|
@@ -66,8 +66,8 @@ cli-testing, cli-docs, plus community itself.
|
|
|
66
66
|
|
|
67
67
|
- **Why:** a pull request that lowers coverage or breaks a contract must fail before it merges; once on `main` it
|
|
68
68
|
blocks every other branch and the next release. It costs 2–3 minutes per pull request in the CLIs (measured: wa
|
|
69
|
-
2.9, tg 2.2, max 3.0) and about
|
|
70
|
-
shards
|
|
69
|
+
2.9, tg 2.2, max 3.0) and about 5 in cli-core, where `test:coverage` alone takes about 4; `node-ci.yml`'s
|
|
70
|
+
`test-shards` input splits it across runners.
|
|
71
71
|
- **Enforced by:** `pr.yml:validation`.
|
|
72
72
|
- **Example:** `pr.yml` runs on `pull_request` and calls the repository's own `ci.yml`, the same full validation as
|
|
73
73
|
`main` (WC-CI-7): lint, typecheck, `test:coverage` with the coverage floor, build, the contracts, the test matrix,
|
|
@@ -133,6 +133,10 @@ cli-core's peer range holds the installed package back; in a repository made fro
|
|
|
133
133
|
that apply are those of the handbook in the installed package. A newer `node-ci.yml` may run a step the repository
|
|
134
134
|
cannot pass yet; the `skip` input names it.
|
|
135
135
|
|
|
136
|
+
The shared workflows are not in the npm package, because CI runs the pinned commit, not the installed copy. To see
|
|
137
|
+
what CI runs, an audit reads the `uses: WireCatLabs/cli-base/.github/workflows/…@<commit>` lines in `ci.yml`,
|
|
138
|
+
`release.yml` and `monthly.yml`, and opens each workflow file at that commit in cli-base.
|
|
139
|
+
|
|
136
140
|
- **Why:** one shared workflow keeps the full suite the same everywhere, and a pin keeps a cli-base change from
|
|
137
141
|
breaking ten repositories at once.
|
|
138
142
|
- **Enforced by:** `ci.yml:ci`.
|
|
@@ -12,18 +12,25 @@ This page is a reference, not a rule page: it assigns no rule ids. How to start
|
|
|
12
12
|
|
|
13
13
|
## What the template imports
|
|
14
14
|
|
|
15
|
-
The list is what the template's `src/`
|
|
15
|
+
The list is what the template's `src/` and `scripts/` import today, type imports and re-exports included. `kit/` imports
|
|
16
|
+
nothing from either package.
|
|
16
17
|
|
|
17
18
|
| Package and path | Exports |
|
|
18
19
|
|---|---|
|
|
19
20
|
| `@wirecat/cli-base` | `catchCrashes`, `CliError`, `configFilePath`, `mcpErrorResult`, `resolvePaths` |
|
|
20
21
|
| `@wirecat/cli-base/commands` | `annotate`, `CommandInfo`, `describeProgram` |
|
|
21
|
-
| `@wirecat/cli-
|
|
22
|
+
| `@wirecat/cli-base/codegen` | `writeArtifacts` (in `scripts/`) |
|
|
23
|
+
| `@wirecat/cli-base/release` | `DocsRules`, `JOURNAL_IDS`, `markdownFiles`, `packContents` (in `scripts/`) |
|
|
24
|
+
| `@wirecat/cli-core/cli` | `AppIdentity`, `changesAuthorityAt`, `commandsCommand`, `configCommand`, `Configuration`, `createProgram`, `environmentOf`, `GlobalFlags`, `itemOf`, `LOCAL_READ`, `outputFor`, `privateFiles`, `profileFree`, `ProgramDefinition`, `ProgramOptions`, `readOnlyLockOf`, `REMOTE_WRITE`, `renderAnswer`, `rootOf`, `run`, `RunOptions`, `runsCommand`, `settingsFor`, `skillCommand`, `upgradeCommand` |
|
|
22
25
|
| `@wirecat/cli-core/mcp` | `McpCommand`, `registerCommands`, `runCommandForAgent`, `toolsFromCommands` |
|
|
23
26
|
| `@wirecat/cli-core/sends` | `Level` |
|
|
24
27
|
| `@wirecat/cli-core/update` | `appUpdate` |
|
|
25
28
|
| `@wirecat/cli-core/testing` | `ARGV_LOG`, `argvLogSetup` |
|
|
26
29
|
|
|
30
|
+
The template's config files also reach cli-base: `tsconfig.json` and `kit/tsconfig.json` extend
|
|
31
|
+
`tsconfig.base.json`, `tsconfig.test.json` and `tsconfig.scripts.json` extend the files of those names, `vitest.config.ts`
|
|
32
|
+
loads `@wirecat/cli-base/testing/setup`, and `.vale.ini` reads the styles in `config/vale`.
|
|
33
|
+
|
|
27
34
|
## When one of them changes
|
|
28
35
|
|
|
29
36
|
A change that makes a CLI change its code for one of these exports ships with two things:
|
|
@@ -82,13 +82,14 @@ on the [CI](ci.md) page.
|
|
|
82
82
|
- **Exceptions:** a shared library that prints no version has no `src/version.ts`.
|
|
83
83
|
- **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** none.
|
|
84
84
|
|
|
85
|
-
### WC-REL-7 · Versions are `x.y.z`; before 1.0.0
|
|
85
|
+
### WC-REL-7 · Versions are `x.y.z`; before 1.0.0 a breaking change raises the minor
|
|
86
86
|
|
|
87
|
-
- **Why:**
|
|
88
|
-
|
|
87
|
+
- **Why:** npm's `^0.40.4` takes every `0.40.x`, so a patch reaches callers who never read its changelog. It must not
|
|
88
|
+
break them.
|
|
89
89
|
- **Enforced by:** review only.
|
|
90
|
-
- **Example:** raise the minor (`0.40.4` to `0.41.0`) for a release with new
|
|
91
|
-
|
|
90
|
+
- **Example:** raise the minor (`0.40.4` to `0.41.0`) for a release with a new feature or with any entry under
|
|
91
|
+
"Changed — may break callers" or "Removed". Raise the patch (`0.40.4` to `0.40.5`) only when every entry is a fix
|
|
92
|
+
or a change to the documents.
|
|
92
93
|
- **Exceptions:** a pre-release version (`0.41.0-rc.1`) is chosen by hand.
|
|
93
94
|
- **Applies to:** all CLIs and shared libraries. **Since:** cli-base 0.41.0. **Rulings:** none.
|
|
94
95
|
|
package/docs/standards/style.md
CHANGED
|
@@ -57,7 +57,8 @@ formatting, so this page does not repeat it.
|
|
|
57
57
|
- **Example:** narrow with a type guard, `instanceof` or a shape check. A test fixture uses an honest type, such as
|
|
58
58
|
`Omit<T, "x"> & { x: string | null }`, not `as unknown as T`.
|
|
59
59
|
- **Exceptions:** `as const` is allowed. A cast that cannot be avoided, because a library is typed too loosely, keeps
|
|
60
|
-
a comment on that line that says why.
|
|
60
|
+
a comment on that line that says why. A cast already on `main` on 17.10.2026, the day cli-base 0.41.0 added this
|
|
61
|
+
rule, may stay until that code is changed; `git blame` shows the date of a line. A cast added later is a finding.
|
|
61
62
|
- **Applies to:** all repositories. **Since:** cli-base 0.41.0. **Rulings:** none.
|
|
62
63
|
|
|
63
64
|
### WC-STYLE-6 · In a `catch`, narrow with `instanceof` and wrap anything else
|
|
@@ -67,10 +67,12 @@ cli-testing, cli-docs, plus community itself.
|
|
|
67
67
|
- **Why:** a folder that no `tsconfig` covers breaks without a sign, and a test that compares against a constant that
|
|
68
68
|
does not exist passes for any input while CI stays green.
|
|
69
69
|
- **Enforced by:** `pnpm typecheck` · `pnpm ci:quick` · `node-ci.yml:check`.
|
|
70
|
-
- **Example:** `tsconfig.test.json` extends
|
|
71
|
-
|
|
72
|
-
`exclude`,
|
|
73
|
-
|
|
70
|
+
- **Example:** `tsconfig.test.json` has `"extends": ["./tsconfig.json", "@wirecat/cli-base/tsconfig.test.json"]`, in
|
|
71
|
+
that order, and adds at most `rootDir` and an `include` for more folders. The shared file covers all of `src/`,
|
|
72
|
+
tests included, checks it with `noEmit` and sets `"exclude": []`, so it must come last and the repository's file sets
|
|
73
|
+
no `exclude` of its own. A file that sets the build's `exclude` again checks no test file and still passes.
|
|
74
|
+
`tsconfig.scripts.json` extends `@wirecat/cli-base/tsconfig.scripts.json` and covers `scripts/`. The `typecheck`
|
|
75
|
+
script runs all three projects.
|
|
74
76
|
- **Exceptions:** a new folder of TypeScript goes into one of these projects in the same pull request. memo has no
|
|
75
77
|
`tsconfig.scripts.json` yet.
|
|
76
78
|
- **Applies to:** the CLIs, cli-core, cli-base and cli-testing. **Since:** cli-base 0.41.0. **Rulings:** none.
|
|
@@ -81,13 +83,14 @@ cli-testing, cli-docs, plus community itself.
|
|
|
81
83
|
the other.
|
|
82
84
|
- **Enforced by:** `pnpm smoke:bun` · `node-ci.yml:bun` · `node-ci.yml:platforms` (the unit suite on Windows and
|
|
83
85
|
macOS).
|
|
84
|
-
- **Example:** `"engines": { "node": ">=
|
|
85
|
-
|
|
86
|
+
- **Example:** `"engines": { "node": "^22.16.0 || >=24" }` in `package.json`, as cli-core and cli-template have it:
|
|
87
|
+
Node 22 from 22.16.0, then 24 and later. Full validation runs on Node 24. `bun test` cannot run the vitest suite, so
|
|
88
|
+
`scripts/smoke.ts` runs the built exports with plain assertions, and the Bun job runs it
|
|
86
89
|
(`pnpm build && pnpm smoke:bun`). A new entry point gets a line there. Node 22 runs in the Windows install matrix of
|
|
87
90
|
tg, max and wa, in zoom's full validation and in cli-core's old-Node job.
|
|
88
|
-
- **Exceptions:** cli-docs and community publish no runtime package. memo, drive and cli-base
|
|
89
|
-
|
|
90
|
-
memo still fail tests there, so theirs is off.
|
|
91
|
+
- **Exceptions:** cli-docs and community publish no runtime package. tg needs `^22.19.0`. memo, drive and cli-base
|
|
92
|
+
do not run Node 22 in any workflow yet, and cli-base's own `engines` is still `>=22`. The unit suite runs on Windows
|
|
93
|
+
and macOS for tg, max and drive (`platforms: true`); zoom and memo still fail tests there, so theirs is off.
|
|
91
94
|
- **Applies to:** the CLIs, cli-core and cli-base. **Since:** cli-base 0.41.0. **Rulings:** NEED-11.
|
|
92
95
|
|
|
93
96
|
### WC-TOOL-7 · Let Biome format the code, and do not format by hand
|
|
@@ -206,11 +209,14 @@ no-unbounded-spread ([WC-TOOL-12](#wc-tool-12--do-not-spread-a-list-of-user-data
|
|
|
206
209
|
### WC-TOOL-17 · Spell-check prose with the shared `cspell` config
|
|
207
210
|
|
|
208
211
|
- **Why:** a typo in a page or a help text is the first thing a reader sees.
|
|
209
|
-
- **Enforced by:** `pnpm docs:spell`.
|
|
212
|
+
- **Enforced by:** `pnpm docs:spell` · `pnpm docs:check`.
|
|
210
213
|
- **Example:** `cspell.json` imports `./config/wirecat/cspell.json` (English, British English, Russian, Spanish and the
|
|
211
|
-
WireCat word list) and adds only the project's own words.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
+
WireCat word list) and adds only the project's own words. A repository checks `README.md` and every user page,
|
|
215
|
+
`docs/*.md`, in `docs:spell` or in `docs:check`. Generated pages (`docs/commands.md`), `docs/dev/` and
|
|
216
|
+
`CHANGELOG.md` may be left out. Code blocks and inline code are not checked.
|
|
217
|
+
- **Exceptions:** a general word goes into the community word list, not into each project. cli-base checks
|
|
218
|
+
`README.md` and `docs/standards/`; cli-docs its site pages in `content/docs/`; community every Markdown file it
|
|
219
|
+
publishes. cli-core, zoom and cli-testing check only `README.md` yet.
|
|
214
220
|
- **Applies to:** the ten repositories. **Since:** cli-base 0.41.0. **Rulings:** none.
|
|
215
221
|
|
|
216
222
|
### WC-TOOL-18 · Lint Markdown with `markdownlint-cli2` and the shared preset
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-base",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.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",
|