@readme/cli 0.1.0 → 0.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 (57) hide show
  1. package/bin/git-format.js +80 -0
  2. package/bin/readme.js +1 -1
  3. package/package.json +54 -26
  4. package/src/bootstrap.js +9 -13
  5. package/src/cli.js +17 -11
  6. package/src/commands/components.js +35 -18
  7. package/src/commands/dev.js +12 -8
  8. package/src/commands/eyes.js +12 -8
  9. package/src/commands/import.js +2200 -2049
  10. package/src/commands/lint.js +33 -29
  11. package/src/commands/oas-bundle.js +6 -2
  12. package/src/commands/oas-sync.js +271 -161
  13. package/src/commands/oas-validate.js +10 -7
  14. package/src/commands/pretty.js +188 -165
  15. package/src/commands/setup.js +24 -26
  16. package/src/commands/versions.js +6 -2
  17. package/src/dev/app/[...slug]/page.js +1 -0
  18. package/src/dev/app/layout.js +18 -11
  19. package/src/dev/app/page.js +1 -0
  20. package/src/dev/lib/docs.js +27 -19
  21. package/src/dev/next.config.mjs +2 -2
  22. package/src/gha.js +7 -7
  23. package/src/prompts/index.js +16 -6
  24. package/src/utils/containment.js +87 -0
  25. package/src/utils/eyes.js +143 -51
  26. package/src/utils/git.js +3 -3
  27. package/src/utils/lint.js +39 -38
  28. package/src/utils/llms.js +94 -91
  29. package/src/utils/markdown.js +256 -0
  30. package/src/utils/marketplace.js +12 -5
  31. package/src/utils/multi-spinner.js +5 -7
  32. package/src/utils/promise-pool.js +7 -5
  33. package/src/utils/reporter.js +88 -94
  34. package/src/utils/setup-templates.js +12 -6
  35. package/src/utils/styles.js +2 -1
  36. package/src/utils/tamagotchi.js +257 -91
  37. package/src/utils/tips.js +18 -9
  38. package/src/utils/url-segs.js +46 -41
  39. package/src/validators/components.js +55 -63
  40. package/src/validators/content.js +16 -14
  41. package/src/validators/duplicates.js +6 -2
  42. package/src/validators/frontmatter.js +21 -29
  43. package/src/validators/links.js +4 -8
  44. package/src/validators/oas-reference.js +25 -18
  45. package/src/validators/oas-schema.js +2 -0
  46. package/src/validators/ordering.js +5 -5
  47. package/src/validators/recipes.js +4 -12
  48. package/vendor/git-format/CLAUDE.md +352 -0
  49. package/vendor/git-format/frontmatter.schema.json +199 -0
  50. package/src/commands/import-oas.test.js +0 -277
  51. package/src/commands/import.classify.test.js +0 -172
  52. package/src/commands/import.landing.test.js +0 -217
  53. package/src/commands/import.slug-planner.test.js +0 -86
  54. package/src/commands/import.staging.test.js +0 -230
  55. package/src/commands/import.test.js +0 -382
  56. package/src/utils/llms.test.js +0 -302
  57. package/src/utils/url-segs.test.js +0 -239
@@ -1,9 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+
3
4
  import OASNormalize from 'oas-normalize';
4
- import { findOasFiles, extractOperations } from './oas-sync.js';
5
- import * as styles from '../utils/styles.js';
5
+
6
6
  import { writeGithubActionsOutputs } from '../utils/gha-output.js';
7
+ import * as styles from '../utils/styles.js';
8
+
9
+ import { findOasFiles, extractOperations } from './oas-sync.js';
7
10
 
8
11
  export const command = 'oas:validate';
9
12
  export const order = 3;
@@ -114,7 +117,7 @@ export async function validateOas({ cwd, dereference = false } = {}) {
114
117
  continue;
115
118
  }
116
119
 
