@wirecat/cli-core 0.343.0 → 0.345.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/dist/cli/bot/command.js +3 -1
  3. package/dist/cli/bot/context.js +8 -4
  4. package/dist/cli/command-contract.d.ts +13 -0
  5. package/dist/cli/command-contract.js +1 -1
  6. package/dist/cli/commands-command.js +9 -12
  7. package/dist/cli/context.d.ts +2 -0
  8. package/dist/cli/guide.d.ts +52 -0
  9. package/dist/cli/guide.js +307 -0
  10. package/dist/cli/help/command-help.d.ts +30 -0
  11. package/dist/cli/help/command-help.js +218 -0
  12. package/dist/cli/help/commands-text.d.ts +9 -0
  13. package/dist/cli/help/commands-text.js +43 -0
  14. package/dist/cli/help/first-screen.d.ts +37 -0
  15. package/dist/cli/help/first-screen.js +109 -0
  16. package/dist/cli/help/sections.d.ts +19 -0
  17. package/dist/cli/help/sections.js +77 -0
  18. package/dist/cli/index.d.ts +4 -0
  19. package/dist/cli/index.js +4 -0
  20. package/dist/cli/list-query.js +2 -4
  21. package/dist/cli/messenger/complete-command.js +2 -0
  22. package/dist/cli/messenger/context.js +12 -11
  23. package/dist/cli/messenger/conversations-command.js +4 -1
  24. package/dist/cli/messenger/inbox.js +1 -1
  25. package/dist/cli/messenger/mcp-command.js +1 -1
  26. package/dist/cli/messenger/messages-search-command.js +2 -2
  27. package/dist/cli/messenger/observed.js +15 -9
  28. package/dist/cli/messenger/private-people-command.js +10 -1
  29. package/dist/cli/messenger/review.js +1 -1
  30. package/dist/cli/messenger/search-command.js +3 -3
  31. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  32. package/dist/cli/messenger/store-maintenance-command.js +8 -5
  33. package/dist/cli/moved.js +1 -1
  34. package/dist/cli/profile.d.ts +5 -1
  35. package/dist/cli/profile.js +13 -3
  36. package/dist/cli/program.d.ts +4 -1
  37. package/dist/cli/program.js +23 -8
  38. package/dist/mcp/index.d.ts +1 -0
  39. package/dist/mcp/index.js +1 -0
  40. package/dist/meetings/cli/index.d.ts +1 -0
  41. package/dist/meetings/cli/index.js +3 -4
  42. package/dist/meetings/cli/project-evidence.js +3 -3
  43. package/dist/meetings/cli/query-options.d.ts +6 -0
  44. package/dist/meetings/cli/query-options.js +21 -0
  45. package/dist/meetings/mcp/index.d.ts +15 -15
  46. package/dist/services/index.d.ts +3 -0
  47. package/dist/services/index.js +1 -0
  48. package/dist/services/project-gathering-request.d.ts +120 -0
  49. package/dist/services/project-gathering-request.js +217 -0
  50. package/dist/services/project-gathering.d.ts +89 -0
  51. package/dist/services/project-gathering.js +202 -0
  52. package/package.json +5 -4
  53. package/parity.json +2 -2
