@frontera-sdk/cli 0.1.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 (53) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -0
  3. package/package.json +47 -0
  4. package/src/api/apps-api.ts +165 -0
  5. package/src/api/automation-api.ts +140 -0
  6. package/src/api/platform-api.ts +193 -0
  7. package/src/api/registry-api.ts +43 -0
  8. package/src/args.ts +108 -0
  9. package/src/commands/agent/compose.ts +155 -0
  10. package/src/commands/agent/index-commands.ts +348 -0
  11. package/src/commands/agent/resolve.ts +58 -0
  12. package/src/commands/app/add.ts +78 -0
  13. package/src/commands/app/deploy.ts +105 -0
  14. package/src/commands/app/init.ts +53 -0
  15. package/src/commands/app/list.ts +51 -0
  16. package/src/commands/app/promote.ts +31 -0
  17. package/src/commands/app/pull.ts +145 -0
  18. package/src/commands/app/save.ts +36 -0
  19. package/src/commands/app/shared.ts +25 -0
  20. package/src/commands/app/versions.ts +38 -0
  21. package/src/commands/automation/index-commands.ts +325 -0
  22. package/src/commands/blueprint/get.ts +160 -0
  23. package/src/commands/blueprint/list.ts +48 -0
  24. package/src/commands/blueprint/reserved.ts +40 -0
  25. package/src/commands/completion.ts +293 -0
  26. package/src/commands/init.ts +33 -0
  27. package/src/commands/knowledge/index-commands.ts +140 -0
  28. package/src/commands/login.ts +103 -0
  29. package/src/commands/plugin/index-commands.ts +112 -0
  30. package/src/commands/registry.ts +405 -0
  31. package/src/commands/skill/index-commands.ts +140 -0
  32. package/src/commands/types.ts +76 -0
  33. package/src/config.ts +142 -0
  34. package/src/context.ts +67 -0
  35. package/src/errors.ts +30 -0
  36. package/src/exit.ts +98 -0
  37. package/src/flag-help.ts +70 -0
  38. package/src/harness.ts +162 -0
  39. package/src/heal.ts +418 -0
  40. package/src/help.ts +128 -0
  41. package/src/main.ts +204 -0
  42. package/src/manifest.ts +80 -0
  43. package/src/output.ts +65 -0
  44. package/src/pack.ts +18 -0
  45. package/src/packaging.ts +116 -0
  46. package/src/project.ts +151 -0
  47. package/src/prompt.ts +48 -0
  48. package/src/registry.ts +62 -0
  49. package/src/secrets.ts +69 -0
  50. package/src/table.ts +47 -0
  51. package/src/tar.ts +73 -0
  52. package/src/template.ts +566 -0
  53. package/src/vendor/sdk-sources.json +25 -0
