@wirecat/cli-core 0.344.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.
- package/CHANGELOG.md +44 -0
- package/dist/cli/bot/command.js +3 -1
- package/dist/cli/command-contract.d.ts +13 -0
- package/dist/cli/command-contract.js +1 -1
- package/dist/cli/context.d.ts +2 -0
- package/dist/cli/guide.d.ts +52 -0
- package/dist/cli/guide.js +307 -0
- package/dist/cli/help/command-help.d.ts +30 -0
- package/dist/cli/help/command-help.js +218 -0
- package/dist/cli/help/commands-text.d.ts +2 -0
- package/dist/cli/help/commands-text.js +1 -1
- package/dist/cli/help/first-screen.d.ts +4 -3
- package/dist/cli/help/first-screen.js +6 -3
- package/dist/cli/help/sections.js +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/messenger/conversations-command.js +4 -1
- package/dist/cli/messenger/inbox.js +1 -1
- package/dist/cli/messenger/observed.js +15 -9
- package/dist/cli/messenger/private-people-command.js +10 -1
- package/dist/cli/messenger/review.js +1 -1
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/messenger/store-maintenance-command.js +8 -5
- package/dist/cli/profile.js +1 -1
- package/dist/cli/program.js +2 -2
- package/dist/meetings/mcp/index.d.ts +15 -15
- package/dist/services/index.d.ts +3 -0
- package/dist/services/index.js +1 -0
- package/dist/services/project-gathering-request.d.ts +120 -0
- package/dist/services/project-gathering-request.js +217 -0
- package/dist/services/project-gathering.d.ts +89 -0
- package/dist/services/project-gathering.js +202 -0
- package/package.json +5 -4
- package/parity.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,50 @@ 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
|
+
|
|
10
54
|
## 0.344.0 — 18.10.2026
|
|
11
55
|
|
|
12
56
|
### Changed — may break callers
|
package/dist/cli/bot/command.js
CHANGED
|
@@ -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 })
|
|
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
|
}));
|
|
@@ -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)]
|
package/dist/cli/context.d.ts
CHANGED
|
@@ -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
|
+
};
|