From 310c981f2843413ee10cbc696d0be66d02d7b980 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Fri, 5 Jun 2026 12:40:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli)(#32):=20canonical=20UX=20=E2=80=94=20?= =?UTF-8?q?--json,=20--help,=20asset-pair=20input,=20auto-help=20on=20erro?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #32 — sphere-cli UX consistency. No more legacy: every command takes the same input shape, defaults to human-friendly output, and prints the full help block (never a one-line "Usage:" stub) on errors. Five-pass implementation (legacy-cli.ts): Pass A — Output formatting - New formatOutput(payload, shape, label?) helper with a typed OutputShape union and per-shape renderers (identity, invoice-terms, invoice-status, transfer-result, swap-status, etc.). - Global --json flag (jsonMode) opts back into JSON; default is a human-readable labelled block. - Replaced 22 console.log(JSON.stringify(...)) sites in the dispatch body. File-write/POST-body JSON.stringify calls remain (wire format). Pass B — Canonical asset input - Dropped parseAssetArg (quoted compound form, "10 UCT"). - New consumeAssetPair(args, startIdx) reads two argv tokens. - invoice-create, invoice-return, swap-propose all take --asset , --offer , --want . - invoice-create now supports multiple --asset flags for multi-asset invoices. Pass C — --help works everywhere - Early-dispatch shim in main() catches --help/-h before the switch, handling sub-subcommand keys ("wallet create", "daemon start") via compound lookup with fallback to top-level. - src/index.ts: legacy subcommands disable commander's built-in --help (helpOption(false)) so the flag reaches our dispatcher. - "help" added to LEGACY_NAMESPACES so `sphere help ` routes through the COMMAND_HELP registry. - Universal flags (--json, --help/-h) added to printCommandHelp output. Pass D — Completion + docs - Added COMMAND_HELP entries for `completions` and `help` so the drift-guard test passes. - README: new "UX — canonical conventions" + "Shell completion" sections documenting the canonical syntax and three install paths (no-sudo, system-wide, per-user zsh fpath). Pass E — Auto-help on invalid input - Single failWithHelp(cmdName, errorMsg) helper prints "Error: " followed by the full COMMAND_HELP block to stderr, then exit(1). - Replaced ~40 `console.error('Usage: …'); process.exit(1)` sites. - New helper resolveInvoiceId() collapses the duplicated invoice-prefix lookup boilerplate across ~10 invoice-* commands. Plus a new src/legacy/legacy-cli-ux.test.ts that statically guards the invariants (no stray JSON.stringify, no "Usage:" stubs, no parseAssetArg calls, completion ↔ COMMAND_HELP alignment) so future drift surfaces as a test failure. Verified: typecheck clean, 113/113 tests pass (incl. 7 new UX guard tests), build clean, smoke-tested `sphere invoice create --help`, `sphere swap propose --help`, `sphere help send`, and the missing-args auto-help path on `sphere payments send`. --- README.md | 72 ++ src/index.ts | 14 + src/legacy/legacy-cli-ux.test.ts | 129 +++ src/legacy/legacy-cli.ts | 1380 ++++++++++++++++-------------- 4 files changed, 965 insertions(+), 630 deletions(-) create mode 100644 src/legacy/legacy-cli-ux.test.ts diff --git a/README.md b/README.md index 30cfe13..27d6471 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,78 @@ infrastructure: Each test creates a throwaway wallet in `/tmp` so runs are fully isolated and never touch real funds. Skip with `SKIP_INTEGRATION=1` when offline. +## UX — canonical conventions (issue #32) + +Every `sphere` command follows the same input/output rules so muscle memory +transfers across the surface. + +### Asset input — ` ` (two positional tokens) + +```bash +sphere payments send @bob 100.5 UCT +sphere invoice create --target @alice --asset 1000000 UCT +sphere invoice create --target @alice --asset 1000000 UCT --asset 500000 USDU +sphere invoice return --recipient @bob --asset 100000 UCT +sphere swap propose --to @bob --offer 10 UCT --want 5 USDU +``` + +`--asset`, `--offer`, and `--want` each consume the **next two argv tokens** +(amount, then coin). No quoted compound form (`--asset "10 UCT"`) is accepted +— that legacy shape has been removed. + +### Output — human-readable by default, `--json` for scripts + +```bash +sphere invoice status # labelled block, easy to read +sphere invoice status --json # raw JSON (machine-parseable) +``` + +`--json` is a global flag that any command honours. + +### Help — `--help` works for every command and subcommand + +```bash +sphere --help # top-level usage summary +sphere help # same +sphere help send # detailed help for one command +sphere wallet --help # namespace-level help +sphere wallet create --help # sub-subcommand help +sphere invoice deliver --help +``` + +On invalid input (missing flag, bad value, unknown subcommand) the CLI prints +`Error: ` followed by the full help block for the closest command — +never a one-line `Usage:` stub. + +## Shell completion + +`sphere completions ` emits a completion script. Pick the +install path that matches your environment: + +```bash +# Bash, no-sudo (recommended) +mkdir -p ~/.local/share/bash-completion/completions +sphere completions bash > ~/.local/share/bash-completion/completions/sphere + +# Bash, system-wide (requires sudo) +sudo sh -c "sphere completions bash > /etc/bash_completion.d/sphere" + +# Zsh +mkdir -p ~/.zsh/completions +sphere completions zsh > ~/.zsh/completions/_sphere +# Add to ~/.zshrc: +# fpath=(~/.zsh/completions $fpath) +# autoload -Uz compinit && compinit + +# Fish +sphere completions fish > ~/.config/fish/completions/sphere.fish +``` + +Re-source your shell rc (`source ~/.bashrc`) or open a new terminal to pick +up the completions. The completion script is regenerated from the same +command table the CLI dispatcher uses; an internal test +(`legacy-cli-ux.test.ts`) keeps the two in lockstep. + ## Design principles 1. **DM-native.** All controller → manager and controller → tenant traffic goes diff --git a/src/index.ts b/src/index.ts index 59cfde8..e568e19 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,9 @@ const LEGACY_NAMESPACES = new Set([ // sphere-sdk → @unicity-sphere/cli extraction. `wallet init` / `wallet // status` map to the same legacy backing case via buildLegacyArgv. 'init', 'status', 'clear', + // Issue #32 Pass C: route `sphere help ` to the legacy dispatcher + // so the COMMAND_HELP registry powers detailed per-command help. + 'help', ]); // Phase 4 namespaces — DM-native, not yet implemented. @@ -90,6 +93,11 @@ export function buildLegacyArgv(namespace: string, tail: string[] = process.argv case 'status': return ['status', ...tail]; case 'clear': return ['clear', ...tail]; + // Issue #32: `sphere help ` and `sphere help ` — + // the legacy dispatcher recognises 'help' as a top-level command + // and prints the COMMAND_HELP entry for the rest of the tail. + case 'help': return ['help', ...tail]; + // faucet → legacy 'topup' case 'faucet': return ['topup', ...tail]; @@ -158,6 +166,12 @@ export function createCli(): Command { .description(`${name} commands (legacy bridge — phase 2)`); sub.allowUnknownOption(true); + // Issue #32 Pass C: forward `--help` / `-h` to the legacy dispatcher + // so per-command help blocks (e.g. `sphere invoice create --help`, + // `sphere swap propose --help`) print the full COMMAND_HELP entry + // instead of commander's thin namespace stub. The top-level + // `sphere --help` still works via the root command. + sub.helpOption(false); sub.action(async () => { const legacyArgv = buildLegacyArgv(name); // Dynamic import keeps the legacy ~40-file dispatcher out of the hot diff --git a/src/legacy/legacy-cli-ux.test.ts b/src/legacy/legacy-cli-ux.test.ts new file mode 100644 index 0000000..f0dfe38 --- /dev/null +++ b/src/legacy/legacy-cli-ux.test.ts @@ -0,0 +1,129 @@ +/** + * Issue #32 — UX consistency guard. + * + * These tests don't exercise SDK behaviour; they inspect the static + * surface of `legacy-cli.ts` (regex over the source) to keep the + * canonical UX invariants from drifting back to legacy patterns. + * + * Invariants enforced: + * 1. No `console.log(JSON.stringify(...))` in the dispatch body — + * output must route through `formatOutput()` so `--json` works. + * 2. No `console.error('Usage: …'); process.exit(…)` pattern — + * validation failures must call `failWithHelp(...)` so the full + * help block prints (Pass E). + * 3. No `parseAssetArg(` calls — asset input is canonical + * `consumeAssetPair(args, idx)` everywhere (Pass B). + * 4. Every entry in the COMMAND_HELP registry appears in the shell + * completion list (or is registered as a sub-subcommand of a + * completion entry that lists `subcommands`). + * + * If any of these regress, the regex will fire and the test will + * report the offending file:line. The CLI must stay consistent. + */ + +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const SOURCE_PATH = path.resolve(__dirname, 'legacy-cli.ts'); +const SOURCE = fs.readFileSync(SOURCE_PATH, 'utf8'); +const LINES = SOURCE.split('\n'); + +/** Return `{ line, text }` for each line that matches `re`. */ +function findMatches(re: RegExp): Array<{ line: number; text: string }> { + const out: Array<{ line: number; text: string }> = []; + for (let i = 0; i < LINES.length; i++) { + if (re.test(LINES[i])) out.push({ line: i + 1, text: LINES[i].trim() }); + } + return out; +} + +describe('issue #32 — UX consistency', () => { + it('no console.log(JSON.stringify(...)) in dispatch body — must use formatOutput()', () => { + // The implementation of formatOutput() itself legitimately calls + // console.log(JSON.stringify(...)) — that's the `--json` branch. + // Locate that function so we can exclude its body from the scan. + const formatStart = LINES.findIndex(l => /^function formatOutput\(/.test(l)); + expect(formatStart, 'formatOutput() helper definition not found').toBeGreaterThan(-1); + // Helper is small — closing brace at column 0 within ~20 lines. + let formatEnd = formatStart; + for (let i = formatStart + 1; i < Math.min(LINES.length, formatStart + 40); i++) { + if (/^\}/.test(LINES[i])) { formatEnd = i; break; } + } + const matches = findMatches(/console\.log\(\s*JSON\.stringify/); + const offenders = matches.filter(m => { + // Outside the formatOutput body + const inFormatOutput = m.line > formatStart && m.line <= formatEnd + 1; + return !inFormatOutput; + }); + expect(offenders, `Found ${offenders.length} stray console.log(JSON.stringify(...)) sites:\n${ + offenders.map(o => ` L${o.line}: ${o.text}`).join('\n') + }\nReplace with formatOutput(value, '', '