117
- const errors = normalizerResult.valid ? [] : (normalizerResult.errors || []);
120
+ const errors = normalizerResult.valid ? [] : normalizerResult.errors || [];
118
121
  const warnings = normalizerResult.warnings || [];
119
122
 
120
123
  if (dereference) {
@@ -158,9 +161,7 @@ export async function validateOasFiles(gitRoot, { dereference = false } = {}) {
158
161
 
159
162
  for (const r of summary.results) {
160
163
  const meta = `${r.filename} · ${r.version} · ${r.opCount} ${r.opCount === 1 ? 'endpoint' : 'endpoints'}`;
161
- const dot = r.errors.length > 0
162
- ? styles.err('●')
163
- : r.warnings.length > 0 ? styles.warn('●') : styles.success('●');
164
+ const dot = r.errors.length > 0 ? styles.err('●') : r.warnings.length > 0 ? styles.warn('●') : styles.success('●');
164
165
 
165
166
  console.log();
166
167
  console.log(` ${dot} ${styles.bold(r.title)} ${styles.dim(`(${meta})`)}`);
@@ -224,7 +225,9 @@ export async function run(options, _cmd, ctx) {
224
225
  styles.error(`${parts.join(' and ')} across ${fileCount} ${fileCount === 1 ? 'spec' : 'specs'}.`);
225
226
  process.exit(1);
226
227
  } else if (totalWarnings > 0) {
227
- styles.warning(`${totalValid} of ${fileCount} specs valid with ${totalWarnings} ${totalWarnings === 1 ? 'warning' : 'warnings'}.`);
228
+ styles.warning(
229
+ `${totalValid} of ${fileCount} specs valid with ${totalWarnings} ${totalWarnings === 1 ? 'warning' : 'warnings'}.`,
230
+ );
228
231
  } else {
229
232
  styles.ok(`${fileCount} ${fileCount === 1 ? 'spec' : 'specs'} validated — all good!`);
230
233
  }
@@ -1,72 +1,73 @@
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'
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+
5
+ import { query } from '@anthropic-ai/claude-agent-sdk';
6
+ import { InvalidArgumentError, Option } from 'commander';
7
+
8
+ import { prettifyPagePrompt, prettifyPageOutputSchema } from '../prompts/index.js';
9
+ import { makeContainment, PathEscapeError } from '../utils/containment.js';
10
+ import { printHeader, isAgenticCli } from '../utils/eyes.js';
11
+ import { collectFiles } from '../utils/lint.js';
12
+ import { parseFrontmatter, toFrontmatter, frontmatterWriteError } from '../utils/markdown.js';
12
13
  import {
13
14
  listLocalComponents,
14
15
  listMarketplaceComponents,
15
16
  installComponent,
16
17
  readCachedReadme,
17
18
  summarizeReadme,
18
- } from '../utils/marketplace.js'
19
- import * as styles from '../utils/styles.js'
19
+ } from '../utils/marketplace.js';
20
+ import { MultiSpinner } from '../utils/multi-spinner.js';
21
+ import { runPool } from '../utils/promise-pool.js';
22
+ import * as styles from '../utils/styles.js';
20
23
 
21
- const require = createRequire(import.meta.url)
22
- const pkg = require('../../package.json')
24
+ const require = createRequire(import.meta.url);
25
+ const pkg = require('../../package.json');
23
26
 
24
27
  /**
25
28
  * Native ReadMe MDX tags that don't need a marketplace install — the renderer
26
29
  * supports them out of the box. We exclude these from auto-install detection.
27
30
  */
28
- const NATIVE_TAGS = new Set([
29
- 'Tabs', 'Tab',
30
- 'Accordion',
31
- 'Cards', 'Card',
32
- 'Columns', 'Column',
33
- 'Image',
34
- ])
31
+ const NATIVE_TAGS = new Set(['Tabs', 'Tab', 'Accordion', 'Cards', 'Card', 'Columns', 'Column', 'Image']);
35
32
 
36
33
  /**
37
34
  * Pull every PascalCase tag name out of a block of mdx. Matches both
38
35
  * `<Foo ... />` and `<Foo>...</Foo>`. Returns a Set of unique names.
39
36
  */
40
37
  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
38
+ const tags = new Set();
39
+ const re = /<([A-Z][A-Za-z0-9]*)\b/g;
40
+ let m;
41
+ while ((m = re.exec(mdx)) !== null) tags.add(m[1]);
42
+ return tags;
46
43
  }
47
44
 
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
45
+ export const command = 'pretty [file]';
46
+ export const order = 2;
47
+ export const description = 'Polish docs into ReadMe-ready MDX with Claude';
48
+ export const beta = true;
52
49
 
53
50
  export function args(cmd) {
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')
51
+ cmd.addOption(
52
+ new Option('-m, --model <name>', 'Claude model alias: haiku, sonnet, opus')
53
+ .choices(['haiku', 'sonnet', 'opus'])
54
+ .default('sonnet'),
55
+ );
56
+
57
+ cmd.option('--dry-run', 'Show what would change without writing files');
58
+ cmd.option('--no-auto-install', "Don't auto-install marketplace components used in output");
58
59
  cmd.option(
59
60
  '-j, --jobs <n>',
60
61
  'Number of files to prettify in parallel',
61
- (raw) => {
62
- const n = Number(raw)
62
+ raw => {
63
+ const n = Number(raw);
63
64
  if (!Number.isInteger(n) || n < 1) {
64
- throw new InvalidArgumentError('must be a positive integer')
65
+ throw new InvalidArgumentError('must be a positive integer');
65
66
  }
66
- return n
67
+ return n;
67
68
  },
68
69
  1,
69
- )
70
+ );
70
71
  }
71
72
 
72
73
  /**
@@ -76,41 +77,41 @@ export function args(cmd) {
76
77
  * through to the next strategy.
77
78
  */
78
79
  async function fetchWithRetry(url, { headers = {}, retries = 3, baseDelayMs = 1000 } = {}) {
79
- const maxAttempts = retries + 1
80
- let lastError
80
+ const maxAttempts = retries + 1;
81
+ let lastError;
81
82
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
82
83
  try {
83
84
  const res = await fetch(url, {
84
85
  redirect: 'follow',
85
86
  headers: { 'User-Agent': 'readme-cli-pretty', ...headers },
86
- })
87
+ });
87
88
  if (!res.ok) {
88
- const err = new Error(`HTTP ${res.status} ${res.statusText}`)
89
- err.status = res.status
90
- throw err
89
+ const err = new Error(`HTTP ${res.status} ${res.statusText}`);
90
+ err.status = res.status;
91
+ throw err;
91
92
  }
92
- return await res.text()
93
+ return await res.text();
93
94
  } catch (err) {
94
- lastError = err
95
- if (err.status && err.status >= 400 && err.status < 500) break
95
+ lastError = err;
96
+ if (err.status && err.status >= 400 && err.status < 500) break;
96
97
  if (attempt < maxAttempts) {
97
- const delay = baseDelayMs * Math.pow(2, attempt - 1)
98
- await new Promise((resolve) => setTimeout(resolve, delay))
98
+ const delay = baseDelayMs * 2 ** (attempt - 1);
99
+ await new Promise(resolve => setTimeout(resolve, delay));
99
100
  }
100
101
  }