package/CHANGELOG.md CHANGED
@@ -7,6 +7,85 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.345.0 — 18.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`gatherProjectEvidence` collects cited evidence for one project** from explicitly selected stored messages,
15
+ mail and imported documents: the project name, aliases, keywords and people's names are alternative search
16
+ lanes, the window defaults to 60 days and is read in 90-day chunks, every invocation has its own read, byte,
17
+ time and item budgets, and an opaque reference-only checkpoint resumes the run with its window frozen. An
18
+ optional host fetch port runs first; a failed fetch or source is reported as coverage, never as absence. It
19
+ writes nothing. See `docs/dev/PROJECT-GATHERING.md`.
20
+ - **`<tool> guide [name]`: scenarios, the commands for a common task in order** (`guideCommand(app, { directory,
21
+ worksWith?, profile? })` from `@wirecat/cli-core/cli`). The list names each scenario and, under "Works with",
22
+ the ones another tool holds (`memo guide person-brief`); one scenario prints its goal, each step as a command and
23
+ why, and what to look at; `--json` gives the same as data. The source is one Markdown file per scenario with
24
+ front matter (`title`, `goal`, `steps: [{ run, why }]`, `look`) in a directory the tool ships and names.
25
+ `guide` takes no profile and is not an MCP tool. `await checkGuide(() => program, { packageRoot })` is for the
26
+ tool's CI: each step's command path exists and its options and arguments parse, with no account and no network,
27
+ and the directory is in the package's `files`. The first screen's "Next" names `guide` once a tool mounts it. The
28
+ peer range takes cli-base 0.46 too (`>=0.45.0 <0.47.0`): `cli-dev lint` knows `guide` from 0.46.0 on.
29
+ - **The run record of a one-shot command's passive listen counts what it received**: the `adapter.listen`
30
+ response carries `counts` by event (`message`, `edit`, …), as `messages.watch` does, so a run shows how many
31
+ messages a store-history messenger handed on and saved during the command.
32
+ - **Every leaf command's `--help` ends with Returns, Next usually and Example**, worked out from its definition when
33
+ the help is shown. Returns names the declared output entity and its first fields, or repeats the description
34
+ when there is none. Next usually lists up to three commands whose arguments take what this one returns (a
35
+ message, a chat, a person, a task…), reads first; `nextUsually(command, { pin, drop })` overrides them. Example
36
+ is the definition's own `examples`, or one built from the usage, a sample value per argument (`Team`, `1234`,
37
+ `"invoice"`) and the mandatory options. Examples leave out the profile, as the first screen does; the Usage line
38
+ shows `<profile>`.
39
+ `commandHelpMarkdown(root, path)` gives the same three lines for `docs/commands.md`, and
40
+ `exampleReport(program)` lists the commands still on a generated example.
41
+ - **`contacts alias set|rm`, `contacts notes list|show|add|edit|remove`, `conversations consents list|revoke` and
42
+ `bot sends list` have descriptions**, in `--help`, `commands` and the commands document.
43
+
44
+ ### Fixed
45
+
46
+ - **`inbox` and `review` declare their output**: they named the view `message.summary`, so the entity looked
47
+ up was `message.message.summary`, which does not exist. `commands schema inbox` now gives the message summary's
48
+ fields instead of open items, and `--help` ends with "Returns: message summary — …". `--json=<fields>` is now
49
+ checked against those fields, so a name they do not have is refused with the list instead of giving empty items.
50
+ The JSON they print is unchanged.
51
+ - **`--brief` and `--full` no longer mention mail in a messenger's help.** They said "found messages and mail"; tg,
52
+ max and wa have no mail. Consumers' help snapshots change on bump.
53
+
54
+ ## 0.344.0 — 18.10.2026
55
+
56
+ ### Changed — may break callers
57
+
58
+ - **`<tool> --help` and a bare `<tool>` show a short first screen instead of commander's full help**: the tool's
59
+ pitch, the usage line, one sentence that account commands start with the profile, what the tool can do, the other
60
+ WireCat tools, and where to go next (`commands`, `help options`, `skill show`, and `setup` or `guide` when the tool
61
+ has them). It no longer lists every command or the options for every command, so a test that looks for a command
62
+ or a global option in the root `--help` must read `--help-all`, which prints the old help, or `help options`.
63
+ Exit codes are unchanged: `--help` exits 0, a bare run exits 2.
64
+ - **`<tool> commands` without `--json` prints a text list, by section, even when piped.** It printed the whole JSON
65
+ document (hundreds of kilobytes) to any reader that was not a terminal, and a table in one. Now every command that
66
+ runs is one line — its usage and the first clause of its description, `(writes)` when it changes something —
67
+ under Read, Write, Organise, Automate, Set up or Other. `commands <path>` narrows it. `--json` and `--jsonl` print
68
+ the same document as before; only the `commands` command's own description changed. A script that parsed the
69
+ output of a bare `commands` must pass `--json`.
70
+
71
+ ### Added
72
+
73
+ - **`registerCommands(server, prefix, commands)` and the `McpCommand` type** (`@wirecat/cli-core/mcp`): the three
74
+ tools `<prefix>_tools_search`, `<prefix>_read` and `<prefix>_write` that tg and max serve in place of one tool per
75
+ command. A CLI without the messenger `mcpCommand`, such as drive, can now serve its own commands the same way.
76
+ - **`ProgramDefinition.firstScreen` and `installHelp(program, screen)`** (`@wirecat/cli-core/cli`): a tool
77
+ supplies its `pitch` and 6–8 `can` lines (`{ command, does }`, the command without the profile);
78
+ `installHelp` puts the same screen on a program the tool builds itself, with `profile: false` for a tool
79
+ whose commands take no leading profile and `start` for its first command. A tool that supplies nothing gets its
80
+ description and no "What you can do" block.
81
+ - **`<tool> help options`** prints the options for every command; `help <command>` prints that command's help.
82
+ `help` and `--help-all` are hidden, so `commands --json` and the MCP tools are unchanged.
83
+ - **`inSection(command, section)` and `SECTIONS`** (`@wirecat/cli-core/cli`): where `commands` lists a group and
84
+ everything under it — `read`, `write`, `organise`, `automate` or `setup`; a command without one, or under none,
85
+ is listed under Other. `createProgram` puts cli-core's own groups (`chats`, `messages`, `tags`, `schedule`,
86
+ `config`…) in theirs; a tool sets its own groups (`session`, `setup`). The section is kept off the commands
87
+ document.
88
+
10
89
  ## 0.343.0 — 18.10.2026
