@wirecat/cli-base 0.65.4 → 0.66.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,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.66.0 — 31.10.2026
12
+
13
+ ### Added
14
+
15
+ - **`wcat`, one command that runs every tool: `wcat tg …` runs `wcat-tg …`** (`@wirecat/cli-base/wcat`). Another
16
+ program named `tg` or `drive` can sit earlier on PATH, and an agent or a script then runs it without any sign; a
17
+ `wcat-<name>` name is the tool's alone. Any `wcat-<name>` program on PATH joins, ours or not. `wcat --help` lists
18
+ what is installed with each tool's description, the WireCat tools that are not, with their install commands, and
19
+ warns when two programs share a name; `--json` prints the list. Built-in names (`help`, `version`, `shortcuts`,
20
+ `doctor`, `update`, `upgrade`, `app`) always belong to `wcat`. An unknown tool is `not_found` with the new reason
21
+ `tool.not-installed`. Nothing changes for a CLI until it calls `wcatStart`.
22
+ - **`wcatStart(tool)` from `@wirecat/cli-base/wcat/start`, the first call in a CLI's bin.** It answers
23
+ `--wcat-describe` with the tool's name, version, description and docs page, runs `wcat` when the `wcat` launcher
24
+ starts the tool, and on any other start makes sure the `wcat` launcher in the tool's global bin folder works. The
25
+ launcher is a small marked script that starts an installed `wcat-<tool>` by name (a `.cmd` and `.ps1` pair on
26
+ Windows), never a link into a package folder, which pnpm renames on every upgrade. A file named `wcat` that
27
+ cli-base did not write is never touched. When the launcher works, the check costs a few `stat` calls; it loads
28
+ only Node built-ins. Call it before `startCli`, and set `process.exitCode` to the `code` it returns.
29
+ - **`wcat shortcuts`, and `listShortcuts`, `addShortcuts` and `removeShortcuts` for a program.** A tool's short name,
30
+ such as `tg`, is made only when no program on PATH has it, and only shortcuts cli-base made are removed. A short
31
+ name the tool's package installs itself shows as `package` and is left to the package manager.
32
+ - **`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)).
33
+ The lint warns when a `"bin"` key does not start with `wcat-`, when a package outside `@wirecat/` takes a reserved
34
+ name, and, with `--cli`, when the built CLI does not answer `--wcat-describe`. The warnings do not fail the run:
35
+ every CLI still installs its short name today.
36
+
37
+ ## 0.65.5 — 31.10.2026
38
+
39
+ ### Added
40
+
41
+ - **`movedRunService(app, configDir)` names a moved run's temporary keyring entry**, so a test or an agent can delete
42
+ it by name.
43
+
44
+ ### Security
45
+
46
+ - **A moved run no longer replaces the owner's keyring entry.** `session start` in a worktree or with
47
+ `HOME=/tmp/x` overwrote the owner's real sign-in. With the system keyring, `Credentials` in a moved run (`isolated`,
48
+ or a moved `HOME` or XDG folder) now writes a temporary entry, `<app>:<absolute config folder>`; reads it first and
49
+ the owner's `<app>` entry when it has none; and `remove` deletes only the temporary entry, so `session end` cleans up.
50
+ A token refreshed in a moved run is saved in the temporary entry too, so a run that never ends its session leaves
51
+ one behind ([WC-SECR-24](docs/standards/security.md#wc-secr-24--a-run-with-moved-folders-shares-the-owners-keyring-entry-and-never-deletes-it)).
52
+ A run with the default folders, and a test with `memoryKeyring()`, are unchanged.
53
+
11
54
  ## 0.65.4 — 31.10.2026
12
55
 
13
56
  ### Fixed
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
 
@@ -14,10 +14,9 @@ export interface CredentialsOptions {
14
14
  /**
15
15
  * **True when the run's folders are not the owner's own** — pass `isMovedRun(…)`.
16
16
  *
17
- * The run still uses the owner's keyring entry (`service`, the same account names): one sign-in per computer, not
18
- * one per temp folder. With the system keyring, `remove` then keeps that entry instead of deleting it, so a test or
19
- * worktree run cannot end the owner's real sign-in (WC-SECR-24). A moved `HOME` or XDG folder in `env` counts as
20
- * moved even when this is false.
17
+ * With the system keyring the run reads the owner's entry (`service`) but never replaces or deletes it: `write`
18
+ * goes to a temporary entry, `movedRunService(service, configDir)`, which `read` prefers and `remove` deletes
19
+ * (WC-SECR-24). A moved `HOME` or XDG folder in `env` counts as moved even when this is false.
21
20
  */
22
21
  isolated?: boolean;
23
22
  /** Read before the keyring, for CI. */
@@ -30,10 +29,15 @@ export interface CredentialsOptions {
30
29
  warn?: (message: string) => void;
31
30
  }
32
31
  /**
33
- * The keyring service a `Credentials` uses: the app's own, for a moved run too. The other two arguments are ignored
34
- * and stay only so that callers written against 0.65.0 still compile.
32
+ * The app's shared keyring service, which a moved run reads too. The other two arguments are ignored and stay only so
33
+ * that callers written against 0.65.0 still compile.
35
34
  */
36
35
  export declare const keyringService: (service: string, _configDir?: string, _isolated?: boolean) => string;
36
+ /**
37
+ * The temporary keyring service a moved run writes instead of the owner's entry: `<app>:<absolute config folder>`, the
38
+ * shape `bin/keyring-dedupe` already finds and removes.
39
+ */
40
+ export declare const movedRunService: (service: string, configDir: string) => string;
37
41
  /**
38
42
  * Environment first, then the OS keyring, then a file.
39
43
  *
@@ -1,15 +1,20 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { writeSecurely } from "./config.js";
4
4
  import { CliError } from "./errors.js";
5
5
  import { systemKeyring } from "./keyring.js";
6
6
  import { homeIsMoved } from "./paths.js";
7
7
  export const CREDENTIAL_STORAGE = ["auto", "keyring", "file"];
8
8
  /**
9
- * The keyring service a `Credentials` uses: the app's own, for a moved run too. The other two arguments are ignored
10
- * and stay only so that callers written against 0.65.0 still compile.
9
+ * The app's shared keyring service, which a moved run reads too. The other two arguments are ignored and stay only so
10
+ * that callers written against 0.65.0 still compile.
11
11
  */
12
12
  export const keyringService = (service, _configDir, _isolated) => service;
13
+ /**
14
+ * The temporary keyring service a moved run writes instead of the owner's entry: `<app>:<absolute config folder>`, the
15
+ * shape `bin/keyring-dedupe` already finds and removes.
16
+ */
17
+ export const movedRunService = (service, configDir) => `${service}:${resolve(configDir)}`;
13
18
  /**
14
19
  * Environment first, then the OS keyring, then a file.
15
20
  *
@@ -26,7 +31,8 @@ export class Credentials {
26
31
  #envVar;
27
32
  #fileName;
28
33
  #warn;
29
- #keepsSharedEntry;
34
+ /** Set only for a moved run with the system keyring. */
35
+ #temporaryService;
30
36
  #warned = false;
31
37
  constructor(options) {
32
38
  this.#configDir = options.configDir;
@@ -37,15 +43,16 @@ export class Credentials {
37
43
  this.#fileName = options.fileName ?? "credentials.json";
38
44
  this.#warn = options.warn ?? ((message) => process.stderr.write(`${message}\n`));
39
45
  this.#service = options.service;
40
- this.#keepsSharedEntry =
41
- this.#keyring === systemKeyring && (options.isolated === true || homeIsMoved({ env: this.#env }));
46
+ const moved = this.#keyring === systemKeyring && (options.isolated === true || homeIsMoved({ env: this.#env }));
47
+ this.#temporaryService = moved ? movedRunService(options.service, options.configDir) : undefined;
42
48
  }
43
49
  read(account) {
44
50
  const fromEnv = this.#envVar ? this.#env[this.#envVar]?.trim() : undefined;
45
51
  if (fromEnv)
46
52
  return { secret: fromEnv, source: "environment" };
47
53
  if (this.#storage !== "file") {
48
- const fromKeyring = this.#tryKeyring(() => this.#keyring.get(this.#service, account));
54
+ const temporary = this.#temporaryService;
55
+ const fromKeyring = this.#tryKeyring(() => (temporary && this.#keyring.get(temporary, account)) || this.#keyring.get(this.#service, account));
49
56
  if (fromKeyring)
50
57
  return { secret: fromKeyring, source: "keyring" };
51
58
  }
@@ -55,7 +62,7 @@ export class Credentials {
55
62
  write(account, secret) {
56
63
  if (this.#storage !== "file") {
57
64
  const stored = this.#tryKeyring(() => {
58
- this.#keyring.set(this.#service, account, secret);
65
+ this.#keyring.set(this.#temporaryService ?? this.#service, account, secret);
59
66
  return true;
60
67
  });
61
68
  if (stored)
@@ -72,12 +79,14 @@ export class Credentials {
72
79
  }
73
80
  remove(account) {
74
81
  const removed = [];
75
- if (this.#storage !== "file" && this.#keepsSharedEntry) {
76
- this.#warn(`kept ${account} in the OS keyring: it is the owner's sign-in, shared with every run, and a run with moved ` +
77
- "folders never removes it");
78
- }
79
- else if (this.#storage !== "file" && this.#tryKeyring(() => this.#keyring.delete(this.#service, account))) {
80
- removed.push("keyring");
82
+ if (this.#storage !== "file") {
83
+ if (this.#tryKeyring(() => this.#keyring.delete(this.#temporaryService ?? this.#service, account))) {
84
+ removed.push("keyring");
85
+ }
86
+ if (this.#temporaryService) {
87
+ this.#warn(`kept ${account} in the OS keyring: it is the owner's sign-in, shared with every run, and a run with moved ` +
88
+ "folders never removes it");
89
+ }
81
90
  }