101
102
  }
102
- throw lastError
103
+ throw lastError;
103
104
  }
104
105
 
105
106
  function withMdExtension(url) {
106
107
  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()
108
+ const u = new URL(url);
109
+ if (u.pathname.endsWith('.md')) return null;
110
+ u.pathname = `${u.pathname.replace(/\/$/, '')}.md`;
111
+ return u.toString();
111
112
  } catch {
112
- if (url.endsWith('.md')) return null
113
- return url.replace(/\/$/, '') + '.md'
113
+ if (url.endsWith('.md')) return null;
114
+ return `${url.replace(/\/$/, '')}.md`;
114
115
  }
115
116
  }
116
117
 
@@ -122,21 +123,21 @@ function withMdExtension(url) {
122
123
  * Each strategy gets its own retry budget for transient failures.
123
124
  */
124
125
  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 })
126
+ const strategies = [];
127
+ const mdUrl = withMdExtension(url);
128
+ if (mdUrl) strategies.push({ url: mdUrl });
129
+ strategies.push({ url, headers: { Accept: 'text/markdown' } });
130
+ strategies.push({ url });
130
131
 
131
- let lastError
132
+ let lastError;
132
133
  for (const strategy of strategies) {
133
134
  try {
134
- return await fetchWithRetry(strategy.url, { ...opts, headers: strategy.headers })
135
+ return await fetchWithRetry(strategy.url, { ...opts, headers: strategy.headers });
135
136
  } catch (err) {
136
- lastError = err
137
+ lastError = err;
137
138
  }
138
139
  }