package/src/heal.ts ADDED
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Repair a working tree that was packaged before the scaffold could package
3
+ * itself.
4
+ *
5
+ * ## The defect
6
+ *
7
+ * `frontera app pull` promises "a working copy". For an app stored by an older
8
+ * toolchain it did not deliver one: the `package.json` in storage declares
9
+ *
10
+ * ```json
11
+ * "@frontera-sdk/blueprint": "file:/Users/…/worktrees/…/packages/sdk-blueprint"
12
+ * ```
13
+ *
14
+ * — an absolute path into a git worktree on one developer's laptop that no
15
+ * longer exists. `bun install` fails on every machine, including the one that
16
+ * authored it. There is no `src/frontera/`, and `tsconfig` carries only `@/*`,
17
+ * so nothing else resolves `@frontera-sdk/*` either.
18
+ *
19
+ * An agent handed that tree cannot install, cannot build, and cannot verify.
20
+ * The session this was written from spent roughly 42 operations working around
21
+ * it and finished by hand-writing a fake `@frontera-sdk/blueprint` — which then
22
+ * produced the screenshots it presented as evidence. Every number in them came
23
+ * from a linear congruential generator the agent wrote twenty minutes earlier.
24
+ *
25
+ * So this runs on pull, not as a separate `frontera app doctor` the model has
26
+ * to know to reach for. A repair the caller must discover is a repair that does
27
+ * not happen.
28
+ *
29
+ * ## How it repairs
30
+ *
31
+ * The vendored SDK is written into `src/frontera/<pkg>/`, the same layout
32
+ * `frontera app init` produces, and each directory is given a `package.json`
33
+ * whose `exports` map its subpaths. The app's `@frontera-sdk/*` dependency is then
34
+ * pointed at that directory with `file:`.
35
+ *
36
+ * That last step is what lets this touch no code. Aliases are the scaffold's
37
+ * mechanism and they are fine when the scaffold writes `vite.config.ts` itself
38
+ * — but a pulled project's Vite config is arbitrary TypeScript, and a healer
39
+ * that rewrites arbitrary TypeScript is a healer that eventually eats someone's
40
+ * config. A `file:` dependency is resolved by the package manager, honoured by
41
+ * both Vite and `tsc`, and expressed entirely in JSON. A scaffolded app keeps
42
+ * its aliases; they point at the same files and win first.
43
+ *
44
+ * Nothing here reaches the network: the SDK source is already compiled into the
45
+ * CLI (`src/vendor/sdk-sources.json`).
46
+ */
47
+
48
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
49
+ import { dirname, join } from 'node:path'
50
+
51
+ import {
52
+ APP_SDK_PACKAGES,
53
+ LEGACY_SDK_ALIASES,
54
+ currentSdkPackage,
55
+ devHostHtml,
56
+ sdkPackageFiles,
57
+ type AppSdkPackage,
58
+ } from './template'
59
+
60
+ /**
61
+ * Dependency specs a package manager cannot resolve away from the machine that
62
+ * wrote them. `workspace:` needs this monorepo; `file:`/`link:`/`portal:` need
63
+ * a path that survived; `git+ssh` needs a key a sandbox does not have.
64
+ */
65
+ const UNRESOLVABLE = /^(file|link|portal|workspace|git\+ssh):/
66
+
67
+ const SDK_PACKAGE_NAMES = Object.keys(APP_SDK_PACKAGES) as AppSdkPackage[]
68
+
69
+ export interface HealResult {
70
+ /** One line per repair, in the order they were applied. Empty = nothing to do. */
71
+ repairs: string[]
72
+ }
73
+
74
+ interface PackageJson {
75
+ name?: string
76
+ dependencies?: Record<string, string>
77
+ devDependencies?: Record<string, string>
78
+ [key: string]: unknown
79
+ }
80
+
81
+ /**
82
+ * Bring a pulled working tree up to something that installs and builds.
83
+ *
84
+ * Idempotent: a tree already in good shape gets no repairs and no writes, so
85
+ * re-pulling does not churn files. Never throws — a project too unusual to
86
+ * repair is left exactly as it was, because a half-repaired tree is worse than
87
+ * an honestly broken one.
88
+ */
89
+ export function healProject(dir: string): HealResult {
90
+ const repairs: string[] = []
91
+
92
+ const manifestPath = join(dir, 'package.json')
93
+ let manifest: PackageJson | null = null
94
+ try {
95
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as PackageJson
96
+ } catch {
97
+ // No manifest, or not JSON. Not a project this CLI knows how to repair —
98
+ // and `pull` has already put the files on disk, which is the part the
99
+ // caller asked for.
100
+ return { repairs }
101
+ }
102
+
103
+ const name = typeof manifest.name === 'string' && manifest.name ? manifest.name : 'app'
104
+ let manifestChanged = false
105
+
106
+ // Packages a legacy tree needs declared under their CURRENT name as well, so
107
+ // the vendored copy resolves the siblings it imports internally.
108
+ const needCurrentScope = new Set<AppSdkPackage>()
109
+
110
+ // Legacy spellings this particular app uses. Only these get tsconfig paths:
111
+ // adding both spellings unconditionally reports a repair on a HEALTHY
112
+ // scaffolded app, which breaks the idempotency this function promises.
113
+ const legacyInUse = new Map<string, AppSdkPackage>()
114
+
115
+ // ── SDK dependencies, in either scope ───────────────────────────────────────
116
+ //
117
+ // A stored app declares whatever scope was current when it was packaged, and
118
+ // every app in storage predates `@frontera-sdk`. Matching only the new scope
119
+ // is matching only trees that do not need repairing.
120
+ //
121
+ // A legacy name keeps its key and is pointed at the vendored directory. The
122
+ // key is what the app's own `from '@frontera/blueprint/hooks'` resolves
123
+ // through, and a `file:` dependency installs under its key regardless of the
124
+ // name in the target's manifest — so one vendored copy serves both spellings
125
+ // and no source file has to be rewritten.
126
+ for (const field of ['dependencies', 'devDependencies'] as const) {
127
+ const deps = manifest[field]
128
+ if (!deps || typeof deps !== 'object') continue
129
+ for (const [dep, spec] of Object.entries(deps)) {
130
+ if (!dep.startsWith('@frontera-sdk/') && !dep.startsWith('@frontera/')) continue
131
+ if (typeof spec !== 'string' || !UNRESOLVABLE.test(spec)) continue
132
+
133
+ const pkg = currentSdkPackage(dep)
134
+ if (pkg) {
135
+ // Already pointing at the copy in this tree — that is the shape this
136
+ // function produces, so re-pulling must not rewrite it back over
137
+ // itself and report a repair that did nothing.
138
+ if (spec === `file:./${APP_SDK_PACKAGES[pkg].dir}` && vendorPresent(dir, pkg)) continue
139
+ vendorSdkPackage(dir, pkg)
140
+ deps[dep] = `file:./${APP_SDK_PACKAGES[pkg].dir}`
141
+ const under = dep === pkg ? '' : ` (legacy name, now ${pkg})`
142
+ repairs.push(`vendored ${dep} into ${APP_SDK_PACKAGES[pkg].dir}${under} (was ${truncate(spec)})`)
143
+
144
+ // The vendored source imports its sibling by the CURRENT name, so a
145
+ // legacy tree needs that spelling installed too or the copy cannot
146
+ // resolve its own imports.
147
+ if (dep !== pkg) {
148
+ needCurrentScope.add(pkg)
149
+ legacyInUse.set(dep, pkg)
150
+ }
151
+ } else {
152
+ // Not vendorable. Leaving a dead `file:` spec is the difference between
153
+ // a tree that installs and one that does not, so it goes — but the two
154
+ // reasons are not the same and the repair line should not pretend they
155
+ // are.
156
+ //
157
+ // The CLI is genuinely not a build input: it lives on PATH, and an app
158
+ // never imports it. Anything else here (`@frontera-sdk/automation`) is a
159
+ // real package that this app may well import, and dropping it trades a
160
+ // failing `bun install` for a failing `bun run build`. Rewriting it to a
161
+ // version range is the actual repair and is not available yet — nothing
162
+ // is on the registry — so the honest move is to say what was lost.
163
+ const isCli = dep.endsWith('/cli')
164
+ delete deps[dep]
165
+ repairs.push(
166
+ isCli
167
+ ? `dropped ${dep} — the CLI is on PATH, not a build input (was ${truncate(spec)})`
168
+ : `dropped ${dep} — unresolvable and not vendorable; anything importing it will not build (was ${truncate(spec)})`,
169
+ )
170
+ }
171
+ manifestChanged = true
172
+ }
173
+ }
174
+
175
+ // An app can also IMPORT the SDK without declaring it — the monorepo used to
176
+ // resolve `@frontera-sdk/*` for free, so a project authored there installs
177
+ // nowhere else and says nothing about why.
178
+ //
179
+ // A SCAFFOLDED app looks the same from the manifest and is not broken: it
180
+ // resolves through tsconfig `paths` and a matching Vite alias into the
181
+ // directory it already carries. `vendorPresent` is what tells them apart, and
182
+ // getting it wrong would write a second, differently-wired copy of the SDK
183
+ // into a project that has one — which is how a project ends up with two
184
+ // Reacts.
185
+ const imported = importedSdkPackages(join(dir, 'src'))
186
+ for (const { specifier, pkg } of imported) {
187
+ if (declares(manifest, specifier) || (specifier === pkg && vendorPresent(dir, pkg))) continue
188
+ vendorSdkPackage(dir, pkg)
189
+ manifest.dependencies ??= {}
190
+ manifest.dependencies[specifier] = `file:./${APP_SDK_PACKAGES[pkg].dir}`
191
+ manifestChanged = true
192
+ const under = specifier === pkg ? '' : ` (legacy name, now ${pkg})`
193
+ repairs.push(`vendored ${specifier} into ${APP_SDK_PACKAGES[pkg].dir}${under} (imported but never declared)`)
194
+ if (specifier !== pkg) {
195
+ needCurrentScope.add(pkg)
196
+ legacyInUse.set(specifier, pkg)
197
+ }
198
+ }
199
+
200
+ // The vendored source's own imports are written in the current scope. A tree
201
+ // that only ever mentions the old one still needs those resolvable, or the
202
+ // copy this function just wrote cannot import its own sibling.
203
+ for (const pkg of needCurrentScope) {
204
+ if (declares(manifest, pkg)) continue
205
+ vendorSdkPackage(dir, pkg)
206
+ manifest.dependencies ??= {}
207
+ manifest.dependencies[pkg] = `file:./${APP_SDK_PACKAGES[pkg].dir}`
208
+ manifestChanged = true
209
+ repairs.push(`declared ${pkg} (the vendored copy imports it under its current name)`)
210
+ }
211
+
212
+ // The SDK's `config.ts` reads `process.env` behind a guard, so vendoring it
213
+ // into a project without `@types/node` turns `bun run typecheck` — one of the
214
+ // two commands the shipped skill says to run before claiming done — red, with
215
+ // four errors inside code the author was told not to edit. An agent that sees
216
+ // that goes and fixes the SDK.
217
+ if (vendored(dir) && !manifest.devDependencies?.['@types/node'] && !manifest.dependencies?.['@types/node']) {
218
+ manifest.devDependencies ??= {}
219
+ manifest.devDependencies['@types/node'] = '^22'
220
+ manifestChanged = true
221
+ repairs.push('added @types/node (the vendored SDK type-checks a `process` guard)')
222
+ }
223
+
224
+ if (manifestChanged) writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
225
+
226
+ // Point the editor at the vendored copy directly. Without this TypeScript
227
+ // reaches it through the `file:` link instead and reports every diagnostic
228
+ // twice — once under `src/`, once under `node_modules/` — for one file.
229
+ // Vite is unaffected either way: it resolves the link to the same real path.
230
+ const tsconfigRepair = ensureTsconfigPaths(dir, legacyInUse)
231
+ if (tsconfigRepair) repairs.push(tsconfigRepair)
232
+
233
+ // ── the dev host ──────────────────────────────────────────────────────────
234
+ // `bun run dev` serves the app at `/`, where `connectToHost` refuses an
235
+ // unknown parent and renders a panel. Without this page there is no way to
236
+ // see the app running at all, and a screenshot of `/` proves nothing.
237
+ const devHost = join(dir, 'dev-host.html')
238
+ if (!existsSync(devHost)) {
239
+ writeFileSync(devHost, devHostHtml(name))
240
+ repairs.push('wrote dev-host.html (open /dev-host.html, never /)')
241
+ }
242
+
243
+ return { repairs }
244
+ }
245
+
246
+ function isSdkPackage(dep: string): dep is AppSdkPackage {
247
+ return (SDK_PACKAGE_NAMES as string[]).includes(dep)
248
+ }
249
+
250
+ /** Does this tree already carry the SDK's source where the scaffold puts it? */
251
+ function vendorPresent(dir: string, pkg: AppSdkPackage): boolean {
252
+ return existsSync(join(dir, APP_SDK_PACKAGES[pkg].dir))
253
+ }
254
+
255
+ /** Does it carry any of them? */
256
+ function vendored(dir: string): boolean {
257
+ return SDK_PACKAGE_NAMES.some((pkg) => vendorPresent(dir, pkg))
258
+ }
259
+
260
+ /**
261
+ * Add the two `@frontera-sdk/*` path mappings, keeping everything already there.
262
+ *
263
+ * Returns a repair line, or null when there was nothing to do — including when
264
+ * `tsconfig.json` is missing or is JSON with comments, which `JSON.parse`
265
+ * refuses. Skipping is the right answer there: the `file:` dependency already
266
+ * makes the imports resolve, so this is a duplicate-diagnostics fix, and a
267
+ * healer that reformats someone's commented config to win it is a bad trade.
268
+ */
269
+ function ensureTsconfigPaths(dir: string, legacyInUse: Map<string, AppSdkPackage>): string | null {
270
+ const path = join(dir, 'tsconfig.json')
271
+ let config: { compilerOptions?: { paths?: Record<string, string[]> } }
272
+ let raw: string
273
+ try {
274
+ raw = readFileSync(path, 'utf8')
275
+ config = JSON.parse(raw) as typeof config
276
+ } catch {
277
+ return null
278
+ }
279
+ if (!config.compilerOptions) return null
280
+
281
+ const paths = config.compilerOptions.paths ?? {}
282
+ let added = false
283
+ // Current names always; legacy names only where this app uses them, so an
284
+ // editor follows a legacy import to the vendored source instead of through the
285
+ // `file:` link and reporting every diagnostic twice.
286
+ const spellings: Array<[string, AppSdkPackage]> = [
287
+ ...SDK_PACKAGE_NAMES.map((pkg) => [pkg, pkg] as [string, AppSdkPackage]),
288
+ ...legacyInUse,
289
+ ]
290
+ for (const [specifier, pkg] of spellings) {
291
+ const key = `${specifier}/*`
292
+ if (paths[key] || !vendorPresent(dir, pkg)) continue
293
+ paths[key] = [`./${APP_SDK_PACKAGES[pkg].dir}/*`]
294
+ added = true
295
+ }
296
+ if (!added) return null
297
+
298
+ config.compilerOptions.paths = paths
299
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`)
300
+ return 'mapped @frontera-sdk/* in tsconfig paths'
301
+ }
302
+
303
+ function declares(manifest: PackageJson, dep: string): boolean {
304
+ return Boolean(manifest.dependencies?.[dep] ?? manifest.devDependencies?.[dep])
305
+ }
306
+
307
+ function truncate(spec: string): string {
308
+ return spec.length > 60 ? `${spec.slice(0, 57)}…` : spec
309
+ }
310
+
311
+ /**
312
+ * Write one SDK package's source into the project, plus the `package.json`
313
+ * that makes the directory resolvable as `@frontera-sdk/<pkg>`.
314
+ *
315
+ * Overwrites unconditionally. The vendored copy is not the author's code — it
316
+ * is the SDK, and the shipped skill says so ("Read it freely; do not edit it").
317
+ * A tree being healed has already proven it cannot resolve the real thing, so
318
+ * preferring whatever partial copy is on disk would mean preferring the fake
319
+ * one an agent wrote to get past this.
320
+ */
321
+ function vendorSdkPackage(dir: string, pkg: AppSdkPackage): void {
322
+ const target = join(dir, APP_SDK_PACKAGES[pkg].dir)
323
+ const sources = sdkPackageFiles(pkg)
324
+
325
+ for (const [rel, content] of Object.entries(sources)) {
326
+ const full = join(target, rel)
327
+ mkdirSync(dirname(full), { recursive: true })
328
+ writeFileSync(full, content)
329
+ }
330
+
331
+ writeFileSync(
332
+ join(target, 'package.json'),
333
+ `${JSON.stringify(vendoredManifest(pkg, Object.keys(sources)), null, 2)}\n`,
334
+ )
335
+ }
336
+
337
+ /**
338
+ * The manifest for a vendored SDK directory.
339
+ *
340
+ * `exports` is derived from the files actually written rather than copied from
341
+ * the real package, so a module added to the SDK cannot be vendored-but-
342
+ * unreachable. Subpaths point at `.ts`/`.tsx` directly: the consumer is always
343
+ * a bundler or `tsc`, never Node, and shipping a build step into every healed
344
+ * app to produce sources it would immediately re-transpile buys nothing.
345
+ */
346
+ export function vendoredManifest(pkg: AppSdkPackage, files: string[]): Record<string, unknown> {
347
+ const exports: Record<string, unknown> = { './package.json': './package.json' }
348
+ for (const rel of files.sort()) {
349
+ if (!/\.tsx?$/.test(rel)) continue
350
+ exports[`./${rel.replace(/\.tsx?$/, '')}`] = { types: `./${rel}`, import: `./${rel}` }
351
+ }
352
+ return {
353
+ name: pkg,
354
+ version: '0.0.0-vendored',
355
+ private: true,
356
+ // The terms the copied source arrives under. `LICENSE` sits beside it,
357
+ // written from the same vendored set as the modules.
358
+ license: 'Apache-2.0',
359
+ type: 'module',
360
+ exports,
361
+ }
362
+ }
363
+
364
+ /** Which SDK packages this project's own source imports. */
365
+ function importedSdkPackages(srcDir: string): Array<{ specifier: string; pkg: AppSdkPackage }> {
366
+ // Keyed by the specifier the app actually wrote, because that is what has to
367
+ // resolve. Scanning for current names only would miss every stored app, all
368
+ // of which import the old scope.
369
+ const found = new Map<string, AppSdkPackage>()
370
+ const candidates = [...SDK_PACKAGE_NAMES as string[], ...Object.keys(LEGACY_SDK_ALIASES)]
371
+ for (const file of walk(srcDir)) {
372
+ if (!/\.(ts|tsx|js|jsx)$/.test(file)) continue
373
+ // Skip the vendored copies themselves: `@frontera-sdk/blueprint` imports
374
+ // `@frontera-sdk/core/client`, and reading that as an app import would make
375
+ // the scan self-fulfilling.
376
+ if (file.includes(`${join('src', 'frontera')}`)) continue
377
+ let text: string
378
+ try {
379
+ text = readFileSync(file, 'utf8')
380
+ } catch {
381
+ continue
382
+ }
383
+ for (const specifier of candidates) {
384
+ if (!text.includes(`'${specifier}/`) && !text.includes(`"${specifier}/`)) continue
385
+ found.set(specifier, currentSdkPackage(specifier)!)
386
+ }
387
+ }
388
+ // blueprint re-exports types from core, so an app that imports only the former
389
+ // still needs both on disk — in whichever spelling it is already using.
390
+ for (const [specifier, pkg] of [...found]) {
391
+ if (pkg !== '@frontera-sdk/blueprint') continue
392
+ const core = specifier.startsWith('@frontera/') ? '@frontera/sdk-core' : '@frontera-sdk/core'
393
+ if (!found.has(core)) found.set(core, '@frontera-sdk/core')
394
+ }
395
+ return [...found].map(([specifier, pkg]) => ({ specifier, pkg }))
396
+ }
397
+
398
+ function walk(dir: string, out: string[] = []): string[] {
399
+ let entries: string[]
400
+ try {
401
+ entries = readdirSync(dir)
402
+ } catch {
403
+ return out
404
+ }
405
+ for (const entry of entries) {
406
+ if (entry === 'node_modules' || entry === 'dist' || entry.startsWith('.')) continue
407
+ const full = join(dir, entry)
408
+ let stats
409
+ try {
410
+ stats = statSync(full)
411
+ } catch {
412
+ continue
413
+ }
414
+ if (stats.isDirectory()) walk(full, out)
415
+ else out.push(full)
416
+ }
417
+ return out
418
+ }
package/src/help.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Layout and colour for help text.
3
+ *
4
+ * Help was a `padEnd(46)` and a summary, which meant every description longer
5
+ * than the remaining columns wrapped at the terminal edge — mid-word, back to
6
+ * column zero, so `frontera app add`'s summary read "…from the registry i /
7
+ * to this project". Codex and Claude Code both wrap with a hanging indent and
8
+ * are legible at 80 columns; Bun does not, and its own `--help` breaks the same
9
+ * way. This follows the ones that work.
10
+ *
11
+ * Colour is opt-out via NO_COLOR, and off entirely when stdout is not a TTY —
12
+ * an agent capturing `--help` gets clean text with no escape sequences to strip.
13
+ */
14
+
15
+ const MIN_WIDTH = 60
16
+ const MAX_WIDTH = 100
17
+
18
+ export function terminalWidth(): number {
19
+ const columns = process.stdout.columns
20
+ if (!columns || Number.isNaN(columns)) return 80
21
+ return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, columns))
22
+ }
23
+
24
+ function colorEnabled(): boolean {
25
+ // https://no-color.org — any value, including empty, disables.
26
+ if (process.env.NO_COLOR !== undefined) return false
27
+ if (process.env.FORCE_COLOR !== undefined) return true
28
+ return Boolean(process.stdout.isTTY)
29
+ }
30
+
31
+ function paint(code: string, close: string) {
32
+ return (text: string): string => (colorEnabled() ? `\x1b[${code}m${text}\x1b[${close}m` : text)
33
+ }
34
+
35
+ export const bold = paint('1', '22')
36
+ export const dim = paint('2', '22')
37
+ /** Command names, matching the accent Bun and Codex use for the same thing. */
38
+ export const accent = paint('36', '39')
39
+
40
+ /** Visible width, ignoring any escape sequences already applied. */
41
+ function visibleLength(text: string): number {
42
+ // eslint-disable-next-line no-control-regex
43
+ return text.replace(/\x1b\[\d+m/g, '').length
44
+ }
45
+
46
+ /**
47
+ * Greedy wrap. Words longer than the line (a URL, a long flag) are left whole
48
+ * and allowed to overflow rather than broken — a split identifier cannot be
49
+ * copied, which is the same reason `table.ts` never truncates an id.
50
+ */
51
+ export function wrap(text: string, width: number): string[] {
52
+ if (width <= 0) return [text]
53
+ const lines: string[] = []
54
+ let current = ''
55
+
56
+ for (const word of text.split(/\s+/).filter(Boolean)) {
57
+ if (!current) current = word
58
+ else if (current.length + 1 + word.length <= width) current += ` ${word}`
59
+ else {
60
+ lines.push(current)
61
+ current = word
62
+ }
63
+ }
64
+ if (current) lines.push(current)
65
+ return lines.length > 0 ? lines : ['']
66
+ }
67
+
68
+ export interface DefinitionOptions {
69
+ /** Leading spaces before the term. */
70
+ indent?: number
71
+ /** Spaces between the term column and the description column. */
72
+ gap?: number
73
+ width?: number
74
+ /**
75
+ * Terms wider than this get their description on the following line rather
76
+ * than pushing every description off the right edge.
77
+ */
78
+ maxTerm?: number
79
+ }
80
+
81
+ /**
82
+ * A term/description list — the shape every section of every help page uses.
83
+ *
84
+ * The description column is computed from the widest term that fits, and
85
+ * continuation lines align under it, so a wrapped summary still reads as one
86
+ * block instead of drifting back to the margin.
87
+ */
88
+ export function definitions(
89
+ rows: ReadonlyArray<readonly [string, string]>,
90
+ options: DefinitionOptions = {},
91
+ ): string[] {
92
+ const indent = options.indent ?? 2
93
+ const gap = options.gap ?? 2
94
+ const width = options.width ?? terminalWidth()
95
+ const maxTerm = options.maxTerm ?? 34
96
+
97
+ const fitting = rows.map(([term]) => visibleLength(term)).filter((n) => n <= maxTerm)
98
+ const termWidth = fitting.length > 0 ? Math.max(...fitting) : maxTerm
99
+ const descColumn = indent + termWidth + gap
100
+ const descWidth = Math.max(20, width - descColumn)
101
+
102
+ const out: string[] = []
103
+ for (const [term, description] of rows) {
104
+ const pad = ' '.repeat(indent)
105
+ if (!description) {
106
+ out.push(`${pad}${term}`)
107
+ continue
108
+ }
109
+
110
+ const lines = wrap(description, descWidth)
111
+ if (visibleLength(term) > termWidth) {
112
+ // Too wide to share a line — the description starts underneath.
113
+ out.push(`${pad}${term}`)
114
+ for (const line of lines) out.push(`${' '.repeat(descColumn)}${line}`)
115
+ continue
116
+ }
117
+
118
+ const padding = ' '.repeat(termWidth - visibleLength(term) + gap)
119
+ out.push(`${pad}${term}${padding}${lines[0]}`)
120
+ for (const line of lines.slice(1)) out.push(`${' '.repeat(descColumn)}${line}`)
121
+ }
122
+ return out
123
+ }
124
+
125
+ /** A section heading, matching the `Usage` / `Commands` style of codex. */
126
+ export function heading(text: string): string {
127
+ return bold(text)
128
+ }