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.
Files changed (77) hide show
  1. package/README.md +25 -1
  2. package/dist/build.d.ts +5 -0
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +68 -5
  5. package/dist/build.js.map +1 -1
  6. package/dist/cli-spec.d.ts +3 -0
  7. package/dist/cli-spec.d.ts.map +1 -0
  8. package/dist/cli-spec.js +667 -0
  9. package/dist/cli-spec.js.map +1 -0
  10. package/dist/cli.d.ts +146 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +80 -14
  13. package/dist/cli.js.map +1 -1
  14. package/dist/cmdspec.json +1219 -0
  15. package/dist/commands.d.ts +78 -0
  16. package/dist/commands.d.ts.map +1 -0
  17. package/dist/commands.js +231 -0
  18. package/dist/commands.js.map +1 -0
  19. package/dist/config.d.ts +1 -0
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +2 -0
  22. package/dist/config.js.map +1 -1
  23. package/dist/db.d.ts +6 -1
  24. package/dist/db.d.ts.map +1 -1
  25. package/dist/db.js +7 -1
  26. package/dist/db.js.map +1 -1
  27. package/dist/discovery.d.ts +22 -3
  28. package/dist/discovery.d.ts.map +1 -1
  29. package/dist/discovery.js +91 -12
  30. package/dist/discovery.js.map +1 -1
  31. package/dist/doctor.d.ts.map +1 -1
  32. package/dist/doctor.js +66 -1
  33. package/dist/doctor.js.map +1 -1
  34. package/dist/help.d.ts +10 -6
  35. package/dist/help.d.ts.map +1 -1
  36. package/dist/help.js +118 -371
  37. package/dist/help.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/init/plan.js +4 -3
  43. package/dist/init/plan.js.map +1 -1
  44. package/dist/init/templates.d.ts.map +1 -1
  45. package/dist/init/templates.js +4 -2
  46. package/dist/init/templates.js.map +1 -1
  47. package/dist/preview.d.ts.map +1 -1
  48. package/dist/preview.js +12 -6
  49. package/dist/preview.js.map +1 -1
  50. package/dist/search.d.ts +2 -0
  51. package/dist/search.d.ts.map +1 -1
  52. package/dist/search.js +66 -22
  53. package/dist/search.js.map +1 -1
  54. package/dist/spec.d.ts +21 -1
  55. package/dist/spec.d.ts.map +1 -1
  56. package/dist/spec.js +24 -2
  57. package/dist/spec.js.map +1 -1
  58. package/dist/sync.d.ts.map +1 -1
  59. package/dist/sync.js +99 -1
  60. package/dist/sync.js.map +1 -1
  61. package/package.json +8 -5
  62. package/src/build.ts +86 -7
  63. package/src/cli-spec.ts +688 -0
  64. package/src/cli.ts +90 -17
  65. package/src/commands.ts +298 -0
  66. package/src/config.ts +4 -1
  67. package/src/db.ts +9 -2
  68. package/src/discovery.ts +113 -12
  69. package/src/doctor.ts +67 -0
  70. package/src/help.ts +138 -380
  71. package/src/index.ts +1 -0
  72. package/src/init/plan.ts +4 -3
  73. package/src/init/templates.ts +4 -2
  74. package/src/preview.ts +14 -13
  75. package/src/search.ts +79 -22
  76. package/src/spec.ts +28 -2
  77. 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
- const OPTIONS = {
26
- help: { type: "boolean", short: "h" },
27
- version: { type: "boolean", short: "v" },
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", short: "q" },
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", short: "p" },
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
- "no-workflow": { type: "boolean" },
60
- "no-build": { type: "boolean" },
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 OPTIONS]?: (typeof OPTIONS)[K] extends { multiple: true }
92
+ [K in keyof typeof OPTION_TYPES]?: (typeof OPTION_TYPES)[K] extends { multiple: true }
86
93
  ? string[]
87
- : (typeof OPTIONS)[K]["type"] extends "boolean"
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({ args: [...argv], options: OPTIONS, allowPositionals: true });
179
- values = parsed.values;
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(HELP);
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 = pkg.kind === "artifact" ? ` ${dim("(declarations)")}` : "";
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["no-workflow"] === true ? false : values.workflow !== false,
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["no-build"] === true ? { build: false } : {}),
994
+ ...(values.build === false ? { build: false } : {}),
922
995
  ...(quiet || json
923
996
  ? {}
924
997
  : {
@@ -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