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/preview.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { commandIndex, matchCommands } from "./commands.js";
3
4
  import { type IndexedChunk, type SearchHit, Store } from "./db.js";
4
5
  import { endpointIndex, matchEndpoints } from "./endpoints.js";
5
6
  import { DocspackError } from "./errors.js";
@@ -86,32 +87,32 @@ export async function previewPackage(options: PreviewOptions): Promise<PreviewRe
86
87
  ...(documents.length === 0 ? {} : { documents }),
87
88
  });
88
89
 
89
- // The same endpoint pinning `queryDocs` does, because `preview` exists to show an author what an
90
+ // The same endpoint and command pinning `queryDocs` does, because `preview` exists to show an author what an
90
91
  // agent would receive. A preview that ranked where the real query path pins would send them
91
92
  // chasing a retrieval problem they do not have.
92
93
  const limit = options.limit ?? DEFAULT_LIMIT;
93
94
  const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
94
- const pinned = matchEndpoints(
95
- options.query,
96
- endpointIndex(
97
- manifest.chunks.map((chunk) => ({
98
- chunkId: chunkId(id, chunk.id),
99
- entities: chunk.entities,
100
- })),
101
- ),
102
- )
103
- .map((match) => store.chunk(match.chunkId))
95
+ const addressable = manifest.chunks.map((chunk) => ({
96
+ chunkId: chunkId(id, chunk.id),
97
+ entities: chunk.entities,
98
+ }));
99
+ const pinnedIds = [
100
+ ...matchEndpoints(options.query, endpointIndex(addressable)),
101
+ ...matchCommands(options.query, commandIndex(addressable)),
102
+ ].map((match) => match.chunkId);
103
+ const pinned = [...new Set(pinnedIds)]
104
+ .map((pinnedId) => store.chunk(pinnedId))
104
105
  .filter((chunk): chunk is SearchHit => chunk !== undefined)
105
106
  .map(describe);
106
107
  const spent = pinned.reduce((total, hit) => total + hit.tokens, 0);
107
- const pinnedIds = new Set(pinned.map((hit) => hit.chunkId));
108
+ const pinnedSet = new Set(pinned.map((hit) => hit.chunkId));
108
109
 
109
110
  const ranked = store
110
111
  .search(options.query, {
111
112
  limit: Math.max(1, limit - pinned.length),
112
113
  maxTokens: Math.max(1, maxTokens - spent),
113
114
  })
114
- .filter((hit) => !pinnedIds.has(hit.chunkId))
115
+ .filter((hit) => !pinnedSet.has(hit.chunkId))
115
116
  .map(describe);
116
117
 
117
118
  const hits = [...pinned, ...ranked];
package/src/search.ts CHANGED
@@ -1,7 +1,13 @@
1
+ import { COMMAND_ENTITY, type CommandEntry, commandIndex, matchCommands } from "./commands.js";
1
2
  import type { PackageKind, SearchHit, Store } from "./db.js";
2
- import { discoverLibraries, discoverPackages } from "./discovery.js";
3
+ import {
4
+ type DiscoveredLibrary,
5
+ discoverCommandLines,
6
+ discoverLibraries,
7
+ discoverPackages,
8
+ } from "./discovery.js";
3
9
  import { endpointIndex, matchEndpoints } from "./endpoints.js";
4
- import { chunkId, formatDocumentedLibrary, isCommunityPackage } from "./spec.js";
10
+ import { CLI_SUFFIX, chunkId, formatDocumentedLibrary, isCommunityPackage } from "./spec.js";
5
11
 
6
12
  /** Ceiling on how much context one query may return, per the blueprint's context-exhaustion rule. */
7
13
  export const DEFAULT_MAX_TOKENS = 3000;
@@ -76,15 +82,27 @@ export interface QueryResult {
76
82
  * chunk, and has to be told that the match was against a template.
77
83
  */
78
84
  readonly endpoints: readonly string[];
85
+ /** Command paths the question named that a described command answers, pinned likewise. */
86
+ readonly commands: readonly string[];
79
87
  }
80
88
 
81
89
  export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
82
90
  const scoped = options.scoped !== false;
83
91
  const installed = scoped ? (await discoverPackages(options.cwd)).packages : undefined;
