@wirecat/cli-base 0.65.5 → 0.66.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,40 @@ 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.66.1 — 31.10.2026
12
+
13
+ ### Fixed
14
+
15
+ - **`implicitTextTerms` counts a bare number as a word.** It left out every number token, so a question with a
16
+ number ("do logs expire after 21 days") looked like query syntax: cli-core's `--discover` then ran strict search
17
+ and usually found nothing. A number that is a field's value (`size:>2`) is still not a bare word.
18
+
19
+ ## 0.66.0 — 31.10.2026
20
+
21
+ ### Added
22
+
23
+ - **`wcat`, one command that runs every tool: `wcat tg …` runs `wcat-tg …`** (`@wirecat/cli-base/wcat`). Another
24
+ program named `tg` or `drive` can sit earlier on PATH, and an agent or a script then runs it without any sign; a
25
+ `wcat-<name>` name is the tool's alone. Any `wcat-<name>` program on PATH joins, ours or not. `wcat --help` lists
26
+ what is installed with each tool's description, the WireCat tools that are not, with their install commands, and
27
+ warns when two programs share a name; `--json` prints the list. Built-in names (`help`, `version`, `shortcuts`,
28
+ `doctor`, `update`, `upgrade`, `app`) always belong to `wcat`. An unknown tool is `not_found` with the new reason
29
+ `tool.not-installed`. Nothing changes for a CLI until it calls `wcatStart`.
30
+ - **`wcatStart(tool)` from `@wirecat/cli-base/wcat/start`, the first call in a CLI's bin.** It answers
31
+ `--wcat-describe` with the tool's name, version, description and docs page, runs `wcat` when the `wcat` launcher
32
+ starts the tool, and on any other start makes sure the `wcat` launcher in the tool's global bin folder works. The
33
+ launcher is a small marked script that starts an installed `wcat-<tool>` by name (a `.cmd` and `.ps1` pair on
34
+ Windows), never a link into a package folder, which pnpm renames on every upgrade. A file named `wcat` that
35
+ cli-base did not write is never touched. When the launcher works, the check costs a few `stat` calls; it loads
36
+ only Node built-ins. Call it before `startCli`, and set `process.exitCode` to the `code` it returns.
37
+ - **`wcat shortcuts`, and `listShortcuts`, `addShortcuts` and `removeShortcuts` for a program.** A tool's short name,
38
+ such as `tg`, is made only when no program on PATH has it, and only shortcuts cli-base made are removed. A short
39
+ name the tool's package installs itself shows as `package` and is left to the package manager.
40
+ - **`RESERVED_NAMES` and a `cli-dev lint` warning for joining `wcat`** ([WC-CMD-29](docs/standards/commands.md#wc-cmd-29--answer---wcat-describe-and-take-no-name-wirecat-reserves)).
41
+ The lint warns when a `"bin"` key does not start with `wcat-`, when a package outside `@wirecat/` takes a reserved
42
+ name, and, with `--cli`, when the built CLI does not answer `--wcat-describe`. The warnings do not fail the run:
43
+ every CLI still installs its short name today.
44
+
11
45
  ## 0.65.5 — 31.10.2026
12
46
 
13
47
  ### Added
package/README.md CHANGED
@@ -187,6 +187,10 @@ operation and never falls back to `any`. 64-bit integers stay exact.
187
187
  - **Update checks that never update unasked.** Which package manager installed the CLI, the command that updates it,
188
188
  and a daily notice only when a person is at a terminal. A tool holding someone's credentials does not change itself.
189
189
  - **Release checks** every CLI shares: changelog shape, dead links, package contents, versions in step.
190
+ - **`wcat`, one command for every tool.** `wcat tg …` runs `wcat-tg …`, like `git foo` runs `git-foo`, and any
191
+ `wcat-<name>` program on PATH joins. `wcat --help` lists what is installed with each tool's description;
192
+ `wcat shortcuts add` gives a tool its short name only where no other program has it. Each tool keeps the `wcat`
193
+ launcher working on start, at the cost of a few `stat` calls.
190
194
 
191
195
  ## Engineering
192
196
 
@@ -1,3 +1,4 @@
1
+ import spawn from "cross-spawn";
1
2
  import { type RunLogProbe } from "./lint.js";
2
3
  export interface DevIo {
3
4
  cwd: string;
@@ -12,4 +13,8 @@ export interface DevIo {
12
13
  * `XDG_STATE_HOME` would point it at the owner's real runs.
13
14
  */
14
15
  export declare const probeRunLog: (cwd: string, bin: string, environment?: NodeJS.ProcessEnv) => RunLogProbe;
16
+ type HomeCall = (args: string[]) => ReturnType<typeof spawn.sync>;
17
+ /** Runs the built CLI in a new empty home with only PATH from this environment, and removes the home after. */
18
+ export declare const inEmptyHome: <T>(cwd: string, bin: string, environment: NodeJS.ProcessEnv, body: (call: HomeCall) => T) => T;
15
19
  export declare const main: (argv: readonly string[], io: DevIo) => Promise<number>;
20
+ export {};
package/dist/dev/main.js CHANGED
@@ -25,6 +25,7 @@ import { baselineOf, judge, lintCommands, RUN_LOG_COMMANDS, walk } from "./lint.
25
25
  import { nextVersion } from "./next-version.js";
26
26
  import { slowTests } from "./slow-tests.js";
27
27
  import { matrixPage, matrixRows, parseArgvLog } from "./test-matrix.js";
28
+ import { binNameWarnings, describeWarnings } from "./wcat-lint.js";
28
29
  const USAGE = `usage: cli-dev <command> [options]
29
30
 
30
31
  slow-tests [report] [count] the slowest tests and files (report: coverage/tests.json, count: 20)
@@ -257,7 +258,25 @@ const PROBE_FLAG = "--cli-dev-run-log-probe";
257
258
  * kept. The CLI sees a new empty home and nothing else from this environment: a stray `*_STATE_DIR` or
258
259
  * `XDG_STATE_HOME` would point it at the owner's real runs.
259
260
  */
260
- export const probeRunLog = (cwd, bin, environment = process.env) => {
261
+ export const probeRunLog = (cwd, bin, environment = process.env) => inEmptyHome(cwd, bin, environment, (call) => {
262
+ const failed = call([PROBE_FLAG]);
263
+ if (failed.status === 0)
264
+ return { kept: false, detail: `\`${PROBE_FLAG}\` did not fail, so nothing could be kept` };
265
+ const listed = call(["runs", "list", "--json"]);
266
+ if (listed.status !== 0)
267
+ return { kept: false, detail: `\`runs list --json\` exited ${listed.status ?? listed.error?.message}` };
268
+ let items;
269
+ try {
270
+ items = JSON.parse(String(listed.stdout)).items;
271
+ }
272
+ catch {
273
+ return { kept: false, detail: "`runs list --json` did not print JSON" };
274
+ }
275
+ const kept = Array.isArray(items) && items.some((item) => item?.status === "failed");
276
+ return kept ? { kept } : { kept, detail: `\`runs list --json\` shows no failed run after \`${PROBE_FLAG}\`` };
277
+ });
278
+ /** Runs the built CLI in a new empty home with only PATH from this environment, and removes the home after. */
279
+ export const inEmptyHome = (cwd, bin, environment, body) => {
261
280
  const home = mkdtempSync(join(tmpdir(), "cli-dev-run-log-"));
262
281
  const env = {
263
282
  PATH: environment.PATH ?? "",
@@ -277,21 +296,7 @@ export const probeRunLog = (cwd, bin, environment = process.env) => {
277
296
  maxBuffer: 16 * 1024 * 1024,
278
297
  });
279
298
  try {
280
- const failed = call([PROBE_FLAG]);
281
- if (failed.status === 0)
282
- return { kept: false, detail: `\`${PROBE_FLAG}\` did not fail, so nothing could be kept` };
283
- const listed = call(["runs", "list", "--json"]);
284
- if (listed.status !== 0)
285
- return { kept: false, detail: `\`runs list --json\` exited ${listed.status ?? listed.error?.message}` };
286
- let items;
287
- try {
288
- items = JSON.parse(String(listed.stdout)).items;
289
- }
290
- catch {
291
- return { kept: false, detail: "`runs list --json` did not print JSON" };
292
- }
293
- const kept = Array.isArray(items) && items.some((item) => item?.status === "failed");
294
- return kept ? { kept } : { kept, detail: `\`runs list --json\` shows no failed run after \`${PROBE_FLAG}\`` };
299
+ return body(call);
295
300
  }
296
301
  finally {
297
302
  rmSync(home, { recursive: true, force: true });
@@ -406,8 +411,18 @@ const lintRun = (args, io) => {
406
411
  if (verdict.overBudget)
407
412
  problems.push(`${overrides.length} overrides; the budget is ${budget}`);
408
413
  io.err(`lint: ${violations.length} violations — ${verdict.baselined} in the baseline, ${verdict.overridden} overridden (budget ${budget}), ${verdict.fresh.length} new`);
414
+ for (const warning of wcatWarnings(io.cwd, values.cli))
415
+ io.err(`warning ${warning.rule} ${warning.target}: ${warning.message}`);
409
416
  return report(io, problems, "lint: ok");
410
417
  };
418
+ const wcatWarnings = (cwd, cli) => {
419
+ const pkgPath = join(cwd, "package.json");
420
+ const pkg = existsSync(pkgPath) ? readJson(pkgPath) : {};
421
+ return [
422
+ ...binNameWarnings(pkg),
423
+ ...(cli ? describeWarnings(inEmptyHome(cwd, cli, process.env, (call) => call(["--wcat-describe"]))) : []),
424
+ ];
425
+ };
411
426
  const byCli = (pairs, flag) => new Map((pairs ?? []).map((pair) => {
412
427
  const [cli, path] = pair.split(/=(.*)/s);
413
428
  if (!cli || !path)
@@ -0,0 +1,15 @@
1
+ export interface WcatWarning {
2
+ rule: "wcat-bin-name" | "wcat-describe";
3
+ target: string;
4
+ message: string;
5
+ }
6
+ /** Each command is `wcat-<name>`, and a package outside `@wirecat/` takes no reserved name. */
7
+ export declare const binNameWarnings: (pkg: {
8
+ name?: unknown;
9
+ bin?: unknown;
10
+ }) => WcatWarning[];
11
+ /** The built CLI answers `--wcat-describe` with its JSON description. */
12
+ export declare const describeWarnings: (answer: {
13
+ status: number | null;
14
+ stdout: unknown;
15
+ }) => WcatWarning[];
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `cli-dev lint`'s checks for joining `wcat` (WC-CMD-29). They warn and never fail the run: every CLI still installs
3
+ * its short name until it moves to `wcat-<name>`, and the warning becomes a failure once they have.
4
+ */
5
+ import { parseDescribe } from "../wcat/describe.js";
6
+ import { DESCRIBE_FLAG, PROGRAM_PREFIX, RESERVED_NAMES, TOOL_NAME } from "../wcat/names.js";
7
+ const WIRECAT_SCOPE = "@wirecat/";
8
+ /** The commands a `package.json` installs: its `"bin"` keys, or the package's own name for a `"bin"` string. */
9
+ const binNames = (pkg) => {
10
+ if (typeof pkg.bin === "string")
11
+ return typeof pkg.name === "string" ? [pkg.name.replace(/^@[^/]+\//, "")] : [];
12
+ return typeof pkg.bin === "object" && pkg.bin !== null ? Object.keys(pkg.bin) : [];
13
+ };
14
+ /** Each command is `wcat-<name>`, and a package outside `@wirecat/` takes no reserved name. */
15
+ export const binNameWarnings = (pkg) => binNames(pkg).flatMap((command) => {
16
+ const target = `package.json "bin" ${command}`;
17
+ if (!command.startsWith(PROGRAM_PREFIX))
18
+ return [
19
+ {
20
+ rule: "wcat-bin-name",
21
+ target,
22
+ message: `install the command as ${PROGRAM_PREFIX}${command}, so no other program on PATH has its name`,
23
+ },
24
+ ];
25
+ const name = command.slice(PROGRAM_PREFIX.length);
26
+ if (!TOOL_NAME.test(name))
27
+ return [{ rule: "wcat-bin-name", target, message: "the name after wcat- is lower-case letters, digits and -" }];
28
+ const ours = typeof pkg.name === "string" && pkg.name.startsWith(WIRECAT_SCOPE);
29
+ if (!ours && RESERVED_NAMES.includes(name))
30
+ return [{ rule: "wcat-bin-name", target, message: `${name} is reserved for WireCat's own tools or wcat itself` }];
31
+ return [];
32
+ });
33
+ /** The built CLI answers `--wcat-describe` with its JSON description. */
34
+ export const describeWarnings = (answer) => answer.status === 0 && parseDescribe(String(answer.stdout))
35
+ ? []
36
+ : [
37
+ {
38
+ rule: "wcat-describe",
39
+ target: DESCRIBE_FLAG,
40
+ message: `the CLI does not answer ${DESCRIBE_FLAG}: call wcatStart from @wirecat/cli-base/wcat/start first in its bin`,
41
+ },
42
+ ];
@@ -0,0 +1,7 @@
1
+ export type Installer = "pnpm" | "npm" | "bun" | "npx" | "checkout" | "unknown";
2
+ /**
3
+ * Read from where the running file really lives — `realpath` of the script, not `which`: the
4
+ * command on `PATH` is a shim or a link, and only its target says who installed it. Paths measured
5
+ * on 2026-09-24 by installing into throwaway prefixes.
6
+ */
7
+ export declare const installerOf: (scriptPath: string) => Installer;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Read from where the running file really lives — `realpath` of the script, not `which`: the
3
+ * command on `PATH` is a shim or a link, and only its target says who installed it. Paths measured
4
+ * on 2026-09-24 by installing into throwaway prefixes.
5
+ */
6
+ export const installerOf = (scriptPath) => {
7
+ const path = scriptPath.replaceAll("\\", "/");
8
+ if (path.includes("/_npx/"))
9
+ return "npx";
10
+ if (path.includes("/install/global/node_modules/"))
11
+ return "bun";
12
+ if (/\/\.?pnpm\//.test(path))
13
+ return "pnpm";
14
+ if (path.includes("/node_modules/"))
15
+ return "npm";
16
+ return path.includes("/dist/") ? "checkout" : "unknown";
17
+ };
@@ -321,7 +321,7 @@ export const implicitTextTerms = (text) => {
321
321
  const tokens = tokensOf(text);
322
322
  if (tokens.some(({ kind }) => ["AND", "OR", "NOT", "+", "-", "!", "(", ")"].includes(kind)))
323
323
  return new Set();
324
- return new Set(tokens.flatMap((token, index) => token.kind === "term" &&
324
+ return new Set(tokens.flatMap((token, index) => (token.kind === "term" || token.kind === "number") &&
325
325
  ![":", "=", "<", ">", "<=", ">="].includes(tokens[index - 1]?.kind ?? "") &&
326
326
  ![":", "=", "<", ">", "<=", ">="].includes(tokens[index + 1]?.kind ?? "")
327
327
  ? [token.span.start]
@@ -61,6 +61,12 @@ export const BASE_REASONS = defineReasons([
61
61
  { reason: "auth.failed", code: "authentication_error", message: "the sign-in did not work", details: [] },
62
62
  { reason: "access.denied", code: "permission_error", message: "this account may not do that", details: [] },
63
63
  { reason: "item.not-found", code: "not_found", message: "nothing found by that name", details: [] },
64
+ {
65
+ reason: "tool.not-installed",
66
+ code: "not_found",
67
+ message: "no wcat-<name> program by that name is on PATH",
68
+ details: ["tool", "installed"],
69
+ },
64
70
  {
65
71
  reason: "confirmation.required",
66
72
  code: "confirmation_required",
@@ -1,13 +1,8 @@
1
1
  export { executableEnvironment, executableOnPath } from "../executable.js";
2
2
  import * as v from "valibot";
3
3
  import type { FetchLike } from "../http/index.js";
4
- export type Installer = "pnpm" | "npm" | "bun" | "npx" | "checkout" | "unknown";
5
- /**
6
- * Read from where the running file really lives — `realpath` of the script, not `which`: the
7
- * command on `PATH` is a shim or a link, and only its target says who installed it. Paths measured
8
- * on 2026-09-24 by installing into throwaway prefixes.
9
- */
10
- export declare const installerOf: (scriptPath: string) => Installer;
4
+ export { type Installer, installerOf } from "../installer.js";
5
+ import type { Installer } from "../installer.js";
11
6
  /** The argv that updates a global install, or `undefined` where there is nothing to run. */
12
7
  export declare const updateCommand: (installer: Installer, packageName: string) => string[] | undefined;
13
8
  /** Plain `x.y.z` only: a pre-release is never offered, so it never counts as newer. */
@@ -12,23 +12,7 @@ import { executableEnvironment, executableOnPath } from "../executable.js";
12
12
  export { executableEnvironment, executableOnPath } from "../executable.js";
13
13
  import * as v from "valibot";
14
14
  import { loadConfigFile, saveConfigFile } from "../config.js";
15
- /**
16
- * Read from where the running file really lives — `realpath` of the script, not `which`: the
17
- * command on `PATH` is a shim or a link, and only its target says who installed it. Paths measured
18
- * on 2026-09-24 by installing into throwaway prefixes.
19
- */
20
- export const installerOf = (scriptPath) => {
21
- const path = scriptPath.replaceAll("\\", "/");
22
- if (path.includes("/_npx/"))
23
- return "npx";
24
- if (path.includes("/install/global/node_modules/"))
25
- return "bun";
26
- if (/\/\.?pnpm\//.test(path))
27
- return "pnpm";
28
- if (path.includes("/node_modules/"))
29
- return "npm";
30
- return path.includes("/dist/") ? "checkout" : "unknown";
31
- };
15
+ export { installerOf } from "../installer.js";
32
16
  /** The argv that updates a global install, or `undefined` where there is nothing to run. */
33
17
  export const updateCommand = (installer, packageName) => {
34
18
  const target = `${packageName}@latest`;
@@ -0,0 +1,22 @@
1
+ /** What a tool says about itself to `wcat`, from `wcat-<name> --wcat-describe` or the tool's own metadata. */
2
+ export interface WcatTool {
3
+ /** The name in `wcat <name>`, and the short command: `tg`. */
4
+ command: string;
5
+ version: string;
6
+ /** One line: what the tool reads or does. */
7
+ description: string;
8
+ /** The tool's documentation page. */
9
+ docs?: string;
10
+ }
11
+ /** Format of the `--wcat-describe` answer; a later format adds keys and keeps these. */
12
+ export declare const DESCRIBE_FORMAT = 1;
13
+ export interface DescribeAnswer {
14
+ wcat_describe: number;
15
+ name: string;
16
+ version: string;
17
+ description: string;
18
+ docs_url?: string;
19
+ }
20
+ export declare const describeAnswer: (tool: WcatTool) => DescribeAnswer;
21
+ /** A `--wcat-describe` answer, or `undefined` for anything else: the program is listed either way. */
22
+ export declare const parseDescribe: (text: string) => Omit<WcatTool, "command"> | undefined;
@@ -0,0 +1,31 @@
1
+ import { singleLine } from "../sanitize.js";
2
+ /** Format of the `--wcat-describe` answer; a later format adds keys and keeps these. */
3
+ export const DESCRIBE_FORMAT = 1;
4
+ export const describeAnswer = (tool) => ({
5
+ wcat_describe: DESCRIBE_FORMAT,
6
+ name: tool.command,
7
+ version: tool.version,
8
+ description: tool.description,
9
+ ...(tool.docs ? { docs_url: tool.docs } : {}),
10
+ });
11
+ const MAX_TEXT = 200;
12
+ const clean = (value) => typeof value === "string" && value.trim() !== "" ? singleLine(value.trim()).slice(0, MAX_TEXT) : undefined;
13
+ /** A `--wcat-describe` answer, or `undefined` for anything else: the program is listed either way. */
14
+ export const parseDescribe = (text) => {
15
+ let answer;
16
+ try {
17
+ answer = JSON.parse(text);
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ if (typeof answer !== "object" || answer === null || !("wcat_describe" in answer))
23
+ return undefined;
24
+ const { version, description, docs_url: docs } = answer;
25
+ const url = clean(docs);
26
+ return {
27
+ version: clean(version) ?? "",
28
+ description: clean(description) ?? "",
29
+ ...(url && /^https?:\/\//.test(url) ? { docs: url } : {}),
30
+ };
31
+ };
@@ -0,0 +1,43 @@
1
+ import type { WcatTool } from "./describe.js";
2
+ import { type SearchEnv } from "./path.js";
3
+ interface Output {
4
+ write: (text: string) => unknown;
5
+ isTTY?: boolean;
6
+ }
7
+ export interface WcatContext extends SearchEnv {
8
+ stdout: Output;
9
+ stderr: Output;
10
+ /** The tool running the dispatcher: described from its metadata, without starting it again. */
11
+ self?: WcatTool;
12
+ /** How long one `--wcat-describe` may take; 2 s when left out. */
13
+ describeTimeoutMs?: number;
14
+ /** Where SIGTERM and SIGHUP arrive, to pass on to the tool; `process` when left out. */
15
+ signals?: Pick<NodeJS.Process, "on" | "off">;
16
+ }
17
+ export interface ListedTool {
18
+ name: string;
19
+ installed: boolean;
20
+ /** The program `wcat <name>` starts. */
21
+ path?: string;
22
+ version?: string;
23
+ description?: string;
24
+ docs_url?: string;
25
+ /** Other programs with the same name, later on PATH: `wcat` never runs them. */
26
+ shadowed?: string[];
27
+ package?: string;
28
+ install?: string;
29
+ }
30
+ /** Every `wcat-<name>` on PATH with what it says about itself, then WireCat's own tools that are not installed. */
31
+ export declare const listTools: (context: WcatContext) => Promise<ListedTool[]>;
32
+ /**
33
+ * Starts `wcat-<name>` with the rest of the words, the same streams and environment, and answers its exit code.
34
+ * SIGTERM and SIGHUP are passed on. Ctrl-C reaches the tool from the terminal by itself, so SIGINT is only kept from
35
+ * ending `wcat` before the tool does.
36
+ */
37
+ export declare const runTool: (path: string, args: readonly string[], context: WcatContext) => Promise<number>;
38
+ /**
39
+ * `wcat` itself: the words after `wcat`. Built-in commands come first, so a `wcat-<name>` program can never take
40
+ * one over; anything else names a tool, and the rest of the line is that tool's.
41
+ */
42
+ export declare const runWcat: (args: readonly string[], context: WcatContext) => Promise<number>;
43
+ export {};
@@ -0,0 +1,184 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:os";
3
+ import crossSpawn from "cross-spawn";
4
+ import { CliError } from "../errors.js";
5
+ import { BUILT_IN_COMMANDS, FIRST_PARTY_TOOLS, PROGRAM_PREFIX, TOOL_NAME } from "./names.js";
6
+ import { findOnPath } from "./path.js";
7
+ import { baseVersion, probeDescribe } from "./probe.js";
8
+ import { addShortcuts, installedPrograms, listShortcuts, removeShortcuts } from "./shortcuts.js";
9
+ const DEFAULT_DESCRIBE_TIMEOUT_MS = 2000;
10
+ /** Every `wcat-<name>` on PATH with what it says about itself, then WireCat's own tools that are not installed. */
11
+ export const listTools = async (context) => {
12
+ const installed = installedPrograms(context);
13
+ const timeoutMs = context.describeTimeoutMs ?? DEFAULT_DESCRIBE_TIMEOUT_MS;
14
+ const found = await Promise.all([...installed].map(async ([name, paths]) => {
15
+ const [path, ...shadowed] = paths;
16
+ const own = context.self?.command === name ? context.self : undefined;
17
+ const described = own ?? (await probeDescribe(path, { env: context.env, timeoutMs }));
18
+ return {
19
+ name,
20
+ installed: true,
21
+ path,
22
+ ...(described?.version ? { version: described.version } : {}),
23
+ ...(described?.description ? { description: described.description } : {}),
24
+ ...(described?.docs ? { docs_url: described.docs } : {}),
25
+ ...(shadowed.length > 0 ? { shadowed } : {}),
26
+ };
27
+ }));
28
+ const missing = FIRST_PARTY_TOOLS.filter((tool) => tool.package && !installed.has(tool.name)).map((tool) => ({
29
+ name: tool.name,
30
+ installed: false,
31
+ package: tool.package,
32
+ install: `npm install -g ${tool.package}`,
33
+ }));
34
+ return [...found, ...missing];
35
+ };
36
+ const envelope = (items, started) => ({
37
+ items,
38
+ page: { mode: "page", limit: Math.max(items.length, 1), has_more: false },
39
+ meta: { run_id: randomUUID(), duration_ms: Math.max(0, Math.round(performance.now() - started)), complete: true },
40
+ });
41
+ const column = (rows) => {
42
+ const widths = rows[0]?.map((_, at) => rows.reduce((wide, row) => Math.max(wide, (row[at] ?? "").length), 0)) ?? [];
43
+ return rows.map((row) => ` ${row.map((cell, at) => cell.padEnd(widths[at] ?? 0)).join(" ")}`.trimEnd());
44
+ };
45
+ const USAGE = [
46
+ "wcat <tool> [arguments] run a tool: wcat tg --help",
47
+ "wcat shortcuts [add|remove] [tool...] short names, such as tg, for installed tools",
48
+ "wcat --json the list below as JSON",
49
+ ];
50
+ const listText = (tools) => {
51
+ const installed = tools.filter((tool) => tool.installed);
52
+ const missing = tools.filter((tool) => !tool.installed);
53
+ return [
54
+ "wcat runs WireCat tools, and any other wcat-<name> program on PATH.",
55
+ "",
56
+ ...USAGE,
57
+ "",
58
+ installed.length > 0 ? "Installed:" : "Installed: none",
59
+ ...column(installed.map((tool) => [tool.name, tool.version ?? "", tool.description ?? "(no description)"])),
60
+ ...(missing.length > 0
61
+ ? ["", "Not installed:", ...column(missing.map((tool) => [tool.name, tool.install ?? ""]))]
62
+ : []),
63
+ "",
64
+ ].join("\n");
65
+ };
66
+ const shadowWarnings = (tools) => tools
67
+ .filter((tool) => tool.shadowed)
68
+ .map((tool) => `wcat: ${(tool.shadowed?.length ?? 0) + 1} programs are named ${PROGRAM_PREFIX}${tool.name}; wcat runs the first:\n${[tool.path, ...(tool.shadowed ?? [])].map((path) => ` ${path}`).join("\n")}\n`)
69
+ .join("");
70
+ const shortcutText = (shortcuts) => {
71
+ if (shortcuts.length === 0)
72
+ return "No wcat-<name> programs are installed.\n";
73
+ const said = (shortcut) => {
74
+ switch (shortcut.status) {
75
+ case "made":
76
+ return `made: ${shortcut.path}`;
77
+ case "free":
78
+ return shortcut.changed ? "removed" : "free: wcat shortcuts add makes it";
79
+ case "package":
80
+ return `installed by the package: ${shortcut.path}`;
81
+ case "taken":
82
+ return `taken by ${shortcut.path}`;
83
+ }
84
+ };
85
+ return `${column(shortcuts.map((shortcut) => [shortcut.name, said(shortcut)])).join("\n")}\n`;
86
+ };
87
+ const signalNumber = (signal) => constants.signals[signal] ?? 1;
88
+ /**
89
+ * Starts `wcat-<name>` with the rest of the words, the same streams and environment, and answers its exit code.
90
+ * SIGTERM and SIGHUP are passed on. Ctrl-C reaches the tool from the terminal by itself, so SIGINT is only kept from
91
+ * ending `wcat` before the tool does.
92
+ */
93
+ export const runTool = (path, args, context) => new Promise((resolve, reject) => {
94
+ const signals = context.signals ?? process;
95
+ const child = crossSpawn(path, [...args], { stdio: "inherit", env: context.env });
96
+ const pass = (signal) => () => child.kill(signal);
97
+ const term = pass("SIGTERM");
98
+ const hup = pass("SIGHUP");
99
+ const ignore = () => { };
100
+ signals.on("SIGTERM", term).on("SIGHUP", hup).on("SIGINT", ignore);
101
+ const done = () => signals.off("SIGTERM", term).off("SIGHUP", hup).off("SIGINT", ignore);
102
+ child.once("error", (error) => {
103
+ done();
104
+ reject(new CliError("configuration_error", `could not start ${path}: ${error.message}`));
105
+ });
106
+ child.once("exit", (code, signal) => {
107
+ done();
108
+ resolve(code ?? (signal ? 128 + signalNumber(signal) : 1));
109
+ });
110
+ });
111
+ const fail = async (error, json, context) => {
112
+ const { errorObjectOf, exitCodeOfError, renderError } = await import("../failure.js");
113
+ const object = errorObjectOf(error);
114
+ context.stderr.write(`${renderError(object, { cli: "wcat", interactive: !json })}\n`);
115
+ return exitCodeOfError(object);
116
+ };
117
+ const shortcuts = async (args, json, context) => {
118
+ const [action, ...names] = args;
119
+ const started = performance.now();
120
+ const result = action === "add"
121
+ ? addShortcuts(context, names)
122
+ : action === "remove"
123
+ ? removeShortcuts(context, names)
124
+ : action === undefined || action === "list"
125
+ ? listShortcuts(context, names)
126
+ : undefined;
127
+ if (result === undefined)
128
+ throw new CliError("validation_error", `wcat shortcuts takes list, add or remove, not "${action}"`, {
129
+ reason: "input.invalid",
130
+ });
131
+ context.stdout.write(json ? `${JSON.stringify(envelope(result, started))}\n` : shortcutText(result));
132
+ return 0;
133
+ };
134
+ /**
135
+ * `wcat` itself: the words after `wcat`. Built-in commands come first, so a `wcat-<name>` program can never take
136
+ * one over; anything else names a tool, and the rest of the line is that tool's.
137
+ */
138
+ export const runWcat = async (args, context) => {
139
+ const [first, ...rest] = args;
140
+ // A tool's own --json is the tool's; wcat reads it only for its own answers and for a tool it cannot find.
141
+ const json = args.includes("--json");
142
+ const words = args.filter((word) => word !== "--json");
143
+ const started = performance.now();
144
+ try {
145
+ if (first === "--version" || first === "-V" || first === "version") {
146
+ const tools = await listTools(context);
147
+ const by = context.self ? `, run by ${PROGRAM_PREFIX}${context.self.command} ${context.self.version}` : "";
148
+ context.stdout.write(`wcat ${baseVersion()} (@wirecat/cli-base${by})\n\n${listText(tools)}`);
149
+ return 0;
150
+ }
151
+ if (words.length === 0 || first === "--help" || first === "-h" || first === "help") {
152
+ const tools = await listTools(context);
153
+ if (json)
154
+ context.stdout.write(`${JSON.stringify(envelope(tools, started))}\n`);
155
+ else {
156
+ context.stderr.write(shadowWarnings(tools));
157
+ context.stdout.write(listText(tools));
158
+ }
159
+ return 0;
160
+ }
161
+ if (first === "shortcuts")
162
+ return await shortcuts(words.slice(1), json, context);
163
+ if (BUILT_IN_COMMANDS.includes(first))
164
+ throw new CliError("validation_error", `wcat ${first} is reserved for a later version of wcat`, {
165
+ reason: "input.invalid",
166
+ });
167
+ if (first?.startsWith("-"))
168
+ throw new CliError("validation_error", `wcat does not take ${first} before a tool's name`, {
169
+ reason: "input.invalid",
170
+ });
171
+ const name = first;
172
+ const [path, ...shadowed] = TOOL_NAME.test(name) ? findOnPath(`${PROGRAM_PREFIX}${name}`, context) : [];
173
+ if (path === undefined) {
174
+ const installed = [...installedPrograms(context).keys()];
175
+ throw new CliError("not_found", `no ${PROGRAM_PREFIX}${name} on PATH; installed: ${installed.length > 0 ? installed.join(", ") : "none"}`, { reason: "tool.not-installed", tool: name, installed });
176
+ }
177
+ if (shadowed.length > 0 && context.stderr.isTTY)
178
+ context.stderr.write(shadowWarnings([{ name, installed: true, path, shadowed }]));
179
+ return await runTool(path, rest, context);
180
+ }
181
+ catch (error) {
182
+ return fail(error, json, context);
183
+ }
184
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `wcat`, the umbrella command: `wcat tg …` runs `wcat-tg …`, as `git foo` runs `git-foo`. Its code is here, so every
3
+ * CLI on cli-base carries a copy; the `wcat` file on PATH is a small launcher that starts an installed tool in
4
+ * dispatch mode, and each tool repairs it on start.
5
+ *
6
+ * A CLI's bin imports `wcatStart` from `@wirecat/cli-base/wcat/start`, which loads only Node built-ins; this entry
7
+ * point is the whole library, for WireCat Desktop and for tests.
8
+ */
9
+ export { type DescribeAnswer, describeAnswer, parseDescribe, type WcatTool } from "./describe.js";
10
+ export { type ListedTool, listTools, runWcat, type WcatContext } from "./dispatch.js";
11
+ export { type BinFolder, binFolder, ensureLauncher, type LauncherState } from "./launcher.js";
12
+ export { BUILT_IN_COMMANDS, DESCRIBE_FLAG, DISPATCH_FLAG, FIRST_PARTY_TOOLS, PROGRAM_PREFIX, RESERVED_NAMES, TOOL_NAME, } from "./names.js";
13
+ export { findOnPath, findTools, type SearchEnv } from "./path.js";
14
+ export { parseShim, SHIM_FORMAT, type ShimKind, shimFiles } from "./shim.js";
15
+ export { addShortcuts, installedPrograms, listShortcuts, removeShortcuts, type Shortcut, type ShortcutChange, type ShortcutStatus, } from "./shortcuts.js";
16
+ export { type WcatStart, type WcatStartIo, wcatStart } from "./start.js";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `wcat`, the umbrella command: `wcat tg …` runs `wcat-tg …`, as `git foo` runs `git-foo`. Its code is here, so every
3
+ * CLI on cli-base carries a copy; the `wcat` file on PATH is a small launcher that starts an installed tool in
4
+ * dispatch mode, and each tool repairs it on start.
5
+ *
6
+ * A CLI's bin imports `wcatStart` from `@wirecat/cli-base/wcat/start`, which loads only Node built-ins; this entry
7
+ * point is the whole library, for WireCat Desktop and for tests.
8
+ */
9
+ export { describeAnswer, parseDescribe } from "./describe.js";
10
+ export { listTools, runWcat } from "./dispatch.js";
11
+ export { binFolder, ensureLauncher } from "./launcher.js";
12
+ export { BUILT_IN_COMMANDS, DESCRIBE_FLAG, DISPATCH_FLAG, FIRST_PARTY_TOOLS, PROGRAM_PREFIX, RESERVED_NAMES, TOOL_NAME, } from "./names.js";
13
+ export { findOnPath, findTools } from "./path.js";
14
+ export { parseShim, SHIM_FORMAT, shimFiles } from "./shim.js";
15
+ export { addShortcuts, installedPrograms, listShortcuts, removeShortcuts, } from "./shortcuts.js";
16
+ export { wcatStart } from "./start.js";