139
- throw new Error(`failed to fetch ${url}: ${lastError?.message || lastError}`)
140
+ throw new Error(`failed to fetch ${url}: ${lastError?.message || lastError}`);
140
141
  }
141
142
 
142
143
  /**
@@ -152,10 +153,14 @@ async function fetchImport(url, opts = {}) {
152
153
  */
153
154
  async function prettifyPage({ source, title, relativePath, model, localComponents, availableComponents }) {
154
155
  const { systemPrompt, userPrompt } = prettifyPagePrompt({
155
- source, title, relativePath, localComponents, availableComponents,
156
- })
157
-
158
- let structured
156
+ source,
157
+ title,
158
+ relativePath,
159
+ localComponents,
160
+ availableComponents,
161
+ });
162
+
163
+ let structured;
159
164
  for await (const message of query({
160
165
  prompt: userPrompt,
161
166
  options: {
@@ -167,131 +172,145 @@ async function prettifyPage({ source, title, relativePath, model, localComponent
167
172
  })) {
168
173
  if (message.type === 'result') {
169
174
  if (message.subtype !== 'success') {
170
- throw new Error(`${message.subtype}${message.error?.message ? ' — ' + message.error.message : ''}`)
175
+ throw new Error(`${message.subtype}${message.error?.message ? ` — ${message.error.message}` : ''}`);
171
176
  }
172
- structured = message.structured_output
173
- break
177
+ structured = message.structured_output;
178
+ break;
174
179
  }
175
180
  }
176
181
 
177
182
  if (!structured || typeof structured !== 'object') {
178
- throw new Error('agent returned no structured output')
183
+ throw new Error('agent returned no structured output');
179
184
  }
180
185
 
181
- let oasPartial = null
186
+ let oasPartial = null;
182
187
  if (typeof structured.oasPartialJson === 'string' && structured.oasPartialJson.length > 0) {
183
188
  try {
184
- oasPartial = JSON.parse(structured.oasPartialJson)
189
+ oasPartial = JSON.parse(structured.oasPartialJson);
185
190
  } catch (e) {
186
- throw new Error(`invalid oasPartialJson (${e.message}); first 200 chars: ${structured.oasPartialJson.slice(0, 200)}`)
191
+ throw new Error(
192
+ `invalid oasPartialJson (${e.message}); first 200 chars: ${structured.oasPartialJson.slice(0, 200)}`,
193
+ { cause: e },
194
+ );
187
195
  }
188
196
  }
189
197
 
190
- return { body: structured.body, isApiRef: structured.isApiRef, oasPartial }
198
+ return { body: structured.body, isApiRef: structured.isApiRef, oasPartial };
191
199
  }
192
200
 
193
201
  export async function run(file, options, _cmd, ctx) {
194
- const { gitRoot } = ctx
202
+ const { gitRoot } = ctx;
195
203
 
196
204
  if (!isAgenticCli()) {
197
- printHeader({ version: pkg.version, binName: styles.binName(), indent: ' ' })
198
- console.log()
205
+ printHeader({ version: pkg.version, binName: styles.binName(), indent: ' ' });
206
+ console.log();
199
207
  }
200
208
 
201
- let files
209
+ // Lexical `path.relative` treats a symlink inside the repo as inside it,
210
+ // and the later write follows the link. Same check `oas:sync` uses.
211
+ const assertContained = makeContainment(gitRoot, { label: 'repository' });
212
+
213
+ let files;
202
214
  if (file) {
203
- const absFile = path.resolve(file)
215
+ const absFile = path.resolve(file);
204
216
  if (!fs.existsSync(absFile)) {
205
- styles.error(`File not found: ${styles.bold(file)}`)
206
- process.exit(1)
217
+ styles.error(`File not found: ${styles.bold(file)}`);
218
+ process.exit(1);
207
219
  }
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)
220
+
221
+ try {
222
+ assertContained(absFile);
223
+ } catch (err) {
224
+ if (!(err instanceof PathEscapeError)) throw err;
225
+ styles.error(err.message);
226
+ process.exit(1);
212
227
  }
213
- files = [relFile]
228
+
229
+ files = [path.relative(gitRoot, absFile)];
214
230
  } else {
215
- files = collectFiles(gitRoot)
231
+ files = collectFiles(gitRoot);
216
232
  }
217
233
 
218
234
  if (files.length === 0) {
219
- styles.warning('No docs files found to prettify.')
220
- return
235
+ styles.warning('No docs files found to prettify.');
236
+ return;
221
237
  }
222
238
 
223
- const localComponents = listLocalComponents(gitRoot)
224
- const localNames = new Set(localComponents.map((c) => c.name))
239
+ const localComponents = listLocalComponents(gitRoot);
240
+ const localNames = new Set(localComponents.map(c => c.name));
225
241
 
226
242
  // Pull the full marketplace catalog so Claude knows what else it can use.
227
243
  // Summaries come from disk cache (populated by `readme components list`);
228
244
  // we don't fetch readmes here to keep the prompt path fast.
229
- let marketplace = []
245
+ let marketplace = [];
230
246
  if (options.autoInstall !== false) {
231
247
  try {
232
- marketplace = await listMarketplaceComponents()
248
+ marketplace = await listMarketplaceComponents();
233
249
  } catch (err) {
234
- styles.warning(`Could not load marketplace catalog: ${err.message} (continuing without it)`)
250
+ styles.warning(`Could not load marketplace catalog: ${err.message} (continuing without it)`);
235
251
  }
236
252
  }
237
- const marketplaceNames = new Set(marketplace.map((c) => c.name))
253
+ const marketplaceNames = new Set(marketplace.map(c => c.name));
238
254
  const availableComponents = marketplace
239
- .filter((c) => !localNames.has(c.name))
240
- .map((c) => ({ name: c.name, summary: summarizeReadme(readCachedReadme(c.name)) }))
255
+ .filter(c => !localNames.has(c.name))
256
+ .map(c => ({ name: c.name, summary: summarizeReadme(readCachedReadme(c.name)) }));
241
257
 
242
258
  // Concurrent workers may both try to install the same component. Coalesce
243
259
  // duplicate installs through a Map<tag, Promise> — the second caller awaits
244
260
  // the first's install instead of clobbering it.
245
- const inFlightInstalls = new Map()
246
- const autoInstalled = new Set()
261
+ const inFlightInstalls = new Map();
262
+ const autoInstalled = new Set();
247
263
  function ensureInstalled(tag) {
248
- if (inFlightInstalls.has(tag)) return inFlightInstalls.get(tag)
264
+ if (inFlightInstalls.has(tag)) return inFlightInstalls.get(tag);
249
265
  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
266
+ await installComponent(gitRoot, tag);
267
+ localNames.add(tag);
268
+ autoInstalled.add(tag);
269
+ })();
270
+ inFlightInstalls.set(tag, p);
271
+ return p;
256
272
  }
