From 557ded588fe6e5f1f2da15c66cf6955855a26a12 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 12:13:49 -0700 Subject: [PATCH 01/17] feat(cli): render human output through a shared formatter kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON stays the source of truth; the plain-text output is now a consistent, greppable projection of it. Adds clients/cli/formatters with an opaque Block type and a small renderer set — emit (the single stdout sink), title, section, text, list, record/records (JSON object -> aligned key: value), table, code, markdown. Output is deterministic plain ASCII (no color, no TTY, no width detection); errors/warnings stay on stderr via cliError. Migrates every command's human branch onto emit + renderers (search, build, component, docs, hook, template, discover, doctor, swizzle, layout, theme, validate-integration, blog). Enforces the funnel by extending @astryx/no-raw-console-cli to ban humanLog/humanWarn in command files (emit is now the only path); humanLog stays internal to the logger and formatters. JSON envelopes are unchanged. CLI test suite (895 tests) passes. Co-authored-by: Cursor --- .../no-raw-console-cli.js | 70 +++-- packages/cli/clients/cli/commands/blog.mjs | 45 ++- .../cli/clients/cli/commands/blog.test.mjs | 3 +- .../cli/clients/cli/commands/build-theme.mjs | 94 +++---- packages/cli/clients/cli/commands/build.mjs | 181 ++++++------ .../clients/cli/commands/component/index.mjs | 161 +++++------ .../cli/commands/detail-levels.test.mjs | 10 +- .../cli/clients/cli/commands/discover.mjs | 120 ++++---- packages/cli/clients/cli/commands/docs.mjs | 29 +- packages/cli/clients/cli/commands/doctor.mjs | 66 +++-- .../cli/clients/cli/commands/hook/index.mjs | 90 +++--- packages/cli/clients/cli/commands/layout.mjs | 67 +++-- packages/cli/clients/cli/commands/search.mjs | 47 ++-- .../cli/clients/cli/commands/search.test.mjs | 7 +- packages/cli/clients/cli/commands/swizzle.mjs | 76 ++--- .../cli/clients/cli/commands/template.mjs | 70 +++-- .../cli/commands/validate-integration.mjs | 26 +- packages/cli/clients/cli/formatters/index.mjs | 259 ++++++++++++++++++ .../cli/clients/cli/formatters/index.test.mjs | 155 +++++++++++ .../cli/clients/cli/formatters/type-tests.mjs | 32 +++ 20 files changed, 1075 insertions(+), 533 deletions(-) create mode 100644 packages/cli/clients/cli/formatters/index.mjs create mode 100644 packages/cli/clients/cli/formatters/index.test.mjs create mode 100644 packages/cli/clients/cli/formatters/type-tests.mjs diff --git a/internal/eslint-plugin-astryx/no-raw-console-cli.js b/internal/eslint-plugin-astryx/no-raw-console-cli.js index 8e4eb8faf475..7062942a1272 100644 --- a/internal/eslint-plugin-astryx/no-raw-console-cli.js +++ b/internal/eslint-plugin-astryx/no-raw-console-cli.js @@ -2,25 +2,29 @@ /** * @file no-raw-console-cli.js - * @description Ban bare `console.log` in CLI runtime files so it can never - * corrupt the `--json` stdout contract (#2467). + * @description Enforce the CLI's output funnels so `--json` stdout stays clean + * and human output stays consistent (#2467). * - * The CLI's machine-readable JSON output owns stdout in `--json` mode. A stray - * `console.log` writes to that same stream and breaks JSON consumers. The - * sanctioned escape hatch is `humanLog()` (lib/json.mjs), which is a no-op in - * JSON mode. This rule pushes authors toward it: + * Two bans: * - * Banned: console.log(...) (writes raw stdout) - * Allowed: console.error(...) (stderr — never corrupts JSON) - * console.warn(...) (stderr) - * humanLog(...) (json-aware stdout) + * 1. Bare `console.log` in ANY CLI runtime file. The machine-readable JSON + * output owns stdout in `--json` mode; a stray `console.log` corrupts it. + * Banned: console.log(...) + * Allowed: console.error / console.warn (stderr — never corrupts JSON) * - * Autofix rewrites `console.log(` → `humanLog(`. The author is responsible for - * ensuring `humanLog` is imported from `../lib/json.mjs`; the fix only renames - * the call so the wrong primitive isn't silently kept. + * 2. `humanLog(...)` / `humanWarn(...)` in COMMAND files + * (clients/cli/commands/**). Command human output must go through the single + * formatter sink `emit(...)` (clients/cli/formatters) so every command renders + * consistently; errors/warnings go through `cliError()` (stderr). `humanLog` + * is now an internal primitive used only by `emit` and the shared `logger` — + * commands should never call it directly. * - * A handful of files legitimately write raw stdout — the JSON envelope writers - * and the banner — and are exempt: + * The console.log autofix rewrites `console.log(` → `humanLog(` for non-command + * files (the logger/formatters/etc.). There is no autofix for ban #2: moving a + * command to `emit` isn't a mechanical rename (emit takes Blocks, not strings). + * + * Files that legitimately write raw stdout or own the funnels are exempt from + * ban #1: * - packages/cli/foundation/response/json.mjs (defines humanLog / jsonOut) * - packages/cli/clients/cli/index.mjs (wiring / banner) * - packages/cli/clients/cli/bin/astryx.mjs (entrypoint / error boundary) @@ -32,9 +36,18 @@ const EXEMPT_SUFFIXES = [ 'packages/cli/clients/cli/bin/astryx.mjs', ]; +// Command files must funnel human output through emit(); humanLog/humanWarn are +// off-limits there (they remain available to the sink implementers: the logger, +// the formatters, and cli-error, none of which live under commands/). +const COMMANDS_DIR = 'clients/cli/commands/'; + +function normalize(filename) { + // Normalize Windows separators so path matching is platform-agnostic. + return filename.replace(/\\/g, '/'); +} + function isExempt(filename) { - // Normalize Windows separators so suffix matching is path-agnostic. - const normalized = filename.replace(/\\/g, '/'); + const normalized = normalize(filename); return EXEMPT_SUFFIXES.some(suffix => normalized.endsWith(suffix)); } @@ -43,7 +56,7 @@ const rule = { type: 'problem', docs: { description: - 'Ban bare console.log in CLI runtime files; use humanLog so --json stdout stays clean', + 'Enforce CLI output funnels: no console.log anywhere; no humanLog/humanWarn in command files (use emit)', category: 'Astryx Conventions', recommended: true, }, @@ -53,6 +66,10 @@ const rule = { 'Do not use console.log in CLI runtime code — it writes raw stdout and ' + 'can corrupt --json output. Use humanLog() (from lib/json.mjs), or ' + 'console.error/console.warn for stderr.', + noHumanLogInCommand: + 'Do not call {{name}}() in a command file — human stdout must go through ' + + 'emit() (clients/cli/formatters), and errors/warnings through cliError(). ' + + 'humanLog/humanWarn are internal primitives for the logger and formatters.', }, schema: [], }, @@ -61,10 +78,13 @@ const rule = { if (isExempt(filename)) { return {}; } + const inCommands = normalize(filename).includes(COMMANDS_DIR); return { CallExpression(node) { const callee = node.callee; + + // Ban #1: console.log (all CLI runtime files). if ( callee.type === 'MemberExpression' && !callee.computed && @@ -82,6 +102,20 @@ const rule = { return fixer.replaceText(callee, 'humanLog'); }, }); + return; + } + + // Ban #2: humanLog/humanWarn inside command files. + if ( + inCommands && + callee.type === 'Identifier' && + (callee.name === 'humanLog' || callee.name === 'humanWarn') + ) { + context.report({ + node: callee, + messageId: 'noHumanLogInCommand', + data: {name: callee.name}, + }); } }, }; diff --git a/packages/cli/clients/cli/commands/blog.mjs b/packages/cli/clients/cli/commands/blog.mjs index 8c92ba0ebe83..ad6fc77df868 100644 --- a/packages/cli/clients/cli/commands/blog.mjs +++ b/packages/cli/clients/cli/commands/blog.mjs @@ -15,31 +15,11 @@ */ import {getRunPrefix} from '../../../foundation/env/package-manager.mjs'; -import {humanLog, jsonOut} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, text, records} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {blog as blogApi} from '../../../api/blog/blog.mjs'; -/** - * @param {import('../../../api/blog/blog.type.mjs').BlogListData} data - * @param {string} run - */ -function formatList({feedUrl, posts}, run) { - const lines = [`\nAstryx blog · feed: ${feedUrl}\n`]; - if (posts.length === 0) { - lines.push('No posts found in the feed.'); - return lines.join('\n'); - } - for (const p of posts) { - lines.push(` ${p.slug}`); - lines.push(` ${p.title}`); - if (p.type) lines.push(` ${p.type}`); - if (p.textUrl) lines.push(` ${p.textUrl}`); - lines.push(''); - } - lines.push(`Read one: ${run} astryx blog `); - return lines.join('\n'); -} - /** * @param {import('commander').Command} program */ @@ -68,11 +48,24 @@ export function registerBlog(program) { } if (result.type === 'blog.list') { - humanLog(formatList(result.data, run)); + const {feedUrl, posts} = result.data; + if (posts.length === 0) { + emit( + section('Astryx blog', `feed: ${feedUrl}`), + text('No posts found in the feed.'), + ); + return; + } + // One record per post — fields mirror the JSON post shape; empty + // fields (type/textUrl) are skipped by record(). + emit( + section('Astryx blog', `feed: ${feedUrl}`), + records(posts, {fields: ['slug', 'title', 'type', 'textUrl']}), + text(`Read one: ${run} astryx blog `), + ); } else { - // blog.detail — print the feed URL, then the plaintext body. - humanLog(`Feed: ${result.data.feedUrl}\n`); - humanLog(result.data.text); + // blog.detail — print the feed URL, then the plaintext body verbatim. + emit(text(`Feed: ${result.data.feedUrl}`), text(result.data.text)); } }); } diff --git a/packages/cli/clients/cli/commands/blog.test.mjs b/packages/cli/clients/cli/commands/blog.test.mjs index 569bcc82799a..c9fec4fb30f0 100644 --- a/packages/cli/clients/cli/commands/blog.test.mjs +++ b/packages/cli/clients/cli/commands/blog.test.mjs @@ -82,7 +82,8 @@ describe('blog CLI — json-enabled', () => { it('blog (non-json) still prints the human feed listing', async () => { const {status, stdout} = await runCli(['blog']); expect(status).toBe(0); - expect(stdout).toMatch(/Astryx blog · feed:/); + expect(stdout).toMatch(/Astryx blog/); + expect(stdout).toMatch(/feed:/); expect(stdout).toMatch(/how-astryx-works/); }); diff --git a/packages/cli/clients/cli/commands/build-theme.mjs b/packages/cli/clients/cli/commands/build-theme.mjs index d5c6d2733f21..225664df6250 100644 --- a/packages/cli/clients/cli/commands/build-theme.mjs +++ b/packages/cli/clients/cli/commands/build-theme.mjs @@ -20,7 +20,8 @@ import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; import {spawn} from 'node:child_process'; import {getCliInvocation} from '../../../foundation/env/package-manager.mjs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, title, text, list, code} from '../formatters/index.mjs'; import {logger} from '../../../api/logger.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs'; @@ -91,10 +92,10 @@ async function runThemeBuildWatch(file, filePath, options) { return; } building = true; - humanLog(`\n♻️ Change detected — rebuilding ${rel}...`); + emit(text(`\nChange detected — rebuilding ${rel}...`)); await runThemeBuildOnceChild(file, options); building = false; - humanLog(`\n👀 Watching ${rel} for changes — press Ctrl-C to stop.`); + emit(text(`\nWatching ${rel} for changes — press Ctrl-C to stop.`)); if (queued) { queued = false; rebuild(); @@ -116,13 +117,13 @@ async function runThemeBuildWatch(file, filePath, options) { // Announce readiness only AFTER fs.watch is armed — the log is the "safe to // edit" signal (tests and humans rely on it), so printing it before the watch // is registered would race: a change in that gap is silently missed. - humanLog(`\n👀 Watching ${rel} for changes — press Ctrl-C to stop.`); + emit(text(`\nWatching ${rel} for changes — press Ctrl-C to stop.`)); await new Promise((/** @type {(value?: void) => void} */ resolve) => { const stop = () => { clearTimeout(debounce); watcher.close(); - humanLog('\nStopped watching.'); + emit(text('\nStopped watching.')); resolve(); }; process.once('SIGINT', stop); @@ -130,6 +131,32 @@ async function runThemeBuildWatch(file, filePath, options) { }); } +/** + * Emit the bundled themes as a bulleted list plus the `theme add` usage hint — + * the human projection of a `theme.list` envelope. Shared by `theme list` and + * the list affordance of `theme add` (bare `theme add` / `--list`). + * @param {import('../../../api/theme/theme.type.mjs').ThemeListEntry[]} themes + */ +function printThemeList(themes) { + if (themes.length === 0) { + emit(text('No themes are bundled with this CLI build.')); + return; + } + const run = getCliInvocation(); + emit( + title('Themes'), + list( + themes.map(t => { + const head = t.maintained ? `${t.slug} (maintained)` : t.slug; + return t.description ? [head, t.description] : head; + }), + ), + text( + `Usage:\n ${run} theme add [target-path] Scaffold a theme file you own`, + ), + ); +} + /** * @param {import('commander').Command} program */ @@ -266,21 +293,7 @@ export function registerTheme(program) { if (json) return jsonOut(result); - const themes = result.data; - if (themes.length === 0) { - humanLog('\nNo themes are bundled with this CLI build.\n'); - return; - } - humanLog('\nThemes:\n'); - for (const t of themes) { - const tag = t.maintained ? ' (maintained)' : ''; - humanLog(` ${t.slug}${tag}`); - if (t.description) humanLog(` ${t.description}`); - } - humanLog('\nUsage:'); - humanLog( - ` ${getCliInvocation()} theme add [target-path] Scaffold a theme file you own\n`, - ); + printThemeList(result.data); }); theme @@ -324,41 +337,30 @@ export function registerTheme(program) { if (json) return jsonOut(result); if (result.type === 'theme.list') { - const themes = result.data; - humanLog('\nThemes:\n'); - for (const t of themes) { - const tag = t.maintained ? ' (maintained)' : ''; - humanLog(` ${t.slug}${tag}`); - if (t.description) humanLog(` ${t.description}`); - } - humanLog('\nUsage:'); - humanLog( - ` ${getCliInvocation()} theme add [target-path] Scaffold a theme file you own\n`, - ); + printThemeList(result.data); return; } // theme.add — print where files landed + how to use the theme. const {displayName, outputDir, entry, exportName, files} = result.data; - humanLog(`\n✓ Added ${displayName} theme to ${outputDir}/`); - for (const f of files) { - humanLog(` ${outputDir}/${f}`); - } const entryModule = importSpecifier( outputDir, entry.replace(/\.tsx?$/, ''), ); - humanLog(` -Use it in your app (import path is relative to a file in src/ — adjust if yours lives elsewhere): - - import { ${exportName} } from '${entryModule}'; - - - - - -This is your copy of the ${displayName} theme — edit ${entry} to make it your own. -`); + emit( + text(`[ok] Added ${displayName} theme to ${outputDir}/`), + list(files.map(f => `${outputDir}/${f}`)), + text( + 'Use it in your app (import path is relative to a file in src/ — adjust if yours lives elsewhere):', + ), + code( + `import { ${exportName} } from '${entryModule}';\n\n` + + `\n \n`, + ), + text( + `This is your copy of the ${displayName} theme — edit ${entry} to make it your own.`, + ), + ); }, ); } diff --git a/packages/cli/clients/cli/commands/build.mjs b/packages/cli/clients/cli/commands/build.mjs index 5100c7b7928a..22e3b9a02aa6 100644 --- a/packages/cli/clients/cli/commands/build.mjs +++ b/packages/cli/clients/cli/commands/build.mjs @@ -13,42 +13,53 @@ */ import {getCliInvocation, formatCliCommand} from '../../../foundation/env/package-manager.mjs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, title, section, text, record, records, ARROW} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {build as buildApi} from '../../../api/build/build.mjs'; /** - * Print the build playbook (shown when `build` is run with no query). + * Emit the build playbook (shown when `build` is run with no query). * @param {string} run - The CLI invocation prefix (e.g. `npx astryx`). */ function printPlaybook(run) { - const lines = [ - '', - 'How to build a page with Astryx', - '', - "1. Find a starting point for what you're building:", - ` ${run} build ""`, - ' → returns the closest [page] template, the [block]s that cover parts,', - ' and the [component]s to fill the gaps, with a "Compose:" suggestion.', - '', - '2. If a [page] template matches → scaffold it and adapt:', - ` ${run} template [path]`, - '', - '3. If nothing matches exactly → compose:', - ` ${run} template --skeleton # study a close page's layout`, - ` ${run} template # drop in each block from the kit`, - ` ${run} component # fill remaining gaps (read props)`, - '', - '4. Rules (keep it on-system):', - ' - No
/raw HTML for layout — use VStack/HStack/Grid/Stack/Card etc.', - ` - No style={{}} — use component props; design tokens via \`${run} docs tokens\`.`, - ' - Wrap the app in and import core reset.css + astryx.css.', - '', - `Tip: \`${run} build ""\` is the fastest way in. For a neutral`, - `lookup of any component/doc/template, use \`${run} search \`.`, - '', - ]; - for (const l of lines) humanLog(l); + emit( + title('How to build a page with Astryx'), + text( + [ + "1. Find a starting point for what you're building:", + ` ${run} build ""`, + ` ${ARROW} returns the closest [page] template, the [block]s that cover parts,`, + ' and the [component]s to fill the gaps, with a "Compose:" suggestion.', + ].join('\n'), + ), + text( + [ + `2. If a [page] template matches ${ARROW} scaffold it and adapt:`, + ` ${run} template [path]`, + ].join('\n'), + ), + text( + [ + `3. If nothing matches exactly ${ARROW} compose:`, + ` ${run} template --skeleton # study a close page's layout`, + ` ${run} template # drop in each block from the kit`, + ` ${run} component # fill remaining gaps (read props)`, + ].join('\n'), + ), + text( + [ + '4. Rules (keep it on-system):', + ' - No
/raw HTML for layout — use VStack/HStack/Grid/Stack/Card etc.', + ` - No style={{}} — use component props; design tokens via \`${run} docs tokens\`.`, + ' - Wrap the app in and import core reset.css + astryx.css.', + ].join('\n'), + ), + text( + `Tip: \`${run} build ""\` is the fastest way in. For a neutral ` + + `lookup of any component/doc/template, use \`${run} search \`.`, + ), + ); } /** @@ -98,71 +109,73 @@ export function registerBuild(program) { result.data; if (!hasResults) { - humanLog(''); - humanLog(`No matches for "${q}".`); - humanLog(`Try a broader term, or browse: ${run} component --list`); - humanLog(''); + emit( + text(`No matches for "${q}".`), + text(`Try a broader term, or browse: ${run} component --list`), + ); return; } - const printItem = (/** @type {import('../../../api/search/search.type.mjs').SearchResultEntry} */ r, /** @type {string} */ label) => { - const display = r.domain === 'template' && r.displayName ? r.displayName : r.name; - humanLog(''); - humanLog(` [${label}] ${display}`); - if (r.description) humanLog(` ${r.description}`); - humanLog(` → ${formatCliCommand(r.command)}`); - if (options.verbose) { - if (r.import) humanLog(` import: ${r.import}`); - humanLog(` match: ${r.reason} (score ${r.score})`); - } - }; - - humanLog(''); - humanLog(`Building "${q}":`); - - // START — the single recommended path. - humanLog(''); - if (directMatch) { - humanLog(`START → Scaffold the \`${pages[0].name}\` page template, then adapt: ${run} template ${pages[0].name} ./src/App.tsx`); - } else if (pages.length) { - humanLog(`START → No exact page template. Use \`${pages[0].name}\` as a layout reference (${run} template ${pages[0].name} --skeleton) and compose the pieces below.`); - } else { - humanLog(`START → No page template fits. Frame with AppShell and compose the blocks + components below.`); - } + // Same JSON->text projection as search, but leaner: the section header + // already says the kind, so drop `domain`/`import` by default (they're in + // --json and under --detail). Keeps each item to name/displayName/desc/cmd. + const fields = options.detail + ? ['name', 'domain', 'displayName', 'score', 'reason', 'import', 'description', 'command'] + : ['name', 'displayName', 'description', 'command']; + /** @type {import('../formatters/index.mjs').RecordOptions} */ + const recordOpts = {fields, format: {command: formatCliCommand}}; + + const startCmd = directMatch + ? `${run} template ${pages[0].name} ./src/App.tsx` + : pages.length + ? `${run} template ${pages[0].name} --skeleton` + : `${run} component AppShell`; + const startNote = directMatch + ? 'Direct match — scaffold this page template, then adapt it:' + : pages.length + ? 'No exact page template — use the closest as a layout reference, then compose:' + : 'No page template fits — frame with AppShell, then compose the pieces below:'; + + /** @type {import('../formatters/index.mjs').Block[]} */ + const out = [ + title(`Building "${q}"`), + section('START', `${startNote}\n${startCmd}`), + ]; - // PAGE if (pages.length) { - humanLog(''); - humanLog(directMatch ? 'PAGE TEMPLATE — direct match:' : 'CLOSEST PAGE TEMPLATES — layout reference:'); - pages.forEach(p => printItem(p, directMatch ? 'page' : 'closest')); + out.push( + section( + 'PAGE TEMPLATES', + directMatch + ? 'Closest full-page templates — scaffold one, then adapt it.' + : 'Closest full-page templates — use as a layout reference.', + ), + records(pages, recordOpts), + ); } - - // FRAME — always (the page shell). - humanLog(''); - humanLog(`FRAME — page shell (always): ${frame.join(', ')}`); - humanLog(` full-page → AppShell; or Layout + SideNav/TopNav. ${run} component AppShell`); - - // BLOCKS — idea-specific composed patterns. if (blocks.length) { - humanLog(''); - humanLog('BLOCKS — drop-in patterns that cover parts of it:'); - blocks.forEach(b => printItem(b, 'block')); + out.push( + section('BLOCKS', 'Drop-in patterns that cover parts of the page.'), + records(blocks, recordOpts), + ); } - - // DOMAIN COMPONENTS — idea-specific atoms. if (domain.length) { - humanLog(''); - humanLog('DOMAIN COMPONENTS — specific to this idea:'); - domain.forEach(c => printItem(c, c.domain === 'hook' ? 'hook' : 'component')); + out.push( + section('DOMAIN COMPONENTS', 'Components specific to this idea.'), + records(domain, recordOpts), + ); } - // FOUNDATION — always (layout/typography/actions). - humanLog(''); - humanLog(`FOUNDATION — always available (layout/text/actions): ${foundation.join(' ')}`); - - // SETUP — so it renders / stays on-system. - humanLog(''); - humanLog('SETUP — import "@astryxdesign/core/reset.css" + "astryx.css". No
/style for layout — use Stack/Grid + tokens.'); - humanLog(''); + out.push( + section('FRAME + FOUNDATION', 'Always-available shell + layout/text/action primitives.'), + record({ + frame, + foundation, + setup: + 'import "@astryxdesign/core/reset.css" + "astryx.css"; no
/style for layout — use Stack/Grid + tokens', + }), + ); + + emit(...out); }); } diff --git a/packages/cli/clients/cli/commands/component/index.mjs b/packages/cli/clients/cli/commands/component/index.mjs index a447136bbfdb..9729c13a6a91 100644 --- a/packages/cli/clients/cli/commands/component/index.mjs +++ b/packages/cli/clients/cli/commands/component/index.mjs @@ -19,7 +19,8 @@ import { } from '../../lib/component-format.mjs'; import {resolveTheme} from '../../lib/resolve-theme.mjs'; import {getCliInvocation} from '../../../../foundation/env/package-manager.mjs'; -import {jsonOut, humanLog} from '../../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../../foundation/response/json.mjs'; +import {emit, section, text, list, record, records, markdown, code} from '../../formatters/index.mjs'; import {cliError} from '../../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../../foundation/response/error-codes.mjs'; import {component as componentApi} from '../../../../api/component/component.mjs'; @@ -114,36 +115,41 @@ export function registerComponent(program) { const coreDir = /** @type {string} */ (findCoreDir(process.cwd())); const themeData = resolveTheme(process.cwd()); + // Footer shared by the compact + names list views (prose → text()). + const listFooter = text( + [ + `Import from the path shown (e.g. import {Button} from '@astryxdesign/core/Button')`, + `Usage: ${run} component `, + ].join('\n'), + ); + switch (result.type) { case 'component.list': { // One list type across all three detail levels; the depth is carried // in result.data.detail and the grouped map in result.data.components. if (result.data.detail === 'full') { - // --detail full — dense per-component docs (signature, props, theming, examples). - humanLog(await formatBriefAll(coreDir, {zh, lang, themeData})); + // --detail full — dense per-component docs (signature, props, theming, + // examples). Verbatim markdown from the shared formatter. + emit(markdown(await formatBriefAll(coreDir, {zh, lang, themeData}))); break; } if (result.data.detail === 'compact') { - // --detail compact — name + 1-line description per entry. + // --detail compact — one record per entry (name + import + 1-line + // description), grouped by category. Fields mirror the JSON keys. const groups = result.data.components; - humanLog(''); const entries = Object.entries(groups); + /** @type {import('../../formatters/index.mjs').Block[]} */ + const out = []; for (const [cat, items] of entries) { // Skip the synthetic group header when there's only one ungrouped category const isUngrouped = entries.length === 1 && items.length === 1 && items[0]?.name === cat; - if (!isUngrouped) humanLog(`${cat} (group)`); - for (const item of items) { - const importHint = item.import ? ` ← ${item.import}` : ''; - const desc = item.description ? ` — ${item.description}` : ''; - humanLog(` XDS${item.name}${importHint}${desc}`); - } - humanLog(''); + if (!isUngrouped) out.push(section(`${cat} (group)`)); + out.push(records(items, {fields: ['name', 'import', 'description']})); } - humanLog(`Import from the path shown (e.g. import {Button} from '@astryxdesign/core/Button')`); - humanLog(`Usage: ${run} component `); - humanLog(''); + out.push(listFooter); + emit(...out); break; } @@ -172,105 +178,104 @@ export function registerComponent(program) { if (isCollision(item.name)) return ` [${item.package}]`; return ''; }; + // The import path hint stays inline (`name <- importPath`), ASCII arrow. + /** @param {import('../../../../api/component/component.type.mjs').ComponentListEntry} item */ + const entryLine = item => + `${item.name} <- ${resolveImportPath(coreDir, item.name)}${pkgSuffix(item)}`; if (options.category) { const [cat, comps] = Object.entries(groups)[0]; - humanLog(`\n${cat}:`); - for (const item of comps) { - const importPath = resolveImportPath(coreDir, item.name); - humanLog(` ${item.name} ← ${importPath}${pkgSuffix(item)}`); + emit(section(`${cat}:`), list(comps.map(entryLine))); + break; + } + + /** @type {import('../../formatters/index.mjs').Block[]} */ + const out = []; + // Batch consecutive ungrouped singles into one tight list; render each + // group as its own section + list. + /** @type {string[]} */ + let singles = []; + const flushSingles = () => { + if (singles.length) { + out.push(list(singles)); + singles = []; } - humanLog(''); - } else { - humanLog(''); - for (const [key, comps] of Object.entries(groups)) { - const isUngrouped = comps.length === 1 && comps[0]?.name === key; - if (isUngrouped) { - const item = comps[0]; - const importPath = resolveImportPath(coreDir, item.name); - humanLog(`${item.name} ← ${importPath}${pkgSuffix(item)}`); - } else { - humanLog(`${key} (group)`); - for (const item of comps) { - const importPath = resolveImportPath(coreDir, item.name); - humanLog(` ${item.name} ← ${importPath}${pkgSuffix(item)}`); - } - } + }; + for (const [key, comps] of Object.entries(groups)) { + const isUngrouped = comps.length === 1 && comps[0]?.name === key; + if (isUngrouped) { + singles.push(entryLine(comps[0])); + } else { + flushSingles(); + out.push(section(`${key} (group)`), list(comps.map(entryLine))); } - humanLog(''); - humanLog(`Import from the path shown (e.g. import {Button} from '@astryxdesign/core/Button')`); - humanLog(`Usage: ${run} component `); - humanLog(''); } + flushSingles(); + out.push(listFooter); + emit(...out); break; } case 'component.detail': { - if (detail === 'brief') { - const resolvedName = (name || '').replace(/^XDS/, ''); - const importHint = resolveImportPath(coreDir, resolvedName); - humanLog(formatBrief(result.data, resolvedName, importHint, {themeData})); - } else if (detail === 'compact') { - const resolvedName = (name || '').replace(/^XDS/, ''); - const importHint = resolveImportPath(coreDir, resolvedName); - humanLog(formatCompact(result.data, resolvedName, importHint)); - } else { - const resolvedName = (name || '').replace(/^XDS/, ''); - const importHint = resolveImportPath(coreDir, resolvedName); - humanLog(formatFull(result.data, {themeData, importHint})); - } - const compName = (name || '').replace(/^XDS/, ''); - const related = await findRelatedBlocks(compName); - if (related.length > 0) { - humanLog('\nRelated block templates:\n'); - for (const b of related) { - humanLog(` ${b.dirName}`); - if (b.description) humanLog(` ${b.description}`); - } - humanLog(''); - } + const resolvedName = (name || '').replace(/^XDS/, ''); + const importHint = resolveImportPath(coreDir, resolvedName); + const doc = + detail === 'brief' + ? markdown(formatBrief(result.data, resolvedName, importHint, {themeData})) + : detail === 'compact' + ? markdown(formatCompact(result.data, resolvedName, importHint)) + : markdown(formatFull(result.data, {themeData, importHint})); + const related = await findRelatedBlocks(resolvedName); + emit( + doc, + related.length > 0 && section('Related block templates'), + related.length > 0 && records(related, {fields: ['dirName', 'description']}), + ); break; } case 'component.detail.props': { const resolvedName = (name || '').replace(/^XDS/, ''); - humanLog(formatProps({props: result.data}, resolvedName)); + emit(markdown(formatProps({props: result.data}, resolvedName))); break; } case 'component.detail.source': { - humanLog(result.data.source); + emit(code(result.data.source)); break; } case 'component.detail.showcase': { - humanLog(result.data.source); + emit(code(result.data.source)); break; } case 'component.detail.blocks': { const {showcase, examples, related} = result.data; + /** @type {import('../../formatters/index.mjs').Block[]} */ + const out = []; if (showcase) { - humanLog(`\nShowcase: ${showcase.displayName}`); - if (showcase.description) humanLog(` ${showcase.description}`); + out.push( + section('Showcase'), + record(showcase, {fields: ['displayName', 'description']}), + ); } if (examples.length > 0) { - humanLog('\nExamples:\n'); - for (const b of examples) { - humanLog(` ${b.name}`); - if (b.description) humanLog(` ${b.description}`); - } + out.push( + section('Examples'), + records(examples, {fields: ['name', 'description']}), + ); } if (related.length > 0) { - humanLog(`\nRelated: ${related.length} blocks that use ${result.data.component}\n`); - for (const b of related) { - humanLog(` ${b.name}`); - } + out.push( + section(`Related: ${related.length} blocks that use ${result.data.component}`), + list(related.map(b => b.name)), + ); } if (!showcase && examples.length === 0 && related.length === 0) { - humanLog(`\nNo blocks found for ${result.data.component}`); + out.push(text(`No blocks found for ${result.data.component}`)); } - humanLog(''); + emit(...out); break; } } diff --git a/packages/cli/clients/cli/commands/detail-levels.test.mjs b/packages/cli/clients/cli/commands/detail-levels.test.mjs index b95261182c76..ec6c96975566 100644 --- a/packages/cli/clients/cli/commands/detail-levels.test.mjs +++ b/packages/cli/clients/cli/commands/detail-levels.test.mjs @@ -58,8 +58,10 @@ describe('--detail level ordering: component --list', () => { expect(brief).not.toMatch(/ \u2014 /); }); - it('compact has 1-line descriptions (name + em-dash separator)', () => { - expect(compact).toMatch(/ \u2014 /); + it('compact has descriptions (records expose a description field)', () => { + // Migrated to the shared formatter kit: compact renders one record per entry + // (name/import/description) rather than a single em-dash-joined line. + expect(compact).toMatch(/^description:/m); }); it('full has dense per-entry docs (props, targets, and import hints)', () => { @@ -114,8 +116,8 @@ describe('--detail level ordering: hook --list', () => { expect(brief).not.toMatch(/ \u2014 /); }); - it('compact has 1-line descriptions (name + em-dash separator)', () => { - expect(compact).toMatch(/ \u2014 /); + it('compact has per-hook descriptions (records with a description field)', () => { + expect(compact).toMatch(/description:/); }); it('full has dense docs (param tables and import statements)', () => { diff --git a/packages/cli/clients/cli/commands/discover.mjs b/packages/cli/clients/cli/commands/discover.mjs index 3f14113bcdec..9f2e1736c223 100644 --- a/packages/cli/clients/cli/commands/discover.mjs +++ b/packages/cli/clients/cli/commands/discover.mjs @@ -11,11 +11,15 @@ */ import {formatFull, formatBrief, formatCompact} from '../lib/component-format.mjs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, title, text, record, records, list, code, markdown} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {discover as discoverApi} from '../../../api/discover/discover.mjs'; import {getCliInvocation} from '../../../foundation/env/package-manager.mjs'; +// Max components to list inline per package before summarizing with "+N more". +const MAX_COMPONENTS_SHOWN = 10; + /** * @param {import('commander').Command} program */ @@ -55,87 +59,77 @@ export function registerDiscover(program) { switch (result.type) { case 'discover.list': { if (result.data.length === 0) { - humanLog(''); if (result.meta && result.meta.configured === false) { - humanLog('No integrations configured.'); - humanLog(''); - humanLog('Add integration package names to astryx.config.mjs:'); - humanLog(''); - humanLog(' export default {'); - humanLog(" integrations: ['@scope/your-integration'],"); - humanLog(' };'); + emit( + text('No integrations configured.'), + text('Add integration package names to astryx.config.mjs:'), + code( + "export default {\n integrations: ['@scope/your-integration'],\n};", + ), + ); } else { - humanLog('No external components found in configured integrations.'); + emit(text('No external components found in configured integrations.')); } - humanLog(''); - } else { - humanLog(''); - for (const pkg of result.data) { - const count = pkg.components.length; - const label = count === 1 ? 'component' : 'components'; - const heading = pkg.displayName - ? pkg.displayName + ' ' + pkg.name + ' (' + count + ' ' + label + ')' - : pkg.name + ' (' + count + ' ' + label + ')'; - humanLog(heading); - if (pkg.description) humanLog(' ' + pkg.description); + break; + } - if (options.components) { - for (const comp of pkg.components) humanLog(' ' + comp); - } else { - const maxShow = 10; - const shown = pkg.components.slice(0, maxShow); - const remaining = count - maxShow; - const list = shown.join(', '); - humanLog(remaining > 0 ? ' ' + list + ', +' + remaining + ' more' : ' ' + list); - } - humanLog(''); - } - humanLog('Usage:'); - humanLog(` ${run} discover Browse a package`); - humanLog(` ${run} discover /Component View component docs`); - humanLog(` ${run} discover Search all packages`); - humanLog(''); + // One record per package — fields mirror the JSON entry. The default + // view summarizes each package's components (first N + "+N more"); + // --components lists them all (record comma-joins the full array). + /** @type {import('../formatters/index.mjs').RecordOptions} */ + const listOpts = {fields: ['displayName', 'name', 'description', 'components']}; + if (!options.components) { + listOpts.format = { + components: (/** @type {string[]} */ comps) => { + const shown = comps.slice(0, MAX_COMPONENTS_SHOWN); + const remaining = comps.length - MAX_COMPONENTS_SHOWN; + return remaining > 0 + ? `${shown.join(', ')}, +${remaining} more` + : shown.join(', '); + }, + }; } + emit( + records(result.data, listOpts), + text( + [ + 'Usage:', + ` ${run} discover Browse a package`, + ` ${run} discover /Component View component docs`, + ` ${run} discover Search all packages`, + ].join('\n'), + ), + ); break; } case 'discover.detail': { - humanLog(''); const d = result.data; - const detailHeading = d.displayName - ? d.displayName + ' ' + d.name + ' (' + d.components.length + ' components)' - : d.name + ' (' + d.components.length + ' components)'; - humanLog(detailHeading); - if (d.description) humanLog(' ' + d.description); - humanLog(''); - for (const comp of result.data.components) humanLog(' ' + comp); - humanLog(''); - humanLog(`Usage: ${run} discover ` + result.data.name + '/'); - humanLog(''); + emit( + record(d, {fields: ['displayName', 'name', 'description', 'components']}), + text(`Usage: ${run} discover ${d.name}/`), + ); break; } case 'discover.detail.doc': { const docs = result.data; - if (detail === 'brief') { - humanLog(formatBrief(docs, docs.name, '')); - } else if (detail === 'compact') { - humanLog(formatCompact(docs, docs.name, '')); - } else { - humanLog(formatFull(docs)); - } - humanLog(''); + const md = + detail === 'brief' + ? formatBrief(docs, docs.name, '') + : detail === 'compact' + ? formatCompact(docs, docs.name, '') + : formatFull(docs); + emit(markdown(md)); break; } case 'discover.search': { - humanLog(''); - humanLog('Found ' + result.data.matches.length + ' matches for "' + result.data.query + '":'); - humanLog(''); - for (const m of result.data.matches) { - humanLog(` ${run} discover ` + m.package + '/' + m.component); - } - humanLog(''); + const {query: q, matches} = result.data; + emit( + title(`Found ${matches.length} matches for "${q}"`), + list(matches.map(m => `${run} discover ${m.package}/${m.component}`)), + ); break; } } diff --git a/packages/cli/clients/cli/commands/docs.mjs b/packages/cli/clients/cli/commands/docs.mjs index 2b29688e08cd..e3f286bf5f51 100644 --- a/packages/cli/clients/cli/commands/docs.mjs +++ b/packages/cli/clients/cli/commands/docs.mjs @@ -13,7 +13,8 @@ */ import {getCliInvocation} from '../../../foundation/env/package-manager.mjs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, title, records, text, markdown} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {docs as docsApi} from '../../../api/docs/docs.mjs'; @@ -76,8 +77,8 @@ function formatBlock(block, detail) { case 'list': { const prefix = block.style === 'ordered' ? (/** @type {number} */ i) => `${i + 1}. ` - : block.style === 'dont' ? () => '❌ ' - : block.style === 'do' ? () => '✓ ' + : block.style === 'dont' ? () => 'x ' + : block.style === 'do' ? () => '+ ' : () => '- '; return block.items.map((item, i) => `${prefix(i)}${item}`).join('\n'); } @@ -158,22 +159,28 @@ export function registerDocs(program) { switch (result.type) { case 'docs.list': { - humanLog('\nAvailable docs:\n'); - for (const entry of result.data) { - humanLog(` ${entry.topic.padEnd(14)} ${entry.description}`); - } - humanLog(`\nUsage: ${run} docs `); - humanLog(` ${run} docs
\n`); + // The text view mirrors the JSON list: one record per topic (topic + + // description), then the usage footer as plain prose. + emit( + title('Available docs'), + records(result.data, {fields: ['topic', 'description']}), + text( + [ + `Usage: ${run} docs `, + ` ${run} docs
`, + ].join('\n'), + ), + ); break; } case 'docs.detail': { - humanLog(formatReferenceFull(result.data, detail)); + emit(markdown(formatReferenceFull(result.data, detail))); break; } case 'docs.detail.section': { - humanLog(formatSection(result.data, detail)); + emit(markdown(formatSection(result.data, detail))); break; } } diff --git a/packages/cli/clients/cli/commands/doctor.mjs b/packages/cli/clients/cli/commands/doctor.mjs index df45a109e800..405e363fe42b 100644 --- a/packages/cli/clients/cli/commands/doctor.mjs +++ b/packages/cli/clients/cli/commands/doctor.mjs @@ -11,45 +11,51 @@ */ import {runChecks} from '../../../api/doctor/doctor.mjs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, title, records, text} from '../formatters/index.mjs'; -/** Status → human glyph (monochrome, matching the rest of the CLI). */ -const GLYPH = { - pass: '\u2713', // ✓ - warn: '\u26a0', // ⚠ - fail: '\u2717', // ✗ - info: '\u2139', // ℹ +/** Status -> ASCII token (plain, matching the rest of the CLI). */ +const STATUS = { + pass: '[ok]', + warn: '[warn]', + fail: '[fail]', + info: '[info]', }; +/** @param {string} status */ +function statusToken(status) { + return STATUS[/** @type {keyof typeof STATUS} */ (status)] ?? status; +} + /** - * Render the report as a human-readable checklist. + * Render the report as a human-readable checklist. Each check is a record whose + * fields mirror the JSON (status/label/message/fix); the status glyph is an + * ASCII token so the output is byte-deterministic in any terminal. * @param {import('../../../api/doctor/doctor.mjs').DoctorReport} report */ function printHuman(report) { - humanLog('astryx doctor — diagnosing your setup\n'); - for (const check of report.checks) { - const glyph = GLYPH[check.status] ?? '\u00b7'; - humanLog(` ${glyph} ${check.label}`); - humanLog(` ${check.message}`); - if (check.fix) { - humanLog(` \u2192 fix: ${check.fix}`); - } - } - const {pass, warn, fail, info} = report.summary; - humanLog(''); - humanLog( - `Summary: ${pass} passed, ${warn} warning${warn === 1 ? '' : 's'}, ` + - `${fail} failure${fail === 1 ? '' : 's'}` + - (info ? `, ${info} info` : ''), + const closing = + fail > 0 + ? 'Some checks failed. Address the items marked [fail] above.' + : warn > 0 + ? 'No failures — but review the [warn] warnings above when you can.' + : 'All checks passed. Your XDS setup looks healthy.'; + + emit( + title('astryx doctor — diagnosing your setup'), + records(report.checks, { + fields: ['status', 'label', 'message', 'fix'], + labels: {label: 'check'}, + format: {status: statusToken}, + }), + text( + `Summary: ${pass} passed, ${warn} warning${warn === 1 ? '' : 's'}, ` + + `${fail} failure${fail === 1 ? '' : 's'}` + + (info ? `, ${info} info` : ''), + ), + text(closing), ); - if (fail > 0) { - humanLog('\nSome checks failed. Address the items marked \u2717 above.'); - } else if (warn > 0) { - humanLog('\nNo failures — but review the \u26a0 warnings above when you can.'); - } else { - humanLog('\nAll checks passed. Your XDS setup looks healthy.'); - } } /** diff --git a/packages/cli/clients/cli/commands/hook/index.mjs b/packages/cli/clients/cli/commands/hook/index.mjs index 22124ba54f4f..00de3bcf510a 100644 --- a/packages/cli/clients/cli/commands/hook/index.mjs +++ b/packages/cli/clients/cli/commands/hook/index.mjs @@ -13,7 +13,8 @@ import { formatHookParams, } from '../../lib/hook-format.mjs'; import {getCliInvocation} from '../../../../foundation/env/package-manager.mjs'; -import {jsonOut, humanLog} from '../../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../../foundation/response/json.mjs'; +import {emit, section, text, list, records, markdown} from '../../formatters/index.mjs'; import {cliError} from '../../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../../foundation/response/error-codes.mjs'; import {hook as hookApi} from '../../../../api/hook/hook.mjs'; @@ -88,64 +89,66 @@ export function registerHook(program) { if (result.data.detail === 'full') { // --detail full — dense per-hook docs grouped by category // (import block, best practices, full params + returns tables, related). + // The whole view is one markdown document: a `## ` heading + // over each category's concatenated hook docs. const groups = result.data.components; - humanLog(''); + /** @type {import('../../formatters/index.mjs').Block[]} */ + const out = []; for (const [cat, items] of Object.entries(groups)) { - humanLog(`## ${cat}\n`); - for (const item of items) { - const importPath = item.importPath || '@astryxdesign/core/hooks'; - humanLog(formatHookCompact(item, importPath)); - } + const body = items + .map(item => + formatHookCompact(item, item.importPath || '@astryxdesign/core/hooks'), + ) + .join('\n'); + out.push(markdown(`## ${cat}\n\n${body}`)); } + emit(...out); break; } if (result.data.detail === 'compact') { - // --detail compact — name + 1-line description per entry. + // --detail compact — one record (name + description) per hook, + // grouped by category. const groups = result.data.components; - humanLog(''); + /** @type {import('../../formatters/index.mjs').Block[]} */ + const out = []; for (const [cat, items] of Object.entries(groups)) { - humanLog(cat); - for (const item of items) { - const desc = item.description ? ` — ${item.description}` : ''; - humanLog(` ${item.name}${desc}`); - } - humanLog(''); + out.push(section(cat), records(items, {fields: ['name', 'description']})); } - humanLog(`Usage: ${run} hook `); - humanLog(''); + out.push(text(`Usage: ${run} hook `)); + emit(...out); break; } - // --detail names (default for list views) — names only. + // --detail names (default for list views) — names only, grouped by + // category. const groups = result.data.components; if (options.category) { const [cat, hookNames] = Object.entries(groups)[0]; - humanLog(`\n${cat}:`); - for (const h of hookNames) humanLog(` ${h}`); - humanLog(''); + emit(section(`${cat}:`), list(hookNames)); } else { - humanLog(''); + /** @type {import('../../formatters/index.mjs').Block[]} */ + const out = []; for (const [category, hookNames] of Object.entries(groups)) { - humanLog(category); - for (const h of hookNames) humanLog(` ${h}`); + out.push(section(category), list(hookNames)); } - humanLog(''); - humanLog(`Usage: ${run} hook `); - humanLog(''); + out.push(text(`Usage: ${run} hook `)); + emit(...out); } break; } case 'hook.detail': { - if (detail === 'brief') { - humanLog(formatHookBrief(result.data)); - } else if (detail === 'compact') { - const importPath = result.data.importPath || '@astryxdesign/core/hooks'; - humanLog(formatHookCompact(result.data, importPath)); - } else { - humanLog(formatHookFull(result.data)); - } + const doc = + detail === 'brief' + ? formatHookBrief(result.data) + : detail === 'compact' + ? formatHookCompact( + result.data, + result.data.importPath || '@astryxdesign/core/hooks', + ) + : formatHookFull(result.data); + // Show related block templates from relatedComponents const relatedComps = result.data.relatedComponents || []; /** @type {import('../../../../api/template/template.mjs').DiscoveredTemplate[]} */ @@ -158,19 +161,18 @@ export function registerHook(program) { } } } - if (allBlocks.length > 0) { - humanLog('\nRelated block templates:\n'); - for (const b of allBlocks) { - humanLog(` ${b.dirName}`); - if (b.description) humanLog(` ${b.description}`); - } - humanLog(''); - } + + emit( + markdown(doc), + allBlocks.length > 0 && section('Related block templates'), + allBlocks.length > 0 && + records(allBlocks, {fields: ['dirName', 'description']}), + ); break; } case 'hook.detail.params': { - humanLog(formatHookParams({params: result.data, name: name})); + emit(markdown(formatHookParams({params: result.data, name}))); break; } } diff --git a/packages/cli/clients/cli/commands/layout.mjs b/packages/cli/clients/cli/commands/layout.mjs index df49a4c138ff..2e0b9ff541a2 100644 --- a/packages/cli/clients/cli/commands/layout.mjs +++ b/packages/cli/clients/cli/commands/layout.mjs @@ -14,7 +14,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, text, list, record, code, WARN} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs'; import {layoutExpand, layoutCheck, layoutGrammar} from '../../../api/layout/layout.mjs'; @@ -136,17 +137,30 @@ export function registerLayout(program) { } if (json) return jsonOut(result); - for (const warning of result.data.warnings) humanLog(`⚠ ${warning}`); + /** @type {import('../formatters/index.mjs').Block[]} */ + const out = []; + if (result.data.warnings.length > 0) { + out.push(text(result.data.warnings.map(w => `${WARN} ${w}`).join('\n'))); + } if (result.data.written) { - humanLog(`\n✓ Expanded to ${result.data.written}`); - humanLog(` Components: ${result.data.componentsUsed.join(', ')}`); - if (result.data.todos.length > 0) { - humanLog(` TODOs: ${result.data.todos.length} (search for "TODO(xle)")`); - } - humanLog(''); + out.push( + text(`[ok] Expanded to ${result.data.written}`), + record( + { + components: result.data.componentsUsed, + todos: + result.data.todos.length > 0 + ? `${result.data.todos.length} (search for "TODO(xle)")` + : '', + }, + {labels: {components: 'Components', todos: 'TODOs'}}, + ), + ); } else { - humanLog(result.data.code); + // Raw expanded TSX (no target path) — preformatted, emitted verbatim. + out.push(code(result.data.code)); } + emit(...out); }); layoutCmd @@ -188,21 +202,27 @@ export function registerLayout(program) { const {valid, form, errors, warnings, compact, outline} = result.data; if (!valid) { - humanLog(`\n✗ Invalid (${errors.length} error${errors.length === 1 ? '' : 's'}):`); - for (const e of errors) { - humanLog(` - ${e.formatted}`); - if (e.suggestions && e.suggestions.length > 0) humanLog(` did you mean: ${e.suggestions.join(', ')}?`); - } - humanLog(''); + // Each error: the formatted issue, with a hanging "did you mean" line. + const items = errors.map(e => + e.suggestions && e.suggestions.length > 0 + ? [e.formatted, `did you mean: ${e.suggestions.join(', ')}?`] + : [e.formatted], + ); + emit( + text(`[fail] Invalid (${errors.length} error${errors.length === 1 ? '' : 's'}):`), + list(items), + ); return; } - humanLog(`\n✓ Valid (parsed as ${form})`); - for (const warning of warnings) humanLog(`⚠ ${warning}`); - humanLog('\ncompact:'); - humanLog(` ${compact}`); - humanLog('\noutline:'); - humanLog(outline.split('\n').map(l => ` ${l}`).join('\n')); - humanLog(''); + + /** @type {import('../formatters/index.mjs').Block[]} */ + const out = [text(`[ok] Valid (parsed as ${form})`)]; + if (warnings.length > 0) { + out.push(text(warnings.map(w => `${WARN} ${w}`).join('\n'))); + } + // The canonical compact/outline surfaces are preformatted — emit verbatim. + out.push(section('compact'), code(compact), section('outline'), code(outline)); + emit(...out); }); layoutCmd @@ -220,6 +240,7 @@ export function registerLayout(program) { return; } if (json) return jsonOut(result); - humanLog(result.data.text); + // The cheatsheet is a preformatted document — emit verbatim. + emit(code(result.data.text)); }); } diff --git a/packages/cli/clients/cli/commands/search.mjs b/packages/cli/clients/cli/commands/search.mjs index fef3c28a6139..ade5ba0ec70c 100644 --- a/packages/cli/clients/cli/commands/search.mjs +++ b/packages/cli/clients/cli/commands/search.mjs @@ -17,17 +17,11 @@ */ import {getCliInvocation, formatCliCommand} from '../../../foundation/env/package-manager.mjs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, title, text, records} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {search as searchApi, SEARCH_DOMAINS} from '../../../api/search/search.mjs'; -const DOMAIN_LABEL = { - component: 'component', - hook: 'hook', - doc: 'docs', - template: 'template', -}; - /** * @param {import('commander').Command} program */ @@ -71,31 +65,24 @@ export function registerSearch(program) { // No matches is a valid, successful outcome — clean message, exit 0. if (results.length === 0) { - humanLog(''); - humanLog(`No results for "${q}".`); - humanLog(''); - humanLog(`Try a broader term, or browse: ${run} component --list`); - humanLog(''); + emit( + text(`No results for "${q}".`), + text(`Try a broader term, or browse: ${run} component --list`), + ); return; } - humanLog(''); - humanLog(`Results for "${q}" (${results.length}):`); - humanLog(''); - for (const r of results) { - const tag = `[${DOMAIN_LABEL[r.domain] || r.domain}]`.padEnd(12); - const display = r.domain === 'template' && r.displayName ? r.displayName : r.name; - humanLog(` ${tag} ${display}`); - if (r.description) { - humanLog(` ${r.description}`); - } - humanLog(` → ${formatCliCommand(r.command)}`); - if (options.verbose) { - if (r.import) humanLog(` import: ${r.import}`); - humanLog(` match: ${r.reason} (score ${r.score})`); - } - humanLog(''); - } + // The text view is just a projection of the JSON: one record per result, + // fields in a fixed order (missing ones skipped), command prefixed for the + // caller's package manager. Ranked order is preserved (best match first). + const fields = options.detail + ? ['name', 'domain', 'displayName', 'score', 'reason', 'import', 'description', 'command'] + : ['name', 'domain', 'displayName', 'import', 'description', 'command']; + + emit( + title(`Results for "${q}" (${results.length})`), + records(results, {fields, format: {command: formatCliCommand}}), + ); }); } diff --git a/packages/cli/clients/cli/commands/search.test.mjs b/packages/cli/clients/cli/commands/search.test.mjs index 8a87039ffc6a..eb5d2d152295 100644 --- a/packages/cli/clients/cli/commands/search.test.mjs +++ b/packages/cli/clients/cli/commands/search.test.mjs @@ -191,10 +191,13 @@ describe('search CLI — exit codes + JSON contract', () => { expect(parsed.data.results).toEqual([]); }); - it('shows the follow-up command hint in human output', async () => { + it('renders each result as a greppable key: value record', async () => { const r = await runCli(['search', 'button'], REPO_ROOT); expect(r.stdout).toContain('astryx component Button'); - expect(r.stdout).toContain('[component]'); + // Fields mirror the JSON object and are line-greppable. + expect(r.stdout).toMatch(/^name:\s+Button$/m); + expect(r.stdout).toMatch(/^domain:\s+component$/m); + expect(r.stdout).toContain('description:'); }); it('--verbose exits 0 and prints import/match detail', async () => { diff --git a/packages/cli/clients/cli/commands/swizzle.mjs b/packages/cli/clients/cli/commands/swizzle.mjs index 4dbc0b024c78..a48ac2fba6eb 100644 --- a/packages/cli/clients/cli/commands/swizzle.mjs +++ b/packages/cli/clients/cli/commands/swizzle.mjs @@ -10,7 +10,8 @@ * commands for the caller's package manager. */ -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, list, text, WARN} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {getCliInvocation} from '../../../foundation/env/package-manager.mjs'; import {swizzle as swizzleApi} from '../../../api/swizzle/swizzle.mjs'; @@ -50,54 +51,63 @@ export function registerSwizzle(program) { if (result.type === 'swizzle.list') { const components = result.data; - humanLog('\nAvailable components:\n'); - for (const name of components) { - humanLog(` ${name}`); - } - humanLog(`\nUsage: ${run} swizzle \n`); - humanLog(`Example: ${run} swizzle Button`); - humanLog(` ${run} swizzle XDSButton (XDS prefix also works)\n`); + emit( + section('Available components'), + list(components), + text(`Usage: ${run} swizzle `), + text( + [ + `Example: ${run} swizzle Button`, + ` ${run} swizzle XDSButton (XDS prefix also works)`, + ].join('\n'), + ), + ); return; } const {package: ownerPackage, outputDir, filesCopied, usesStyleX, feedback} = result.data; - humanLog(`\n✓ Copied ${filesCopied} files to ${outputDir}/\n`); - humanLog(`Relative imports have been rewritten to use ${ownerPackage}.`); - humanLog('You can now customize the component source freely.\n'); + /** @type {import('../formatters/index.mjs').Block[]} */ + const out = [ + text(`[ok] Copied ${filesCopied} files to ${outputDir}/`), + text( + [ + `Relative imports have been rewritten to use ${ownerPackage}.`, + 'You can now customize the component source freely.', + ].join('\n'), + ), + ]; // StyleX build requirement — swizzled StyleX source renders unstyled with // no error unless the consumer's build runs a StyleX compiler. if (usesStyleX) { - humanLog( - '⚠ These components use StyleX and require a StyleX compiler in your build.', - ); - humanLog( - ' Without one they render unstyled (no error). See setup per framework:', - ); - humanLog(` ${run} docs styling`); - humanLog( - ' Next.js note: the StyleX Babel plugin disables SWC and breaks next/font —', + out.push( + text( + [ + `${WARN} These components use StyleX and require a StyleX compiler in your build.`, + ' Without one they render unstyled (no error). See setup per framework:', + ` ${run} docs styling`, + ' Next.js note: the StyleX Babel plugin disables SWC and breaks next/font -', + ' use an SWC-based StyleX transform instead (covered in the guide).', + ].join('\n'), + ), ); - humanLog( - ' use an SWC-based StyleX transform instead (covered in the guide).', - ); - humanLog(''); } // Maintainer feedback note (skipped when the owner ships no issues URL). if (feedback) { - humanLog( - 'Customizing a component often signals a gap in the design system.', + out.push( + text( + [ + 'Customizing a component often signals a gap in the design system.', + 'Let the maintainers know what you needed:', + ` ${feedback.ghCommand || feedback.issuesUrl}`, + ].join('\n'), + ), ); - humanLog('Let the maintainers know what you needed:'); - if (feedback.ghCommand) { - humanLog(` ${feedback.ghCommand}`); - } else { - humanLog(` ${feedback.issuesUrl}`); - } - humanLog(''); } + + emit(...out); }); } diff --git a/packages/cli/clients/cli/commands/template.mjs b/packages/cli/clients/cli/commands/template.mjs index fb8d518dd31b..a36d41a0b499 100644 --- a/packages/cli/clients/cli/commands/template.mjs +++ b/packages/cli/clients/cli/commands/template.mjs @@ -6,7 +6,8 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, section, text, records, code} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs'; import {template as templateApi} from '../../../api/template/template.mjs'; @@ -126,48 +127,59 @@ export function registerTemplate(program) { case 'template.list': { const pages = result.data.filter(t => t.type === 'page'); const blocks = result.data.filter(t => t.type === 'block'); + // Project each entry to its JSON-mirroring fields; the WIP marker is + // folded into `name` (as before) and the package is shown only when it + // isn't the built-in core package. /** @param {import('../../../api/template/template.type.mjs').TemplateListEntry} t */ - const renderEntry = t => { - const status = t.isReady ? '' : ' (WIP)'; - const pkg = - t.package && t.package !== '@astryxdesign/core' - ? ` [${t.package}]` - : ''; - humanLog(` ${t.name}${status}${pkg}`); - if (t.description) humanLog(` ${t.description}`); - }; - if (pages.length > 0) { - humanLog('\nPage Templates:\n'); - for (const t of pages) renderEntry(t); - } - if (blocks.length > 0) { - humanLog('\nBlock Templates:\n'); - for (const t of blocks) renderEntry(t); - } - humanLog('\nUsage:'); - humanLog(` ${run} template [target-path] Scaffold page or block`); - humanLog(` ${run} template --skeleton Layout reference`); - humanLog(` ${run} template --list --type block List only blocks`); - humanLog(` ${run} template --list --package List from one package\n`); + const toRow = t => ({ + name: t.isReady ? t.name : `${t.name} (WIP)`, + description: t.description, + package: + t.package && t.package !== '@astryxdesign/core' ? t.package : '', + }); + const fields = ['name', 'description', 'package']; + emit( + pages.length > 0 && section('Page Templates'), + pages.length > 0 && records(pages.map(toRow), {fields}), + blocks.length > 0 && section('Block Templates'), + blocks.length > 0 && records(blocks.map(toRow), {fields}), + section('Usage'), + text( + [ + `${run} template [target-path] Scaffold page or block`, + `${run} template --skeleton Layout reference`, + `${run} template --list --type block List only blocks`, + `${run} template --list --package List from one package`, + ].join('\n'), + ), + ); break; } case 'template.skeleton': { const {template: tName, description, components, skeleton} = result.data; - humanLog(`\n# ${tName}${description ? ' — ' + description : ''}`); - humanLog(`# Components: ${components.join(', ')}\n`); - humanLog(skeleton); - humanLog(''); + emit( + text( + `# ${tName}${description ? ' — ' + description : ''}\n` + + `# Components: ${components.join(', ')}`, + ), + code(skeleton), + ); break; } case 'template.show': { - humanLog(result.data.source); + // Source must survive piping byte-for-byte. + emit(code(result.data.source)); break; } case 'template.copy': { - humanLog(`\n✓ Copied template to ${result.data.outputDir}/${result.data.fileName}\n`); + emit( + text( + `Copied template to ${result.data.outputDir}/${result.data.fileName}`, + ), + ); break; } } diff --git a/packages/cli/clients/cli/commands/validate-integration.mjs b/packages/cli/clients/cli/commands/validate-integration.mjs index 0ca04fadd333..375673cf01d9 100644 --- a/packages/cli/clients/cli/commands/validate-integration.mjs +++ b/packages/cli/clients/cli/commands/validate-integration.mjs @@ -14,7 +14,8 @@ * an integration package is not a failure). */ -import {jsonOut, humanLog} from '../../../foundation/response/json.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; +import {emit, title, text, records} from '../formatters/index.mjs'; import { validateIntegration, summarizeIssues, @@ -27,21 +28,24 @@ import { function printHuman(data) { const label = data.version != null ? `${data.name}@${data.version}` : data.name; - humanLog(`Validating integration: ${label}`); if (data.issues.length === 0) { - humanLog('\n\u2713 No issues found.'); + emit( + title(`Validating integration: ${label}`), + text('[ok] No issues found.'), + ); return; } - humanLog(''); - for (const issue of data.issues) { - humanLog(` ${issue.severity} ${issue.code}: ${issue.message}`); - } - + // The issue list is a projection of the JSON: one record per issue, fields + // mirroring the JSON keys (severity/code/message). const {errors, warnings} = summarizeIssues(data.issues); - humanLog( - `\n${data.issues.length} issue(s): ${errors} error(s), ${warnings} warning(s)`, + emit( + title(`Validating integration: ${label}`), + records(data.issues, {fields: ['severity', 'code', 'message']}), + text( + `${data.issues.length} issue(s): ${errors} error(s), ${warnings} warning(s)`, + ), ); } @@ -77,7 +81,7 @@ export function registerValidateIntegration(program) { jsonOut(result); } else if (result.data.name === null) { // No-arg + no local manifest: guidance, not an error. - humanLog(NO_MANIFEST_GUIDANCE); + emit(text(NO_MANIFEST_GUIDANCE)); } else { printHuman(result.data); } diff --git a/packages/cli/clients/cli/formatters/index.mjs b/packages/cli/clients/cli/formatters/index.mjs new file mode 100644 index 000000000000..91387ae200ad --- /dev/null +++ b/packages/cli/clients/cli/formatters/index.mjs @@ -0,0 +1,259 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file The CLI's human-output formatters. + * + * The mental model: `--json` is the source of truth, and the plain-text output + * is just a human-readable projection of that same JSON. So the core renderers + * take JSON-native values (objects, arrays of objects, string arrays) directly + * and turn them into readable text — a command shouldn't have to hand-map every + * field. `record(obj)` / `records(arr)` are the workhorses; you mostly just pick + * which fields to show and in what order. + * + * Constraints (deliberately narrow): + * - Plain ASCII only. No color, no TTY detection, no width wrapping. Output is + * byte-for-byte deterministic whether printed or piped to an agent. + * - Renderers return an opaque {@link Block}; `emit` accepts ONLY Blocks, so a + * stray string can't leak onto stdout (the compiler rejects `emit('x')`). + * `emit` is the one stdout sink; errors/warnings go to stderr via + * cli-error.mjs, never here. + * - Field names in the text mirror the JSON keys 1:1, so every field is + * greppable (`grep '^description:'`) and the two views stay in sync. + */ + +import {humanLog} from '../../../foundation/response/json.mjs'; + +/** + * Shared ASCII vocabulary — used here and by stderr diagnostics (cli-error.mjs) + * so the whole CLI speaks one plain-text dialect. ASCII on purpose: renders + * identically in every terminal, pager, and captured log. + */ +export const ARROW = '->'; +export const BULLET = '-'; +export const ERR = '!!'; +export const WARN = '!'; + +// Gap between table columns. +const COL_GAP = ' '; + +/** + * An opaque, renderer-produced block of output. Nominal via a private field: + * nothing outside this file can construct one, so `emit` can trust that whatever + * it receives came from a renderer (a plain string is not assignable to Block). + */ +export class Block { + /** @type {string} */ + #text; + /** @param {string} text */ + constructor(text) { + this.#text = text; + } + /** @returns {string} */ + toString() { + return this.#text; + } +} + +/** + * Anything `emit` accepts: a Block, or a falsy value so callers can inline + * conditionals (`cond && section(...)`). Falsy entries are dropped; a raw string + * is intentionally NOT assignable. + * @typedef {Block | false | null | undefined} Emittable + */ + +/** + * Options shared by {@link record} and {@link records}. + * @typedef {object} RecordOptions + * @property {string[]} [fields] - Keys to show, in order. Missing/empty keys are + * skipped. Defaults to the object's own keys. + * @property {string[]} [omit] - Keys to exclude (when `fields` is not given). + * @property {Record} [labels] - Rename a key for display. + * @property {Record string>} [format] - Transform a + * value before rendering (e.g. prefix a command with the package manager). + */ + +/** @param {unknown} v @returns {boolean} */ +function isEmpty(v) { + return ( + v === null || + v === undefined || + v === '' || + (Array.isArray(v) && v.length === 0) + ); +} + +/** @param {unknown} v @returns {string} */ +function renderValue(v) { + if (Array.isArray(v)) return v.map(x => String(x)).join(', '); + return String(v); +} + +/** + * A headline for a command's output (e.g. `Results for "button" (20)`). + * @param {string} content + * @returns {Block} + */ +export function title(content) { + return new Block(String(content)); +} + +/** + * A group label, optionally with an explanatory subtitle rendered on the line(s) + * directly beneath the heading (no blank line between). Whatever list/records + * follow are separate blocks, so emit's blank line separates them from the + * header + subtitle. + * @param {string} heading + * @param {string} [subtitle] + * @returns {Block} + */ +export function section(heading, subtitle) { + return new Block(subtitle ? `${heading}\n${subtitle}` : String(heading)); +} + +/** + * A prose block, printed as-is (no wrapping). Multi-line strings kept verbatim. + * @param {string} content + * @returns {Block} + */ +export function text(content) { + return new Block(String(content)); +} + +/** + * A bulleted list of primitives (e.g. a string array). Each item is a string or + * an array of lines (first line is the head, the rest hang-indent). For arrays + * of OBJECTS use {@link records} instead. + * @param {Array} items + * @returns {Block} + */ +export function list(items) { + const hang = ' '.repeat(BULLET.length + 1); + const rendered = items.map(item => { + const lines = Array.isArray(item) ? item : String(item).split('\n'); + const [head = '', ...rest] = lines; + return [`${BULLET} ${head}`, ...rest.map(l => `${hang}${l}`)].join('\n'); + }); + const multiline = rendered.some(r => r.includes('\n')); + return new Block(rendered.join(multiline ? '\n\n' : '\n')); +} + +/** + * Render a JSON object as aligned `key: value` lines — the readable projection + * of that object. Values: strings as-is, arrays comma-joined, other primitives + * String()'d; empty/missing fields are skipped. Field names mirror the JSON keys + * so the output is greppable and maps 1:1 to `--json`. + * @param {any} obj + * @param {RecordOptions} [options] + * @returns {Block} + */ +export function record(obj, options = {}) { + const src = /** @type {Record} */ (obj); + const keys = (options.fields ?? Object.keys(src)).filter( + k => !options.omit?.includes(k) && !isEmpty(src[k]), + ); + /** @param {string} k */ + const label = k => options.labels?.[k] ?? k; + const keyWidth = keys.reduce((max, k) => Math.max(max, label(k).length), 0); + const lines = keys.map(k => { + const fmt = options.format?.[k]; + const value = fmt ? fmt(src[k]) : renderValue(src[k]); + return `${`${label(k)}:`.padEnd(keyWidth + 2)}${value}`; + }); + return new Block(lines.join('\n')); +} + +/** + * Render an array of JSON objects as {@link record}s, one per object, separated + * by a blank line. The single easiest way to turn `data.results` (or any object + * array) into readable text. + * @param {any[]} items + * @param {RecordOptions} [options] + * @returns {Block} + */ +export function records(items, options = {}) { + const blocks = items.map(o => record(o, options).toString()).filter(Boolean); + return new Block(blocks.join('\n\n')); +} + +/** + * A text table with content-aligned columns. The caller decides the columns and + * cells; the last column is not padded (no trailing spaces). An optional header + * row is underlined. Best for short, uniform values (not long descriptions). + * @param {string[][]} rows + * @param {{head?: string[]}} [options] + * @returns {Block} + */ +export function table(rows, options = {}) { + const {head} = options; + const all = head ? [head, ...rows] : rows; + if (all.length === 0) return new Block(''); + + const cols = all.reduce((max, r) => Math.max(max, r.length), 0); + /** @type {number[]} */ + const widths = []; + for (let c = 0; c < cols; c++) { + widths[c] = all.reduce((max, r) => Math.max(max, (r[c] ?? '').length), 0); + } + + /** @param {string[]} row */ + const fmt = row => + row + .map((cell, c) => (c === cols - 1 ? (cell ?? '') : (cell ?? '').padEnd(widths[c]))) + .join(COL_GAP) + .replace(/\s+$/, ''); + + /** @type {string[]} */ + const lines = []; + if (head) { + lines.push(fmt(head)); + lines.push(widths.map(w => '-'.repeat(w)).join(COL_GAP).replace(/\s+$/, '')); + } + for (const r of rows) lines.push(fmt(r)); + return new Block(lines.join('\n')); +} + +/** + * A verbatim preformatted block — source dumps, layout skeletons; survives + * piping byte-for-byte (`astryx template X > file.tsx`). + * @param {string} source + * @param {{lang?: string}} [_options] - Reserved (intent label); output verbatim. + * @returns {Block} + */ +export function code(source, _options = {}) { + return new Block(String(source)); +} + +/** + * A verbatim Markdown document — component / docs / hook detail. Verbatim today; + * the semantic name is a single seam to pretty-render later. + * @param {string} md + * @returns {Block} + */ +export function markdown(md) { + return new Block(String(md)); +} + +/** + * @param {Emittable} b + * @returns {b is Block} + */ +function isBlock(b) { + return b instanceof Block; +} + +/** + * The one sanctioned way to write human output to stdout. Joins blocks with a + * single blank line and hands the result to `humanLog`, which is a no-op in + * `--json` mode (stdout carries only the envelope there). Accepts only Blocks + * (plus falsy placeholders); a bare string will not type-check. + * @param {Emittable[]} blocks + * @returns {void} + */ +export function emit(...blocks) { + const body = blocks + .filter(isBlock) + .map(b => b.toString()) + .filter(s => s.length > 0) + .join('\n\n'); + humanLog(body); +} diff --git a/packages/cli/clients/cli/formatters/index.test.mjs b/packages/cli/clients/cli/formatters/index.test.mjs new file mode 100644 index 000000000000..ba9c221b354d --- /dev/null +++ b/packages/cli/clients/cli/formatters/index.test.mjs @@ -0,0 +1,155 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {describe, it, expect, vi, afterEach} from 'vitest'; +import { + emit, + title, + section, + text, + list, + record, + records, + table, + code, + markdown, + Block, + BULLET, + ARROW, +} from './index.mjs'; +import {setJsonMode} from '../../../foundation/response/json.mjs'; + +afterEach(() => { + setJsonMode(false); + vi.restoreAllMocks(); +}); + +describe('constants', () => { + it('are plain ASCII', () => { + expect(BULLET).toBe('-'); + expect(ARROW).toBe('->'); + expect(/[^\x20-\x7E]/.test(`${BULLET}${ARROW}`)).toBe(false); + }); +}); + +describe('renderers return Block', () => { + it('produces nominal Block instances', () => { + expect(title('x')).toBeInstanceOf(Block); + expect(record({a: 1})).toBeInstanceOf(Block); + expect(records([{a: 1}])).toBeInstanceOf(Block); + expect(code('a')).toBeInstanceOf(Block); + }); +}); + +describe('section', () => { + it('renders a heading alone, or heading + subtitle directly beneath', () => { + expect(section('PAGE TEMPLATES').toString()).toBe('PAGE TEMPLATES'); + expect(section('PAGE TEMPLATES', 'Closest full-page templates.').toString()).toBe( + 'PAGE TEMPLATES\nClosest full-page templates.', + ); + }); +}); + +describe('list', () => { + it('renders single-line items tightly with bullets', () => { + expect(list(['alpha', 'beta']).toString()).toBe('- alpha\n- beta'); + }); + + it('hang-indents multi-line items and separates them with a blank line', () => { + expect(list([['head', 'detail'], 'solo']).toString()).toBe( + '- head\n detail\n\n- solo', + ); + }); +}); + +describe('record', () => { + it('renders a JSON object as aligned key: value lines', () => { + const lines = record({ + name: 'Button', + domain: 'component', + description: 'A button.', + }) + .toString() + .split('\n'); + expect(lines[0]).toMatch(/^name:\s+Button$/); + expect(lines[1]).toMatch(/^domain:\s+component$/); + expect(lines[2]).toMatch(/^description: A button\.$/); + // Values align to one column (keyWidth 'description' = 11, +2 = 13). + expect(lines[0].indexOf('Button')).toBe(13); + expect(lines[2].indexOf('A button.')).toBe(13); + }); + + it('picks + orders fields and skips missing/empty ones', () => { + const out = record( + {name: 'X', domain: 'component', description: ''}, + {fields: ['domain', 'name', 'displayName', 'description']}, + ).toString(); + expect(out).toBe('domain: component\nname: X'); + }); + + it('applies labels and format transforms', () => { + const out = record( + {command: 'astryx x'}, + {labels: {command: 'run'}, format: {command: v => `pnpm exec ${v}`}}, + ).toString(); + expect(out).toBe('run: pnpm exec astryx x'); + }); + + it('joins array values with commas', () => { + expect(record({frame: ['AppShell', 'TopNav']}).toString()).toBe( + 'frame: AppShell, TopNav', + ); + }); +}); + +describe('records', () => { + it('renders one record per object separated by a blank line', () => { + expect(records([{name: 'A'}, {name: 'B'}]).toString()).toBe('name: A\n\nname: B'); + }); +}); + +describe('table', () => { + it('aligns columns to their widest cell and does not pad the last column', () => { + const out = table( + [ + ['Button', '100'], + ['IconButton', '90'], + ], + {head: ['Name', 'Score']}, + ).toString(); + expect(out).toBe( + ['Name Score', '---------- -----', 'Button 100', 'IconButton 90'].join( + '\n', + ), + ); + }); +}); + +describe('code / markdown', () => { + it('are byte-for-byte verbatim', () => { + const src = 'const x = 1;\n const y = 2;\n'; + expect(code(src).toString()).toBe(src); + expect(markdown('# Title\n\n- a\n').toString()).toBe('# Title\n\n- a\n'); + }); +}); + +describe('emit', () => { + it('joins blocks with a single blank line via one console.log', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + emit(title('A'), text('B')); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('A\n\nB'); + }); + + it('drops falsy placeholders', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + emit(title('A'), false, null, undefined, text('B')); + expect(spy).toHaveBeenCalledWith('A\n\nB'); + }); + + it('is a no-op in --json mode (stdout stays clean)', () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + setJsonMode(true); + emit(title('A'), text('B')); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/clients/cli/formatters/type-tests.mjs b/packages/cli/clients/cli/formatters/type-tests.mjs new file mode 100644 index 000000000000..3d2547d7497a --- /dev/null +++ b/packages/cli/clients/cli/formatters/type-tests.mjs @@ -0,0 +1,32 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Compile-time guards for the formatter contract. Never executed — its + * only job is to make `tsc` (checkJs + strict, via tsconfig.strict.json) prove + * that `emit` accepts renderer output and rejects raw strings. If any + * `@ts-expect-error` below stops erroring, the contract has regressed and the + * type check fails. + */ + +import {emit, title, section, text, list, record, records, table, code, markdown} from './index.mjs'; + +/** @returns {void} */ +export function __formatterTypeGuards() { + // Renderer output is emittable. + emit(title('t'), section('s'), section('H', 'subtitle'), text('p')); + emit(list(['a', ['head', 'detail']])); + emit(record({name: 'Button', domain: 'component'}, {fields: ['name', 'domain']})); + emit(records([{name: 'A'}, {name: 'B'}], {format: {name: v => String(v)}})); + emit(table([['a', 'b']], {head: ['x', 'y']})); + emit(code('const x = 1;'), markdown('# doc')); + + // Falsy placeholders are allowed (inline conditionals). + const show = false; + emit(title('t'), show && section('maybe'), null, undefined); + + // @ts-expect-error a bare string is not a Block — must go through a renderer. + emit('raw string'); + + // @ts-expect-error a number is not a Block. + emit(42); +} From 806f3ffab5e54ac2e2484c40b9d0771f2947e9f4 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 12:16:08 -0700 Subject: [PATCH 02/17] feat(cli): lead build output with a kit legend (what it is + section order) Replace the status-like "Building \"\"" header with a titled "Build kit for \"\"" plus a short legend: what the output is, how to use it (run START, then pull from the sections; each item has a `command:`), and the exact order of the sections that follow (computed from the ones actually present) so it reads clearly and parses predictably. Co-authored-by: Cursor --- packages/cli/clients/cli/commands/build.mjs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/cli/clients/cli/commands/build.mjs b/packages/cli/clients/cli/commands/build.mjs index 22e3b9a02aa6..5a83d7a38f1c 100644 --- a/packages/cli/clients/cli/commands/build.mjs +++ b/packages/cli/clients/cli/commands/build.mjs @@ -136,9 +136,24 @@ export function registerBuild(program) { ? 'No exact page template — use the closest as a layout reference, then compose:' : 'No page template fits — frame with AppShell, then compose the pieces below:'; + // A short legend up top: what this output is, how to use it, and the exact + // order of the sections below (only the ones actually present) so it reads + // clearly and parses predictably. + const sectionsOrder = ['START']; + if (pages.length) sectionsOrder.push('PAGE TEMPLATES'); + if (blocks.length) sectionsOrder.push('BLOCKS'); + if (domain.length) sectionsOrder.push('DOMAIN COMPONENTS'); + sectionsOrder.push('FRAME + FOUNDATION'); + /** @type {import('../formatters/index.mjs').Block[]} */ const out = [ - title(`Building "${q}"`), + title(`Build kit for "${q}"`), + text( + 'A recommended set of pieces to assemble this page, in the order to use them. ' + + 'Run START first, then pull from the sections below — each recommended item ' + + 'includes a `command:` to run next.\n' + + `Sections in order: ${sectionsOrder.join(', ')}.`, + ), section('START', `${startNote}\n${startCmd}`), ]; From bc7a99282759af1c0341a65d003603348b780ea6 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 12:29:36 -0700 Subject: [PATCH 03/17] feat(cli): clarify build's recommended start (reasoning + fallback) Rename the build kit's "START" section to "RECOMMENDED START" and explain why: the closest page template is called out as the recommendation ("this template appears closest ... we recommend starting here"), with an explicit fallback ("otherwise browse PAGE TEMPLATES, then BLOCKS and DOMAIN COMPONENTS"). Keeps the top legend in sync. Co-authored-by: Cursor --- packages/cli/clients/cli/commands/build.mjs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cli/clients/cli/commands/build.mjs b/packages/cli/clients/cli/commands/build.mjs index 5a83d7a38f1c..3d687623258c 100644 --- a/packages/cli/clients/cli/commands/build.mjs +++ b/packages/cli/clients/cli/commands/build.mjs @@ -131,15 +131,15 @@ export function registerBuild(program) { ? `${run} template ${pages[0].name} --skeleton` : `${run} component AppShell`; const startNote = directMatch - ? 'Direct match — scaffold this page template, then adapt it:' + ? `This \`${pages[0].name}\` page template appears to be the closest to what you want, so we recommend starting here — scaffold it and adapt. Otherwise, browse PAGE TEMPLATES first, then BLOCKS and DOMAIN COMPONENTS below.` : pages.length - ? 'No exact page template — use the closest as a layout reference, then compose:' - : 'No page template fits — frame with AppShell, then compose the pieces below:'; + ? `No exact match, but \`${pages[0].name}\` is the closest page template — start from it as a layout reference. Otherwise, browse PAGE TEMPLATES first, then BLOCKS and DOMAIN COMPONENTS below.` + : 'No page template fits — frame with AppShell, then compose from BLOCKS and DOMAIN COMPONENTS below.'; // A short legend up top: what this output is, how to use it, and the exact // order of the sections below (only the ones actually present) so it reads // clearly and parses predictably. - const sectionsOrder = ['START']; + const sectionsOrder = ['RECOMMENDED START']; if (pages.length) sectionsOrder.push('PAGE TEMPLATES'); if (blocks.length) sectionsOrder.push('BLOCKS'); if (domain.length) sectionsOrder.push('DOMAIN COMPONENTS'); @@ -150,11 +150,11 @@ export function registerBuild(program) { title(`Build kit for "${q}"`), text( 'A recommended set of pieces to assemble this page, in the order to use them. ' + - 'Run START first, then pull from the sections below — each recommended item ' + - 'includes a `command:` to run next.\n' + + 'Begin with RECOMMENDED START, then pull from the sections below — each ' + + 'recommended item includes a `command:` to run next.\n' + `Sections in order: ${sectionsOrder.join(', ')}.`, ), - section('START', `${startNote}\n${startCmd}`), + section('RECOMMENDED START', `${startNote}\n${startCmd}`), ]; if (pages.length) { From 7e7c93e9d8a0db1ec90bbdbbcf053f5b786abc49 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 12:31:58 -0700 Subject: [PATCH 04/17] fix(cli): use a placeholder in build's recommended start The recommended scaffold command hardcoded `./src/App.tsx`, which wrongly assumes the consumer's project layout. Use a `` placeholder instead and explain in the note that it's the file (or folder) to write the template to. The non-direct-match case now points at the printed `--skeleton` layout as the reference rather than implying a scaffold path. Co-authored-by: Cursor --- packages/cli/clients/cli/commands/build.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/clients/cli/commands/build.mjs b/packages/cli/clients/cli/commands/build.mjs index 3d687623258c..311d5d9f4289 100644 --- a/packages/cli/clients/cli/commands/build.mjs +++ b/packages/cli/clients/cli/commands/build.mjs @@ -125,15 +125,18 @@ export function registerBuild(program) { /** @type {import('../formatters/index.mjs').RecordOptions} */ const recordOpts = {fields, format: {command: formatCliCommand}}; + // `template ` scaffolds into your project; is the file + // (or folder) to write it to — a placeholder, since we can't know your + // layout. `--skeleton` and `component ` just print, so no path. const startCmd = directMatch - ? `${run} template ${pages[0].name} ./src/App.tsx` + ? `${run} template ${pages[0].name} ` : pages.length ? `${run} template ${pages[0].name} --skeleton` : `${run} component AppShell`; const startNote = directMatch - ? `This \`${pages[0].name}\` page template appears to be the closest to what you want, so we recommend starting here — scaffold it and adapt. Otherwise, browse PAGE TEMPLATES first, then BLOCKS and DOMAIN COMPONENTS below.` + ? `This \`${pages[0].name}\` page template appears to be the closest to what you want, so we recommend scaffolding it into your project — replace \`\` with the file (or folder) to write it to — then adapting. Otherwise, browse PAGE TEMPLATES first, then BLOCKS and DOMAIN COMPONENTS below.` : pages.length - ? `No exact match, but \`${pages[0].name}\` is the closest page template — start from it as a layout reference. Otherwise, browse PAGE TEMPLATES first, then BLOCKS and DOMAIN COMPONENTS below.` + ? `No exact match, but \`${pages[0].name}\` is the closest page template — run the above to print its layout as a reference, then compose. Otherwise, browse PAGE TEMPLATES first, then BLOCKS and DOMAIN COMPONENTS below.` : 'No page template fits — frame with AppShell, then compose from BLOCKS and DOMAIN COMPONENTS below.'; // A short legend up top: what this output is, how to use it, and the exact From ced762838add453f9b5d1e495df2ed4ea83f37ce Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 12:36:56 -0700 Subject: [PATCH 05/17] feat(cli): normalize non-ASCII typography to ASCII in rendered output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Human output is meant to be plain ASCII, but data (e.g. template display names like "Avatar — Group") and prose carried em/en dashes, curly quotes, and ellipses. The prose/data renderers (title, section, text, list, record, table) now normalize these to ASCII (— -> -, curly quotes -> straight, … -> ...); the verbatim renderers (code, markdown) and --json are untouched. Table cells are normalized before width calc so alignment stays correct. Co-authored-by: Cursor --- packages/cli/clients/cli/formatters/index.mjs | 37 +++++++++++++++---- .../cli/clients/cli/formatters/index.test.mjs | 16 ++++++++ 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/cli/clients/cli/formatters/index.mjs b/packages/cli/clients/cli/formatters/index.mjs index 91387ae200ad..583526b41f05 100644 --- a/packages/cli/clients/cli/formatters/index.mjs +++ b/packages/cli/clients/cli/formatters/index.mjs @@ -88,13 +88,31 @@ function renderValue(v) { return String(v); } +/** + * Normalize common non-ASCII typography to ASCII so human output stays plain and + * consistent no matter what the source data contains (em/en dashes -> "-", curly + * quotes -> straight, ellipsis -> "...", non-breaking space -> space). The + * verbatim renderers (code, markdown) deliberately skip this. `--json` is + * unaffected — it always carries the original data. + * @param {string} s + * @returns {string} + */ +function toAscii(s) { + return String(s) + .replace(/[\u2014\u2013]/g, '-') + .replace(/[\u2018\u2019]/g, "'") + .replace(/[\u201C\u201D]/g, '"') + .replace(/\u2026/g, '...') + .replace(/\u00a0/g, ' '); +} + /** * A headline for a command's output (e.g. `Results for "button" (20)`). * @param {string} content * @returns {Block} */ export function title(content) { - return new Block(String(content)); + return new Block(toAscii(String(content))); } /** @@ -107,7 +125,7 @@ export function title(content) { * @returns {Block} */ export function section(heading, subtitle) { - return new Block(subtitle ? `${heading}\n${subtitle}` : String(heading)); + return new Block(toAscii(subtitle ? `${heading}\n${subtitle}` : String(heading))); } /** @@ -116,7 +134,7 @@ export function section(heading, subtitle) { * @returns {Block} */ export function text(content) { - return new Block(String(content)); + return new Block(toAscii(String(content))); } /** @@ -134,7 +152,7 @@ export function list(items) { return [`${BULLET} ${head}`, ...rest.map(l => `${hang}${l}`)].join('\n'); }); const multiline = rendered.some(r => r.includes('\n')); - return new Block(rendered.join(multiline ? '\n\n' : '\n')); + return new Block(toAscii(rendered.join(multiline ? '\n\n' : '\n'))); } /** @@ -159,7 +177,7 @@ export function record(obj, options = {}) { const value = fmt ? fmt(src[k]) : renderValue(src[k]); return `${`${label(k)}:`.padEnd(keyWidth + 2)}${value}`; }); - return new Block(lines.join('\n')); + return new Block(toAscii(lines.join('\n'))); } /** @@ -184,8 +202,11 @@ export function records(items, options = {}) { * @returns {Block} */ export function table(rows, options = {}) { - const {head} = options; - const all = head ? [head, ...rows] : rows; + // Normalize cells to ASCII up front so column widths are computed on the + // final glyphs (e.g. an ellipsis "…" -> "..." must widen the column). + const head = options.head ? options.head.map(c => toAscii(c ?? '')) : undefined; + const body = rows.map(r => r.map(c => toAscii(c ?? ''))); + const all = head ? [head, ...body] : body; if (all.length === 0) return new Block(''); const cols = all.reduce((max, r) => Math.max(max, r.length), 0); @@ -208,7 +229,7 @@ export function table(rows, options = {}) { lines.push(fmt(head)); lines.push(widths.map(w => '-'.repeat(w)).join(COL_GAP).replace(/\s+$/, '')); } - for (const r of rows) lines.push(fmt(r)); + for (const r of body) lines.push(fmt(r)); return new Block(lines.join('\n')); } diff --git a/packages/cli/clients/cli/formatters/index.test.mjs b/packages/cli/clients/cli/formatters/index.test.mjs index ba9c221b354d..d7cfef0f3ed4 100644 --- a/packages/cli/clients/cli/formatters/index.test.mjs +++ b/packages/cli/clients/cli/formatters/index.test.mjs @@ -107,6 +107,22 @@ describe('records', () => { }); }); +describe('ASCII normalization', () => { + it('converts em/en dashes, curly quotes, and ellipsis in prose + records', () => { + expect(text('a \u2014 b').toString()).toBe('a - b'); + expect(title('X \u2013 Y').toString()).toBe('X - Y'); + expect(record({name: '\u201cSettings\u201d \u2014 Form\u2026'}).toString()).toBe( + 'name: "Settings" - Form...', + ); + expect(list(['Avatar \u2014 Group']).toString()).toBe('- Avatar - Group'); + }); + + it('leaves code and markdown verbatim (no normalization)', () => { + expect(code('a \u2014 b').toString()).toBe('a \u2014 b'); + expect(markdown('a \u2014 b').toString()).toBe('a \u2014 b'); + }); +}); + describe('table', () => { it('aligns columns to their widest cell and does not pad the last column', () => { const out = table( From ba05fc7e193aaab91caf548ef274f72a7c014d2b Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 14:05:17 -0700 Subject: [PATCH 06/17] fix(cli): keep --help on one line per option/command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commander wraps each help description to a column width (80 when captured non-TTY), splitting long descriptions like --json across several indented lines. Override Help.wrap to a no-op via configureHelp so every option/command renders on a single line — deterministic and width-independent, matching the rest of the CLI. Set before subcommands register so they inherit it (copyInheritedSettings). Co-authored-by: Cursor --- packages/cli/clients/cli/index.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/cli/clients/cli/index.mjs b/packages/cli/clients/cli/index.mjs index fafd861a5923..ec3bbd2729d3 100644 --- a/packages/cli/clients/cli/index.mjs +++ b/packages/cli/clients/cli/index.mjs @@ -135,6 +135,14 @@ const SETUP_NUDGE_EXEMPT = new Set(['init', 'agent-docs']); export async function createProgram() { const program = new Command(); + // Deterministic, single-line help. Commander wraps each option/command + // description to a column width (80 when captured non-TTY), which splits long + // descriptions like --json across several indented lines. Override `wrap` to a + // no-op so every item stays on one line — matching the rest of the CLI's + // plain, unwrapped, width-independent output. Set before subcommands are + // registered so they inherit it via copyInheritedSettings. + program.configureHelp({wrap: (str) => str}); + program .name('astryx') .description('Design system CLI — components, themes, and tooling') From e02d683500d0bbd16b01c0d1d42055e41fe7bc04 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 14:09:20 -0700 Subject: [PATCH 07/17] fix(cli): render `component --list` as one uniform table The names view interleaved headerless standalone components with "(group)" family sections and ragged blank lines, which read as choppy/weird. Render a single sorted table of Component + Import instead (the import column already conveys families); package-qualify external/colliding names. Also drop the "(group)" suffix from the --detail compact headers. Co-authored-by: Cursor --- .../clients/cli/commands/component/index.mjs | 71 +++++++------------ 1 file changed, 24 insertions(+), 47 deletions(-) diff --git a/packages/cli/clients/cli/commands/component/index.mjs b/packages/cli/clients/cli/commands/component/index.mjs index 9729c13a6a91..66b854b9e978 100644 --- a/packages/cli/clients/cli/commands/component/index.mjs +++ b/packages/cli/clients/cli/commands/component/index.mjs @@ -20,7 +20,7 @@ import { import {resolveTheme} from '../../lib/resolve-theme.mjs'; import {getCliInvocation} from '../../../../foundation/env/package-manager.mjs'; import {jsonOut} from '../../../../foundation/response/json.mjs'; -import {emit, section, text, list, record, records, markdown, code} from '../../formatters/index.mjs'; +import {emit, title, section, text, list, record, records, table, markdown, code} from '../../formatters/index.mjs'; import {cliError} from '../../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../../foundation/response/error-codes.mjs'; import {component as componentApi} from '../../../../api/component/component.mjs'; @@ -145,7 +145,7 @@ export function registerComponent(program) { // Skip the synthetic group header when there's only one ungrouped category const isUngrouped = entries.length === 1 && items.length === 1 && items[0]?.name === cat; - if (!isUngrouped) out.push(section(`${cat} (group)`)); + if (!isUngrouped) out.push(section(cat)); out.push(records(items, {fields: ['name', 'import', 'description']})); } out.push(listFooter); @@ -153,14 +153,13 @@ export function registerComponent(program) { break; } - // --detail names (default for list views). The API now returns - // package-qualified entries ({name, package}); the human view omits - // the core package label for readability but ALWAYS shows the package - // for integration components (and whenever names collide). + // --detail names (default for list views): one uniform table of every + // component + its import path, sorted A-Z. The import column already + // conveys families, so we skip the choppy per-family grouping that + // interleaved headerless singletons with "(group)" sections. External + // or name-colliding entries stay package-qualified. const groups = result.data.components; const CORE_PKG = '@astryxdesign/core'; - // Names that appear under more than one package across the whole - // listing — these must always be package-qualified to disambiguate. /** @type {Map>} */ const nameCounts = new Map(); for (const items of Object.values(groups)) { @@ -170,49 +169,27 @@ export function registerComponent(program) { nameCounts.set(item.name, set); } } - /** @param {string} n */ - const isCollision = n => (nameCounts.get(n)?.size ?? 0) > 1; /** @param {import('../../../../api/component/component.type.mjs').ComponentListEntry} item */ - const pkgSuffix = item => { - if (item.package !== CORE_PKG) return ` [${item.package}]`; - if (isCollision(item.name)) return ` [${item.package}]`; - return ''; + const importCell = item => { + const importPath = resolveImportPath(coreDir, item.name); + const qualify = + item.package !== CORE_PKG || (nameCounts.get(item.name)?.size ?? 0) > 1; + return qualify ? `${importPath} [${item.package}]` : importPath; }; - // The import path hint stays inline (`name <- importPath`), ASCII arrow. - /** @param {import('../../../../api/component/component.type.mjs').ComponentListEntry} item */ - const entryLine = item => - `${item.name} <- ${resolveImportPath(coreDir, item.name)}${pkgSuffix(item)}`; - if (options.category) { - const [cat, comps] = Object.entries(groups)[0]; - emit(section(`${cat}:`), list(comps.map(entryLine))); - break; - } + const firstGroup = Object.entries(groups)[0]; + const entries = + options.category && firstGroup ? firstGroup[1] : Object.values(groups).flat(); + const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); + const rows = sorted.map(item => [item.name, importCell(item)]); - /** @type {import('../../formatters/index.mjs').Block[]} */ - const out = []; - // Batch consecutive ungrouped singles into one tight list; render each - // group as its own section + list. - /** @type {string[]} */ - let singles = []; - const flushSingles = () => { - if (singles.length) { - out.push(list(singles)); - singles = []; - } - }; - for (const [key, comps] of Object.entries(groups)) { - const isUngrouped = comps.length === 1 && comps[0]?.name === key; - if (isUngrouped) { - singles.push(entryLine(comps[0])); - } else { - flushSingles(); - out.push(section(`${key} (group)`), list(comps.map(entryLine))); - } - } - flushSingles(); - out.push(listFooter); - emit(...out); + emit( + options.category && firstGroup + ? section(firstGroup[0]) + : title(`Components (${sorted.length})`), + table(rows, {head: ['Component', 'Import']}), + listFooter, + ); break; } From 7936db221776086e1d1befb6237c26b4c39a9d8f Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 14:12:51 -0700 Subject: [PATCH 08/17] fix(cli): render manifest human view as a greppable table The manifest summary used a nested 2/4-space indent (command on one line, description indented under it) plus a raw em-dash, which was hard to grep and inconsistent. Render it through the formatter as a single Command | JSON | Description table instead (one row per command, ASCII-normalized). Co-authored-by: Cursor --- packages/cli/clients/cli/index.mjs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/cli/clients/cli/index.mjs b/packages/cli/clients/cli/index.mjs index ec3bbd2729d3..1aed8f3c8812 100644 --- a/packages/cli/clients/cli/index.mjs +++ b/packages/cli/clients/cli/index.mjs @@ -21,6 +21,7 @@ import {getCliInvocation} from '../../foundation/env/package-manager.mjs'; import {API_VERSION, setJsonMode} from '../../foundation/response/json.mjs'; import {buildManifest} from './lib/manifest.mjs'; import {cliError} from './lib/cli-error.mjs'; +import {emit, title, text, table} from './formatters/index.mjs'; import {ERROR_CODES} from '../../foundation/response/error-codes.mjs'; import {levenshteinDistance} from '../../foundation/text/string-utils.mjs'; import {installJsonShim} from './lib/json-shim.mjs'; @@ -368,14 +369,17 @@ export async function createProgram() { console.log(JSON.stringify({apiVersion: API_VERSION, type: 'manifest', data: manifest}, null, 2)); return; } - // Human-readable summary. Agents should use --json. - console.log(`\n${manifest.name} v${manifest.version} — ${manifest.commands.length} commands\n`); - for (const c of manifest.commands) { - const tag = c.json ? ' [--json]' : ''; - console.log(` ${c.name}${tag}`); - if (c.description) console.log(` ${c.description}`); - } - console.log(`\nRun \`${getCliInvocation()} manifest --json\` for the full structured manifest.\n`); + // Human-readable summary as a single greppable table (agents should use + // --json). One row per command: name, whether it supports --json, and the + // description — no nested indent. + emit( + title(`${manifest.name} v${manifest.version} (${manifest.commands.length} commands)`), + table( + manifest.commands.map(c => [c.name, c.json ? 'yes' : '', c.description || '']), + {head: ['Command', 'JSON', 'Description']}, + ), + text(`Run \`${getCliInvocation()} manifest --json\` for the full structured manifest.`), + ); }); // Hidden command used by package.json postinstall scripts From 106b8e73d93d7ac741be0b7c2cc110501be0f7ac Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 14:16:50 -0700 Subject: [PATCH 09/17] docs(cli): document the text output format contract in --help Add an "Output format" section to `astryx --help` describing the plain-text contract so agents can parse/grep it reliably: records are blocks of aligned "key: value" lines separated by a single blank line (RFC-822 / recfile style), sections are a header line + optional subtitle, and --json is the stable structured surface. Formalizes the convention rather than adding per-item divider lines (a blank line is the standard record separator). Co-authored-by: Cursor --- packages/cli/clients/cli/index.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/cli/clients/cli/index.mjs b/packages/cli/clients/cli/index.mjs index 1aed8f3c8812..ca9e2c5dd207 100644 --- a/packages/cli/clients/cli/index.mjs +++ b/packages/cli/clients/cli/index.mjs @@ -144,6 +144,22 @@ export async function createProgram() { // registered so they inherit it via copyInheritedSettings. program.configureHelp({wrap: (str) => str}); + // Document the text-output contract in --help so agents know how to parse/grep + // it (and when to reach for --json instead). Kept in sync with the formatter + // kit in clients/cli/formatters. + program.addHelpText( + 'after', + ` +Output format: + Text output is a plain-ASCII projection of --json. + - Record: a block of aligned "key: value" lines (one item). Records are + separated by a single blank line. + - Section: a header line (no "key:"), optionally a one-line subtitle, then its + records. + - Grep one field across records, e.g. astryx search button | grep "^command:" + For stable, structured parsing use --json (most commands; see astryx manifest --json).`, + ); + program .name('astryx') .description('Design system CLI — components, themes, and tooling') From b2c283a9ff3c232943e3083177556a7f4ca7d094 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 14:31:04 -0700 Subject: [PATCH 10/17] refactor(cli): reduce output vocabulary to a documented canonical set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the formatter kit to one small, formalized set and document it in --help so every current and future command conforms and agents can rely on it: - Data is always records (key: value blocks, blank-line separated) — dropped `table`; `component --list` and `manifest` are now records too. - Merged `title` into `section` (a heading with no subtitle) and `markdown` into `code` (one verbatim block: source, skeleton, or doc). - Final vocabulary: section, record/records, list, text, code, emit. - `astryx --help` now documents the block types (Record/Section/List/Text/Code), the blank-line record separator, how to grep a field, and that --json is the structured surface. Net -75 lines. All CLI tests pass; tsc strict + lint clean. Co-authored-by: Cursor --- .../cli/clients/cli/commands/build-theme.mjs | 4 +- packages/cli/clients/cli/commands/build.mjs | 6 +- .../clients/cli/commands/component/index.mjs | 32 +++++---- .../cli/clients/cli/commands/discover.mjs | 6 +- packages/cli/clients/cli/commands/docs.mjs | 12 ++-- packages/cli/clients/cli/commands/doctor.mjs | 4 +- .../cli/clients/cli/commands/hook/index.mjs | 8 +-- packages/cli/clients/cli/commands/search.mjs | 4 +- .../cli/commands/validate-integration.mjs | 6 +- packages/cli/clients/cli/formatters/index.mjs | 69 ++----------------- .../cli/clients/cli/formatters/index.test.mjs | 39 +++-------- .../cli/clients/cli/formatters/type-tests.mjs | 9 ++- packages/cli/clients/cli/index.mjs | 36 ++++++---- 13 files changed, 80 insertions(+), 155 deletions(-) diff --git a/packages/cli/clients/cli/commands/build-theme.mjs b/packages/cli/clients/cli/commands/build-theme.mjs index 225664df6250..eec3ac35e0c4 100644 --- a/packages/cli/clients/cli/commands/build-theme.mjs +++ b/packages/cli/clients/cli/commands/build-theme.mjs @@ -21,7 +21,7 @@ import {fileURLToPath} from 'node:url'; import {spawn} from 'node:child_process'; import {getCliInvocation} from '../../../foundation/env/package-manager.mjs'; import {jsonOut} from '../../../foundation/response/json.mjs'; -import {emit, title, text, list, code} from '../formatters/index.mjs'; +import {emit, section, text, list, code} from '../formatters/index.mjs'; import {logger} from '../../../api/logger.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs'; @@ -144,7 +144,7 @@ function printThemeList(themes) { } const run = getCliInvocation(); emit( - title('Themes'), + section('Themes'), list( themes.map(t => { const head = t.maintained ? `${t.slug} (maintained)` : t.slug; diff --git a/packages/cli/clients/cli/commands/build.mjs b/packages/cli/clients/cli/commands/build.mjs index 311d5d9f4289..b7c9e9be8334 100644 --- a/packages/cli/clients/cli/commands/build.mjs +++ b/packages/cli/clients/cli/commands/build.mjs @@ -14,7 +14,7 @@ import {getCliInvocation, formatCliCommand} from '../../../foundation/env/package-manager.mjs'; import {jsonOut} from '../../../foundation/response/json.mjs'; -import {emit, title, section, text, record, records, ARROW} from '../formatters/index.mjs'; +import {emit, section, text, record, records, ARROW} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {build as buildApi} from '../../../api/build/build.mjs'; @@ -24,7 +24,7 @@ import {build as buildApi} from '../../../api/build/build.mjs'; */ function printPlaybook(run) { emit( - title('How to build a page with Astryx'), + section('How to build a page with Astryx'), text( [ "1. Find a starting point for what you're building:", @@ -150,7 +150,7 @@ export function registerBuild(program) { /** @type {import('../formatters/index.mjs').Block[]} */ const out = [ - title(`Build kit for "${q}"`), + section(`Build kit for "${q}"`), text( 'A recommended set of pieces to assemble this page, in the order to use them. ' + 'Begin with RECOMMENDED START, then pull from the sections below — each ' + diff --git a/packages/cli/clients/cli/commands/component/index.mjs b/packages/cli/clients/cli/commands/component/index.mjs index 66b854b9e978..88fefffc3505 100644 --- a/packages/cli/clients/cli/commands/component/index.mjs +++ b/packages/cli/clients/cli/commands/component/index.mjs @@ -20,7 +20,7 @@ import { import {resolveTheme} from '../../lib/resolve-theme.mjs'; import {getCliInvocation} from '../../../../foundation/env/package-manager.mjs'; import {jsonOut} from '../../../../foundation/response/json.mjs'; -import {emit, title, section, text, list, record, records, table, markdown, code} from '../../formatters/index.mjs'; +import {emit, section, text, list, record, records, code} from '../../formatters/index.mjs'; import {cliError} from '../../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../../foundation/response/error-codes.mjs'; import {component as componentApi} from '../../../../api/component/component.mjs'; @@ -129,8 +129,8 @@ export function registerComponent(program) { // in result.data.detail and the grouped map in result.data.components. if (result.data.detail === 'full') { // --detail full — dense per-component docs (signature, props, theming, - // examples). Verbatim markdown from the shared formatter. - emit(markdown(await formatBriefAll(coreDir, {zh, lang, themeData}))); + // examples). Verbatim doc block from the shared formatter. + emit(code(await formatBriefAll(coreDir, {zh, lang, themeData}))); break; } @@ -153,11 +153,11 @@ export function registerComponent(program) { break; } - // --detail names (default for list views): one uniform table of every - // component + its import path, sorted A-Z. The import column already - // conveys families, so we skip the choppy per-family grouping that - // interleaved headerless singletons with "(group)" sections. External - // or name-colliding entries stay package-qualified. + // --detail names (default for list views): one record per component + // (name + import), sorted A-Z. The import field already conveys + // families, so we skip the choppy per-family grouping that interleaved + // headerless singletons with "(group)" sections. External or + // name-colliding entries stay package-qualified. const groups = result.data.components; const CORE_PKG = '@astryxdesign/core'; /** @type {Map>} */ @@ -181,13 +181,15 @@ export function registerComponent(program) { const entries = options.category && firstGroup ? firstGroup[1] : Object.values(groups).flat(); const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name)); - const rows = sorted.map(item => [item.name, importCell(item)]); emit( options.category && firstGroup ? section(firstGroup[0]) - : title(`Components (${sorted.length})`), - table(rows, {head: ['Component', 'Import']}), + : section(`Components (${sorted.length})`), + records( + sorted.map(item => ({name: item.name, import: importCell(item)})), + {fields: ['name', 'import']}, + ), listFooter, ); break; @@ -198,10 +200,10 @@ export function registerComponent(program) { const importHint = resolveImportPath(coreDir, resolvedName); const doc = detail === 'brief' - ? markdown(formatBrief(result.data, resolvedName, importHint, {themeData})) + ? code(formatBrief(result.data, resolvedName, importHint, {themeData})) : detail === 'compact' - ? markdown(formatCompact(result.data, resolvedName, importHint)) - : markdown(formatFull(result.data, {themeData, importHint})); + ? code(formatCompact(result.data, resolvedName, importHint)) + : code(formatFull(result.data, {themeData, importHint})); const related = await findRelatedBlocks(resolvedName); emit( doc, @@ -213,7 +215,7 @@ export function registerComponent(program) { case 'component.detail.props': { const resolvedName = (name || '').replace(/^XDS/, ''); - emit(markdown(formatProps({props: result.data}, resolvedName))); + emit(code(formatProps({props: result.data}, resolvedName))); break; } diff --git a/packages/cli/clients/cli/commands/discover.mjs b/packages/cli/clients/cli/commands/discover.mjs index 9f2e1736c223..159aa6618399 100644 --- a/packages/cli/clients/cli/commands/discover.mjs +++ b/packages/cli/clients/cli/commands/discover.mjs @@ -12,7 +12,7 @@ import {formatFull, formatBrief, formatCompact} from '../lib/component-format.mjs'; import {jsonOut} from '../../../foundation/response/json.mjs'; -import {emit, title, text, record, records, list, code, markdown} from '../formatters/index.mjs'; +import {emit, section, text, record, records, list, code} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {discover as discoverApi} from '../../../api/discover/discover.mjs'; import {getCliInvocation} from '../../../foundation/env/package-manager.mjs'; @@ -120,14 +120,14 @@ export function registerDiscover(program) { : detail === 'compact' ? formatCompact(docs, docs.name, '') : formatFull(docs); - emit(markdown(md)); + emit(code(md)); break; } case 'discover.search': { const {query: q, matches} = result.data; emit( - title(`Found ${matches.length} matches for "${q}"`), + section(`Found ${matches.length} matches for "${q}"`), list(matches.map(m => `${run} discover ${m.package}/${m.component}`)), ); break; diff --git a/packages/cli/clients/cli/commands/docs.mjs b/packages/cli/clients/cli/commands/docs.mjs index e3f286bf5f51..fc641b0f5b3c 100644 --- a/packages/cli/clients/cli/commands/docs.mjs +++ b/packages/cli/clients/cli/commands/docs.mjs @@ -14,7 +14,7 @@ import {getCliInvocation} from '../../../foundation/env/package-manager.mjs'; import {jsonOut} from '../../../foundation/response/json.mjs'; -import {emit, title, records, text, markdown} from '../formatters/index.mjs'; +import {emit, section, records, text, code} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {docs as docsApi} from '../../../api/docs/docs.mjs'; @@ -136,7 +136,7 @@ export function registerDocs(program) { program .command('docs [topic] [section]') .description('Print reference docs') - .action(async (/** @type {string | undefined} */ topic, /** @type {string | undefined} */ section) => { + .action(async (/** @type {string | undefined} */ topic, /** @type {string | undefined} */ sectionName) => { const run = getCliInvocation(); const lang = program.opts().lang || null; const zh = program.opts().zh || false; @@ -146,7 +146,7 @@ export function registerDocs(program) { let result; try { - result = await docsApi(topic, section, {lang, zh, dense}); + result = await docsApi(topic, sectionName, {lang, zh, dense}); } catch (e) { // docs API throws structured errors with {name, reason} suggestions — // pass them through untouched so the CLI envelope matches the API. @@ -162,7 +162,7 @@ export function registerDocs(program) { // The text view mirrors the JSON list: one record per topic (topic + // description), then the usage footer as plain prose. emit( - title('Available docs'), + section('Available docs'), records(result.data, {fields: ['topic', 'description']}), text( [ @@ -175,12 +175,12 @@ export function registerDocs(program) { } case 'docs.detail': { - emit(markdown(formatReferenceFull(result.data, detail))); + emit(code(formatReferenceFull(result.data, detail))); break; } case 'docs.detail.section': { - emit(markdown(formatSection(result.data, detail))); + emit(code(formatSection(result.data, detail))); break; } } diff --git a/packages/cli/clients/cli/commands/doctor.mjs b/packages/cli/clients/cli/commands/doctor.mjs index 405e363fe42b..4811ac86de03 100644 --- a/packages/cli/clients/cli/commands/doctor.mjs +++ b/packages/cli/clients/cli/commands/doctor.mjs @@ -12,7 +12,7 @@ import {runChecks} from '../../../api/doctor/doctor.mjs'; import {jsonOut} from '../../../foundation/response/json.mjs'; -import {emit, title, records, text} from '../formatters/index.mjs'; +import {emit, section, records, text} from '../formatters/index.mjs'; /** Status -> ASCII token (plain, matching the rest of the CLI). */ const STATUS = { @@ -43,7 +43,7 @@ function printHuman(report) { : 'All checks passed. Your XDS setup looks healthy.'; emit( - title('astryx doctor — diagnosing your setup'), + section('astryx doctor — diagnosing your setup'), records(report.checks, { fields: ['status', 'label', 'message', 'fix'], labels: {label: 'check'}, diff --git a/packages/cli/clients/cli/commands/hook/index.mjs b/packages/cli/clients/cli/commands/hook/index.mjs index 00de3bcf510a..1ee308328d0b 100644 --- a/packages/cli/clients/cli/commands/hook/index.mjs +++ b/packages/cli/clients/cli/commands/hook/index.mjs @@ -14,7 +14,7 @@ import { } from '../../lib/hook-format.mjs'; import {getCliInvocation} from '../../../../foundation/env/package-manager.mjs'; import {jsonOut} from '../../../../foundation/response/json.mjs'; -import {emit, section, text, list, records, markdown} from '../../formatters/index.mjs'; +import {emit, section, text, list, records, code} from '../../formatters/index.mjs'; import {cliError} from '../../lib/cli-error.mjs'; import {ERROR_CODES} from '../../../../foundation/response/error-codes.mjs'; import {hook as hookApi} from '../../../../api/hook/hook.mjs'; @@ -100,7 +100,7 @@ export function registerHook(program) { formatHookCompact(item, item.importPath || '@astryxdesign/core/hooks'), ) .join('\n'); - out.push(markdown(`## ${cat}\n\n${body}`)); + out.push(code(`## ${cat}\n\n${body}`)); } emit(...out); break; @@ -163,7 +163,7 @@ export function registerHook(program) { } emit( - markdown(doc), + code(doc), allBlocks.length > 0 && section('Related block templates'), allBlocks.length > 0 && records(allBlocks, {fields: ['dirName', 'description']}), @@ -172,7 +172,7 @@ export function registerHook(program) { } case 'hook.detail.params': { - emit(markdown(formatHookParams({params: result.data, name}))); + emit(code(formatHookParams({params: result.data, name}))); break; } } diff --git a/packages/cli/clients/cli/commands/search.mjs b/packages/cli/clients/cli/commands/search.mjs index ade5ba0ec70c..d8e9637ee33a 100644 --- a/packages/cli/clients/cli/commands/search.mjs +++ b/packages/cli/clients/cli/commands/search.mjs @@ -18,7 +18,7 @@ import {getCliInvocation, formatCliCommand} from '../../../foundation/env/package-manager.mjs'; import {jsonOut} from '../../../foundation/response/json.mjs'; -import {emit, title, text, records} from '../formatters/index.mjs'; +import {emit, section, text, records} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {search as searchApi, SEARCH_DOMAINS} from '../../../api/search/search.mjs'; @@ -80,7 +80,7 @@ export function registerSearch(program) { : ['name', 'domain', 'displayName', 'import', 'description', 'command']; emit( - title(`Results for "${q}" (${results.length})`), + section(`Results for "${q}" (${results.length})`), records(results, {fields, format: {command: formatCliCommand}}), ); }); diff --git a/packages/cli/clients/cli/commands/validate-integration.mjs b/packages/cli/clients/cli/commands/validate-integration.mjs index 375673cf01d9..9014417a08fb 100644 --- a/packages/cli/clients/cli/commands/validate-integration.mjs +++ b/packages/cli/clients/cli/commands/validate-integration.mjs @@ -15,7 +15,7 @@ */ import {jsonOut} from '../../../foundation/response/json.mjs'; -import {emit, title, text, records} from '../formatters/index.mjs'; +import {emit, section, text, records} from '../formatters/index.mjs'; import { validateIntegration, summarizeIssues, @@ -31,7 +31,7 @@ function printHuman(data) { if (data.issues.length === 0) { emit( - title(`Validating integration: ${label}`), + section(`Validating integration: ${label}`), text('[ok] No issues found.'), ); return; @@ -41,7 +41,7 @@ function printHuman(data) { // mirroring the JSON keys (severity/code/message). const {errors, warnings} = summarizeIssues(data.issues); emit( - title(`Validating integration: ${label}`), + section(`Validating integration: ${label}`), records(data.issues, {fields: ['severity', 'code', 'message']}), text( `${data.issues.length} issue(s): ${errors} error(s), ${warnings} warning(s)`, diff --git a/packages/cli/clients/cli/formatters/index.mjs b/packages/cli/clients/cli/formatters/index.mjs index 583526b41f05..8947b1c1c88e 100644 --- a/packages/cli/clients/cli/formatters/index.mjs +++ b/packages/cli/clients/cli/formatters/index.mjs @@ -33,9 +33,6 @@ export const BULLET = '-'; export const ERR = '!!'; export const WARN = '!'; -// Gap between table columns. -const COL_GAP = ' '; - /** * An opaque, renderer-produced block of output. Nominal via a private field: * nothing outside this file can construct one, so `emit` can trust that whatever @@ -92,7 +89,7 @@ function renderValue(v) { * Normalize common non-ASCII typography to ASCII so human output stays plain and * consistent no matter what the source data contains (em/en dashes -> "-", curly * quotes -> straight, ellipsis -> "...", non-breaking space -> space). The - * verbatim renderers (code, markdown) deliberately skip this. `--json` is + * the verbatim renderer (code) deliberately skips this. `--json` is * unaffected — it always carries the original data. * @param {string} s * @returns {string} @@ -106,15 +103,6 @@ function toAscii(s) { .replace(/\u00a0/g, ' '); } -/** - * A headline for a command's output (e.g. `Results for "button" (20)`). - * @param {string} content - * @returns {Block} - */ -export function title(content) { - return new Block(toAscii(String(content))); -} - /** * A group label, optionally with an explanatory subtitle rendered on the line(s) * directly beneath the heading (no blank line between). Whatever list/records @@ -194,48 +182,9 @@ export function records(items, options = {}) { } /** - * A text table with content-aligned columns. The caller decides the columns and - * cells; the last column is not padded (no trailing spaces). An optional header - * row is underlined. Best for short, uniform values (not long descriptions). - * @param {string[][]} rows - * @param {{head?: string[]}} [options] - * @returns {Block} - */ -export function table(rows, options = {}) { - // Normalize cells to ASCII up front so column widths are computed on the - // final glyphs (e.g. an ellipsis "…" -> "..." must widen the column). - const head = options.head ? options.head.map(c => toAscii(c ?? '')) : undefined; - const body = rows.map(r => r.map(c => toAscii(c ?? ''))); - const all = head ? [head, ...body] : body; - if (all.length === 0) return new Block(''); - - const cols = all.reduce((max, r) => Math.max(max, r.length), 0); - /** @type {number[]} */ - const widths = []; - for (let c = 0; c < cols; c++) { - widths[c] = all.reduce((max, r) => Math.max(max, (r[c] ?? '').length), 0); - } - - /** @param {string[]} row */ - const fmt = row => - row - .map((cell, c) => (c === cols - 1 ? (cell ?? '') : (cell ?? '').padEnd(widths[c]))) - .join(COL_GAP) - .replace(/\s+$/, ''); - - /** @type {string[]} */ - const lines = []; - if (head) { - lines.push(fmt(head)); - lines.push(widths.map(w => '-'.repeat(w)).join(COL_GAP).replace(/\s+$/, '')); - } - for (const r of body) lines.push(fmt(r)); - return new Block(lines.join('\n')); -} - -/** - * A verbatim preformatted block — source dumps, layout skeletons; survives - * piping byte-for-byte (`astryx template X > file.tsx`). + * A verbatim block — source dumps, layout skeletons, or a markdown doc. Output + * is byte-for-byte (NOT ASCII-normalized), so it survives piping + * (`astryx template X > file.tsx`) and preserves doc/source content exactly. * @param {string} source * @param {{lang?: string}} [_options] - Reserved (intent label); output verbatim. * @returns {Block} @@ -244,16 +193,6 @@ export function code(source, _options = {}) { return new Block(String(source)); } -/** - * A verbatim Markdown document — component / docs / hook detail. Verbatim today; - * the semantic name is a single seam to pretty-render later. - * @param {string} md - * @returns {Block} - */ -export function markdown(md) { - return new Block(String(md)); -} - /** * @param {Emittable} b * @returns {b is Block} diff --git a/packages/cli/clients/cli/formatters/index.test.mjs b/packages/cli/clients/cli/formatters/index.test.mjs index d7cfef0f3ed4..efc1a518ee2f 100644 --- a/packages/cli/clients/cli/formatters/index.test.mjs +++ b/packages/cli/clients/cli/formatters/index.test.mjs @@ -3,15 +3,12 @@ import {describe, it, expect, vi, afterEach} from 'vitest'; import { emit, - title, section, text, list, record, records, - table, code, - markdown, Block, BULLET, ARROW, @@ -33,7 +30,7 @@ describe('constants', () => { describe('renderers return Block', () => { it('produces nominal Block instances', () => { - expect(title('x')).toBeInstanceOf(Block); + expect(section('x')).toBeInstanceOf(Block); expect(record({a: 1})).toBeInstanceOf(Block); expect(records([{a: 1}])).toBeInstanceOf(Block); expect(code('a')).toBeInstanceOf(Block); @@ -110,62 +107,44 @@ describe('records', () => { describe('ASCII normalization', () => { it('converts em/en dashes, curly quotes, and ellipsis in prose + records', () => { expect(text('a \u2014 b').toString()).toBe('a - b'); - expect(title('X \u2013 Y').toString()).toBe('X - Y'); + expect(section('X \u2013 Y').toString()).toBe('X - Y'); expect(record({name: '\u201cSettings\u201d \u2014 Form\u2026'}).toString()).toBe( 'name: "Settings" - Form...', ); expect(list(['Avatar \u2014 Group']).toString()).toBe('- Avatar - Group'); }); - it('leaves code and markdown verbatim (no normalization)', () => { + it('leaves code verbatim (no normalization)', () => { expect(code('a \u2014 b').toString()).toBe('a \u2014 b'); - expect(markdown('a \u2014 b').toString()).toBe('a \u2014 b'); }); }); -describe('table', () => { - it('aligns columns to their widest cell and does not pad the last column', () => { - const out = table( - [ - ['Button', '100'], - ['IconButton', '90'], - ], - {head: ['Name', 'Score']}, - ).toString(); - expect(out).toBe( - ['Name Score', '---------- -----', 'Button 100', 'IconButton 90'].join( - '\n', - ), - ); - }); -}); - -describe('code / markdown', () => { - it('are byte-for-byte verbatim', () => { +describe('code', () => { + it('is byte-for-byte verbatim (source or docs)', () => { const src = 'const x = 1;\n const y = 2;\n'; expect(code(src).toString()).toBe(src); - expect(markdown('# Title\n\n- a\n').toString()).toBe('# Title\n\n- a\n'); + expect(code('# Title\n\n- a\n').toString()).toBe('# Title\n\n- a\n'); }); }); describe('emit', () => { it('joins blocks with a single blank line via one console.log', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); - emit(title('A'), text('B')); + emit(section('A'), text('B')); expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledWith('A\n\nB'); }); it('drops falsy placeholders', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); - emit(title('A'), false, null, undefined, text('B')); + emit(section('A'), false, null, undefined, text('B')); expect(spy).toHaveBeenCalledWith('A\n\nB'); }); it('is a no-op in --json mode (stdout stays clean)', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); setJsonMode(true); - emit(title('A'), text('B')); + emit(section('A'), text('B')); expect(spy).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/clients/cli/formatters/type-tests.mjs b/packages/cli/clients/cli/formatters/type-tests.mjs index 3d2547d7497a..769bffc56276 100644 --- a/packages/cli/clients/cli/formatters/type-tests.mjs +++ b/packages/cli/clients/cli/formatters/type-tests.mjs @@ -8,21 +8,20 @@ * type check fails. */ -import {emit, title, section, text, list, record, records, table, code, markdown} from './index.mjs'; +import {emit, section, text, list, record, records, code} from './index.mjs'; /** @returns {void} */ export function __formatterTypeGuards() { // Renderer output is emittable. - emit(title('t'), section('s'), section('H', 'subtitle'), text('p')); + emit(section('s'), section('H', 'subtitle'), text('p')); emit(list(['a', ['head', 'detail']])); emit(record({name: 'Button', domain: 'component'}, {fields: ['name', 'domain']})); emit(records([{name: 'A'}, {name: 'B'}], {format: {name: v => String(v)}})); - emit(table([['a', 'b']], {head: ['x', 'y']})); - emit(code('const x = 1;'), markdown('# doc')); + emit(code('const x = 1;')); // Falsy placeholders are allowed (inline conditionals). const show = false; - emit(title('t'), show && section('maybe'), null, undefined); + emit(section('s'), show && section('maybe'), null, undefined); // @ts-expect-error a bare string is not a Block — must go through a renderer. emit('raw string'); diff --git a/packages/cli/clients/cli/index.mjs b/packages/cli/clients/cli/index.mjs index ca9e2c5dd207..fc8d08da3ec0 100644 --- a/packages/cli/clients/cli/index.mjs +++ b/packages/cli/clients/cli/index.mjs @@ -21,7 +21,7 @@ import {getCliInvocation} from '../../foundation/env/package-manager.mjs'; import {API_VERSION, setJsonMode} from '../../foundation/response/json.mjs'; import {buildManifest} from './lib/manifest.mjs'; import {cliError} from './lib/cli-error.mjs'; -import {emit, title, text, table} from './formatters/index.mjs'; +import {emit, section, text, records} from './formatters/index.mjs'; import {ERROR_CODES} from '../../foundation/response/error-codes.mjs'; import {levenshteinDistance} from '../../foundation/text/string-utils.mjs'; import {installJsonShim} from './lib/json-shim.mjs'; @@ -150,14 +150,16 @@ export async function createProgram() { program.addHelpText( 'after', ` -Output format: - Text output is a plain-ASCII projection of --json. - - Record: a block of aligned "key: value" lines (one item). Records are - separated by a single blank line. +Output format (--json is the machine-readable surface; text mirrors it): + Text is plain ASCII, built from a fixed set of blocks: + - Record: aligned "key: value" lines = one item; records separated by a blank + line. Grep a field, e.g. astryx search button | grep "^command:" - Section: a header line (no "key:"), optionally a one-line subtitle, then its - records. - - Grep one field across records, e.g. astryx search button | grep "^command:" - For stable, structured parsing use --json (most commands; see astryx manifest --json).`, + records or list. + - List: "- value" lines, for a simple sequence of values. + - Text: free-form prose / notes. + - Code: a verbatim block (source, skeleton, or doc), emitted exactly. + Errors and warnings go to stderr; use --json for stable structured parsing.`, ); program @@ -385,14 +387,18 @@ Output format: console.log(JSON.stringify({apiVersion: API_VERSION, type: 'manifest', data: manifest}, null, 2)); return; } - // Human-readable summary as a single greppable table (agents should use - // --json). One row per command: name, whether it supports --json, and the - // description — no nested indent. + // Human-readable summary as greppable records (agents should use --json). + // One record per command: name, whether it supports --json, and the + // description. emit( - title(`${manifest.name} v${manifest.version} (${manifest.commands.length} commands)`), - table( - manifest.commands.map(c => [c.name, c.json ? 'yes' : '', c.description || '']), - {head: ['Command', 'JSON', 'Description']}, + section(`${manifest.name} v${manifest.version} (${manifest.commands.length} commands)`), + records( + manifest.commands.map(c => ({ + command: c.name, + json: c.json ? 'yes' : '', + description: c.description || '', + })), + {fields: ['command', 'json', 'description']}, ), text(`Run \`${getCliInvocation()} manifest --json\` for the full structured manifest.`), ); From 28297f25b48ab9602b634004cad1edee5582d13f Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 16:25:19 -0700 Subject: [PATCH 11/17] docs(cli): make the --help output-format spec self-demonstrating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the "Output format" help through the formatter itself — a section heading, one record per block type (block/shape), and a text footer — so the spec is written in the very format it documents (records separated by blank lines). Co-authored-by: Cursor --- packages/cli/clients/cli/index.mjs | 40 ++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/cli/clients/cli/index.mjs b/packages/cli/clients/cli/index.mjs index fc8d08da3ec0..989cb81416fe 100644 --- a/packages/cli/clients/cli/index.mjs +++ b/packages/cli/clients/cli/index.mjs @@ -149,17 +149,35 @@ export async function createProgram() { // kit in clients/cli/formatters. program.addHelpText( 'after', - ` -Output format (--json is the machine-readable surface; text mirrors it): - Text is plain ASCII, built from a fixed set of blocks: - - Record: aligned "key: value" lines = one item; records separated by a blank - line. Grep a field, e.g. astryx search button | grep "^command:" - - Section: a header line (no "key:"), optionally a one-line subtitle, then its - records or list. - - List: "- value" lines, for a simple sequence of values. - - Text: free-form prose / notes. - - Code: a verbatim block (source, skeleton, or doc), emitted exactly. - Errors and warnings go to stderr; use --json for stable structured parsing.`, + '\n' + + [ + section( + 'Output format', + 'Text mirrors --json (the machine-readable surface); it is built from these blocks:', + ), + records( + [ + { + block: 'Record', + shape: 'aligned "key: value" lines = one item; records separated by a blank line', + }, + { + block: 'Section', + shape: 'a header line (no "key:"), optional one-line subtitle, then its records/list', + }, + {block: 'List', shape: '"- value" lines for a simple sequence of values'}, + {block: 'Text', shape: 'free-form prose / notes'}, + {block: 'Code', shape: 'a verbatim block (source, skeleton, or doc), emitted exactly'}, + ], + {fields: ['block', 'shape']}, + ), + text( + 'Grep a field across records, e.g. astryx search button | grep "^command:". ' + + 'Errors/warnings go to stderr; use --json for structured parsing.', + ), + ] + .map(block => block.toString()) + .join('\n\n'), ); program From 1e5b20375ce65e01ff26509cfb5149618ce0c7c6 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 16:33:41 -0700 Subject: [PATCH 12/17] chore: add changeset for the CLI output formatter work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @astryxdesign/cli patch — human output now renders through the documented formatter kit; --json unchanged. Co-authored-by: Cursor --- .changeset/cli-output-formatters.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/cli-output-formatters.md diff --git a/.changeset/cli-output-formatters.md b/.changeset/cli-output-formatters.md new file mode 100644 index 000000000000..7f3135e943f0 --- /dev/null +++ b/.changeset/cli-output-formatters.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/cli': patch +--- + +[feat] CLI human (non-`--json`) output now renders through a small, documented formatter kit: consistent, plain-ASCII `key: value` records/sections that mirror `--json` and are greppable by field. Every command was migrated onto it (a lint rule keeps output funneled through the single `emit` sink), and `astryx --help` documents the output contract. `--json` output is unchanged. (#4686) +@joeyfarina From 77c7d20b955ad4efd3060beb0161012339a5b2ca Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 16:39:41 -0700 Subject: [PATCH 13/17] fix(cli): emit blog post body verbatim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blog detail body was rendered via text(), which ASCII-normalizes content (em/en dashes, curly quotes, ellipses) — silently altering the article. Use code() so the post body is emitted byte-for-byte, and render the feed URL as a record. Caught by Bugbot. Co-authored-by: Cursor --- packages/cli/clients/cli/commands/blog.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/cli/clients/cli/commands/blog.mjs b/packages/cli/clients/cli/commands/blog.mjs index ad6fc77df868..062f612e17dc 100644 --- a/packages/cli/clients/cli/commands/blog.mjs +++ b/packages/cli/clients/cli/commands/blog.mjs @@ -16,7 +16,7 @@ import {getRunPrefix} from '../../../foundation/env/package-manager.mjs'; import {jsonOut} from '../../../foundation/response/json.mjs'; -import {emit, section, text, records} from '../formatters/index.mjs'; +import {emit, section, text, record, records, code} from '../formatters/index.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {blog as blogApi} from '../../../api/blog/blog.mjs'; @@ -64,8 +64,9 @@ export function registerBlog(program) { text(`Read one: ${run} astryx blog `), ); } else { - // blog.detail — print the feed URL, then the plaintext body verbatim. - emit(text(`Feed: ${result.data.feedUrl}`), text(result.data.text)); + // blog.detail — the feed URL, then the post body emitted verbatim + // (code() so article typography/spacing isn't ASCII-normalized). + emit(record({feed: result.data.feedUrl}), code(result.data.text)); } }); } From d97eb8a996144b7851ed0853be0b547aa0e74c75 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Mon, 3 Aug 2026 16:53:41 -0700 Subject: [PATCH 14/17] test(cli): update search --verbose assertion for records output Post-merge, main's new `--verbose` regression test asserted the old combined `match:` line. The formatter renders ranking detail as separate `score:` / `reason:` record fields (mirroring --json), so assert those instead. Co-authored-by: Cursor --- packages/cli/clients/cli/commands/search.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/clients/cli/commands/search.test.mjs b/packages/cli/clients/cli/commands/search.test.mjs index eb5d2d152295..a4731923f877 100644 --- a/packages/cli/clients/cli/commands/search.test.mjs +++ b/packages/cli/clients/cli/commands/search.test.mjs @@ -208,6 +208,9 @@ describe('search CLI — exit codes + JSON contract', () => { const r = await runCli(['search', 'button', '--verbose'], REPO_ROOT); expect(r.status).toBe(0); expect(r.stdout).toContain('import:'); - expect(r.stdout).toContain('match:'); + // Ranking detail: pre-formatter this was a single `match: (score N)` + // line; it's now separate `score:` / `reason:` record fields mirroring --json. + expect(r.stdout).toContain('score:'); + expect(r.stdout).toContain('reason:'); }); }, SCAN_TIMEOUT); From 83a3a760798191e332027f73eefcdea694cfea25 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Tue, 4 Aug 2026 14:34:35 -0700 Subject: [PATCH 15/17] fix(cli): align build/search render with main's --verbose flag rename Co-authored-by: Cursor --- packages/cli/clients/cli/commands/build.mjs | 4 ++-- packages/cli/clients/cli/commands/search.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/clients/cli/commands/build.mjs b/packages/cli/clients/cli/commands/build.mjs index b7c9e9be8334..59aef6ce4502 100644 --- a/packages/cli/clients/cli/commands/build.mjs +++ b/packages/cli/clients/cli/commands/build.mjs @@ -118,8 +118,8 @@ export function registerBuild(program) { // Same JSON->text projection as search, but leaner: the section header // already says the kind, so drop `domain`/`import` by default (they're in - // --json and under --detail). Keeps each item to name/displayName/desc/cmd. - const fields = options.detail + // --json and under --verbose). Keeps each item to name/displayName/desc/cmd. + const fields = options.verbose ? ['name', 'domain', 'displayName', 'score', 'reason', 'import', 'description', 'command'] : ['name', 'displayName', 'description', 'command']; /** @type {import('../formatters/index.mjs').RecordOptions} */ diff --git a/packages/cli/clients/cli/commands/search.mjs b/packages/cli/clients/cli/commands/search.mjs index d8e9637ee33a..b7368b3c76a9 100644 --- a/packages/cli/clients/cli/commands/search.mjs +++ b/packages/cli/clients/cli/commands/search.mjs @@ -75,7 +75,7 @@ export function registerSearch(program) { // The text view is just a projection of the JSON: one record per result, // fields in a fixed order (missing ones skipped), command prefixed for the // caller's package manager. Ranked order is preserved (best match first). - const fields = options.detail + const fields = options.verbose ? ['name', 'domain', 'displayName', 'score', 'reason', 'import', 'description', 'command'] : ['name', 'domain', 'displayName', 'import', 'description', 'command']; From 104df7e9f732ab8f5b4951d33fcb260bc2321236 Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Tue, 4 Aug 2026 14:49:24 -0700 Subject: [PATCH 16/17] fix(cli): discover smoke-test targets from --json, not human text The formatter refactor reprojects `component --list` and `docs` as records, so scraping their human output (indented names, `(group)` headers, aligned topic columns) discovered 0 components and aborted. Read the `--json` envelope (the source of truth) instead: component names + `--category` keys from `component --list --json`, topics from `docs --json`. Co-authored-by: Cursor --- .github/scripts/cli-smoke-test.mjs | 45 ++++++++++++++++++------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/.github/scripts/cli-smoke-test.mjs b/.github/scripts/cli-smoke-test.mjs index adea93013036..148164b1dc5a 100644 --- a/.github/scripts/cli-smoke-test.mjs +++ b/.github/scripts/cli-smoke-test.mjs @@ -85,43 +85,52 @@ const DETAIL_LEVELS = detailMatch console.log(`detail levels: ${DETAIL_LEVELS.join(', ')}`); +// Parse a command's --json envelope. The JSON output is the CLI's source of +// truth; the human text is a projection, so discovery must not scrape it. +function runJson(args) { + const {stdout} = run(args); + try { + return JSON.parse(stdout); + } catch { + return null; + } +} + // --------------------------------------------------------------------------- -// 2. Discover components from astryx component --list +// 2. Discover components from astryx component --list --json // --------------------------------------------------------------------------- console.log('\ncomponent listing'); check('astryx component --list', ['component', '--list']); -const listResult = run(['component', '--list']); -const componentNames = listResult.stdout - .split('\n') - .filter(line => /^\s+[A-Z]/.test(line)) - .map(line => line.trim().split(/\s{2,}/)[0]); +// data.components is a map of category/group key -> [{name, package}, ...]. +const listData = runJson(['component', '--list', '--json'])?.data; +const componentGroups = + /** @type {Record>} */ (listData?.components ?? {}); +const componentNames = Object.values(componentGroups) + .flat() + .map(entry => entry.name); console.log(`discovered ${componentNames.length} components`); if (componentNames.length === 0) { - console.log('\nFATAL: no components discovered from --list output. Aborting.'); + console.log('\nFATAL: no components discovered from --list --json output. Aborting.'); process.exit(1); } // --------------------------------------------------------------------------- -// 3. Discover categories from the --list output +// 3. Categories are the group keys of the component map (valid --category args) // --------------------------------------------------------------------------- -const categories = listResult.stdout - .split('\n') - .filter(line => /^[A-Z].*\(group\)$/.test(line)) - .map(line => line.replace(/\s*\(group\)$/, '')); +const categories = Object.keys(componentGroups); console.log(`discovered ${categories.length} categories: ${categories.join(', ')}`); // --------------------------------------------------------------------------- -// 4. Discover doc topics from astryx docs +// 4. Discover doc topics from astryx docs --json // --------------------------------------------------------------------------- -const docsResult = run(['docs']); -const docTopics = docsResult.stdout - .split('\n') - .filter(line => /^\s{2}\w+\s{2,}/.test(line)) - .map(line => line.trim().split(/\s{2,}/)[0]); +const docsData = runJson(['docs', '--json'])?.data; +const docTopics = + /** @type {Array<{topic: string}>} */ (Array.isArray(docsData) ? docsData : []) + .map(entry => entry.topic); console.log(`discovered ${docTopics.length} doc topics: ${docTopics.join(', ')}`); From 8e4a10fce67bc07b3dfa4d32b2d16b8c78bd8cbb Mon Sep 17 00:00:00 2001 From: Joey Farina Date: Tue, 4 Aug 2026 15:07:20 -0700 Subject: [PATCH 17/17] test(cli): update no-raw-console-cli for the humanLog-in-commands ban The rule now forbids humanLog/humanWarn inside clients/cli/commands (Ban #2), but its test still asserted humanLog was valid in a command file. Move the valid humanLog example outside commands (the formatter sink) and add invalid cases for humanLog/humanWarn in command files. Co-authored-by: Cursor --- .../no-raw-console-cli.test.mjs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/eslint-plugin-astryx/no-raw-console-cli.test.mjs b/internal/eslint-plugin-astryx/no-raw-console-cli.test.mjs index 6ba4e6be414a..1a62d40804af 100644 --- a/internal/eslint-plugin-astryx/no-raw-console-cli.test.mjs +++ b/internal/eslint-plugin-astryx/no-raw-console-cli.test.mjs @@ -26,10 +26,11 @@ ruleTester.run('no-raw-console-cli', rule, { code: `console.warn('heads up');`, filename: '/repo/packages/cli/clients/cli/commands/search.mjs', }, - // humanLog is the sanctioned json-aware stdout primitive + // humanLog is allowed OUTSIDE command files — it's the stdout primitive the + // formatter sink (emit) and the shared logger are built on. { code: `import {humanLog} from '../../lib/json.mjs'; humanLog('hi');`, - filename: '/repo/packages/cli/clients/cli/commands/search.mjs', + filename: '/repo/packages/cli/clients/cli/formatters/index.mjs', }, // Exempt: lib/json.mjs defines the raw writers { @@ -77,6 +78,18 @@ ruleTester.run('no-raw-console-cli', rule, { {messageId: 'noRawConsoleLog'}, ], }, + // Ban #2: humanLog() in a command file must funnel through emit() + { + code: `humanLog('hi');`, + filename: '/repo/packages/cli/clients/cli/commands/search.mjs', + errors: [{messageId: 'noHumanLogInCommand'}], + }, + // Ban #2: humanWarn() in a command file — warnings go through cliError() + { + code: `humanWarn('careful');`, + filename: '/repo/packages/cli/clients/cli/commands/build.mjs', + errors: [{messageId: 'noHumanLogInCommand'}], + }, ], });