82
91
  const store = this.#readFile();
83
92
  if (store?.[account] !== undefined) {
@@ -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
+ ];
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { BATCH_LIMITS, type BatchContext, type BatchHandler, type BatchOperation, type BatchReport, type BatchResult, type PreparedBatchRequest, prepareBatch, projectBatchAnswers, readBatchInput, runBatch, } from "./batch.js";
2
2
  export { loadConfigFile, saveConfigFile, writeSecurely } from "./config.js";
3
3
  export type { CredentialSource, CredentialStorage, CredentialsOptions, StoredCredential } from "./credentials.js";
4
- export { CREDENTIAL_STORAGE, Credentials, keyringService } from "./credentials.js";
4
+ export { CREDENTIAL_STORAGE, Credentials, keyringService, movedRunService } from "./credentials.js";
5
5
  export { createDeadline, type Deadline, type DeadlineOptions } from "./deadline.js";
6
6
  export { CliError, type CliErrorDetails, type ErrorCode, errorCodes, isCliError } from "./errors.js";
7
7
  export { EXIT_CODES, exitCodeFor, GENERIC_FAILURE } from "./exit-codes.js";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { BATCH_LIMITS, prepareBatch, projectBatchAnswers, readBatchInput, runBatch, } from "./batch.js";
2
2
  export { loadConfigFile, saveConfigFile, writeSecurely } from "./config.js";
3
- export { CREDENTIAL_STORAGE, Credentials, keyringService } from "./credentials.js";
3
+ export { CREDENTIAL_STORAGE, Credentials, keyringService, movedRunService } from "./credentials.js";
4
4
  export { createDeadline } from "./deadline.js";
5
5
  export { CliError, errorCodes, isCliError } from "./errors.js";
6
6
  export { EXIT_CODES, exitCodeFor, GENERIC_FAILURE } from "./exit-codes.js";
@@ -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
+ };
@@ -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 {};