257
273
 
258
274
  styles.info(
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
- )
275
+ `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)') : ''}.`,
276
+ );
261
277
  if (localComponents.length > 0) {
262
278
  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
- )
279
+ `Found ${styles.bold(String(localComponents.length))} local component${localComponents.length === 1 ? '' : 's'} in ${styles.bold('./components/')}: ${localComponents.map(c => c.name).join(', ')}`,
280
+ );
265
281
  }
266
282
  if (options.autoInstall !== false && availableComponents.length > 0) {
267
283
  styles.info(
268
284
  `${styles.bold(String(availableComponents.length))} additional marketplace component${availableComponents.length === 1 ? '' : 's'} will be auto-installed if used.`,
269
- )
285
+ );
270
286
  }
271
- console.log()
287
+ console.log();
272
288
 
273
- let updated = 0
274
- let unchanged = 0
275
- const failures = []
289
+ let updated = 0;
290
+ let unchanged = 0;
291
+ const failures = [];
276
292
 
277
- const rows = Math.min(options.jobs, files.length)
278
- const multi = new MultiSpinner({ rows, color: 'blue' })
279
- multi.start()
293
+ const rows = Math.min(options.jobs, files.length);
294
+ const multi = new MultiSpinner({ rows, color: 'blue' });
295
+ multi.start();
280
296
 
