@readme/cli 0.0.26 → 0.0.27

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.
@@ -1,133 +1,370 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { createRequire } from 'node:module';
4
- import { query } from '@anthropic-ai/claude-agent-sdk';
5
- import matter from 'gray-matter';
6
- import ora from 'ora';
7
- import { collectFiles } from '../utils/lint.js';
8
- import { prettifyPagePrompt, stripCodeFences } from '../prompts/index.js';
9
- import { printHeader, isAgenticCli } from '../utils/eyes.js';
10
- import * as styles from '../utils/styles.js';
11
-
12
- const require = createRequire(import.meta.url);
13
- const pkg = require('../../package.json');
14
-
15
- export const command = 'pretty';
16
- export const order = 2;
17
- export const description = 'Polish docs into ReadMe-ready MDX with Claude';
18
- export const beta = true;
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { createRequire } from 'node:module'
4
+ import { query } from '@anthropic-ai/claude-agent-sdk'
5
+ import { InvalidArgumentError, Option } from 'commander'
6
+ import matter from 'gray-matter'
7
+ import { collectFiles } from '../utils/lint.js'
8
+ import { prettifyPagePrompt, prettifyPageOutputSchema } from '../prompts/index.js'
9
+ import { printHeader, isAgenticCli } from '../utils/eyes.js'
10
+ import { MultiSpinner } from '../utils/multi-spinner.js'
11
+ import { runPool } from '../utils/promise-pool.js'
12
+ import {
13
+ listLocalComponents,
14
+ listMarketplaceComponents,
15
+ installComponent,
16
+ readCachedReadme,
17
+ summarizeReadme,
18
+ } from '../utils/marketplace.js'
19
+ import * as styles from '../utils/styles.js'
20
+
21
+ const require = createRequire(import.meta.url)
22
+ const pkg = require('../../package.json')
23
+
24
+ /**
25
+ * Native ReadMe MDX tags that don't need a marketplace install — the renderer
26
+ * supports them out of the box. We exclude these from auto-install detection.
27
+ */
28
+ const NATIVE_TAGS = new Set([
29
+ 'Tabs', 'Tab',
30
+ 'Accordion',
31
+ 'Cards', 'Card',
32
+ 'Columns', 'Column',
33
+ 'Image',
34
+ ])
35
+
36
+ /**
37
+ * Pull every PascalCase tag name out of a block of mdx. Matches both
38
+ * `<Foo ... />` and `<Foo>...</Foo>`. Returns a Set of unique names.
39
+ */
40
+ function extractUsedTags(mdx) {
41
+ const tags = new Set()
42
+ const re = /<([A-Z][A-Za-z0-9]*)\b/g
43
+ let m
44
+ while ((m = re.exec(mdx)) !== null) tags.add(m[1])
45
+ return tags
46
+ }
47
+
48
+ export const command = 'pretty [file]'
49
+ export const order = 2
50
+ export const description = 'Polish docs into ReadMe-ready MDX with Claude'
51
+ export const beta = true
19
52
 
20
53
  export function args(cmd) {
21
- cmd.option('--model <name>', 'Claude model alias: haiku, sonnet, opus', 'sonnet');
22
- cmd.option('--dry-run', 'Show what would change without writing files');
54
+ cmd.addOption(new Option('-m, --model <name>', 'Claude model alias: haiku, sonnet, opus').choices(['haiku', 'sonnet', 'opus']).default('sonnet'))
55
+
56
+ cmd.option('--dry-run', 'Show what would change without writing files')
57
+ cmd.option('--no-auto-install', 'Don\'t auto-install marketplace components used in output')
58
+ cmd.option(
59
+ '-j, --jobs <n>',
60
+ 'Number of files to prettify in parallel',
61
+ (raw) => {
62
+ const n = Number(raw)
63
+ if (!Number.isInteger(n) || n < 1) {
64
+ throw new InvalidArgumentError('must be a positive integer')
65
+ }
66
+ return n
67
+ },
68
+ 1,
69
+ )
23
70
  }
24
71
 
25
72
  /**
26
- * Send one page through Claude using the prettify system prompt and parse the
27
- * JSON response. Returns the parsed object or throws on failure.
73
+ * Fetch with exponential backoff for transient (5xx / network) failures.
74
+ * `retries: 3` does up to 4 attempts with delays of 1s, 2s, 4s before each
75
+ * retry. 4xx responses are definitive and short-circuit so callers can fall
76
+ * through to the next strategy.
28
77
  */
