docspack 1.1.0 → 1.3.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/README.md +25 -1
- package/dist/build.d.ts +5 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +68 -5
- package/dist/build.js.map +1 -1
- package/dist/cli-spec.d.ts +3 -0
- package/dist/cli-spec.d.ts.map +1 -0
- package/dist/cli-spec.js +667 -0
- package/dist/cli-spec.js.map +1 -0
- package/dist/cli.d.ts +146 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +80 -14
- package/dist/cli.js.map +1 -1
- package/dist/cmdspec.json +1219 -0
- package/dist/commands.d.ts +78 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +231 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/db.d.ts +6 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +7 -1
- package/dist/db.js.map +1 -1
- package/dist/discovery.d.ts +22 -3
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +91 -12
- package/dist/discovery.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +66 -1
- package/dist/doctor.js.map +1 -1
- package/dist/help.d.ts +10 -6
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +118 -371
- package/dist/help.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/init/plan.js +4 -3
- package/dist/init/plan.js.map +1 -1
- package/dist/init/templates.d.ts.map +1 -1
- package/dist/init/templates.js +4 -2
- package/dist/init/templates.js.map +1 -1
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +12 -6
- package/dist/preview.js.map +1 -1
- package/dist/search.d.ts +2 -0
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +66 -22
- package/dist/search.js.map +1 -1
- package/dist/spec.d.ts +21 -1
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js +24 -2
- package/dist/spec.js.map +1 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +99 -1
- package/dist/sync.js.map +1 -1
- package/package.json +8 -5
- package/src/build.ts +86 -7
- package/src/cli-spec.ts +688 -0
- package/src/cli.ts +90 -17
- package/src/commands.ts +298 -0
- package/src/config.ts +4 -1
- package/src/db.ts +9 -2
- package/src/discovery.ts +113 -12
- package/src/doctor.ts +67 -0
- package/src/help.ts +138 -380
- package/src/index.ts +1 -0
- package/src/init/plan.ts +4 -3
- package/src/init/templates.ts +4 -2
- package/src/preview.ts +14 -13
- package/src/search.ts +79 -22
- package/src/spec.ts +28 -2
- package/src/sync.ts +120 -1
package/src/cli.ts
CHANGED
|
@@ -3,14 +3,16 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { posix } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
+
import { parseArgsConfig, type ResolvedCommand, resolveDocument } from "@docspack/cmdspec";
|
|
6
7
|
import { applyAgentSetup, planAgentSetup } from "./agent.js";
|
|
7
8
|
import { changedSurface } from "./changed.js";
|
|
9
|
+
import { CLI } from "./cli-spec.js";
|
|
8
10
|
import { measureCoverage } from "./coverage.js";
|
|
9
11
|
import { defaultStorePath, localStorePath, Store, silenceSqliteWarning } from "./db.js";
|
|
10
12
|
import { discoverPackages } from "./discovery.js";
|
|
11
13
|
import { type DoctorReport, runDoctor } from "./doctor.js";
|
|
12
14
|
import { DocspackError } from "./errors.js";
|
|
13
|
-
import { commandHelpText, findCommandHelp, globalHelpText } from "./help.js";
|
|
15
|
+
import { cliDocument, commandHelpText, findCommandHelp, globalHelpText } from "./help.js";
|
|
14
16
|
import { renderTree } from "./init/write.js";
|
|
15
17
|
import { previewPackage } from "./preview.js";
|
|
16
18
|
import { type QueryResult, queryDocs, renderAnswer } from "./search.js";
|
|
@@ -22,11 +24,16 @@ import { verifyProject } from "./verify.js";
|
|
|
22
24
|
// answer a query, so the commands that need them import them at the point of use. Every
|
|
23
25
|
// `docspack ask` pays for what it uses and nothing else.
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* What each option's value is, by the key `parseArgs` reports it under — for the type of `values`
|
|
29
|
+
* only. Which command accepts which option is the CLI's cmdspec document (`cli-spec.ts`), and
|
|
30
|
+
* `tests/cli-spec.test.ts` fails if this map and that document disagree about a key or a type.
|
|
31
|
+
*/
|
|
32
|
+
export const OPTION_TYPES = {
|
|
33
|
+
help: { type: "boolean" },
|
|
34
|
+
version: { type: "boolean" },
|
|
28
35
|
json: { type: "boolean" },
|
|
29
|
-
quiet: { type: "boolean"
|
|
36
|
+
quiet: { type: "boolean" },
|
|
30
37
|
cwd: { type: "string" },
|
|
31
38
|
store: { type: "string" },
|
|
32
39
|
force: { type: "boolean" },
|
|
@@ -35,13 +42,14 @@ const OPTIONS = {
|
|
|
35
42
|
hooks: { type: "boolean" },
|
|
36
43
|
mcp: { type: "boolean" },
|
|
37
44
|
feedback: { type: "boolean" },
|
|
38
|
-
package: { type: "string"
|
|
45
|
+
package: { type: "string" },
|
|
39
46
|
limit: { type: "string" },
|
|
40
47
|
"max-tokens": { type: "string" },
|
|
41
48
|
all: { type: "boolean" },
|
|
42
49
|
from: { type: "string" },
|
|
43
50
|
"from-json": { type: "string" },
|
|
44
51
|
openapi: { type: "string" },
|
|
52
|
+
cmdspec: { type: "string" },
|
|
45
53
|
out: { type: "string" },
|
|
46
54
|
name: { type: "string" },
|
|
47
55
|
"pkg-version": { type: "string" },
|
|
@@ -56,9 +64,8 @@ const OPTIONS = {
|
|
|
56
64
|
template: { type: "string" },
|
|
57
65
|
community: { type: "boolean" },
|
|
58
66
|
workflow: { type: "boolean" },
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
yes: { type: "boolean", short: "y" },
|
|
67
|
+
build: { type: "boolean" },
|
|
68
|
+
yes: { type: "boolean" },
|
|
62
69
|
"dry-run": { type: "boolean" },
|
|
63
70
|
strict: { type: "boolean" },
|
|
64
71
|
pedantic: { type: "boolean" },
|
|
@@ -82,13 +89,55 @@ const EXIT_NO_MATCH = 4;
|
|
|
82
89
|
const HELP = globalHelpText(defaultStorePath(), EXIT_NOT_INDEXED, EXIT_NO_MATCH);
|
|
83
90
|
|
|
84
91
|
type Values = {
|
|
85
|
-
[K in keyof typeof
|
|
92
|
+
[K in keyof typeof OPTION_TYPES]?: (typeof OPTION_TYPES)[K] extends { multiple: true }
|
|
86
93
|
? string[]
|
|
87
|
-
: (typeof
|
|
94
|
+
: (typeof OPTION_TYPES)[K]["type"] extends "boolean"
|
|
88
95
|
? boolean
|
|
89
96
|
: string;
|
|
90
97
|
};
|
|
91
98
|
|
|
99
|
+
const SPEC = resolveDocument(CLI);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The command an argument list invokes: its leading words, walked down the CLI's command tree.
|
|
103
|
+
* Options before or between the words are skipped, along with the value of any that takes one,
|
|
104
|
+
* so `docspack --store ./s.db agent install` finds `agent install`.
|
|
105
|
+
*/
|
|
106
|
+
function invokedCommand(argv: readonly string[]): ResolvedCommand {
|
|
107
|
+
let current = SPEC.commands[0] as ResolvedCommand;
|
|
108
|
+
let skipValue = false;
|
|
109
|
+
for (const token of argv) {
|
|
110
|
+
if (skipValue) {
|
|
111
|
+
skipValue = false;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (token === "--") break;
|
|
115
|
+
if (token.startsWith("-")) {
|
|
116
|
+
const spelling = token.split("=")[0] ?? token;
|
|
117
|
+
const option = [...current.inherited, ...current.options].find(
|
|
118
|
+
(candidate) => candidate.name === spelling || (candidate.aliases ?? []).includes(spelling),
|
|
119
|
+
);
|
|
120
|
+
skipValue = !token.includes("=") && option?.value !== undefined;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const next = SPEC.commands.find(
|
|
124
|
+
(candidate) =>
|
|
125
|
+
candidate.path.length === current.path.length + 1 &&
|
|
126
|
+
candidate.path[candidate.path.length - 1] === token &&
|
|
127
|
+
candidate.path.slice(0, -1).join(" ") === current.path.join(" "),
|
|
128
|
+
);
|
|
129
|
+
if (next === undefined) break;
|
|
130
|
+
current = next;
|
|
131
|
+
}
|
|
132
|
+
return current;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The help page for a command, or the global one for the root and for `help`. */
|
|
136
|
+
function helpFor(command: ResolvedCommand): string {
|
|
137
|
+
const subject = command.path.length > 1 ? findCommandHelp(command.path[1] as string) : undefined;
|
|
138
|
+
return subject === undefined ? HELP : commandHelpText(subject);
|
|
139
|
+
}
|
|
140
|
+
|
|
92
141
|
const color = process.env.NO_COLOR === undefined && process.stdout.isTTY === true;
|
|
93
142
|
const paint = (code: string, text: string): string =>
|
|
94
143
|
color ? `\u001b[${code}m${text}\u001b[0m` : text;
|
|
@@ -172,20 +221,35 @@ function openLocalStore(values: Values, cwd: string): Store {
|
|
|
172
221
|
}
|
|
173
222
|
|
|
174
223
|
async function main(argv: readonly string[]): Promise<number> {
|
|
224
|
+
// Each command is parsed with the options its document gives it — its own and those it
|
|
225
|
+
// inherits — so a flag another command reads is an error here rather than silently ignored.
|
|
226
|
+
const invoked = invokedCommand(argv);
|
|
227
|
+
const config = parseArgsConfig(invoked);
|
|
175
228
|
let values: Values;
|
|
176
229
|
let positionals: string[];
|
|
177
230
|
try {
|
|
178
|
-
const parsed = parseArgs({
|
|
179
|
-
|
|
231
|
+
const parsed = parseArgs({
|
|
232
|
+
args: [...argv],
|
|
233
|
+
options: config.options,
|
|
234
|
+
allowPositionals: true,
|
|
235
|
+
allowNegative: config.allowNegative,
|
|
236
|
+
strict: true,
|
|
237
|
+
});
|
|
238
|
+
values = parsed.values as Values;
|
|
180
239
|
positionals = parsed.positionals;
|
|
181
240
|
} catch (error) {
|
|
182
241
|
process.stderr.write(
|
|
183
242
|
`${red("error")} ${error instanceof Error ? error.message : String(error)}\n\n`,
|
|
184
243
|
);
|
|
185
|
-
process.stderr.write(
|
|
244
|
+
process.stderr.write(helpFor(invoked));
|
|
186
245
|
return 2;
|
|
187
246
|
}
|
|
188
247
|
|
|
248
|
+
// The root's `--cmdspec` is a switch; `build`'s takes a file. Only the root's prints.
|
|
249
|
+
if (invoked.path.length === 1 && (values as { cmdspec?: unknown }).cmdspec === true) {
|
|
250
|
+
process.stdout.write(`${JSON.stringify(cliDocument(version()), null, 2)}\n`);
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
189
253
|
if (values.version === true) {
|
|
190
254
|
process.stdout.write(`${version()}\n`);
|
|
191
255
|
return 0;
|
|
@@ -242,7 +306,12 @@ async function main(argv: readonly string[]): Promise<number> {
|
|
|
242
306
|
const trust = pkg.trusted ? "" : ` ${yellow("(community)")}`;
|
|
243
307
|
// Declarations read from an installed build are not documentation somebody wrote, and
|
|
244
308
|
// the listing says so rather than letting them pass for it.
|
|
245
|
-
const kind =
|
|
309
|
+
const kind =
|
|
310
|
+
pkg.kind === "artifact"
|
|
311
|
+
? ` ${dim("(declarations)")}`
|
|
312
|
+
: pkg.kind === "cli"
|
|
313
|
+
? ` ${dim("(command line)")}`
|
|
314
|
+
: "";
|
|
246
315
|
process.stdout.write(
|
|
247
316
|
`${mark} ${bold(pkg.id)} ${pkg.chunks} chunks ${dim(pkg.status)}${trust}${kind}\n`,
|
|
248
317
|
);
|
|
@@ -437,6 +506,9 @@ async function main(argv: readonly string[]): Promise<number> {
|
|
|
437
506
|
// and got a chunk about a template path needs to see that the match was the template.
|
|
438
507
|
process.stdout.write(dim(`matched by endpoint: ${result.endpoints.join(", ")}\n`));
|
|
439
508
|
}
|
|
509
|
+
if (result.commands.length > 0) {
|
|
510
|
+
process.stdout.write(dim(`matched by command: ${result.commands.join(", ")}\n`));
|
|
511
|
+
}
|
|
440
512
|
process.stdout.write(
|
|
441
513
|
dim(`${result.tokens} tokens across ${plural(result.hits.length, "chunk")}\n`),
|
|
442
514
|
);
|
|
@@ -853,6 +925,7 @@ async function main(argv: readonly string[]): Promise<number> {
|
|
|
853
925
|
...(values.from === undefined ? {} : { from: values.from }),
|
|
854
926
|
...(values["from-json"] === undefined ? {} : { json: values["from-json"] }),
|
|
855
927
|
...(values.openapi === undefined ? {} : { openapi: values.openapi }),
|
|
928
|
+
...(values.cmdspec === undefined ? {} : { cmdspec: values.cmdspec }),
|
|
856
929
|
...(values.local === true ? { local: true } : {}),
|
|
857
930
|
...(values.name === undefined ? {} : { name: values.name }),
|
|
858
931
|
...(values["pkg-version"] === undefined ? {} : { version: values["pkg-version"] }),
|
|
@@ -914,11 +987,11 @@ async function main(argv: readonly string[]): Promise<number> {
|
|
|
914
987
|
? {}
|
|
915
988
|
: { template: values.template as "full" | "minimal" }),
|
|
916
989
|
...(values.community === true ? { community: true } : {}),
|
|
917
|
-
workflow: values
|
|
990
|
+
workflow: values.workflow !== false,
|
|
918
991
|
...(values.yes === true || json ? { yes: true } : {}),
|
|
919
992
|
...(values["dry-run"] === true ? { dryRun: true } : {}),
|
|
920
993
|
...(values.force === true ? { force: true } : {}),
|
|
921
|
-
...(values
|
|
994
|
+
...(values.build === false ? { build: false } : {}),
|
|
922
995
|
...(quiet || json
|
|
923
996
|
? {}
|
|
924
997
|
: {
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CmdspecDocument,
|
|
3
|
+
commandDigest,
|
|
4
|
+
commandMap,
|
|
5
|
+
commandPath,
|
|
6
|
+
commandSummary,
|
|
7
|
+
programName,
|
|
8
|
+
type ResolvedCommand,
|
|
9
|
+
type ResolvedDocument,
|
|
10
|
+
resolveDocument,
|
|
11
|
+
} from "@docspack/cmdspec";
|
|
12
|
+
import { estimateTokens } from "./spec.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A command-line interface as chunks, and a question that names a command answered by address.
|
|
16
|
+
*
|
|
17
|
+
* The same two halves `build --openapi` and `endpoints.ts` are for an HTTP API, applied to a CLI
|
|
18
|
+
* described in cmdspec (`packages/cmdspec`, `docs/design/cmdspec.md`): one chunk per command
|
|
19
|
+
* carrying `@docspack/cmdspec`'s digest — synopsis, inputs with their types and defaults, effects,
|
|
20
|
+
* outputs, exit statuses, examples — and the command's path recorded as an entity, so that
|
|
21
|
+
* `docspack ask "git remote add"` pins that chunk rather than ranking every chunk that says
|
|
22
|
+
* "remote".
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How a chunk entity says it is a command path: `$ git remote add`. A shell prompt, because no
|
|
27
|
+
* identifier or endpoint can start with one, so the three kinds of entity never collide.
|
|
28
|
+
*/
|
|
29
|
+
export const COMMAND_ENTITY = "$ ";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How many commands one answer may pin. Two, like endpoints: a question names one command,
|
|
33
|
+
* occasionally two ("init, then build"), and past that the names are incidental.
|
|
34
|
+
*/
|
|
35
|
+
export const MAX_COMMANDS = 2;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The most the overview's command list may cost. Past it the overview lists the top-level
|
|
39
|
+
* commands only: mise's 290 commands, one line each, are about 8,000 tokens — more than two whole
|
|
40
|
+
* answers — and the full map is what `cmdspec map` is for.
|
|
41
|
+
*/
|
|
42
|
+
const OVERVIEW_MAP_TOKENS = 800;
|
|
43
|
+
|
|
44
|
+
/** One chunk's worth of a CLI, before it is a file or a row. */
|
|
45
|
+
export interface CommandChunk {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
/** The command's path — `git remote add` — or undefined for the overview. */
|
|
48
|
+
readonly path?: string;
|
|
49
|
+
readonly title: string;
|
|
50
|
+
readonly text: string;
|
|
51
|
+
readonly tags: readonly string[];
|
|
52
|
+
readonly entities: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The chunks a CLI becomes: an overview, then one per command a caller runs.
|
|
57
|
+
*
|
|
58
|
+
* The overview carries the program's prose, the root's digest — the options every command
|
|
59
|
+
* accepts, with what they do, which a subcommand's digest only names — and the list of commands.
|
|
60
|
+
* The root has a chunk of its own only when it is itself a command a caller runs, as ffmpeg's is.
|
|
61
|
+
* A command that only groups others gets none unless it takes something its subcommands do not
|
|
62
|
+
* inherit, and a hidden command gets none for the reason it is hidden.
|
|
63
|
+
*/
|
|
64
|
+
export function commandChunks(document: CmdspecDocument): CommandChunk[] {
|
|
65
|
+
const resolved = resolveDocument(document);
|
|
66
|
+
const chunks: CommandChunk[] = [overview(resolved)];
|
|
67
|
+
for (const command of resolved.commands) {
|
|
68
|
+
if (!isChunked(command)) continue;
|
|
69
|
+
const path = commandPath(command);
|
|
70
|
+
const summary = commandSummary(command);
|
|
71
|
+
const prose = command.declared.description?.trim();
|
|
72
|
+
const text = [
|
|
73
|
+
prose === undefined || prose.length === 0 ? undefined : prose,
|
|
74
|
+
`\`\`\`text\n${commandDigest(command)}\n\`\`\``,
|
|
75
|
+
]
|
|
76
|
+
.filter((part) => part !== undefined)
|
|
77
|
+
.join("\n\n");
|
|
78
|
+
chunks.push({
|
|
79
|
+
id: commandChunkId(command.path),
|
|
80
|
+
path,
|
|
81
|
+
title: summary === undefined ? path : `${path} — ${summary}`,
|
|
82
|
+
text,
|
|
83
|
+
tags: [
|
|
84
|
+
"cli",
|
|
85
|
+
"command",
|
|
86
|
+
...command.path,
|
|
87
|
+
...(command.declared.group === undefined ? [] : [command.declared.group]),
|
|
88
|
+
],
|
|
89
|
+
entities: commandEntities(command),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return chunks;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A chunk id for a command path: `cli-git-remote-add`. Stable for as long as the path is. */
|
|
96
|
+
export function commandChunkId(path: readonly string[]): string {
|
|
97
|
+
const slug = path
|
|
98
|
+
.join("-")
|
|
99
|
+
.toLowerCase()
|
|
100
|
+
.normalize("NFKD")
|
|
101
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
102
|
+
.replace(/^-+|-+$/g, "")
|
|
103
|
+
.slice(0, 80)
|
|
104
|
+
.replace(/-+$/g, "");
|
|
105
|
+
return `cli-${slug.length > 0 ? slug : "command"}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The checks a CLI description can fail without being invalid — what makes its chunks answer
|
|
110
|
+
* worse, not what stops them existing. `build` prints them and `doctor` runs them against the
|
|
111
|
+
* package's configured `docspack.cmdspec`.
|
|
112
|
+
*/
|
|
113
|
+
export function commandFindings(document: CmdspecDocument): CommandFinding[] {
|
|
114
|
+
const resolved = resolveDocument(document);
|
|
115
|
+
const chunked = resolved.commands.filter(isChunked);
|
|
116
|
+
const findings: CommandFinding[] = [];
|
|
117
|
+
|
|
118
|
+
const unsummarized = chunked.filter((command) => commandSummary(command) === undefined);
|
|
119
|
+
if (unsummarized.length > 0) {
|
|
120
|
+
findings.push({
|
|
121
|
+
severity: "warn",
|
|
122
|
+
message: `${unsummarized.length} of ${chunked.length} commands have no summary or description: ${sample(unsummarized)}. The summary is the line a question is matched against.`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// A command that only dispatches to its subcommands is run through them; it has no example or
|
|
126
|
+
// effect of its own to state.
|
|
127
|
+
const runnable = chunked.filter((command) => command.declared.subcommandRequired !== true);
|
|
128
|
+
const unexampled = runnable.filter((command) => (command.declared.examples ?? []).length === 0);
|
|
129
|
+
if (unexampled.length > 0) {
|
|
130
|
+
findings.push({
|
|
131
|
+
severity: "info",
|
|
132
|
+
message: `${unexampled.length} of ${runnable.length} commands have no example: ${sample(unexampled)}. An example is the line an agent copies.`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (chunked.every((command) => Object.keys(command.exits).length === 0)) {
|
|
136
|
+
findings.push({
|
|
137
|
+
severity: "info",
|
|
138
|
+
message:
|
|
139
|
+
"No exit status is described. Declare at least what 0, 1 and 2 mean on the root, which every command inherits.",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const unstated = runnable.filter((command) => command.declared.effects === undefined);
|
|
143
|
+
if (unstated.length > 0) {
|
|
144
|
+
findings.push({
|
|
145
|
+
severity: "info",
|
|
146
|
+
message: `${unstated.length} of ${runnable.length} commands do not state their effects: ${sample(unstated)}. An agent reads that as unknown, not as safe.`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return findings;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Something a CLI description could say better. A missing summary is a warning — the summary is
|
|
154
|
+
* what a question is matched against — and the rest are notes.
|
|
155
|
+
*/
|
|
156
|
+
export interface CommandFinding {
|
|
157
|
+
readonly severity: "warn" | "info";
|
|
158
|
+
readonly message: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A command path the question names, and the chunk that describes it. */
|
|
162
|
+
export interface CommandEntry {
|
|
163
|
+
readonly chunkId: string;
|
|
164
|
+
/** The path as written: `git remote add`. */
|
|
165
|
+
readonly path: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Collects the command paths a set of chunks declares, from their entities. */
|
|
169
|
+
export function commandIndex(
|
|
170
|
+
chunks: Iterable<{ readonly chunkId: string; readonly entities: readonly string[] }>,
|
|
171
|
+
): CommandEntry[] {
|
|
172
|
+
const entries: CommandEntry[] = [];
|
|
173
|
+
for (const chunk of chunks) {
|
|
174
|
+
for (const entity of chunk.entities) {
|
|
175
|
+
if (entity.startsWith(COMMAND_ENTITY)) {
|
|
176
|
+
entries.push({ chunkId: chunk.chunkId, path: entity.slice(COMMAND_ENTITY.length) });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return entries;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The commands a question names, in the order it names them.
|
|
185
|
+
*
|
|
186
|
+
* A path matches where its words appear in the question in order and next to each other, exactly
|
|
187
|
+
* as written — command names are case-sensitive, and `Git Remote` is prose. Where two paths match
|
|
188
|
+
* at the same place the longer wins, so `git remote add origin …` pins `git remote add`, not
|
|
189
|
+
* `git remote` as well.
|
|
190
|
+
*/
|
|
191
|
+
export function matchCommands(
|
|
192
|
+
query: string,
|
|
193
|
+
index: readonly CommandEntry[],
|
|
194
|
+
limit = MAX_COMMANDS,
|
|
195
|
+
): CommandEntry[] {
|
|
196
|
+
if (index.length === 0) return [];
|
|
197
|
+
const words = query
|
|
198
|
+
.split(/\s+/)
|
|
199
|
+
.map((word) => word.replace(/^[`'"(]+|[`'".,;:!?)]+$/g, ""))
|
|
200
|
+
.filter((word) => word.length > 0);
|
|
201
|
+
|
|
202
|
+
const matches: { readonly entry: CommandEntry; readonly at: number; readonly length: number }[] =
|
|
203
|
+
[];
|
|
204
|
+
for (const entry of index) {
|
|
205
|
+
const path = entry.path.split(" ");
|
|
206
|
+
for (let at = 0; at + path.length <= words.length; at += 1) {
|
|
207
|
+
if (path.every((word, offset) => words[at + offset] === word)) {
|
|
208
|
+
matches.push({ entry, at, length: path.length });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const longest = matches.filter(
|
|
214
|
+
(match) =>
|
|
215
|
+
!matches.some(
|
|
216
|
+
(other) =>
|
|
217
|
+
other.at <= match.at &&
|
|
218
|
+
other.at + other.length >= match.at + match.length &&
|
|
219
|
+
other.length > match.length,
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
longest.sort((a, b) => a.at - b.at);
|
|
223
|
+
|
|
224
|
+
const found: CommandEntry[] = [];
|
|
225
|
+
const seen = new Set<string>();
|
|
226
|
+
for (const { entry } of longest) {
|
|
227
|
+
if (found.length >= limit) break;
|
|
228
|
+
if (seen.has(entry.chunkId)) continue;
|
|
229
|
+
seen.add(entry.chunkId);
|
|
230
|
+
found.push(entry);
|
|
231
|
+
}
|
|
232
|
+
return found;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function overview(resolved: ResolvedDocument): CommandChunk {
|
|
236
|
+
const root = resolved.commands[0] as ResolvedCommand;
|
|
237
|
+
const { document } = resolved;
|
|
238
|
+
const program = programName(document);
|
|
239
|
+
const map = commandMap(resolved);
|
|
240
|
+
const topLevel = resolved.commands.filter(
|
|
241
|
+
(command) => command.path.length === 2 && !command.hidden,
|
|
242
|
+
);
|
|
243
|
+
const fits = estimateTokens(map) <= OVERVIEW_MAP_TOKENS;
|
|
244
|
+
const list = fits
|
|
245
|
+
? map
|
|
246
|
+
: topLevel
|
|
247
|
+
.map((command) => `${commandPath(command)} # ${commandSummary(command) ?? ""}`)
|
|
248
|
+
.join("\n");
|
|
249
|
+
const more = fits
|
|
250
|
+
? ""
|
|
251
|
+
: `\n\nThe ${resolved.commands.length - 1} commands are too many to list here; ask for one by its path, such as \`${commandPath(topLevel[0] ?? (resolved.commands[0] as ResolvedCommand))}\`.`;
|
|
252
|
+
const prose = [document.info.summary, document.info.description]
|
|
253
|
+
.filter((part) => part !== undefined && part.trim().length > 0)
|
|
254
|
+
.join("\n\n");
|
|
255
|
+
return {
|
|
256
|
+
id: "cli",
|
|
257
|
+
title: `${program} ${document.info.version} — commands`,
|
|
258
|
+
text: [
|
|
259
|
+
prose.length > 0 ? prose : undefined,
|
|
260
|
+
isChunked(root) ? undefined : `\`\`\`text\n${commandDigest(root)}\n\`\`\``,
|
|
261
|
+
`Commands:\n\n\`\`\`text\n${list}\n\`\`\`${more}`,
|
|
262
|
+
]
|
|
263
|
+
.filter((part) => part !== undefined)
|
|
264
|
+
.join("\n\n"),
|
|
265
|
+
tags: ["cli", "commands", "overview", program],
|
|
266
|
+
entities: [],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isChunked(command: ResolvedCommand): boolean {
|
|
271
|
+
// Hidden itself or under a hidden command: mise's legacy `macos-defaults` group repeats the
|
|
272
|
+
// commands of `macos defaults`, and its subcommands do not each say they are hidden.
|
|
273
|
+
if (command.hidden) return false;
|
|
274
|
+
if (command.declared.subcommandRequired !== true) return true;
|
|
275
|
+
// The root's digest is in the overview, so a root that only dispatches needs nothing more.
|
|
276
|
+
if (command.path.length === 1) return false;
|
|
277
|
+
return (
|
|
278
|
+
command.arguments.length > 0 || command.options.some((option) => option.inherited !== true)
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The command's path, and the path through each of its own aliases: `git remote rm`. */
|
|
283
|
+
function commandEntities(command: ResolvedCommand): string[] {
|
|
284
|
+
const path = commandPath(command);
|
|
285
|
+
const parent = command.path.slice(0, -1).join(" ");
|
|
286
|
+
const aliases =
|
|
287
|
+
command.path.length > 1 && "aliases" in command.declared
|
|
288
|
+
? ((command.declared as { readonly aliases?: readonly string[] }).aliases ?? [])
|
|
289
|
+
: [];
|
|
290
|
+
return [path, ...aliases.map((alias) => `${parent} ${alias}`)].map(
|
|
291
|
+
(entry) => `${COMMAND_ENTITY}${entry}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function sample(commands: readonly ResolvedCommand[]): string {
|
|
296
|
+
const shown = commands.slice(0, 3).map(commandPath).join(", ");
|
|
297
|
+
return commands.length > 3 ? `${shown} and ${commands.length - 3} more` : shown;
|
|
298
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface FeedbackChannel {
|
|
|
27
27
|
export interface BuildConfig {
|
|
28
28
|
readonly from?: string;
|
|
29
29
|
readonly openapi?: string;
|
|
30
|
+
readonly cmdspec?: string;
|
|
30
31
|
readonly source?: string;
|
|
31
32
|
/**
|
|
32
33
|
* The libraries this package documents, each `name` or `name@version`. What `docspack verify`
|
|
@@ -69,7 +70,7 @@ export function parseBuildConfig(value: unknown): BuildConfig {
|
|
|
69
70
|
}
|
|
70
71
|
const raw = value as Record<string, unknown>;
|
|
71
72
|
|
|
72
|
-
const text = (key: "from" | "openapi" | "source"): string | undefined => {
|
|
73
|
+
const text = (key: "from" | "openapi" | "cmdspec" | "source"): string | undefined => {
|
|
73
74
|
const found = raw[key];
|
|
74
75
|
if (found === undefined) return undefined;
|
|
75
76
|
if (typeof found !== "string" || found.length === 0) {
|
|
@@ -89,6 +90,7 @@ export function parseBuildConfig(value: unknown): BuildConfig {
|
|
|
89
90
|
|
|
90
91
|
const from = text("from");
|
|
91
92
|
const openapi = text("openapi");
|
|
93
|
+
const cmdspec = text("cmdspec");
|
|
92
94
|
const source = text("source");
|
|
93
95
|
const documents = parseDocuments(raw.documents);
|
|
94
96
|
const feedback = parseFeedbackChannel(raw.feedback);
|
|
@@ -99,6 +101,7 @@ export function parseBuildConfig(value: unknown): BuildConfig {
|
|
|
99
101
|
return {
|
|
100
102
|
...(from === undefined ? {} : { from }),
|
|
101
103
|
...(openapi === undefined ? {} : { openapi }),
|
|
104
|
+
...(cmdspec === undefined ? {} : { cmdspec }),
|
|
102
105
|
...(source === undefined ? {} : { source }),
|
|
103
106
|
...(documents === undefined ? {} : { documents }),
|
|
104
107
|
...(feedback === undefined ? {} : { feedback }),
|
package/src/db.ts
CHANGED
|
@@ -16,7 +16,7 @@ const require = createRequire(import.meta.url);
|
|
|
16
16
|
* author, and a working corpus is a copy that can go stale under the reader — so it is stored
|
|
17
17
|
* rather than inferred from the name.
|
|
18
18
|
*/
|
|
19
|
-
export type PackageKind = "docs" | "artifact" | "local";
|
|
19
|
+
export type PackageKind = "docs" | "artifact" | "local" | "cli";
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Reads the stored kind, defaulting an unrecognized one to `docs`.
|
|
@@ -26,7 +26,7 @@ export type PackageKind = "docs" | "artifact" | "local";
|
|
|
26
26
|
* a local corpus would end up presented as published documentation.
|
|
27
27
|
*/
|
|
28
28
|
export function toPackageKind(value: string): PackageKind {
|
|
29
|
-
return value === "artifact" || value === "local" ? value : "docs";
|
|
29
|
+
return value === "artifact" || value === "local" || value === "cli" ? value : "docs";
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export interface IndexedPackage {
|
|
@@ -328,6 +328,13 @@ export class Store {
|
|
|
328
328
|
return rows.map((row) => row.name);
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
/** One package's names with the chunk each points at — a CLI's command paths, for one. */
|
|
332
|
+
symbolTable(packageId: string): { name: string; chunkId: string }[] {
|
|
333
|
+
return this.#db
|
|
334
|
+
.prepare("SELECT name, chunk_id AS chunkId FROM symbols WHERE package_id = ? ORDER BY name")
|
|
335
|
+
.all(packageId) as { name: string; chunkId: string }[];
|
|
336
|
+
}
|
|
337
|
+
|
|
331
338
|
/** One chunk by its id, however it was found. */
|
|
332
339
|
chunk(chunkId: string): SearchHit | undefined {
|
|
333
340
|
const row = this.#db
|