281
297
  await runPool(files, options.jobs, async (relativePath, _i, workerIdx) => {
282
- multi.setLine(workerIdx, relativePath)
298
+ multi.setLine(workerIdx, relativePath);
283
299
 
284
300
  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']
301
+ const filePath = path.join(gitRoot, relativePath);
302
+ assertContained(filePath);
303
+ const original = fs.readFileSync(filePath, 'utf-8');
304
+ const parsed = parseFrontmatter(original);
305
+ const parseError = frontmatterWriteError(parsed);
306
+ if (parseError) throw parseError;
307
+
308
+ let source = parsed.content;
309
+ const importUrl = parsed.data?.['x-import'];
291
310
  if (importUrl) {
292
- multi.setLine(workerIdx, `${relativePath} ${styles.dim(`(fetching ${importUrl})`)}`)
293
- source = await fetchImport(importUrl)
294
- multi.setLine(workerIdx, relativePath)
311
+ multi.setLine(workerIdx, `${relativePath} ${styles.dim(`(fetching ${importUrl})`)}`);
312
+ source = await fetchImport(importUrl);
313
+ multi.setLine(workerIdx, relativePath);
295
314
  }
296
315
 
297
316
  const result = await prettifyPage({
@@ -301,10 +320,10 @@ export async function run(file, options, _cmd, ctx) {
301
320
  model: options.model,
302
321
  localComponents,
303
322
  availableComponents: options.autoInstall === false ? [] : availableComponents,
304
- })
323
+ });
305
324
 
306
325
  if (typeof result?.body !== 'string') {
307
- throw new Error('response missing "body" field')
326
+ throw new TypeError('response missing "body" field');
308
327
  }
309
328
 
310
329
  // Auto-install marketplace components Claude used in the body. We do
@@ -312,59 +331,63 @@ export async function run(file, options, _cmd, ctx) {
312
331
  // together — and we share the inFlightInstalls map across workers so a
313
332
  // tag is only fetched once.
314
333
  if (options.autoInstall !== false && !options.dryRun) {
315
- const used = extractUsedTags(result.body)
334
+ const used = extractUsedTags(result.body);
316
335
  for (const tag of used) {
317
- if (NATIVE_TAGS.has(tag)) continue
318
- if (localNames.has(tag)) continue
319
- if (!marketplaceNames.has(tag)) continue
336
+ if (NATIVE_TAGS.has(tag)) continue;
337
+ if (localNames.has(tag)) continue;
338
+ if (!marketplaceNames.has(tag)) continue;
320
339
  try {
321
- multi.setLine(workerIdx, `${relativePath} ${styles.dim(`(installing <${tag}>)`)}`)
322
- await ensureInstalled(tag)
340
+ multi.setLine(workerIdx, `${relativePath} ${styles.dim(`(installing <${tag}>)`)}`);
341
+ await ensureInstalled(tag);
323
342
  } catch (err) {
324
- failures.push({ relativePath, message: `auto-install <${tag}> failed: ${err.message}` })
343
+ failures.push({ relativePath, message: `auto-install <${tag}> failed: ${err.message}` });
325
344
  }
326
345
  }
327
- multi.setLine(workerIdx, relativePath)
346
+ multi.setLine(workerIdx, relativePath);
328
347
  }
329
348
 
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)
349
+ const newFm = { ...parsed.data };
350
+ delete newFm.hidden;
351
+ delete newFm['x-import'];
352
+ if (result.excerpt && !newFm.excerpt) newFm.excerpt = result.excerpt;
353
+ const next = toFrontmatter(newFm, result.body);
335
354
 
336
355
  if (next === original) {
337
- unchanged++
338
- if (!process.stderr.isTTY) multi.log(` ${styles.dim('·')} ${relativePath}`)
356
+ unchanged++;
357
+ if (!process.stderr.isTTY) multi.log(` ${styles.dim('·')} ${relativePath}`);
339
358
  } else {
340
- if (!options.dryRun) fs.writeFileSync(filePath, next, 'utf-8')
341
- updated++
342
- if (!process.stderr.isTTY) multi.log(` ${styles.success('✓')} ${relativePath}`)
359
+ if (!options.dryRun) fs.writeFileSync(filePath, next, 'utf-8');
360
+ updated++;
361
+ if (!process.stderr.isTTY) multi.log(` ${styles.success('✓')} ${relativePath}`);
343
362
  }
344
363
  } catch (err) {
345
- failures.push({ relativePath, message: err.message || String(err) })
364
+ failures.push({ relativePath, message: err.message || String(err) });
346
365
  if (!process.stderr.isTTY) {
347
- multi.log(` ${styles.err('●')} ${relativePath} ${styles.dim('— ' + (err.message || String(err)))}`)
366
+ multi.log(` ${styles.err('●')} ${relativePath} ${styles.dim(`— ${err.message || String(err)}`)}`);
348
367
  }
349
368
  } finally {
350
- multi.clearLine(workerIdx)
369
+ multi.clearLine(workerIdx);
351
370
  }