84
- const packageIds = installed?.map((pkg) => pkg.id);
85
- const unindexed = (installed ?? [])
86
- .filter((pkg) => !options.store.hasPackage(pkg.id))
87
- .map((pkg) => pkg.id);
92
+ // Read once: the libraries scope both the declarations and the CLIs a question can reach.
93
+ const libraries = scoped ? await discoverLibraries(options.cwd) : undefined;
94
+ const commandLines = scoped
95
+ ? (await discoverCommandLines(options.cwd, libraries)).map((cli) => cli.id)
96
+ : options.store
97
+ .listPackages()
98
+ .filter((pkg) => pkg.kind === "cli")
99
+ .map((pkg) => pkg.id);
100
+ const packageIds =
101
+ installed === undefined ? undefined : [...installed.map((pkg) => pkg.id), ...commandLines];
102
+ const unindexed = [
103
+ ...(installed ?? []).map((pkg) => pkg.id),
104
+ ...(scoped ? commandLines : []),
105
+ ].filter((id) => !options.store.hasPackage(id));
88
106
 
89
107
  // Read from the installed manifests rather than the index: the store is a cache of chunk text,
90
108
  // and adding a column to it would make every existing store need a rebuild to answer this.
@@ -107,9 +125,25 @@ export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
107
125
  const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
108
126
  const limit = options.limit ?? DEFAULT_LIMIT;
109
127
 
110
- // Endpoints first, and the ranker is then asked for less: a pinned operation is the answer, and
111
- // three ranked chunks beside it would spend the budget restating what it already says.
112
- const { hits: pinned, endpoints } = findEndpoints(options, operations, documented, maxTokens);
128
+ // A described CLI's command paths: from a docs package's manifest entities, and from the name
129
+ // table of each CLI read out of an installed package by `sync`.
130
+ const commandEntries = [
131
+ ...commandIndex(operations),
132
+ ...commandLines.flatMap((id) =>
133
+ options.store
134
+ .symbolTable(id)
135
+ .filter((row) => row.name.startsWith(COMMAND_ENTITY))
136
+ .map((row) => ({ chunkId: row.chunkId, path: row.name.slice(COMMAND_ENTITY.length) })),
137
+ ),
138
+ ];
139
+
140
+ // Addressed chunks first, and the ranker is then asked for less: a pinned operation or command
141
+ // is the answer, and three ranked chunks beside it would spend the budget restating it.
142
+ const {
143
+ hits: pinned,
144
+ endpoints,
145
+ commands,
146
+ } = findAddressed(options, operations, commandEntries, documented, maxTokens);
113
147
  const spent = pinned.reduce((total, hit) => total + hit.tokens, 0);
114
148
  const pinnedIds = new Set(pinned.map((hit) => hit.chunkId));
115
149
 
@@ -123,8 +157,9 @@ export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
123
157
  maxTokens: Math.max(1, maxTokens - spent),
124
158
  // Declarations are addressed by name, never ranked against prose: a library exports far
125
159
  // more names than its documentation has pages, and ranking them together would answer
126
- // every question with type machinery.
127
- kinds: ["docs"],
160
+ // every question with type machinery. A CLI's command chunks are prose about what to type,
161
+ // one per command, and are ranked like the documentation they are.
162
+ kinds: ["docs", "cli"],
128
163
  })
129
164
  .filter((hit) => !pinnedIds.has(hit.chunkId))
130
165
  .map((hit) => toQueryHit(hit, "docs", documented));
@@ -132,7 +167,7 @@ export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
132
167
  const { declarations, undocumented } =
133
168
  options.artifacts === false
134
169
  ? { declarations: [] as QueryHit[], undocumented: [] as string[] }
135
- : await findDeclarations(options, [...pinned, ...prose], maxTokens - spent);
170
+ : await findDeclarations(options, [...pinned, ...prose], maxTokens - spent, libraries);
136
171
 
137
172
  // Pinned operations, then declarations, then the ranked prose. Both of the first two were
138
173
  // addressed by name rather than guessed at, and for an agent about to write a call the exact
@@ -146,39 +181,55 @@ export async function queryDocs(options: QueryOptions): Promise<QueryResult> {
146
181
  unindexed,
147
182
  undocumented,
148
183
  endpoints,
184
+ commands,
149
185
  };
150
186
  }
151
187
 
152
188
  /**
153
- * Operation chunks the question addressed.
189
+ * Operation and command chunks the question addressed — operations first, then commands.
154
190
  *
155
191
  * Bounded by the same token budget as everything else, and the first match is always kept: an answer
156
- * whose one pinned operation did not fit would have pinned nothing and said nothing about it.
192
+ * whose one pinned chunk did not fit would have pinned nothing and said nothing about it.
157
193
  */