29
- async function prettifyPage({ source, title, relativePath, model }) {
30
- const { systemPrompt, userPrompt } = prettifyPagePrompt({ source, title, relativePath });
78
+ async function fetchWithRetry(url, { headers = {}, retries = 3, baseDelayMs = 1000 } = {}) {
79
+ const maxAttempts = retries + 1
80
+ let lastError
81
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
82
+ try {
83
+ const res = await fetch(url, {
84
+ redirect: 'follow',
85
+ headers: { 'User-Agent': 'readme-cli-pretty', ...headers },
86
+ })
87
+ if (!res.ok) {
88
+ const err = new Error(`HTTP ${res.status} ${res.statusText}`)
89
+ err.status = res.status
90
+ throw err
91
+ }
92
+ return await res.text()
93
+ } catch (err) {
94
+ lastError = err
95
+ if (err.status && err.status >= 400 && err.status < 500) break
96
+ if (attempt < maxAttempts) {
97
+ const delay = baseDelayMs * Math.pow(2, attempt - 1)
98
+ await new Promise((resolve) => setTimeout(resolve, delay))
99
+ }
100
+ }
101
+ }
102
+ throw lastError
103
+ }
104
+
105
+ function withMdExtension(url) {
106
+ try {
107
+ const u = new URL(url)
108
+ if (u.pathname.endsWith('.md')) return null
109
+ u.pathname = u.pathname.replace(/\/$/, '') + '.md'
110
+ return u.toString()
111
+ } catch {
112
+ if (url.endsWith('.md')) return null
113
+ return url.replace(/\/$/, '') + '.md'
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Fetch a doc URL, preferring markdown. Falls through in order:
119
+ * 1. <url>.md (when not already .md)
120
+ * 2. <url> with Accept: text/markdown
121
+ * 3. <url> directly
122
+ * Each strategy gets its own retry budget for transient failures.
123
+ */
124
+ async function fetchImport(url, opts = {}) {
125
+ const strategies = []
126
+ const mdUrl = withMdExtension(url)
127
+ if (mdUrl) strategies.push({ url: mdUrl })
128
+ strategies.push({ url, headers: { Accept: 'text/markdown' } })
129
+ strategies.push({ url })
130
+
131
+ let lastError
132
+ for (const strategy of strategies) {
133
+ try {
134
+ return await fetchWithRetry(strategy.url, { ...opts, headers: strategy.headers })
135
+ } catch (err) {
136
+ lastError = err
137
+ }
138
+ }
139
+ throw new Error(`failed to fetch ${url}: ${lastError?.message || lastError}`)
140
+ }
31
141
 
32
- let text = '';
142
+ /**
143
+ * Send one page through Claude using the prettify system prompt and the agent
144
+ * SDK's structured-output mode. The runtime enforces `prettifyPageOutputSchema`
145
+ * and surfaces the parsed object on the result message's `structured_output`
146
+ * field — no fence-stripping or manual JSON.parse needed.
147
+ *
148
+ * `oasPartialJson` arrives as a string (the schema models it as such to dodge
149
+ * structured outputs' `additionalProperties: false` rule, which is
150
+ * incompatible with OpenAPI's free-form path/schema keys). We parse it here
151
+ * and re-expose it as `oasPartial` so callers see the conventional shape.
152
+ */
153
+ async function prettifyPage({ source, title, relativePath, model, localComponents, availableComponents }) {
154
+ const { systemPrompt, userPrompt } = prettifyPagePrompt({
155
+ source, title, relativePath, localComponents, availableComponents,
156
+ })
157
+
158
+ let structured
33
159
  for await (const message of query({
34
160
  prompt: userPrompt,
35
161
  options: {
36
162
  systemPrompt,
37
163
  allowedTools: [],
164
+ outputFormat: { type: 'json_schema', schema: prettifyPageOutputSchema },
38
165
  ...(model ? { model } : {}),
39
166
  },
40
167
  })) {
41
- if (message.type === 'assistant' && message.message?.content) {
42
- for (const block of message.message.content) {
43
- if (block.type === 'text' && block.text) text += block.text;
168
+ if (message.type === 'result') {
169
+ if (message.subtype !== 'success') {
170
+ throw new Error(`${message.subtype}${message.error?.message ? ' — ' + message.error.message : ''}`)
44
171
  }
45
- } else if (message.type === 'result') {
46
- if (message.subtype && message.subtype !== 'success') {
47
- throw new Error(`${message.subtype}${message.error?.message ? ' — ' + message.error.message : ''}`);
48
- }
49
- break;
172
+ structured = message.structured_output
173
+ break
50
174
  }
51
175
  }
52
176
 
53
- const stripped = stripCodeFences(text);
54
- try {
55
- return JSON.parse(stripped);
56
- } catch (e) {
57
- throw new Error(`invalid JSON (${e.message}); first 200 chars: ${stripped.slice(0, 200)}`);
177
+ if (!structured || typeof structured !== 'object') {
178
+ throw new Error('agent returned no structured output')
179
+ }
180
+
181
+ let oasPartial = null
182
+ if (typeof structured.oasPartialJson === 'string' && structured.oasPartialJson.length > 0) {
183
+ try {
184
+ oasPartial = JSON.parse(structured.oasPartialJson)
185
+ } catch (e) {
186
+ throw new Error(`invalid oasPartialJson (${e.message}); first 200 chars: ${structured.oasPartialJson.slice(0, 200)}`)
187
+ }
58
188
  }
189
+
190
+ return { body: structured.body, isApiRef: structured.isApiRef, oasPartial }
59
191
  }
60
192
 
61
- export async function run(options, _cmd, ctx) {
62
- const { gitRoot } = ctx;
193
+ export async function run(file, options, _cmd, ctx) {
194
+ const { gitRoot } = ctx
63
195
 
64
196
  if (!isAgenticCli()) {
65
- printHeader({ version: pkg.version, binName: styles.binName(), indent: ' ' });
66
- console.log();
197
+ printHeader({ version: pkg.version, binName: styles.binName(), indent: ' ' })
198
+ console.log()
199
+ }
200
+
201
+ let files
202
+ if (file) {
203
+ const absFile = path.resolve(file)
204
+ if (!fs.existsSync(absFile)) {
205
+ styles.error(`File not found: ${styles.bold(file)}`)
206
+ process.exit(1)
207
+ }
208
+ const relFile = path.relative(gitRoot, absFile)
209
+ if (relFile.startsWith('..') || path.isAbsolute(relFile)) {
210
+ styles.error(`File must be inside the repo: ${styles.bold(file)}`)
211
+ process.exit(1)
212
+ }
213
+ files = [relFile]
214
+ } else {
215
+ files = collectFiles(gitRoot)
67
216
  }
68
217
 
69
- const files = collectFiles(gitRoot);
70
218
  if (files.length === 0) {
71
- styles.warning('No docs files found to prettify.');
72
- return;
219
+ styles.warning('No docs files found to prettify.')
220
+ return
221
+ }
222
+
223
+ const localComponents = listLocalComponents(gitRoot)
224
+ const localNames = new Set(localComponents.map((c) => c.name))
225
+
226
+ // Pull the full marketplace catalog so Claude knows what else it can use.
227
+ // Summaries come from disk cache (populated by `readme components list`);
228
+ // we don't fetch readmes here to keep the prompt path fast.
229
+ let marketplace = []
230
+ if (options.autoInstall !== false) {
231
+ try {
232
+ marketplace = await listMarketplaceComponents()
233
+ } catch (err) {
234
+ styles.warning(`Could not load marketplace catalog: ${err.message} (continuing without it)`)
235
+ }
236
+ }
237
+ const marketplaceNames = new Set(marketplace.map((c) => c.name))
238
+ const availableComponents = marketplace
239
+ .filter((c) => !localNames.has(c.name))
240
+ .map((c) => ({ name: c.name, summary: summarizeReadme(readCachedReadme(c.name)) }))
241
+
242
+ // Concurrent workers may both try to install the same component. Coalesce
243
+ // duplicate installs through a Map<tag, Promise> — the second caller awaits
244
+ // the first's install instead of clobbering it.
245
+ const inFlightInstalls = new Map()
246
+ const autoInstalled = new Set()
247
+ function ensureInstalled(tag) {
248
+ if (inFlightInstalls.has(tag)) return inFlightInstalls.get(tag)
249
+ const p = (async () => {
250
+ await installComponent(gitRoot, tag)
251
+ localNames.add(tag)
252
+ autoInstalled.add(tag)
253
+ })()
254
+ inFlightInstalls.set(tag, p)
255
+ return p
73
256
  }
74
257
 
75
258
  styles.info(
76
- `Prettifying ${styles.bold(String(files.length))} file${files.length === 1 ? '' : 's'} with ${styles.bold(options.model)}${options.dryRun ? styles.dim(' (dry run)') : ''}.`,
77
- );
78
- console.log();
259
+ `Prettifying ${styles.bold(String(files.length))} file${files.length === 1 ? '' : 's'} with ${styles.bold(options.model)}${options.jobs > 1 ? ` (${styles.bold('-j ' + options.jobs)})` : ''}${options.dryRun ? styles.dim(' (dry run)') : ''}.`,
260
+ )
261
+ if (localComponents.length > 0) {
262
+ styles.info(
263
+ `Found ${styles.bold(String(localComponents.length))} local component${localComponents.length === 1 ? '' : 's'} in ${styles.bold('./components/')}: ${localComponents.map((c) => c.name).join(', ')}`,
264
+ )
265
+ }
266
+ if (options.autoInstall !== false && availableComponents.length > 0) {
267
+ styles.info(
268
+ `${styles.bold(String(availableComponents.length))} additional marketplace component${availableComponents.length === 1 ? '' : 's'} will be auto-installed if used.`,
269
+ )
270
+ }
271
+ console.log()
272
+
273
+ let updated = 0
274
+ let unchanged = 0
275
+ const failures = []
79
276
 
80
- const spinner = ora({ color: 'blue' }).start();
81
- let updated = 0;
82
- let unchanged = 0;
83
- const failures = [];
277
+ const rows = Math.min(options.jobs, files.length)
278
+ const multi = new MultiSpinner({ rows, color: 'blue' })
279
+ multi.start()
84
280
 
85
- for (const relativePath of files) {
86
- spinner.text = relativePath;
87
- const filePath = path.join(gitRoot, relativePath);
88
- const original = fs.readFileSync(filePath, 'utf-8');
89
- const parsed = matter(original);
281
+ await runPool(files, options.jobs, async (relativePath, _i, workerIdx) => {
282
+ multi.setLine(workerIdx, relativePath)
90
283
 
91
284
  try {
285
+ const filePath = path.join(gitRoot, relativePath)
286
+ const original = fs.readFileSync(filePath, 'utf-8')
287
+ const parsed = matter(original)
288
+
289
+ let source = parsed.content
290
+ const importUrl = parsed.data?.['x-import']
291
+ if (importUrl) {
292
+ multi.setLine(workerIdx, `${relativePath} ${styles.dim(`(fetching ${importUrl})`)}`)
293
+ source = await fetchImport(importUrl)
294
+ multi.setLine(workerIdx, relativePath)
295
+ }
296
+
92
297
  const result = await prettifyPage({
93
- source: parsed.content,
298
+ source,
94
299
  title: parsed.data?.title,
95
300
  relativePath,
96
301
  model: options.model,
97
- });
302
+ localComponents,
303
+ availableComponents: options.autoInstall === false ? [] : availableComponents,
304
+ })
98
305
 
99
306
  if (typeof result?.body !== 'string') {
100
- throw new Error('response missing "body" field');
307
+ throw new Error('response missing "body" field')
308
+ }
309
+
310
+ // Auto-install marketplace components Claude used in the body. We do
311
+ // this before writing the file so the docs page and its components land
312
+ // together — and we share the inFlightInstalls map across workers so a
313
+ // tag is only fetched once.
314
+ if (options.autoInstall !== false && !options.dryRun) {
315
+ const used = extractUsedTags(result.body)
316
+ for (const tag of used) {
317
+ if (NATIVE_TAGS.has(tag)) continue
318
+ if (localNames.has(tag)) continue
319
+ if (!marketplaceNames.has(tag)) continue
320
+ try {
321
+ multi.setLine(workerIdx, `${relativePath} ${styles.dim(`(installing <${tag}>)`)}`)
322
+ await ensureInstalled(tag)
323
+ } catch (err) {
324
+ failures.push({ relativePath, message: `auto-install <${tag}> failed: ${err.message}` })
325
+ }
326
+ }
327
+ multi.setLine(workerIdx, relativePath)
101
328
  }
102
329
 
103
- const newFm = { ...parsed.data };
104
- if (result.excerpt && !newFm.excerpt) newFm.excerpt = result.excerpt;
105
- const next = matter.stringify(result.body, newFm);
330
+ const newFm = { ...parsed.data }
331
+ delete newFm.hidden
332
+ delete newFm['x-import']
333
+ if (result.excerpt && !newFm.excerpt) newFm.excerpt = result.excerpt
334
+ const next = matter.stringify(result.body, newFm)
106
335
 
107
336
  if (next === original) {
108
- unchanged++;
337
+ unchanged++
338
+ if (!process.stderr.isTTY) multi.log(` ${styles.dim('·')} ${relativePath}`)
109
339
  } else {
110
- if (!options.dryRun) fs.writeFileSync(filePath, next, 'utf-8');
111
- updated++;
340
+ if (!options.dryRun) fs.writeFileSync(filePath, next, 'utf-8')
341
+ updated++
342
+ if (!process.stderr.isTTY) multi.log(` ${styles.success('✓')} ${relativePath}`)
112
343
  }
113
344
  } catch (err) {
114
- failures.push({ relativePath, message: err.message || String(err) });
345
+ failures.push({ relativePath, message: err.message || String(err) })
346
+ if (!process.stderr.isTTY) {
347
+ multi.log(` ${styles.err('●')} ${relativePath} ${styles.dim('— ' + (err.message || String(err)))}`)
348
+ }
349
+ } finally {
350
+ multi.clearLine(workerIdx)
115
351
  }
116
- }
352
+ })
117
353
 
118
- spinner.stop();
354
+ multi.stop()
119
355
 
120
- console.log();
121
- const verb = options.dryRun ? 'would update' : 'updated';
122
- styles.ok(
123
- `${styles.bold(String(updated))} ${verb} · ${styles.bold(String(unchanged))} unchanged${failures.length ? ` · ${styles.bold(String(failures.length))} failed` : ''}`,
124
- );
356
+ console.log()
357
+ const verb = options.dryRun ? 'would update' : 'updated'
358
+ styles.ok(`${styles.bold(String(updated))} ${verb} · ${styles.bold(String(unchanged))} unchanged${failures.length ? ` · ${styles.bold(String(failures.length))} failed` : ''}`)
359
+ if (autoInstalled.size > 0) {
360
+ styles.ok(`Auto-installed ${styles.bold(String(autoInstalled.size))} component${autoInstalled.size === 1 ? '' : 's'}: ${[...autoInstalled].join(', ')}`)
361
+ }
125
362
 
126
363
  if (failures.length > 0) {
127
- console.log();
364
+ console.log()
128
365
  for (const f of failures) {
129
- console.log(` ${styles.err('●')} ${styles.bold(f.relativePath)} ${styles.dim('— ' + f.message)}`);
366
+ console.log(` ${styles.err('●')} ${styles.bold(f.relativePath)} ${styles.dim('— ' + f.message)}`)
130
367
  }
131
- process.exit(1);
368
+ process.exit(1)
132
369
  }
133
370
  }
@@ -296,16 +296,44 @@ If the page IS an API reference, extract the endpoints into a partial OpenAPI 3.
296
296
  - Use realistic types inferred from the documentation (don't use "object" or "string" for everything)
297
297
  - Include the server/base URL if mentioned
298
298
 
299
- ## Output Format
300
- Respond with ONLY a JSON object (no markdown fences, no explanation):
301
- {
302
- "excerpt": "one-line summary of the page",
303
- "body": "the full MDX-fixed markdown content (no frontmatter)",
304
- "isApiRef": true/false,
305
- "suggestedCategory": "Best Category Name",
306
- "oasPartial": null OR {"paths": {"/endpoint": {"get": {...}}}, "schemas": {"ModelName": {...}}}
307
- }
308
- When isApiRef=false, set oasPartial to null. When isApiRef=true, include the extracted paths and schemas.`;
299
+ ## Output
300
+ The runtime enforces the response shape via a JSON schema, so don't worry about formatting — just populate every field with the right content.
301
+
302
+ - \`body\`: the full MDX-fixed markdown content with no frontmatter. This is the load-bearing field — get the content right, render it human-readable, and don't leave broken MDX.
303
+ - \`isApiRef\`: true if this page primarily documents API endpoints (per the rules above), false otherwise.
304
+ - \`oasPartialJson\`: when \`isApiRef\` is true, set this to a JSON-stringified partial OpenAPI 3.0 fragment of the form \`{"paths": {"/endpoint": {"get": {...}}}, "schemas": {"ModelName": {...}}}\`. The caller will \`JSON.parse\` it. When \`isApiRef\` is false, set it to \`null\`.
305
+ `;
306
+
307
+ /**
308
+ * JSON schema describing the structured output shape returned by the prettify
309
+ * agent. Pass to the Claude Agent SDK's \`outputFormat\` option so the runtime
310
+ * enforces the shape and returns parsed data on the result message's
311
+ * \`structured_output\` field — no manual fence-stripping or JSON.parse needed.
312
+ *
313
+ * \`oasPartialJson\` is a stringified OAS fragment rather than a nested object
314
+ * because Anthropic's structured outputs require \`additionalProperties: false\`
315
+ * on every object, which is incompatible with OpenAPI's free-form path/schema
316
+ * keys. The caller parses the string client-side.
317
+ */
318
+ export const prettifyPageOutputSchema = {
319
+ type: 'object',
320
+ additionalProperties: false,
321
+ required: ['body', 'isApiRef', 'oasPartialJson'],
322
+ properties: {
323
+ body: {
324
+ type: 'string',
325
+ description: 'The MDX-fixed markdown content for the page (no frontmatter).',
326
+ },
327
+ isApiRef: {
328
+ type: 'boolean',
329
+ description: 'True if the page primarily documents REST/GraphQL/gRPC endpoints.',
330
+ },
331
+ oasPartialJson: {
332
+ anyOf: [{ type: 'string' }, { type: 'null' }],
333
+ description: 'JSON-stringified partial OpenAPI 3.0 fragment when isApiRef is true; null otherwise.',
334
+ },
335
+ },
336
+ };
309
337
 
310
338
  /**
311
339
  * @param {object} input
@@ -326,9 +354,56 @@ export function buildPrettifyPageUserPrompt({ source, title, relativePath }) {
326
354
  ].join('\n');
327
355
  }
328
356
 
357
+ /**
358
+ * Build a short addendum describing the user's locally-installed marketplace
359
+ * components so Claude can use them in prettified output. We pull the first
360
+ * usage signature out of each component's mdx (a `<Name ... />` line near the
361
+ * bottom of the file).
362
+ *
363
+ * @param {Array<{ name: string, source: string }>} components
364
+ * @returns {string} — the addendum, or '' if there are no components.
365
+ */
366
+ export function buildLocalComponentsSection(components) {
367
+ if (!components || components.length === 0) return '';
368
+ const lines = ['', '### Project-specific components (installed in ./components/)'];
369
+ lines.push(
370
+ 'The following custom components are installed in this project and may be used in prettified output. Prefer them when they fit the content better than plain markdown — same liberal-upgrade bias as the built-in components above. Do NOT invent components that are not listed here or in the built-in set.',
371
+ '',
372
+ );
373
+ for (const c of components) {
374
+ const sig = (c.source.match(new RegExp(`<${c.name}[\\s\\S]*?(?:/>|</${c.name}>)`, 'm')) || [])[0];
375
+ lines.push(`- \`<${c.name}>\`${sig ? ` — e.g. \`${sig.replace(/\s+/g, ' ').slice(0, 200)}\`` : ''}`);
376
+ }
377
+ return lines.join('\n');
378
+ }
379
+
380
+ /**
381
+ * Build a short section listing additional marketplace components that are
382
+ * NOT yet installed but can be — the CLI will auto-install any of them that
383
+ * the model uses in its output.
384
+ *
385
+ * @param {Array<{ name: string, summary?: string }>} available
386
+ * Marketplace components not currently in ./components/.
387
+ * @returns {string} — the addendum, or '' if there are none.
388
+ */
389
+ export function buildAvailableComponentsSection(available) {
390
+ if (!available || available.length === 0) return '';
391
+ const lines = ['', '### Marketplace components (auto-installed on use)'];
392
+ lines.push(
393
+ 'These components are NOT yet installed locally, but the CLI will copy them into ./components/ automatically if you use any of them. Use them when they clearly fit the content — same upgrade bias as built-in components. Do NOT invent components outside this list and the built-in set.',
394
+ '',
395
+ );
396
+ for (const c of available) {
397
+ lines.push(`- \`<${c.name}>\`${c.summary ? ` — ${c.summary}` : ''}`);
398
+ }
399
+ return lines.join('\n');
400
+ }
401
+
329
402
  export function prettifyPagePrompt(input) {
403
+ const localAddendum = buildLocalComponentsSection(input.localComponents);
404
+ const availableAddendum = buildAvailableComponentsSection(input.availableComponents);
330
405
  return {
331
- systemPrompt: prettifyPageSystemPrompt,
406
+ systemPrompt: prettifyPageSystemPrompt + localAddendum + availableAddendum,
332
407
  userPrompt: buildPrettifyPageUserPrompt(input),
333
408
  };
334
409
  }