352
- })
371
+ });
353
372
 
354
- multi.stop()
373
+ multi.stop();
355
374
 
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` : ''}`)
375
+ console.log();
376
+ const verb = options.dryRun ? 'would update' : 'updated';
377
+ styles.ok(
378
+ `${styles.bold(String(updated))} ${verb} · ${styles.bold(String(unchanged))} unchanged${failures.length ? ` · ${styles.bold(String(failures.length))} failed` : ''}`,
379
+ );
359
380
  if (autoInstalled.size > 0) {
360
- styles.ok(`Auto-installed ${styles.bold(String(autoInstalled.size))} component${autoInstalled.size === 1 ? '' : 's'}: ${[...autoInstalled].join(', ')}`)
381
+ styles.ok(
382
+ `Auto-installed ${styles.bold(String(autoInstalled.size))} component${autoInstalled.size === 1 ? '' : 's'}: ${[...autoInstalled].join(', ')}`,
383
+ );
361
384
  }
362
385
 
363
386
  if (failures.length > 0) {
364
- console.log()
387
+ console.log();
365
388
  for (const f of failures) {
366
- console.log(` ${styles.err('●')} ${styles.bold(f.relativePath)} ${styles.dim('— ' + f.message)}`)
389
+ console.log(` ${styles.err('●')} ${styles.bold(f.relativePath)} ${styles.dim(`— ${f.message}`)}`);
367
390
  }
368
- process.exit(1)
391
+ process.exit(1);
369
392
  }
370
393
  }