Skip to content

Commit efb22ef

Browse files
author
Vladimir Rogojin
committed
feat(cli)(#32): canonical UX — --json, --help, asset-pair input, auto-help on errors
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 <amount> <coin>, --offer <amount> <coin>, --want <amount> <coin>. - 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 <cmd>` 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: <msg>" 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`.
1 parent 28c878e commit efb22ef

4 files changed

Lines changed: 932 additions & 587 deletions

File tree

README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,78 @@ infrastructure:
7777
Each test creates a throwaway wallet in `/tmp` so runs are fully isolated and
7878
never touch real funds. Skip with `SKIP_INTEGRATION=1` when offline.
7979

80+
## UX — canonical conventions (issue #32)
81+
82+
Every `sphere` command follows the same input/output rules so muscle memory
83+
transfers across the surface.
84+
85+
### Asset input — `<amount> <coin>` (two positional tokens)
86+
87+
```bash
88+
sphere payments send @bob 100.5 UCT
89+
sphere invoice create --target @alice --asset 1000000 UCT
90+
sphere invoice create --target @alice --asset 1000000 UCT --asset 500000 USDU
91+
sphere invoice return <id> --recipient @bob --asset 100000 UCT
92+
sphere swap propose --to @bob --offer 10 UCT --want 5 USDU
93+
```
94+
95+
`--asset`, `--offer`, and `--want` each consume the **next two argv tokens**
96+
(amount, then coin). No quoted compound form (`--asset "10 UCT"`) is accepted
97+
— that legacy shape has been removed.
98+
99+
### Output — human-readable by default, `--json` for scripts
100+
101+
```bash
102+
sphere invoice status <id> # labelled block, easy to read
103+
sphere invoice status <id> --json # raw JSON (machine-parseable)
104+
```
105+
106+
`--json` is a global flag that any command honours.
107+
108+
### Help — `--help` works for every command and subcommand
109+
110+
```bash
111+
sphere --help # top-level usage summary
112+
sphere help # same
113+
sphere help send # detailed help for one command
114+
sphere wallet --help # namespace-level help
115+
sphere wallet create --help # sub-subcommand help
116+
sphere invoice deliver --help
117+
```
118+
119+
On invalid input (missing flag, bad value, unknown subcommand) the CLI prints
120+
`Error: <message>` followed by the full help block for the closest command —
121+
never a one-line `Usage:` stub.
122+
123+
## Shell completion
124+
125+
`sphere completions <bash|zsh|fish>` emits a completion script. Pick the
126+
install path that matches your environment:
127+
128+
```bash
129+
# Bash, no-sudo (recommended)
130+
mkdir -p ~/.local/share/bash-completion/completions
131+
sphere completions bash > ~/.local/share/bash-completion/completions/sphere
132+
133+
# Bash, system-wide (requires sudo)
134+
sudo sh -c "sphere completions bash > /etc/bash_completion.d/sphere"
135+
136+
# Zsh
137+
mkdir -p ~/.zsh/completions
138+
sphere completions zsh > ~/.zsh/completions/_sphere
139+
# Add to ~/.zshrc:
140+
# fpath=(~/.zsh/completions $fpath)
141+
# autoload -Uz compinit && compinit
142+
143+
# Fish
144+
sphere completions fish > ~/.config/fish/completions/sphere.fish
145+
```
146+
147+
Re-source your shell rc (`source ~/.bashrc`) or open a new terminal to pick
148+
up the completions. The completion script is regenerated from the same
149+
command table the CLI dispatcher uses; an internal test
150+
(`legacy-cli-ux.test.ts`) keeps the two in lockstep.
151+
80152
## Design principles
81153

82154
1. **DM-native.** All controller → manager and controller → tenant traffic goes

src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ const LEGACY_NAMESPACES = new Set([
3737
// sphere-sdk → @unicity-sphere/cli extraction. `wallet init` / `wallet
3838
// status` map to the same legacy backing case via buildLegacyArgv.
3939
'init', 'status', 'clear',
40+
// Issue #32 Pass C: route `sphere help <cmd>` to the legacy dispatcher
41+
// so the COMMAND_HELP registry powers detailed per-command help.
42+
'help',
4043
]);
4144

4245
// Phase 4 namespaces — DM-native, not yet implemented.
@@ -90,6 +93,11 @@ export function buildLegacyArgv(namespace: string, tail: string[] = process.argv
9093
case 'status': return ['status', ...tail];
9194
case 'clear': return ['clear', ...tail];
9295

96+
// Issue #32: `sphere help <cmd>` and `sphere help <cmd> <sub>` —
97+
// the legacy dispatcher recognises 'help' as a top-level command
98+
// and prints the COMMAND_HELP entry for the rest of the tail.
99+
case 'help': return ['help', ...tail];
100+
93101
// faucet → legacy 'topup'
94102
case 'faucet': return ['topup', ...tail];
95103

@@ -158,6 +166,12 @@ export function createCli(): Command {
158166
.description(`${name} commands (legacy bridge — phase 2)`);
159167

160168
sub.allowUnknownOption(true);
169+
// Issue #32 Pass C: forward `--help` / `-h` to the legacy dispatcher
170+
// so per-command help blocks (e.g. `sphere invoice create --help`,
171+
// `sphere swap propose --help`) print the full COMMAND_HELP entry
172+
// instead of commander's thin namespace stub. The top-level
173+
// `sphere --help` still works via the root command.
174+
sub.helpOption(false);
161175
sub.action(async () => {
162176
const legacyArgv = buildLegacyArgv(name);
163177
// Dynamic import keeps the legacy ~40-file dispatcher out of the hot

src/legacy/legacy-cli-ux.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Issue #32 — UX consistency guard.
3+
*
4+
* These tests don't exercise SDK behaviour; they inspect the static
5+
* surface of `legacy-cli.ts` (regex over the source) to keep the
6+
* canonical UX invariants from drifting back to legacy patterns.
7+
*
8+
* Invariants enforced:
9+
* 1. No `console.log(JSON.stringify(...))` in the dispatch body —
10+
* output must route through `formatOutput()` so `--json` works.
11+
* 2. No `console.error('Usage: …'); process.exit(…)` pattern —
12+
* validation failures must call `failWithHelp(...)` so the full
13+
* help block prints (Pass E).
14+
* 3. No `parseAssetArg(` calls — asset input is canonical
15+
* `consumeAssetPair(args, idx)` everywhere (Pass B).
16+
* 4. Every entry in the COMMAND_HELP registry appears in the shell
17+
* completion list (or is registered as a sub-subcommand of a
18+
* completion entry that lists `subcommands`).
19+
*
20+
* If any of these regress, the regex will fire and the test will
21+
* report the offending file:line. The CLI must stay consistent.
22+
*/
23+
24+
import { describe, it, expect } from 'vitest';
25+
import * as fs from 'fs';
26+
import * as path from 'path';
27+
import { fileURLToPath } from 'url';
28+
29+
const __filename = fileURLToPath(import.meta.url);
30+
const __dirname = path.dirname(__filename);
31+
const SOURCE_PATH = path.resolve(__dirname, 'legacy-cli.ts');
32+
const SOURCE = fs.readFileSync(SOURCE_PATH, 'utf8');
33+
const LINES = SOURCE.split('\n');
34+
35+
/** Return `{ line, text }` for each line that matches `re`. */
36+
function findMatches(re: RegExp): Array<{ line: number; text: string }> {
37+
const out: Array<{ line: number; text: string }> = [];
38+
for (let i = 0; i < LINES.length; i++) {
39+
if (re.test(LINES[i])) out.push({ line: i + 1, text: LINES[i].trim() });
40+
}
41+
return out;
42+
}
43+
44+
describe('issue #32 — UX consistency', () => {
45+
it('no console.log(JSON.stringify(...)) in dispatch body — must use formatOutput()', () => {
46+
// The implementation of formatOutput() itself legitimately calls
47+
// console.log(JSON.stringify(...)) — that's the `--json` branch.
48+
// Locate that function so we can exclude its body from the scan.
49+
const formatStart = LINES.findIndex(l => /^function formatOutput\(/.test(l));
50+
expect(formatStart, 'formatOutput() helper definition not found').toBeGreaterThan(-1);
51+
// Helper is small — closing brace at column 0 within ~20 lines.
52+
let formatEnd = formatStart;
53+
for (let i = formatStart + 1; i < Math.min(LINES.length, formatStart + 40); i++) {
54+
if (/^\}/.test(LINES[i])) { formatEnd = i; break; }
55+
}
56+
const matches = findMatches(/console\.log\(\s*JSON\.stringify/);
57+
const offenders = matches.filter(m => {
58+
// Outside the formatOutput body
59+
const inFormatOutput = m.line > formatStart && m.line <= formatEnd + 1;
60+
return !inFormatOutput;
61+
});
62+
expect(offenders, `Found ${offenders.length} stray console.log(JSON.stringify(...)) sites:\n${
63+
offenders.map(o => ` L${o.line}: ${o.text}`).join('\n')
64+
}\nReplace with formatOutput(value, '<shape>', '<label>') so --json works.`).toEqual([]);
65+
});
66+
67+
it('no console.error("Usage: ...") / process.exit(1) pattern — must use failWithHelp()', () => {
68+
const matches = findMatches(/console\.error\(['"`]Usage:/);
69+
expect(matches, `Found ${matches.length} legacy "Usage:" stubs:\n${
70+
matches.map(m => ` L${m.line}: ${m.text}`).join('\n')
71+
}\nReplace with failWithHelp('<cmd>', '<error>') so the full help block prints.`).toEqual([]);
72+
});
73+
74+
it('no parseAssetArg() call sites — asset input is consumeAssetPair() everywhere', () => {
75+
// Allow the helper to be referenced in comments/docs only.
76+
const matches = findMatches(/parseAssetArg\(/);
77+
const offenders = matches.filter(m => !/^\s*\/[/*]/.test(m.text));
78+
expect(offenders, `Found ${offenders.length} parseAssetArg() calls — drop legacy quoted form, use consumeAssetPair(args, i + 1).`).toEqual([]);
79+
});
80+
81+
it('every COMMAND_HELP key is reachable via the completion generator', () => {
82+
// Parse top-level COMMAND_HELP keys + compound keys.
83+
const helpKeys = new Set<string>();
84+
const helpBlockStart = SOURCE.indexOf('const COMMAND_HELP');
85+
const helpBlockEnd = SOURCE.indexOf('};\n\n/**\n * Print', helpBlockStart);
86+
const helpBlock = SOURCE.slice(helpBlockStart, helpBlockEnd);
87+
const helpRe = /^ {2}'([^']+)':\s*{/gm;
88+
let hm: RegExpExecArray | null;
89+
while ((hm = helpRe.exec(helpBlock))) helpKeys.add(hm[1]);
90+
91+
// Parse completion structure
92+
const compStart = SOURCE.indexOf('function getCompletionCommands');
93+
const compEnd = SOURCE.indexOf('function generateBash', compStart);
94+
const compBlock = SOURCE.slice(compStart, compEnd);
95+
// Collect every `name: '...'` — this captures both top-level and subcommands.
96+
const compNames = new Set<string>();
97+
const compRe = /name:\s*'([^']+)'/g;
98+
let cm: RegExpExecArray | null;
99+
while ((cm = compRe.exec(compBlock))) compNames.add(cm[1]);
100+
101+
// For every COMMAND_HELP key, ensure at least one component is present.
102+
const missing: string[] = [];
103+
for (const key of helpKeys) {
104+
const parts = key.split(' ');
105+
// Compound key "wallet create" satisfied if BOTH 'wallet' and 'create'
106+
// appear (the second as a subcommand entry under the first).
107+
const allPresent = parts.every(p => compNames.has(p));
108+
if (!allPresent) missing.push(key);
109+
}
110+
111+
expect(missing, `Completion generator is missing entries for:\n${
112+
missing.map(k => ` - ${k}`).join('\n')
113+
}\nAdd them to getCompletionCommands() so tab-completion stays in sync.`).toEqual([]);
114+
});
115+
116+
it('formatOutput and failWithHelp helpers exist', () => {
117+
expect(SOURCE).toMatch(/function formatOutput\(/);
118+
expect(SOURCE).toMatch(/function failWithHelp\(/);
119+
expect(SOURCE).toMatch(/function consumeAssetPair\(/);
120+
});
121+
122+
it('--json global flag is wired in the early dispatch shim', () => {
123+
expect(SOURCE).toMatch(/jsonMode = args\.includes\('--json'\)/);
124+
});
125+
126+
it('--help / -h early dispatch shim is wired before the switch', () => {
127+
expect(SOURCE).toMatch(/args\.includes\('--help'\)\s*\|\|\s*args\.includes\('-h'\)/);
128+
});
129+
});

0 commit comments

Comments
 (0)