Imported from pjt222/agent-almanac (
i18n/ja/skills/design-cli-output/SKILL.md). Install upstream withnpx skills add pjt222/agent-almanac --skill design-cli-output. Copyright stays with the author (MIT).
Design CLI Output
コマンドラインツールのために、一貫したマルチレベルのターミナル出力を設計する。
使用タイミング
- CLI ツール用の新しいレポーターモジュールを構築するとき
- 標準的なトランザクション出力に加えて温かい・物語的な出力を追加するとき
- 複数コマンドにわたって出力フォーマットを標準化するとき
- 人間可読出力と並行する JSON マシン出力を設計するとき
- 新しいターミナルツールのカラー、グリフ、冗長度レベルを選ぶとき
入力
- 必須: CLI ツール名と主な対象(開発者、運用者、エンドユーザー)
- 必須: 出力フォーマットが必要なコマンド
- 任意: 「セレモニー」または物語出力バリアントが望まれるか
- 任意: ブランディング制約(カラーパレット、トーン)
手順
ステップ1: カラーパレットを定義する
chalk を使って名前付きパレットオブジェクトを作成する。
chalk を no-color フォールバックの背後でロードする。 フォールバックはパレットが使うすべての呼び出し形を代替しなければならず、それは文字列をそのまま通すことだけでは足りない:
// A factory returns a *function*; a direct style returns a string. Enumerate
// this list against the installed chalk, not from memory — chalk 6 added the
// three underline* variants, and a list that omits them is wrong for those names.
const FACTORIES = new Set(['ansi256', 'bgAnsi256', 'bgHex', 'bgRgb', 'hex',
'rgb', 'underlineAnsi256', 'underlineHex', 'underlineRgb']);
function makeChalkStub() {
return new Proxy((text) => text, {
get(target, prop) {
if (prop === 'then') return undefined; // must not be a thenable
if (prop === 'level') return 0; // no color support, truthfully
if (typeof prop === 'symbol') return Reflect.get(target, prop);
return FACTORIES.has(prop) ? () => makeChalkStub() : makeChalkStub();
},
});
}
let chalk;
try { chalk = (await import('chalk')).default; }
catch { chalk = makeChalkStub(); }
4 つの不変条件。より短いスタブはそれぞれを取り違える:
- Proxy のターゲットが呼び出し可能:
{}ではなく(text) => text。チェーン(chalk.bold.cyan('x'))はすべてのホップがインデックス可能かつ呼び出し可能であることを要求する。 - ファクトリは関数を返す:
new Proxy({}, { get: () => (s) => s })は直接スタイルを満たしつつファクトリを壊す。chalk.hex('#FF6B35')はそのとき 文字列'#FF6B35'であり、それを呼ぶとTypeError: ... is not a functionを投げる。パレットはモジュールロード時に構築されるため、そのフォールバックはインポート時点でツールを落とす — プレーンテキストへの劣化こそが目的だったまさにその状況で。 thenがundefined: すべてのプロパティに関数を返すスタブはawait chalkを永久にハングさせる。ランタイムが.thenを呼び、誰も呼ばないコールバックを待つ。Node はDetected unsettled top-level awaitを報告して 13 で終了する。levelが数値: 能力ゲートはchalk.level >= 1を読む。真値のスタブは背後にカラーサポートがないままそれを開けてしまう。
そのインポートを生き延びたオブジェクトからパレットを構築する。
標準パレット(トランザクション出力):
// Status colors
const ok = chalk.green; // success
const fail = chalk.red; // errors
const warn = chalk.yellow; // warnings
const info = chalk.cyan; // identifiers, names
const dim = chalk.dim; // secondary info, paths
const bold = chalk.bold; // headers
温かいパレット(セレモニー/物語出力):
const C = {
flame: chalk.hex('#FF6B35'), // active elements, fire
amber: chalk.hex('#FFB347'), // arriving items, warm highlights
spark: chalk.hex('#FFF4E0'), // individual items (sparks/skills)
ember: chalk.hex('#8B4513'), // cold/dormant states
warm: chalk.hex('#D4A574'), // neutral warm text
dim: chalk.dim, // background, secondary
fail: chalk.red, // errors stay red (honest)
};
パレット設計ルール:
- 常に no-color フォールバックを提供し、パレットが実際に使う呼び出し形に対して検査する — 上の温かいパレットはほぼすべてファクトリである
- カスタムパレットには hex カラーを使う(
chalk.hex('#FF6B35')) - パレットテーマに関係なく fail/error カラーは赤を保つ
- パレットエントリは見た目ではなく意味的役割で命名する
- スタブは各インポート箇所で作り直すのではなくモジュール間で 1 つを共有する。さもなければ同じ欠陥をコピーごとに見つけて直すことになる
期待結果: 名前付きエントリを持つパレットオブジェクトと、単に書かれただけでなく実行されたフォールバック。
失敗時: フォールバックパスを直接実行する。壊れていることをパレットで発見するのは間違った場所である。スタブがスコープにある状態で:
console.assert(chalk.dim('x') === 'x'); // direct style
console.assert(chalk.hex('#fff')('x') === 'x'); // factory — the usual defect
console.assert(chalk.bold.cyan('x') === 'x'); // chain
console.assert(chalk.level === 0); // capability gate stays shut
await chalk; // must not hang
NO_COLOR=1 はこれをカバーしない。それはエスケープを出さないことを選ぶ 動作している chalk を実行するが、フォールバックはインポートに失敗した chalk を実行する。2 つのパスはコードを共有しない。注釈付きの本番スタブ、欠陥の再現、上記チェックの実行可能版は拡張例を参照。
ステップ2: ステータスインジケーターを選ぶ
ステータス通信のための Unicode グリフまたは ASCII 文字を選ぶ:
ASCII(最大互換性):
+ created/installed (green)
- removed/deleted (red)
= skipped/unchanged (dim)
! error/warning (red)
Unicode(より豊か、UTF-8 ターミナルが必要):
✦ item/skill/practice (spark)
◉ active/burning state
◎ cooling/embers state
○ cold/dormant state
◌ available/not installed
✗ failed item
✓ success (use sparingly — not all terminals render it well)
選択基準:
- CI またはパイプ文脈で動くツールには ASCII
- インタラクティブなターミナルユーザーがいるツールには Unicode
--asciiフラグまたはNO_COLOR検出を介して両方を提供- グリフをテスト: macOS Terminal、Windows Terminal、VS Code terminal、SSH セッション
期待結果: カラーだけに頼らずに一目でステータスを伝えるグリフセット。
失敗時: テストでグリフが ? または箱として描画されたら、ASCII 等価物に置き換える。+/-/=/! セットはどこでも動く。
ステップ3: 冗長度レベルを設計する
すべてのコマンドは 4 つの出力レベルをサポートすべき:
| Level | Flag | Audience | Content |
|---|---|---|---|
| Default | (none) | Human at terminal | Formatted, colored, informative |
| Verbose | --verbose or --ceremonial |
Human wanting detail | Per-item breakdown, arrival sequences |
| Quiet | --quiet |
Scripts, CI | Minimal lines, status icons, no decoration |
| JSON | --json |
Machine consumers | Structured, parseable, complete |
実装パターン:
function output(data, options) {
if (options.json) {
console.log(JSON.stringify(data, null, 2));
return;
}
if (options.quiet) {
for (const item of data.items) {
const icon = item.ok ? '+' : '!';
console.log(`${icon} ${item.id}`);
}
return;
}
// Default (or verbose) human output
printFormatted(data, { verbose: options.verbose });
}
JSON 出力ルール:
- 常に有効な JSON(人間テキストと混在させない)
- 人間出力が示すすべてのデータ + マシン有用フィールドを含む
- コマンド間で一貫したキー命名を使う
- 出力モードに関係なく、成功は終了コード 0、エラーは 1
期待結果: コマンド間で一貫した挙動を持つ 4 つの明確な出力レベル。
失敗時: verbose モードがうるさすぎるなら、段階的冗長度レベルではなくオプトイン(--ceremonial)にする。
ステップ4: ボイスルールを確立する
すべての出力関数が従うべきトーンとスタイルを定義する。これがコマンド間の不整合を防ぐ。
ボイスルール例(campfire レポーターより):
- 現在形、能動態: 「mystic arrives」、「mystic has been installed」ではなく
- 感嘆符なし: 静かな自信。ツールは叫ばない。
- メタファーで専門用語を置換: 「practices」、「dependencies」ではなく(セレモニーモードのみ)
- 失敗は正直に、破滅的にではなく: 「A spark was lost」、「ERROR: installation failed with exit code 1」ではなく
- 終わりの行が状態を反映: すべての操作はステータス概要で終わる
- 絵文字なし: Unicode グリフは装飾的でなく視覚的重みを持つ
- すべての単語が情報を運ぶ: 単語が理解を加えないなら、削除する
標準(非セレモニー)出力のボイスルール:
- 簡潔な事実の行
- ステータスアイコン + アイテム ID + 文脈
- カウント付き概要行
- エラーメッセージは是正措置を提案する
期待結果: 出力関数が従うべき 3-7 のボイスルールの書き起こしセット。
失敗時: ルールが恣意的に感じられるなら、テストする: 各ルールあり/なしで同じ出力を書く。ルールを除いても出力品質が変わらないなら、そのルールは不要。
ステップ5: レポーター関数を実装する
焦点を絞った関数を持つレポーターモジュールに出力を整理する:
// reporter.js — standard output
export function printResults(results) { ... }
export function printItemTable(items) { ... }
export function printDetections(detections) { ... }
export function printAudit(auditResults) { ... }
export function printDryRun() { ... }
export function warn(msg) { ... }
export function error(msg) { ... }
export { chalk };
各関数は同じ構造に従う:
- 空/null 入力を優雅に扱う
- レイアウトを計算(カラム幅、パディング)
- パレット色で出力
- 一番下に概要行
セレモニー出力には別モジュールを作る:
// campfire-reporter.js — warm narrative output
export function printArrival({ teamId, agents, results, ceremonial }) { ... }
export function printScatter({ teamId, agents, results }) { ... }
export function printTend(fires) { ... }
export function printCampfireList({ teams, state, reg }) { ... }
export function printFireSummary({ team, fireData, reg }) { ... }
export function printJson(data) { ... }
期待結果: 独立して使えるレポーター関数 — 各々は呼び出し元状態に依存せず自身のフォーマットを扱う。
失敗時: 関数が ~50 行を超えて成長したらヘルパーを抽出する。レポーター関数は単独でレビューしやすくあるべき。
ステップ6: 環境間で出力をテストする
異なる文脈で出力が正しく描画されることを検証する:
# With colors (interactive terminal)
node cli/index.js list --domains
# Without colors (piped)
node cli/index.js list --domains | cat
# With NO_COLOR environment variable
NO_COLOR=1 node cli/index.js list --domains
# JSON mode (parseable)
node cli/index.js campfire --json | jq .
# In CI (typically no TTY)
CI=true node cli/index.js audit
# The no-color fallback. A failed import cannot be provoked with an env var, so
# assert on the stub itself in the suite rather than reaching it through the CLI.
# Pass a glob, not a directory: `node --test <dir>` stopped expanding at Node 22.
node --test 'cli/test/*.test.js'
確認事項:
- インタラクティブモードでカラーが正しく表示される
- パイプ/リダイレクト出力に ANSI エスケープコードが漏れない
- JSON が有効(
jq .にパイプして検証) - ターゲットターミナルで Unicode グリフが描画される
- 内容幅が変動してもカラム整列が保たれる
- no-color フォールバックがパレットが使うすべての呼び出し形に応答し、手作業で一度示すのではなくスイートでアサートされている
期待結果: 6 つの文脈すべてで出力が正しい。
失敗時: ANSI コードが漏れたら chalk が NO_COLOR を尊重するか確認する。Unicode が壊れたら ASCII フォールバックモードを提供する。緑のスイートはカラーについてどちらの意味でも何も語らないことに注意: テストランナーは stdout をパイプするため chalk.level は 0 になり、カラー出力と非カラー出力はバイト単位で同一になって、カラーが完全に壊れていてもアサーションは通る。カラーが動作することを証明するには FORCE_COLOR=3 とエスケープシーケンスに対するアサーションが必要である。
バリデーション
- カラーパレットに no-color フォールバックがあり、そのフォールバックが実行済み: 直接スタイル、ファクトリ、チェーン、
level === 0、awaitをすべて確認 - ステータスインジケーターがカラー・no-color 両モードで動く
- 4 つの冗長度レベルすべてが有用な出力を生む
- JSON 出力が有効で
jqで解析可能 - ボイスルールが文書化され一貫して従われる
- レポーター関数が空/null 入力を優雅に扱う
- 出力をテスト: terminal、piped、NO_COLOR、CI
よくある落とし穴
- 直接スタイルしか扱わない no-color フォールバック:
new Proxy({}, { get: () => (s) => s })は完全なものに見え、chalk.dimとchalk.redは実際にカバーするが、そのときすべてのファクトリは呼び出し元が直後に呼ぼうとする文字列を返す。パレットはモジュールロード時に構築されるため、TypeErrorはインポート時点で発生する — フォールバックはそれが存在する唯一の場面で最も激しく失敗する。スタブが満たすべき 4 つの不変条件はステップ1に列挙されている。 - 人間テキストと JSON を混ぜる:
--jsonモードでは有効 JSON のみを出力。一行でも混じる(「DRY RUN」など)と JSON パーサーが壊れる。コマンドが両方を表示しなければならないなら、明確に分離するか JSON モードで人間テキストを抑制する。 - ハードコードされたカラム幅: 内容長は変動する。
Math.max(...items.map(i => i.id.length))を使ってパディングを動的に計算する。 - 意味のないカラー: カラーが成功と失敗を区別する唯一の方法なら、色覚多様性ユーザーとパイプ出力は情報を失う。常にカラーをテキストインジケーター(
+、OK、ERR)と組み合わせる。 - 誤った文脈でのセレモニー: 温かい物語出力はインタラクティブターミナルセッションに適している。CI、スクリプト、
--quietモードではノイズになる。セレモニー出力は明示的フラグでゲートする。 - 概要行を忘れる: ユーザーは最後の行を最初にスキャンする。すべての操作は一行の概要(成功/失敗/スキップのカウント)で終わるべき。
関連スキル
scaffold-cli-command— この出力を使うコマンドtest-cli-application— 出力が期待に一致することのテストbuild-cli-plugin— プラグインはこの出力システムを通じて結果をレポートする