11
90
 
12
91
  ### Added
@@ -54,6 +133,20 @@ shape of this file.
54
133
  permission governs those commands. Commands that a profile's permissions govern still need one.
55
134
  - **`serve`'s relay socket fits on macOS with a deep `TMPDIR`.** When the temporary folder is too deep for a socket
56
135
  path too, the socket goes under `/tmp` (still in an owner-only folder). It made tg's macOS release check fail.
136
+ - **Help and errors say what a person typed.** The usage line of every command that uses a profile now reads
137
+ `<tool> <profile> …` (`Usage: tg <profile> mcp`); `commands`, `complete`, `help`, `skill` and `upgrade` keep the bare
138
+ form. Consumers' help snapshots change with this version.
139
+ - A command that takes a free query (`chats list`, `meetings list`, `meetings projects evidence`) refuses a word that
140
+ starts with `--` as an unknown option, with commander's suggestion (`unknown option '--limt' (Did you mean
141
+ --limit?)`), instead of reading it as query text; a negated term like `-kind:channel` is still query, and after `--`
142
+ every word is.
143
+ - A commander error is one line: `unknown command 'chts' (Did you mean chats?) …`.
144
+ - The profile may follow global options: `tg --json personal chats list` works.
145
+ - `complete --help` prints the help and exits 0.
146
+ - A bot with no token, given a chat by name, says to store a token (exit 4) instead of "no chat matches".
147
+ - "nothing recorded for profile" says "run the command once without --offline" only when `--offline` was given, and
148
+ names the profile in `serve`/`watch`.
149
+ - `mcp setup` says "1 tool that may write"; the search help calls memo "the separate memo CLI".
57
150
 
58
151
  ### Security
59
152
 
@@ -159,7 +159,9 @@ const recipientsCommand = (bot) => {
159
159
  };
160
160
  const sendsCommand = (bot) => new Command("sends")
161
161
  .description("what this bot sent, edited and deleted from this machine — ids and outcomes, never text")
162
- .addCommand(annotate(new Command("list"), { effects: LOCAL_READ }).action(async function () {
162
+ .addCommand(annotate(new Command("list"), { effects: LOCAL_READ })
163
+ .description("every send, edit and delete this bot made from this machine, with its outcome")
164
+ .action(async function () {
163
165
  const context = botContext(this, bot);
164
166
  await context.run(async () => renderAnswer(this, context, wholeList("bot sends list", context.journal().entries().map(snakeKeys))));
165
167
  }));
@@ -29,6 +29,7 @@ export const botContext = (command, bot) => {
29
29
  base.track(adapter);
30
30
  return adapter;
31
31
  };
32
+ const noToken = () => new CliError("authentication_error", `no bot token for profile "${profile}" — run \`${words} auth set\``);
32
33
  return {
33
34
  ...base,
34
35
  profile,
@@ -64,7 +65,11 @@ export const botContext = (command, bot) => {
64
65
  const trimmed = typed.trim();
65
66
  if (/^-?\d+$/.test(trimmed) || /^user:\d+$/.test(trimmed))
66
67
  return trimmed;
67
- return pickChat(trimmed, registry.list()).id;
68
+ const known = registry.list();
69
+ // A bot that never had a token has seen no chat, and its missing token is what needs fixing.
70
+ if (known.length === 0 && !tokens.read())
71
+ throw noToken();
72
+ return pickChat(trimmed, known).id;
68
73
  },
69
74
  readSecret: (prompt) => {
70
75
  return bot.readSecret?.(command, prompt) ?? readSecret(prompt, secretInputOf(command));
@@ -74,9 +79,8 @@ export const botContext = (command, bot) => {
74
79
  /** A client for the stored token, or a refusal that says how to store one. */
75
80
  authenticated: (options = {}) => {
76
81
  const stored = tokens.read();
77
- if (!stored) {
78
- throw new CliError("authentication_error", `no bot token for profile "${profile}" — run \`${words} auth set\``);
79
- }
82
+ if (!stored)
83
+ throw noToken();
80
84
  return connect(stored.token, options);
81
85
  },
82
86
  };
@@ -1,6 +1,19 @@
1
1
  import type { Command } from "commander";
2
2
  export type JsonSchema = Record<string, unknown>;
3
3
  export declare const SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
4
+ /**
5
+ * What `--json` prints, from the entity the command declares (`annotate(…, { output })`): a list or one thing of it.
6
+ * A command that declares none answers the same envelopes with items of any shape; `--jsonl` streams the items.
7
+ */
8
+ export declare const outputOf: (command: Command) => {
9
+ schema: {
10
+ [x: string]: unknown;
11
+ };
12
+ item: {
13
+ [x: string]: unknown;
14
+ };
15
+ coverage: string;
16
+ };
4
17
  export declare const commandPathOf: (command: Command) => string[];
5
18
  export declare const findCommand: (root: Command, path: readonly string[]) => Command;
6
19
  export declare const commandContract: (command: Command) => {
@@ -9,7 +9,7 @@ export const SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
9
9
  * What `--json` prints, from the entity the command declares (`annotate(…, { output })`): a list or one thing of it.
10
10
  * A command that declares none answers the same envelopes with items of any shape; `--jsonl` streams the items.
11
11
  */
12
- const outputOf = (command) => {
12
+ export const outputOf = (command) => {
13
13
  const output = metaOf(command).output;
14
14
  const entity = output
15
15
  ? ENTITIES[(output.view ? `${output.entity}.${output.view}` : output.entity)]
@@ -1,11 +1,12 @@
1
1
  import { CliError, EXIT_CODES, GENERIC_FAILURE } from "@wirecat/cli-base";
2
- import { annotate, describeOptions, describeProgram, flatten } from "@wirecat/cli-base/commands";
3
- import { CRASH_CODE, writes } from "@wirecat/cli-base/standards";
2
+ import { annotate, describeOptions, describeProgram } from "@wirecat/cli-base/commands";
3
+ import { CRASH_CODE } from "@wirecat/cli-base/standards";
4
4
  import { Command } from "commander";
5
5
  import { commandContract, findCommand } from "./command-contract.js";
6
- import { outputFor } from "./context.js";
6
+ import { environmentOf, outputFor } from "./context.js";
7
7
  import { NO_EFFECTS } from "./effects.js";
8
8
  import { renderDocument } from "./envelope.js";
9
+ import { commandsText } from "./help/commands-text.js";
9
10
  import { rootOf } from "./profile.js";
10
11
  /**
11
12
  * The major version of the JSON every command prints. The output types live in this package, so the
@@ -18,22 +19,18 @@ export const CONTRACT = 3;
18
19
  */
19
20
  export const commandsCommand = (app) => {
20
21
  const command = annotate(new Command("commands"), { effects: NO_EFFECTS })
21
- .description("commands, options and exit codes as JSON — inspect one command path per call")
22
+ .description("every command by section, one line each; --json: the contract, one path per call")
22
23
  .argument("[path...]", "one command path, for example: search messages; inspect other groups in separate calls")
23
24
  .action(function (path) {
24
25
  const root = rootOf(this);
25
26
  const output = outputFor(this);
26
- const { renderer, format } = output;
27
27
  const all = describeProgram(root);
28
28
  const scoped = path.length > 0 ? scopeOf(root, all, path) : undefined;
29
29
  const commands = scoped ? [scoped.command] : all;
30
- if (format === "pretty") {
31
- renderer.result(flatten(commands).map(({ usage, summary, description, effects, state }) => ({
32
- command: usage,
33
- description: summary ?? description,
34
- writes: effects !== undefined && writes(effects) ? "yes" : "",
35
- ...(state ? { state } : {}),
36
- })));
30
+ const { json, jsonl } = this.optsWithGlobals();
31
+ // Asked by format, not by terminal: piped, an agent got the whole JSON tree it never asked for.
32
+ if (json === undefined && !jsonl && !environmentOf(this).agent) {
33
+ output.streams.data(commandsText(root, commands, scoped?.command.path).replace(/\n$/, ""));
37
34
  return;
38
35
  }
39
36
  renderDocument(output, {
@@ -33,6 +33,8 @@ export interface BaseEnvironment {
33
33
  platform?: NodeJS.Platform;
34
34
  /** The local speech recognizer in place of the downloaded model; tests hand one in. */
35
35
  recognizer?: OpenRecognizer;
36
+ /** How long `store restore` and `store reset` wait on another connection's write, 1 s unless a test hands one in. */
37
+ storeLockWaitMs?: number;
36
38
  /** Where raw bytes go when stdout is not this process's own — an MCP session relayed by serve. */
37
39
  stdout?: NodeJS.WritableStream;
38
40
  /** Where a prompt and what is typed at it go, in place of the process's stderr; tests hand one in. */
@@ -0,0 +1,52 @@
1
+ import { Command } from "commander";
2
+ import type { AppIdentity } from "../ports/app.js";
3
+ export interface GuideStep {
4
+ /** The command as a person types it: the tool's name first, no profile — `tg review --since 1d`. */
5
+ run: string;
6
+ why: string;
7
+ }
8
+ export interface Scenario {
9
+ name: string;
10
+ title: string;
11
+ goal: string;
12
+ steps: GuideStep[];
13
+ look: string[];
14
+ }
15
+ /** A scenario another tool holds — memo's cross-tool ones — listed by reference under "Works with". */
16
+ export interface GuideReference {
17
+ tool: string;
18
+ name: string;
19
+ title: string;
20
+ }
21
+ export interface GuideOptions {
22
+ /** Absolute path of the tool's scenario files: `fileURLToPath(new URL("../scenarios", import.meta.url))`. */
23
+ directory: string;
24
+ worksWith?: readonly GuideReference[];
25
+ /** `false` for a tool whose commands take no leading profile. */
26
+ profile?: boolean;
27
+ }
28
+ /** One scenario file, or what is wrong with it. The Markdown body is for the site's page; the CLI reads none of it. */
29
+ export declare const parseScenario: (file: string, content: string) => Promise<{
30
+ scenario?: Scenario;
31
+ problems: string[];
32
+ }>;
33
+ export declare const loadScenarios: (directory: string) => Promise<{
34
+ scenarios: Scenario[];
35
+ problems: string[];
36
+ }>;
37
+ /**
38
+ * `<tool> guide` lists the tool's scenarios and the ones it works with; `<tool> guide <name>` prints one: the goal,
39
+ * each step as a command and why, and what to look at. The files are read when the command runs, never when the
40
+ * program is built. `checkGuide` validates them in the tool's CI.
41
+ */
42
+ export declare const guideCommand: (app: Pick<AppIdentity, "command">, options: GuideOptions) => Command;
43
+ /** What is wrong with one step on this program: the path, each option and its value, the arguments. Never runs it. */
44
+ export declare const checkStep: (program: Command, run: string) => string[];
45
+ /**
46
+ * For a tool's CI: every scenario file parses, and each step's command exists and its options and arguments parse
47
+ * on a program built fresh per step — no account, no network, no action runs. With `packageRoot`, the scenario
48
+ * directory must also be in the package's `files`, so it ships. Returns the problems; empty means sound.
49
+ */
50
+ export declare const checkGuide: (build: () => Command, { packageRoot }?: {
51
+ packageRoot?: string;
52
+ }) => Promise<string[]>;
@@ -0,0 +1,307 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { basename, join, relative, resolve, sep } from "node:path";
3
+ import { CliError } from "@wirecat/cli-base";
4
+ import { annotate } from "@wirecat/cli-base/commands";
5
+ import { Command } from "commander";
6
+ import { environmentOf, outputFor } from "./context.js";
7
+ import { NO_EFFECTS } from "./effects.js";
8
+ import { itemOf, listOf, renderAnswer } from "./envelope.js";
9
+ import { PROFILE_FREE } from "./profile.js";
10
+ const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
11
+ const FIELDS = new Set(["title", "goal", "steps", "look"]);
12
+ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
+ const text = (value) => typeof value === "string" && value.trim().length > 0;
14
+ /** One scenario file, or what is wrong with it. The Markdown body is for the site's page; the CLI reads none of it. */
15
+ export const parseScenario = async (file, content) => {
16
+ const name = basename(file, ".md");
17
+ const at = (problem) => `${basename(file)}: ${problem}`;
18
+ if (!NAME.test(name))
19
+ return { problems: [at("the file name is the scenario's name: lower case words and dashes")] };
20
+ const match = FRONT_MATTER.exec(content);
21
+ if (!match)
22
+ return { problems: [at("needs front matter between --- lines")] };
23
+ // Loaded here, not at the top: every tool start loads this module, and only `guide` reads YAML.
24
+ const { parseDocument } = await import("yaml");
25
+ const yaml = parseDocument(match[1] ?? "", { uniqueKeys: true });
26
+ if (yaml.errors.length > 0)
27
+ return { problems: [at("front matter is not valid YAML")] };
28
+ const data = yaml.toJS({ maxAliasCount: 0 });
29
+ if (!data || typeof data !== "object" || Array.isArray(data))
30
+ return { problems: [at("front matter is not a map")] };
31
+ const fields = data;
32
+ const problems = Object.keys(fields)
33
+ .filter((key) => !FIELDS.has(key))
34
+ .map((key) => at(`unknown field ${key}; the fields are ${[...FIELDS].join(", ")}`));
35
+ if (!text(fields.title))
36
+ problems.push(at("title is a line of text"));
37
+ if (!text(fields.goal))
38
+ problems.push(at("goal is a line of text"));
39
+ const steps = Array.isArray(fields.steps) ? fields.steps : [];
40
+ if (steps.length === 0)
41
+ problems.push(at("steps is a list of { run, why }"));
42
+ steps.forEach((step, index) => {
43
+ const one = (step ?? {});
44
+ if (!text(one.run) || !text(one.why))
45
+ problems.push(at(`step ${index + 1} needs run and why`));
46
+ });
47
+ const look = typeof fields.look === "string" ? [fields.look] : fields.look;
48
+ if (!Array.isArray(look) || look.length === 0 || !look.every(text))
49
+ problems.push(at("look is what to look at in the output: a line or a list of lines"));
50
+ if (problems.length > 0)
51
+ return { problems };
52
+ return {
53
+ scenario: {
54
+ name,
55
+ title: String(fields.title).trim(),
56
+ goal: String(fields.goal).trim(),
57
+ steps: steps.map(({ run, why }) => ({ run: run.trim(), why: why.trim() })),
58
+ look: look.map((line) => line.trim()),
59
+ },
60
+ problems: [],
61
+ };
62
+ };
63
+ export const loadScenarios = async (directory) => {
64
+ let files;
65
+ try {
66
+ files = readdirSync(directory).filter((file) => file.endsWith(".md") && file.toLowerCase() !== "readme.md");
67
+ }
68
+ catch {
69
+ return { scenarios: [], problems: [`${directory}: no such directory`] };
70
+ }
71
+ const parsed = await Promise.all(files.sort().map((file) => parseScenario(file, readFileSync(join(directory, file), "utf8"))));
72
+ return {
73
+ scenarios: parsed.flatMap(({ scenario }) => (scenario ? [scenario] : [])),
74
+ problems: parsed.flatMap(({ problems }) => problems),
75
+ };
76
+ };
77
+ const guides = new WeakMap();
78
+ const rows = (pairs) => {
79
+ const width = pairs.reduce((most, [left]) => Math.max(most, left.length), 0) + 2;
80
+ return pairs.map(([left, right]) => ` ${left.padEnd(width)}${right}`);
81
+ };
82
+ const listText = (cli, scenarios, worksWith) => [
83
+ scenarios.length > 0 ? `Scenarios — ${cli} guide <name> for the steps:` : `${cli} has no scenarios yet.`,
84
+ ...rows(scenarios.map((one) => [one.name, one.title])),
85
+ ...(worksWith.length > 0
86
+ ? ["", "Works with:", ...rows(worksWith.map((one) => [`${one.tool} guide ${one.name}`, one.title]))]
87
+ : []),
88
+ ].join("\n");
89
+ const needsProfile = (cli, run) => {
90
+ const [tool, first] = words(run);
91
+ return tool === cli && first !== undefined && !first.startsWith("-") && !PROFILE_FREE.has(first);
92
+ };
93
+ const scenarioText = (cli, scenario, profile) => [
94
+ scenario.title,
95
+ "",
96
+ `Goal: ${scenario.goal}`,
97
+ "",
98
+ profile && scenario.steps.some((step) => needsProfile(cli, step.run))
99
+ ? `Steps — account commands start with your profile: ${cli} <profile> …`
100
+ : "Steps:",
101
+ ...scenario.steps.flatMap((step, index) => [` ${index + 1}. ${step.run}`, ` ${step.why}`]),
102
+ "",
103
+ "Look at:",
104
+ ...scenario.look.map((line) => ` - ${line}`),
105
+ ].join("\n");
106
+ /**
107
+ * `<tool> guide` lists the tool's scenarios and the ones it works with; `<tool> guide <name>` prints one: the goal,
108
+ * each step as a command and why, and what to look at. The files are read when the command runs, never when the
109
+ * program is built. `checkGuide` validates them in the tool's CI.
110
+ */
111
+ export const guideCommand = (app, options) => {
112
+ const command = annotate(new Command("guide"), {
113
+ effects: NO_EFFECTS,
114
+ mcp: false,
115
+ examples: [`${app.command} guide`, `${app.command} guide <name> --json`],
116
+ })
117
+ .description("scenarios: the commands for a common task, in order, and why each")
118
+ .argument("[name]", "one scenario; without it, the list")
119
+ .action(async function (name) {
120
+ const cli = app.command;
121
+ const { scenarios, problems } = await loadScenarios(options.directory);
122
+ if (problems.length > 0)
123
+ throw new CliError("configuration_error", `broken scenario files: ${problems.join("; ")}`);
124
+ const worksWith = options.worksWith ?? [];
125
+ const output = outputFor(this);
126
+ const { json, jsonl } = this.optsWithGlobals();
127
+ const asText = json === undefined && !jsonl && !environmentOf(this).agent;
128
+ if (name === undefined) {
129
+ const items = [
130
+ ...scenarios.map(({ name, title, goal }) => ({
131
+ tool: cli,
132
+ name,
133
+ title,
134
+ goal,
135
+ command: `${cli} guide ${name}`,
136
+ })),
137
+ ...worksWith.map(({ tool, name, title }) => ({ tool, name, title, command: `${tool} guide ${name}` })),
138
+ ];
139
+ if (asText)
140
+ output.streams.data(listText(cli, scenarios, worksWith));
141
+ else
142
+ renderAnswer(this, output, listOf("guide", { items, limit: Math.max(1, items.length), has_more: false }));
143
+ return;
144
+ }
145
+ const scenario = scenarios.find((one) => one.name === name);
146
+ if (!scenario) {
147
+ const elsewhere = worksWith.find((one) => one.name === name);
148
+ throw new CliError("not_found", elsewhere
149
+ ? `"${name}" is ${elsewhere.tool}'s scenario: ${elsewhere.tool} guide ${name}`
150
+ : `no scenario "${name}"; ${cli} guide lists them`, { name, available: scenarios.map((one) => one.name) });
151
+ }
152
+ if (asText)
153
+ output.streams.data(scenarioText(cli, scenario, options.profile !== false));
154
+ else
155
+ renderAnswer(this, output, itemOf(scenario));
156
+ });
157
+ guides.set(command, options);
158
+ return command;
159
+ };
160
+ /** A step's words, split as a shell would on spaces, with "…" and '…' kept together. */
161
+ const words = (line) => {
162
+ const found = [];
163
+ let word;
164
+ let quote;
165
+ for (const char of line) {
166
+ if (quote) {
167
+ if (char === quote)
168
+ quote = undefined;
169
+ else
170
+ word = (word ?? "") + char;
171
+ }
172
+ else if (char === '"' || char === "'") {
173
+ quote = char;
174
+ word ??= "";
175
+ }
176
+ else if (/\s/.test(char)) {
177
+ if (word !== undefined)
178
+ found.push(word);
179
+ word = undefined;
180
+ }
181
+ else
182
+ word = (word ?? "") + char;
183
+ }
184
+ if (word !== undefined)
185
+ found.push(word);
186
+ return found;
187
+ };
188
+ const silent = (command) => command.exitOverride().configureOutput({ writeOut: () => { }, writeErr: () => { }, outputError: () => { } });
189
+ const ownerOf = (chain, flag) => {
190
+ for (const owner of chain) {
191
+ const option = owner.options.find((one) => one.long === flag || one.short === flag);
192
+ if (option)
193
+ return { owner, option };
194
+ }
195
+ return undefined;
196
+ };
197
+ /** What is wrong with one step on this program: the path, each option and its value, the arguments. Never runs it. */
198
+ export const checkStep = (program, run) => {
199
+ const [tool, ...rest] = words(run);
200
+ if (tool !== program.name())
201
+ return [`starts with "${tool ?? ""}", not ${program.name()}`];
202
+ const chain = [program];
203
+ let at = program;
204
+ let index = 0;
205
+ for (; index < rest.length; index++) {
206
+ const next = at.commands.find((one) => one.name() === rest[index] || one.aliases().includes(rest[index] ?? ""));
207
+ if (!next)
208
+ break;
209
+ chain.unshift(next);
210
+ at = next;
211
+ }
212
+ if (at === program)
213
+ return [`no command "${rest[0] ?? ""}" — steps leave out the profile`];
214
+ if (at.commands.length > 0)
215
+ return [`needs one of: ${at.commands.map((one) => one.name()).join(", ")}`];
216
+ for (const command of chain)
217
+ silent(command);
218
+ const problems = [];
219
+ const operands = [];
220
+ const left = rest.slice(index);
221
+ for (let word = 0; word < left.length; word++) {
222
+ const token = left[word];
223
+ if (token === "--") {
224
+ operands.push(...left.slice(word + 1));
225
+ break;
226
+ }
227
+ if (!token.startsWith("-") || token === "-" || /^-\d/.test(token)) {
228
+ operands.push(token);
229
+ continue;
230
+ }
231
+ const flag = token.split("=")[0];
232
+ const found = ownerOf(chain, flag);
233
+ if (!found) {
234
+ problems.push(`no option ${flag}`);
235
+ continue;
236
+ }
237
+ const tokens = [token];
238
+ const takes = found.option.required || found.option.optional;
239
+ if (takes && !token.includes("=")) {
240
+ while (left[word + 1] !== undefined && !left[word + 1].startsWith("-")) {
241
+ tokens.push(left[++word]);
242
+ if (!found.option.variadic)
243
+ break;
244
+ }
245
+ if (found.option.required && tokens.length === 1) {
246
+ problems.push(`${flag} needs a value`);
247
+ continue;
248
+ }
249
+ }
250
+ try {
251
+ found.owner.parseOptions(tokens);
252
+ }
253
+ catch (error) {
254
+ problems.push(`${flag}: ${error instanceof Error ? error.message.replace(/^error: /, "") : String(error)}`);
255
+ }
256
+ }
257
+ for (const option of at.options)
258
+ if (option.mandatory && at.getOptionValue(option.attributeName()) === undefined)
259
+ problems.push(`${option.long ?? option.flags} is required`);
260
+ const declared = at.registeredArguments;
261
+ const required = declared.filter((one) => one.required).length;
262
+ const unbounded = declared.at(-1)?.variadic === true;
263
+ if (operands.length < required)
264
+ problems.push(`needs ${declared
265
+ .filter((one) => one.required)
266
+ .map((one) => `<${one.name()}>`)
267
+ .join(" ")}`);
268
+ if (!unbounded && operands.length > declared.length)
269
+ problems.push(`takes ${declared.length} argument(s), got ${operands.length}: ${operands.join(" ")}`);
270
+ declared.forEach((argument, position) => {
271
+ const value = operands[position];
272
+ if (value !== undefined && argument.argChoices && !argument.argChoices.includes(value))
273
+ problems.push(`<${argument.name()}> is one of ${argument.argChoices.join(", ")}, not ${value}`);
274
+ });
275
+ return problems;
276
+ };
277
+ const covered = (files, path) => files.some((entry) => {
278
+ const plain = entry.replace(/(\/\*\*?)?(\/\*)?\/?$/, "").replace(/^\.\//, "");
279
+ return path === plain || path.startsWith(`${plain}/`);
280
+ });
281
+ /**
282
+ * For a tool's CI: every scenario file parses, and each step's command exists and its options and arguments parse
283
+ * on a program built fresh per step — no account, no network, no action runs. With `packageRoot`, the scenario
284
+ * directory must also be in the package's `files`, so it ships. Returns the problems; empty means sound.
285
+ */
286
+ export const checkGuide = async (build, { packageRoot } = {}) => {
287
+ const guide = build().commands.find((one) => one.name() === "guide");
288
+ const options = guide ? guides.get(guide) : undefined;
289
+ if (!options)
290
+ return ["the program has no guideCommand"];
291
+ const { scenarios, problems } = await loadScenarios(options.directory);
292
+ for (const scenario of scenarios)
293
+ scenario.steps.forEach((step, index) => {
294
+ for (const problem of checkStep(build(), step.run))
295
+ problems.push(`${scenario.name}.md step ${index + 1} (${step.run}): ${problem}`);
296
+ });
297
+ for (const reference of options.worksWith ?? [])
298
+ if (!NAME.test(reference.name) || !text(reference.tool) || !text(reference.title))
299
+ problems.push(`works with ${reference.tool} ${reference.name}: needs tool, a lower-case name and a title`);
300
+ if (packageRoot !== undefined) {
301
+ const path = relative(resolve(packageRoot), resolve(options.directory)).split(sep).join("/");
302
+ const files = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).files;
303
+ if (files !== undefined && !covered(files, path))
304
+ problems.push(`package.json files leaves out ${path}`);
305
+ }
306
+ return problems;
307
+ };
@@ -0,0 +1,30 @@
1
+ import type { Command } from "commander";
2
+ export interface NextChoice {
3
+ /** Command paths shown first, in this order: `messages context`. */
4
+ pin?: readonly string[];
5
+ /** Command paths never shown, though their arguments take what this one returns. */
6
+ drop?: readonly string[];
7
+ }
8
+ /** Overrides the "Next usually" a command's help derives from types. Kept off `annotate`, like its section. */
9
+ export declare const nextUsually: <T extends Command>(command: T, choice: NextChoice) => T;
10
+ export interface CommandHelp {
11
+ returns: string;
12
+ next: readonly string[];
13
+ examples: readonly string[];
14
+ /** `generated` until the definition has an example of its own; the examples report lists those. */
15
+ origin: "written" | "generated";
16
+ }
17
+ /** Returns, Next usually and Example for one command, from its definition alone. */
18
+ export declare const commandHelp: (command: Command) => CommandHelp;
19
+ /** The lines `--help` prints after a command's options. */
20
+ export declare const commandHelpText: (command: Command) => string;
21
+ /** The same, as Markdown lines for `docs/commands.md`: hand it to cli-base's `commandsPage` per command. */
22
+ export declare const commandHelpMarkdown: (root: Command, path: readonly string[]) => string[];
23
+ /** Puts Returns, Next usually and Example on every leaf command's `--help`, worked out when the help is shown. */
24
+ export declare const installCommandHelp: (program: Command) => void;
25
+ /** Which leaf commands still show a generated example, so the important ones get a written one. Report only. */
26
+ export declare const exampleReport: (program: Command) => {
27
+ written: string[];
28
+ generated: string[];
29
+ text: string;
30
+ };