@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.
@@ -0,0 +1,104 @@
1
+ import { lstatSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { DISPATCH_FLAG } from "./names.js";
4
+ /**
5
+ * The small files cli-base writes into a bin folder: the `wcat` launcher and the short-name shortcuts. Each starts a
6
+ * program in the same folder by its stable name (`wcat-tg`), never a file inside a package: pnpm keeps packages in
7
+ * folders named after their version, so a link into one breaks on the first upgrade.
8
+ *
9
+ * Every file carries a mark line, so cli-base can tell its own files from anyone else's and never touches the rest.
10
+ */
11
+ export const SHIM_FORMAT = 1;
12
+ const MARK = /^(?:#|REM) wirecat-shim (\d+) (launcher|shortcut) (\S+)\r?$/m;
13
+ const NOTE = {
14
+ launcher: "Written by @wirecat/cli-base. Any WireCat tool rewrites it when it breaks; it is safe to delete.",
15
+ shortcut: "Made by `wcat shortcuts add`; `wcat shortcuts remove` takes it out.",
16
+ };
17
+ const shellQuote = (text) => `'${text.replaceAll("'", `'\\''`)}'`;
18
+ /**
19
+ * The files of one shim: `<name>` on Linux and macOS; `<name>.cmd` and `<name>.ps1` on Windows, the pair npm writes
20
+ * for its own commands. The POSIX file names the folder in full, so a link to it from elsewhere still works; the
21
+ * Windows pair finds the folder from where it is, as npm's own wrappers do.
22
+ */
23
+ export const shimFiles = (kind, { dir, name, target, platform }) => {
24
+ const mark = `wirecat-shim ${SHIM_FORMAT} ${kind} ${target}`;
25
+ const extra = kind === "launcher" ? ` ${DISPATCH_FLAG}` : "";
26
+ if (platform !== "win32")
27
+ return [
28
+ {
29
+ path: join(dir, name),
30
+ text: `#!/bin/sh\n# ${mark}\n# ${NOTE[kind]}\nexec ${shellQuote(join(dir, target))}${extra} "$@"\n`,
31
+ },
32
+ ];
33
+ return [
34
+ {
35
+ path: join(dir, `${name}.cmd`),
36
+ text: ["@ECHO off", `REM ${mark}`, `REM ${NOTE[kind]}`, `"%~dp0${target}"${extra} %*`, ""].join("\r\n"),
37
+ },
38
+ {
39
+ path: join(dir, `${name}.ps1`),
40
+ text: [
41
+ `# ${mark}`,
42
+ `# ${NOTE[kind]}`,
43
+ // PowerShell hands a .cmd its arguments unquoted, so `a&b` would run `b`; npm and pnpm write a .ps1 beside
44
+ // their .cmd, and it is started instead whenever it is there.
45
+ `$stem = Join-Path $PSScriptRoot "${target.replace(/\.(cmd|exe|bat)$/i, "")}"`,
46
+ `$target = if (Test-Path -LiteralPath "$stem.ps1") { "$stem.ps1" } else { Join-Path $PSScriptRoot "${target}" }`,
47
+ "if ($MyInvocation.ExpectingInput) {",
48
+ ` $input | & $target${extra} @args`,
49
+ "} else {",
50
+ ` & $target${extra} @args`,
51
+ "}",
52
+ "exit $LASTEXITCODE",
53
+ "",
54
+ ].join("\r\n"),
55
+ },
56
+ ];
57
+ };
58
+ export const parseShim = (text) => {
59
+ const found = MARK.exec(text.split("\n", 3).join("\n"));
60
+ if (!found)
61
+ return undefined;
62
+ return { format: Number(found[1]), kind: found[2], target: found[3] };
63
+ };
64
+ /** A shim file is a few hundred bytes; anything larger is not one of ours and is not read. */
65
+ const MAX_SHIM_BYTES = 4096;
66
+ /** What sits at `path`: nothing, one of our shims, or a file or link that belongs to someone else. */
67
+ export const fileState = (path) => {
68
+ let stat;
69
+ try {
70
+ stat = lstatSync(path);
71
+ }
72
+ catch (error) {
73
+ return error.code === "ENOENT" ? { state: "missing" } : { state: "foreign" };
74
+ }
75
+ if (!stat.isFile() || stat.size > MAX_SHIM_BYTES)
76
+ return { state: "foreign" };
77
+ try {
78
+ const text = readFileSync(path, "utf8");
79
+ const mark = parseShim(text);
80
+ return mark ? { state: "ours", mark, text } : { state: "foreign" };
81
+ }
82
+ catch {
83
+ return { state: "foreign" };
84
+ }
85
+ };
86
+ export const exists = (path) => {
87
+ try {
88
+ return statSync(path).isFile();
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ };
94
+ /** Written beside the target and renamed over it, so two tools starting at once never leave half a file. */
95
+ export const writeShim = ({ path, text }) => {
96
+ const temporary = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
97
+ try {
98
+ writeFileSync(temporary, text, { mode: 0o755 });
99
+ renameSync(temporary, path);
100
+ }
101
+ finally {
102
+ rmSync(temporary, { force: true });
103
+ }
104
+ };
@@ -0,0 +1,31 @@
1
+ import { type SearchEnv } from "./path.js";
2
+ /**
3
+ * - `made`: the short name is a shortcut cli-base made, and `remove` may take it out.
4
+ * - `free`: no program on PATH has the name, so `add` may make it.
5
+ * - `package`: the tool's package installs the short name itself; the package manager owns it.
6
+ * - `taken`: another program has the name; it is never touched.
7
+ */
8
+ export type ShortcutStatus = "made" | "free" | "package" | "taken";
9
+ export interface Shortcut {
10
+ /** The short name, which is also the tool's name: `tg`. */
11
+ name: string;
12
+ status: ShortcutStatus;
13
+ /** The file that has the name, for every status but `free`. */
14
+ path?: string;
15
+ /** The `wcat-<name>` program the shortcut starts. */
16
+ program: string;
17
+ }
18
+ export interface ShortcutChange extends Shortcut {
19
+ changed: boolean;
20
+ }
21
+ /** Every installed `wcat-<name>` program by name, the first on PATH for each, built-in names left out. */
22
+ export declare const installedPrograms: (search: SearchEnv) => Map<string, string[]>;
23
+ /** The short name of each installed tool, or of the tools named: made, free, installed by the package or taken. */
24
+ export declare const listShortcuts: (search: SearchEnv, names?: readonly string[]) => Shortcut[];
25
+ /**
26
+ * Makes a shortcut for each free name, in the folder of the program it starts. Only a name no program on PATH has is
27
+ * made, so a shortcut never hides another program and never sits in front of one.
28
+ */
29
+ export declare const addShortcuts: (search: SearchEnv, names?: readonly string[]) => ShortcutChange[];
30
+ /** Removes the shortcuts cli-base made; a file it did not make stays, whatever its name. */
31
+ export declare const removeShortcuts: (search: SearchEnv, names?: readonly string[]) => ShortcutChange[];
@@ -0,0 +1,105 @@
1
+ import { closeSync, openSync, readSync, realpathSync, rmSync } from "node:fs";
2
+ import { basename, dirname } from "node:path";
3
+ import { CliError } from "../errors.js";
4
+ import { findOnPath, findTools, isBuiltIn } from "./path.js";
5
+ import { fileState, shimFiles, writeShim } from "./shim.js";
6
+ const WIRECAT_PACKAGE = /node_modules[\\/]+@wirecat[\\/]/;
7
+ /** A link into an `@wirecat/*` package, or a package manager's wrapper that starts one (npm on Windows, pnpm). */
8
+ const isWirecatPackage = (path) => {
9
+ try {
10
+ if (WIRECAT_PACKAGE.test(realpathSync(path)))
11
+ return true;
12
+ const buffer = Buffer.alloc(16 * 1024);
13
+ const fd = openSync(path, "r");
14
+ try {
15
+ const read = readSync(fd, buffer, 0, buffer.length, 0);
16
+ return WIRECAT_PACKAGE.test(buffer.subarray(0, read).toString("latin1"));
17
+ }
18
+ finally {
19
+ closeSync(fd);
20
+ }
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ };
26
+ const ourShortcut = (path) => {
27
+ const state = fileState(path);
28
+ return state.state === "ours" && state.mark.kind === "shortcut";
29
+ };
30
+ const statusOf = (name, program, search) => {
31
+ const files = shimFiles("shortcut", {
32
+ dir: dirname(program),
33
+ name,
34
+ target: basename(program),
35
+ platform: search.platform,
36
+ });
37
+ const named = findOnPath(name, search);
38
+ const made = [...files.map((file) => file.path), ...named].find(ourShortcut);
39
+ if (made)
40
+ return { name, status: "made", path: made, program };
41
+ const first = named[0];
42
+ if (first === undefined)
43
+ return { name, status: "free", program };
44
+ return { name, status: isWirecatPackage(first) ? "package" : "taken", path: first, program };
45
+ };
46
+ /** A bin folder only an administrator may change is the usual cause, and the person can act on it. */
47
+ const changeFile = (path, change) => {
48
+ try {
49
+ change();
50
+ }
51
+ catch (error) {
52
+ throw new CliError("permission_error", `could not change ${path}: ${error.code ?? error}`, {
53
+ reason: "access.denied",
54
+ });
55
+ }
56
+ };
57
+ /** Every installed `wcat-<name>` program by name, the first on PATH for each, built-in names left out. */
58
+ export const installedPrograms = (search) => new Map([...findTools(search)].filter(([name]) => !isBuiltIn(name)));
59
+ const chosen = (names, search) => {
60
+ const installed = installedPrograms(search);
61
+ if (names === undefined || names.length === 0)
62
+ return [...installed].map(([name, paths]) => [name, paths[0]]);
63
+ const missing = names.filter((name) => !installed.has(name));
64
+ if (missing.length > 0)
65
+ throw new CliError("not_found", `not installed: ${missing.map((name) => `wcat-${name}`).join(", ")}`, {
66
+ reason: "tool.not-installed",
67
+ tool: missing[0],
68
+ installed: [...installed.keys()],
69
+ });
70
+ return [...new Set(names)].map((name) => [name, installed.get(name)[0]]);
71
+ };
72
+ /** The short name of each installed tool, or of the tools named: made, free, installed by the package or taken. */
73
+ export const listShortcuts = (search, names) => chosen(names, search).map(([name, program]) => statusOf(name, program, search));
74
+ /**
75
+ * Makes a shortcut for each free name, in the folder of the program it starts. Only a name no program on PATH has is
76
+ * made, so a shortcut never hides another program and never sits in front of one.
77
+ */
78
+ export const addShortcuts = (search, names) => listShortcuts(search, names).map((shortcut) => {
79
+ if (shortcut.status !== "free")
80
+ return { ...shortcut, changed: false };
81
+ const files = shimFiles("shortcut", {
82
+ dir: dirname(shortcut.program),
83
+ name: shortcut.name,
84
+ target: basename(shortcut.program),
85
+ platform: search.platform,
86
+ });
87
+ for (const file of files)
88
+ changeFile(file.path, () => writeShim(file));
89
+ return { ...shortcut, status: "made", path: files[0]?.path, changed: true };
90
+ });
91
+ /** Removes the shortcuts cli-base made; a file it did not make stays, whatever its name. */
92
+ export const removeShortcuts = (search, names) => listShortcuts(search, names).map((shortcut) => {
93
+ if (shortcut.status !== "made")
94
+ return { ...shortcut, changed: false };
95
+ const files = shimFiles("shortcut", {
96
+ dir: dirname(shortcut.path),
97
+ name: shortcut.name,
98
+ target: basename(shortcut.program),
99
+ platform: search.platform,
100
+ });
101
+ for (const file of files)
102
+ if (ourShortcut(file.path))
103
+ changeFile(file.path, () => rmSync(file.path, { force: true }));
104
+ return { ...statusOf(shortcut.name, shortcut.program, search), changed: true };
105
+ });
@@ -0,0 +1,38 @@
1
+ import { type WcatTool } from "./describe.js";
2
+ import { type LauncherState } from "./launcher.js";
3
+ export interface WcatStartIo {
4
+ /** `process.argv`: the runtime, the script, then the words. */
5
+ argv: readonly string[];
6
+ env: NodeJS.ProcessEnv;
7
+ platform: NodeJS.Platform;
8
+ stdout: {
9
+ write: (text: string) => unknown;
10
+ isTTY?: boolean;
11
+ };
12
+ stderr: {
13
+ write: (text: string) => unknown;
14
+ isTTY?: boolean;
15
+ };
16
+ /** A file of the installed package, links resolved; cli-base's own file, which sits in the same install. */
17
+ script: string;
18
+ }
19
+ export interface WcatStart {
20
+ /** The exit code when `wcat` handled this start (`--wcat-describe`, or dispatch mode); `undefined` otherwise. */
21
+ code?: number;
22
+ /** What happened to the `wcat` launcher on a normal start; `undefined` when the tool is not a global install. */
23
+ launcher?: LauncherState;
24
+ }
25
+ /**
26
+ * The first call in a CLI's bin, before its program loads:
27
+ *
28
+ * ```ts
29
+ * const { code } = await wcatStart({ command: "tg", version, description })
30
+ * if (code !== undefined) process.exitCode = code
31
+ * else await startCli(…)
32
+ * ```
33
+ *
34
+ * - `wcat-tg --wcat-describe` prints the tool's description as JSON for `wcat --help`.
35
+ * - `wcat-tg --wcat-dispatch …`, which only the launcher passes, runs `wcat …`.
36
+ * - Any other start makes sure the `wcat` launcher in the tool's bin folder works: a few `stat` calls when it does.
37
+ */
38
+ export declare const wcatStart: (tool: WcatTool, io?: Partial<WcatStartIo>) => Promise<WcatStart>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The part of `wcat` a CLI loads on every start, kept to Node built-ins: answering `--wcat-describe`, handing over to
3
+ * the dispatcher when the launcher asks, and otherwise checking the `wcat` launcher with a few `stat` calls.
4
+ */
5
+ import { fileURLToPath } from "node:url";
6
+ import { describeAnswer } from "./describe.js";
7
+ import { binFolder, ensureLauncher } from "./launcher.js";
8
+ import { DESCRIBE_FLAG, DISPATCH_FLAG } from "./names.js";
9
+ /**
10
+ * The first call in a CLI's bin, before its program loads:
11
+ *
12
+ * ```ts
13
+ * const { code } = await wcatStart({ command: "tg", version, description })
14
+ * if (code !== undefined) process.exitCode = code
15
+ * else await startCli(…)
16
+ * ```
17
+ *
18
+ * - `wcat-tg --wcat-describe` prints the tool's description as JSON for `wcat --help`.
19
+ * - `wcat-tg --wcat-dispatch …`, which only the launcher passes, runs `wcat …`.
20
+ * - Any other start makes sure the `wcat` launcher in the tool's bin folder works: a few `stat` calls when it does.
21
+ */
22
+ export const wcatStart = async (tool, io = {}) => {
23
+ const argv = io.argv ?? process.argv;
24
+ const env = io.env ?? process.env;
25
+ const platform = io.platform ?? process.platform;
26
+ const stdout = io.stdout ?? process.stdout;
27
+ const [first, ...rest] = argv.slice(2);
28
+ if (first === DESCRIBE_FLAG) {
29
+ stdout.write(`${JSON.stringify(describeAnswer(tool))}\n`);
30
+ return { code: 0 };
31
+ }
32
+ if (first === DISPATCH_FLAG) {
33
+ const { runWcat } = await import("./dispatch.js");
34
+ const code = await runWcat(rest, { env, platform, stdout, stderr: io.stderr ?? process.stderr, self: tool });
35
+ return { code };
36
+ }
37
+ const folder = binFolder({
38
+ name: tool.command,
39
+ argv1: argv[1],
40
+ script: io.script ?? fileURLToPath(import.meta.url),
41
+ env,
42
+ platform,
43
+ });
44
+ return folder ? { launcher: ensureLauncher({ ...folder, platform }) } : {};
45
+ };
@@ -29,7 +29,7 @@ the four steps that need a person. A new CLI is ready for its first release when
29
29
  |---|---|---:|---|
30
30
  | [Toolchain](toolchain.md) | `TOOL` | 20 | TypeScript, Biome, grit plugins, size caps, import cycles, spelling, Markdown, Go |
31
31
  | [Layout](layout.md) | `LAY` | 10 | Repository layout, entry points, exports, file size, one job per file, splitting a unit, the bundled bin |
32
- | [Commands](commands.md) | `CMD` | 28 | Paths, vocabulary, flags, arguments, effects, lifetime, query, help, external references |
32
+ | [Commands](commands.md) | `CMD` | 29 | Paths, vocabulary, flags, arguments, effects, lifetime, query, help, external references, joining `wcat` |
33
33
  | [Output](output.md) | `OUT` | 23 | Data on stdout only, envelopes, errors, exit codes, partial answers, projection |
34
34
  | [MCP](mcp.md) | `MCP` | 13 | The MCP tools, how they come from commands, what each permission shows |
35
35
  | [Accounts](accounts.md) | `ACCT` | 12 | The explicit profile, reads across accounts, scope flags, linked identities, sign-in commands |
@@ -7,7 +7,7 @@ lifetime it declares, the entity it prints, the query language for lists, the `m
7
7
 
8
8
  Rule ids on this page are `WC-CMD-<n>`. How to read a rule, how ids work and which rule wins are in the
9
9
  [index](README.md). What a command prints is on the [output](output.md) page. How long it runs and how it ends is on
10
- the [lifecycle](lifecycle.md) page.
10
+ the [lifecycle](lifecycle.md) page. How a CLI joins the `wcat` command is under "Joining `wcat`" below.
11
11
 
12
12
  The words themselves are not copied here. They live in the cli-base registries, and `cli-dev lint` reads them from
13
13
  there:
@@ -360,6 +360,25 @@ The references, each for its own part:
360
360
  requirement.
361
361
  - **Applies to:** all CLIs. **Since:** cli-base 0.43.0. **Rulings:** none.
362
362
 
363
+ ## Joining `wcat`
364
+
365
+ ### WC-CMD-29 · Answer `--wcat-describe`, and take no name WireCat reserves
366
+
367
+ - **Why:** `wcat <name>` runs any `wcat-<name>` program on PATH, and `wcat --help` lists each one with what it says
368
+ about itself. A name WireCat reserves would be taken over by WireCat's own tool or by a `wcat` command.
369
+ - **Enforced by:** `src/dev/wcat-lint.ts`.
370
+ - **Example:** the bin's first call is `wcatStart({ command: "gcal", version, description, docs })` from
371
+ `@wirecat/cli-base/wcat/start`. `wcat-gcal --wcat-describe` then prints
372
+ `{"wcat_describe":1,"name":"gcal","version":"1.2.0","description":"…","docs_url":"…"}`. `cli-dev lint` warns when a
373
+ `"bin"` key does not start with `wcat-`, when a package outside `@wirecat/` takes a name from `RESERVED_NAMES` in
374
+ `@wirecat/cli-base/wcat` (`tg`, `max`, `wa`, `zm`, `drive`, `memo`, `mail`, `email`, `wcat` and the built-in commands
375
+ `help`, `version`, `shortcuts`, `doctor`, `update`, `upgrade`, `app`), and, with `--cli`, when the built CLI does not
376
+ answer `--wcat-describe`.
377
+ - **Exceptions:** a CLI keeps its short `"bin"` name beside `wcat-<name>` until callers have moved to the new name; the
378
+ warning stays until then.
379
+ - **Applies to:** all CLIs, and third-party CLIs that join `wcat`. **Since:** cli-base 0.66.0. **Rulings:** NEED-149,
380
+ NEED-153, NEED-154.
381
+
363
382
  ## Sources
364
383
 
365
384
  These documents explain the rules on this page in more detail. Where one of them disagrees with this page, this page
@@ -70,6 +70,32 @@ A template-owned page that is wrong for this CLI is a gap in cli-template, under
70
70
  wrong sentence: fix it there, and `upgrade` brings the fix. Change it in the CLI only where the CLI must differ for
71
71
  good; `upgrade` then asks before it touches the change.
72
72
 
73
+ ## Naming the CLI and joining `wcat`
74
+
75
+ `wcat <name> …` runs a command named `wcat-<name>` from PATH, the way `git foo` runs `git-foo`. Any CLI joins by
76
+ installing that command; nothing else is registered anywhere.
77
+
78
+ - **The name.** Lower-case letters, digits and `-`, starting with a letter. WireCat reserves its own tools' names and
79
+ `wcat`'s built-in commands: `RESERVED_NAMES` in `@wirecat/cli-base/wcat` holds the list, and
80
+ [WC-CMD-29](commands.md#wc-cmd-29--answer---wcat-describe-and-take-no-name-wirecat-reserves) asks a CLI outside
81
+ WireCat not to take one.
82
+ - **The command.** `"bin": { "wcat-<name>": "dist/bin/<name>.js" }`. `cli-dev lint` warns about a `"bin"` key without
83
+ the `wcat-` prefix. The template still writes the short name, and moves to `wcat-<name>` in a later release.
84
+ - **The description `wcat --help` shows.** Call `wcatStart` first in the bin, before the program loads:
85
+
86
+ ```ts
87
+ import { wcatStart } from "@wirecat/cli-base/wcat/start"
88
+
89
+ const { code } = await wcatStart({ command: "gcal", version, description, docs: "https://example.com/gcal" })
90
+ if (code !== undefined) process.exitCode = code
91
+ else await startCli(…)
92
+ ```
93
+
94
+ It answers `--wcat-describe`, runs `wcat` when the `wcat` launcher starts the tool, and on any other start makes sure
95
+ the `wcat` launcher in the tool's bin folder works. `cli-dev lint --cli` warns when the built CLI does not answer.
96
+ - **The short name.** `wcat shortcuts add <name>` makes `<name>` a shortcut to `wcat-<name>`, only when no program on
97
+ PATH has that name. A CLI does not install its short name itself.
98
+
73
99
  ## Four steps that need a person
74
100
 
75
101
  `new` prints these at the end.
@@ -68,7 +68,8 @@ name. Any other top-level key is written as `[not allowed]`. A key that names co
68
68
 
69
69
  cli-base's `Credentials` reads the environment variable first (for CI), then the OS keyring, then a file with mode
70
70
  `0600`. The keyring service is the CLI's app name, `appName` in its identity (`max-cli`, `gcal-cli`, `cli-memo`), not
71
- the command word people type (`max`, `gcal`). A run with moved folders uses the same entry and never deletes it
71
+ the command word people type (`max`, `gcal`). A run with moved folders reads the same entry, writes a temporary one,
72
+ and never replaces or deletes the owner's
72
73
  ([WC-SECR-24](#wc-secr-24--a-run-with-moved-folders-shares-the-owners-keyring-entry-and-never-deletes-it)).
73
74
 
74
75
  - **Why:** the keyring is the safest store on the computer.
@@ -327,23 +328,36 @@ store and runs no search. A CLI declares its provider and may replace any step.
327
328
 
328
329
  ### WC-SECR-24 · A run with moved folders shares the owner's keyring entry and never deletes it
329
330
 
330
- - **Why:** a per-folder keyring entry (`<app>:<config folder>`) left one copy of a sign-in for every test run, agent
331
- check or worktree, about 1,500 of them in the owner's keyring, and nothing removed them. One entry per computer is
332
- enough. But a moved run then reaches the owner's real sign-in: `HOME=/tmp/x gcal personal session end` deleted the
333
- owner's Google sign-in, because only `<PREFIX>_*_DIR` counted as moved (a security review, 2026-10-30).
331
+ - **Why:** a per-folder keyring entry (`<app>:<config folder>`) for every run left one copy of a sign-in for every test
332
+ run, agent check or worktree, about 1,500 of them in the owner's keyring, and nothing removed them. One entry per
333
+ computer is enough. But a moved run then reaches the owner's real sign-in: `HOME=/tmp/x gcal personal session end`
334
+ deleted the owner's Google sign-in, because only `<PREFIX>_*_DIR` counted as moved (a security review, 2026-10-30),
335
+ and `session start` in a worktree replaced it.
334
336
  - **Enforced by:** `src/credentials.test.ts` · `src/paths.test.ts`.
335
337
  - **Example:** a run is moved when `isMovedRun({ appName, prefix, env })` is true: a `<PREFIX>_CONFIG_DIR`,
336
338
  `_STATE_DIR` or `_CACHE_DIR` override, or a `HOME` or XDG folder that puts the default folders anywhere but under
337
- the account's own home. A CLI passes that as `isolated`. `Credentials` then reads and writes the app's own entry
338
- (`<app>` / `oauth:<provider>:<profile>`), as a run with the default folders does. With the system keyring, its
339
- `remove` keeps that entry, warns on stderr and leaves `"keyring"` out of what it returns; a moved `HOME` or XDG
340
- folder in its `env` counts even when `isolated` is false. A CLI checks `isMovedRun` before it revokes a sign-in at
341
- the provider, and says so when `session end` removed nothing. A test passes `memoryKeyring()`; a run that must not
342
- see the keyring at all also runs with `DBUS_SESSION_BUS_ADDRESS` removed, since the keyring is reached over D-Bus.
343
- - **Exceptions:** a write from a moved run, such as `session start` in a worktree, replaces the owner's entry.
344
- - **Applies to:** every CLI, its tests and every agent that runs a CLI. **Since:** cli-base 0.65.4.
345
- **Rulings:** owner, 2026-10-29 (sign-ins copied per work folder should have reused the existing entry); owner,
346
- 2026-10-30 (share the keyring entry, do not multiply it).
339
+ the account's own home. A CLI passes that as `isolated`; a moved `HOME` or XDG folder in `env` counts even when
340
+ `isolated` is false. With the system keyring, `Credentials` in a moved run:
341
+ - **writes** only a temporary entry, `movedRunService(app, configDir)` = `<app>:<absolute config folder>`, never the
342
+ owner's `<app>` entry;
343
+ - **reads** that temporary entry first and the owner's `<app>` entry when it has none, so a moved run still uses
344
+ the owner's sign-in;
345
+ - **removes** only the temporary entry, warns on stderr that the owner's was kept, and returns `"keyring"` only when
346
+ a temporary entry was there. `session end` in a moved run therefore cleans up after `session start`.
347
+
348
+ A CLI never revokes a sign-in at the provider in a moved run, even one read from the temporary entry: a refreshed
349
+ token is still the owner's grant. A test passes `memoryKeyring()`; a run that must not see the keyring at all also
350
+ runs with `DBUS_SESSION_BUS_ADDRESS` removed, since the keyring is reached over D-Bus.
351
+ - **Cleanup:** a moved run that never reaches `session end` leaves its temporary entry, and so does one that refreshes
352
+ a token, since the refreshed token is written there. `bin/keyring-dedupe --delete` in the projects folder removes
353
+ temporary entries that sit beside the owner's real one; a test or agent can also delete one by name with
354
+ `movedRunService`. When only `<PREFIX>_STATE_DIR` or `_CACHE_DIR` is moved, the temporary entry is named after the
355
+ owner's own config folder, which `keyring-dedupe` treats as a real entry: remove it with `session end`.
356
+ - **Exceptions:** none.
357
+ - **Applies to:** every CLI, its tests and every agent that runs a CLI. **Since:** cli-base 0.65.4 (temporary entry:
358
+ 0.65.5). **Rulings:** owner, 2026-10-29 (sign-ins copied per work folder should have reused the existing entry);
359
+ owner, 2026-10-30 (share the keyring entry, do not multiply it); owner, 2026-10-31 (do not replace it: a test of a
360
+ new session writes a temporary entry and cleans it up).
347
361
 
348
362
  ## Sources
349
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.65.4",
3
+ "version": "0.66.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",
@@ -90,6 +90,14 @@
90
90
  "types": "./dist/standards/tables.d.ts",
91
91
  "default": "./dist/standards/tables.js"
92
92
  },
93
+ "./wcat": {
94
+ "types": "./dist/wcat/index.d.ts",
95
+ "default": "./dist/wcat/index.js"
96
+ },
97
+ "./wcat/start": {
98
+ "types": "./dist/wcat/start.d.ts",
99
+ "default": "./dist/wcat/start.js"
100
+ },
93
101
  "./biome": "./config/biome.base.json",
94
102
  "./tsconfig.base.json": "./config/tsconfig.base.json",
95
103
  "./tsconfig.test.json": "./config/tsconfig.test.json",