158
- function findEndpoints(
194
+ function findAddressed(
159
195
  options: QueryOptions,
160
196
  operations: readonly { chunkId: string; entities: readonly string[] }[],
197
+ commandEntries: readonly CommandEntry[],
161
198
  documented: ReadonlyMap<string, readonly string[]>,
162
199
  maxTokens: number,
163
- ): { hits: QueryHit[]; endpoints: string[] } {
164
- const matches = matchEndpoints(options.query, endpointIndex(operations));
200
+ ): { hits: QueryHit[]; endpoints: string[]; commands: string[] } {
201
+ const matches = [
202
+ ...matchEndpoints(options.query, endpointIndex(operations)).map((match) => ({
203
+ chunkId: match.chunkId,
204
+ endpoint: `${match.method} ${match.path}`,
205
+ })),
206
+ ...matchCommands(options.query, commandEntries).map((match) => ({
207
+ chunkId: match.chunkId,
208
+ command: match.path,
209
+ })),
210
+ ];
165
211
  const hits: QueryHit[] = [];
166
212
  const endpoints: string[] = [];
213
+ const commands: string[] = [];
214
+ const seen = new Set<string>();
167
215
  let spent = 0;
168
216
 
169
217
  for (const match of matches) {
218
+ if (seen.has(match.chunkId)) continue;
170
219
  if (options.packageFilter !== undefined && !match.chunkId.includes(options.packageFilter)) {
171
220
  continue;
172
221
  }
173
222
  const chunk = options.store.chunk(match.chunkId);
174
223
  if (chunk === undefined) continue;
175
224
  if (hits.length > 0 && spent + chunk.tokens > maxTokens) break;
225
+ seen.add(match.chunkId);
176
226
  hits.push(toQueryHit(chunk, "docs", documented));
177
- endpoints.push(`${match.method} ${match.path}`);
227
+ if ("endpoint" in match) endpoints.push(match.endpoint);
228
+ else commands.push(match.command);
178
229
  spent += chunk.tokens;
179
230
  }
180
231
 
181
- return { hits, endpoints };
232
+ return { hits, endpoints, commands };
182
233
  }
183
234
 
184
235
  /**
@@ -192,11 +243,12 @@ async function findDeclarations(
192
243
  options: QueryOptions,
193
244
  prose: readonly QueryHit[],
194
245
  maxTokens: number,
246
+ libraries: readonly DiscoveredLibrary[] | undefined,
195
247
  ): Promise<{ declarations: QueryHit[]; undocumented: string[] }> {
196
248
  const scope =
197
249
  options.scoped === false
198
250
  ? undefined
199
- : (await discoverLibraries(options.cwd)).map(
251
+ : (libraries ?? (await discoverLibraries(options.cwd))).map(
200
252
  (library) => `${library.name}@${library.version}`,
201
253
  );
202
254
 
@@ -261,7 +313,8 @@ function toQueryHit(
261
313
  ...hit,
262
314
  name,
263
315
  version,
264
- kind,
316
+ // A CLI's chunks are found by ranking and by address alike; the id says which kind they are.
317
+ kind: hit.packageId.endsWith(CLI_SUFFIX) ? "cli" : kind,
265
318
  trusted: !isCommunityPackage(name),
266
319
  ...(documents === undefined ? {} : { documents }),
267
320
  };
@@ -297,7 +350,9 @@ export function renderAnswer(result: QueryResult, query: string): string {
297
350
  const source =
298
351
  hit.kind === "artifact"
299
352
  ? `Source: ${hit.name}@${hit.version} — declared in ${hit.filePath}, read from the installed package`
300
- : `Source: ${hit.name}@${hit.version} — ${hit.filePath}${describes}`;
353
+ : hit.kind === "cli"
354
+ ? `Source: ${hit.name}@${hit.version} — ${hit.filePath}, the installed package's own description of its command line`
355
+ : `Source: ${hit.name}@${hit.version} — ${hit.filePath}${describes}`;
301
356
  return [`## ${hit.chunkId}${trust}`, source, "", hit.content].join("\n");
302
357
  });
303
358
 
@@ -322,6 +377,8 @@ function undocumentedNotice(result: QueryResult): string {
322
377
 
323
378
  /** Splits `@stripe/docspack@2025.4.1` into its name and version. */
324
379
  export function splitPackageId(id: string): { name: string; version: string } {
380
+ // A CLI's commands are indexed under the library's id with a marker; the release is the same.
381
+ if (id.endsWith(CLI_SUFFIX)) return splitPackageId(id.slice(0, -CLI_SUFFIX.length));
325
382
  const at = id.lastIndexOf("@");
326
383
  if (at <= 0) return { name: id, version: "" };
327
384
  return { name: id.slice(0, at), version: id.slice(at + 1) };
package/src/spec.ts CHANGED
@@ -46,9 +46,21 @@ export interface PackageManifest {
46
46
 
47
47
  const CHUNK_ID = /^[a-z0-9][a-z0-9._-]*$/i;
48
48
 
49
- /** Official vendor packages: `@stripe/docspack`. */
49
+ /**
50
+ * Official vendor packages: `@stripe/docspack`, and its siblings `@stripe/openapi-docspack`.
51
+ *
52
+ * The suffixed form is for a vendor that also redistributes somebody else's documentation, where
53
+ * licence, version, cadence and attribution belong to a different upstream and cannot be merged
54
+ * into one package. It costs nothing the single name was protecting: discovery stays a pure name
55
+ * check against `package.json`, and npm scope ownership is the boundary trust rests on — whoever
56
+ * can publish `@stripe/docspack` can publish `@stripe/openapi-docspack` and nobody else can.
57
+ *
58
+ * Derivation is a separate question and unchanged: `@stripe/docspack` is the one name inferable
59
+ * from a dependency on `@stripe/sdk`, so that is still the only name `init` suggests. Derive to
60
+ * suggest; match to discover.
61
+ */
50
62
  export function isVendorPackage(name: string): boolean {
51
- return /^@[^/]+\/docspack$/.test(name);
63
+ return /^@[^/]+\/(?:[^/]+-)?docspack$/.test(name);
52
64
  }
53
65
 
54
66
  /** Community packages: `@docspack-community/jira`. */
@@ -60,11 +72,25 @@ export function isDocsPackage(name: string): boolean {
60
72
  return isVendorPackage(name) || isCommunityPackage(name);
61
73
  }
62
74
 
75
+ /** The discoverable name shapes, for an error that has to say what it would have accepted. */
76
+ export const DISCOVERABLE_NAMES =
77
+ "@vendor/docspack, @vendor/<name>-docspack or @docspack-community/<name>";
78
+
63
79
  /** Stable identifier used as the primary key in the store: `@stripe/docspack@2025.4.1`. */
64
80
  export function packageId(name: string, version: string): string {
65
81
  return `${name}@${version}`;
66
82
  }
67
83
 
84
+ /**
85
+ * What marks a package of command chunks derived from an installed CLI's own description. Its id
86
+ * cannot be the library's, which its type declarations already hold.
87
+ */
88
+ export const CLI_SUFFIX = "#cli";
89
+
90
+ export function cliPackageId(name: string, version: string): string {
91
+ return `${packageId(name, version)}${CLI_SUFFIX}`;
92
+ }
93
+
68
94
  export function chunkId(pkgId: string, chunk: string): string {
69
95
  return `${pkgId}/${chunk}`;
70
96
  }
package/src/sync.ts CHANGED
@@ -1,7 +1,15 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import { relative } from "node:path";
2
3
  import { readArtifact } from "./artifact.js";
4
+ import { COMMAND_ENTITY, commandChunks } from "./commands.js";
3
5
  import type { IndexedChunk, PackageKind, Store } from "./db.js";
4
- import { type DiscoveredPackage, discoverLibraries, discoverPackages } from "./discovery.js";
6
+ import {
7
+ type DiscoveredCommandLine,
8
+ type DiscoveredPackage,
9
+ discoverCommandLines,
10
+ discoverLibraries,
11
+ discoverPackages,
12
+ } from "./discovery.js";
5
13
  import { chunkId, estimateTokens, resolveChunkFile } from "./spec.js";
6
14
 
7
15
  export interface SyncOptions {
@@ -84,10 +92,121 @@ export async function syncProject(options: SyncOptions): Promise<SyncResult> {
84
92
  }
85
93
 
86
94
  if (options.artifacts !== false) synced.push(...(await syncArtifacts(options)));
95
+ synced.push(...(await syncCommandLines(options, problems)));
87
96
 
88
97
  return { packages: synced, problems };
89
98
  }
90
99
 
100
+ /**
101
+ * Indexes the command-line interface each installed library describes in its own `package.json`
102
+ * (`"cmdspec"`, `packages/cmdspec/SPEC.md` §13): one chunk per command, ranked like documentation
103
+ * and addressed by command path, for exactly the version installed.
104
+ *
105
+ * Not tied to `--no-artifacts`. That flag skips the declarations, which are many and machine-made;
106
+ * a CLI's description is one document its authors wrote, and leaving it out would make `ask` report
107
+ * the package as installed but not indexed on every question.
108
+ */
109
+ async function syncCommandLines(
110
+ options: SyncOptions,
111
+ problems: string[],
112
+ ): Promise<SyncedPackage[]> {
113
+ const commandLines = await discoverCommandLines(options.cwd);
114
+ if (commandLines.length === 0) return [];
115
+ const synced: SyncedPackage[] = [];
116
+
117
+ for (const cli of commandLines) {
118
+ if (options.force !== true && options.store.hasPackage(cli.id)) {
119
+ synced.push({
120
+ id: cli.id,
121
+ name: cli.name,
122
+ version: cli.version,
123
+ chunks: options.store.countChunks(cli.id),
124
+ tokens: 0,
125
+ status: "cached",
126
+ trusted: true,
127
+ kind: "cli",
128
+ });
129
+ continue;
130
+ }
131
+
132
+ options.onProgress?.(`reading ${cli.id}`);
133
+ const { chunks, symbols } = await readCommandLine(cli, problems);
134
+ if (chunks.length === 0) continue;
135
+ options.store.indexPackage(
136
+ { id: cli.id, name: cli.name, version: cli.version, kind: "cli" },
137
+ chunks,
138
+ symbols,
139
+ );
140
+ synced.push({
141
+ id: cli.id,
142
+ name: cli.name,
143
+ version: cli.version,
144
+ chunks: chunks.length,
145
+ tokens: chunks.reduce((total, chunk) => total + chunk.tokens, 0),
146
+ status: "indexed",
147
+ trusted: true,
148
+ kind: "cli",
149
+ });
150
+ }
151
+ return synced;
152
+ }
153
+
154
+ /**
155
+ * A library's CLI descriptions as chunks, and its command paths as names pointing at them.
156
+ *
157
+ * A description that is invalid, or that describes another version than the one installed, is
158
+ * reported and skipped: indexing it would answer with a command line the installed program does
159
+ * not accept, which is the one failure this tool exists to prevent.
160
+ */
161
+ async function readCommandLine(
162
+ cli: DiscoveredCommandLine,
163
+ problems: string[],
164
+ ): Promise<{ chunks: IndexedChunk[]; symbols: Map<string, string> }> {
165
+ const { readFile: readDocument } = await import("@docspack/cmdspec/read");
166
+ const chunks: IndexedChunk[] = [];
167
+ const symbols = new Map<string, string>();
168
+ const taken = new Set<string>();
169
+
170
+ for (const path of cli.documents) {
171
+ const where = relative(cli.dir, path);
172
+ let document: Awaited<ReturnType<typeof readDocument>>["document"];
173
+ try {
174
+ ({ document } = await readDocument(path));
175
+ } catch (error) {
176
+ problems.push(
177
+ `${cli.name}: ${where} ${error instanceof Error ? error.message : String(error)}`,
178
+ );
179
+ continue;
180
+ }
181
+ if (document.info.version !== cli.version) {
182
+ problems.push(
183
+ `${cli.name}: ${where} describes version ${document.info.version}, but ${cli.version} is installed — not indexed`,
184
+ );
185
+ continue;
186
+ }
187
+
188
+ for (const chunk of commandChunks(document)) {
189
+ // Two executables in one package each have an overview called `cli`.
190
+ let id = chunk.id;
191
+ for (let suffix = 2; taken.has(id); suffix += 1) id = `${chunk.id}-${suffix}`;
192
+ taken.add(id);
193
+ const content = `# ${chunk.title}\n\n${chunk.text}`;
194
+ const full = chunkId(cli.id, id);
195
+ chunks.push({
196
+ chunkId: full,
197
+ filePath: chunk.path === undefined ? where : `${where}#${chunk.path}`,
198
+ tokens: estimateTokens(content),
199
+ content,
200
+ tags: [...chunk.tags, ...chunk.entities],
201
+ });
202
+ for (const entity of chunk.entities) {
203
+ if (entity.startsWith(COMMAND_ENTITY)) symbols.set(entity, full);
204
+ }
205
+ }
206
+ }
207
+ return { chunks, symbols };
208
+ }
209
+
91
210
  /**
92
211
  * Indexes each installed library's exported declarations.
93
212
  *