Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — `<amount> <coin>` (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 <id> --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 <id> # labelled block, easy to read
sphere invoice status <id> --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: <message>` followed by the full help block for the closest command —
never a one-line `Usage:` stub.

## Shell completion

`sphere completions <bash|zsh|fish>` 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
Expand Down
14 changes: 14 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cmd>` to the legacy dispatcher
// so the COMMAND_HELP registry powers detailed per-command help.
'help',
]);

// Phase 4 namespaces — DM-native, not yet implemented.
Expand Down Expand Up @@ -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 <cmd>` and `sphere help <cmd> <sub>` —
// 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];

Expand Down Expand Up @@ -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
Expand Down
129 changes: 129 additions & 0 deletions src/legacy/legacy-cli-ux.test.ts
Original file line number Diff line number Diff line change
@@ -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, '<shape>', '<label>') so --json works.`).toEqual([]);
});

it('no console.error("Usage: ...") / process.exit(1) pattern — must use failWithHelp()', () => {
const matches = findMatches(/console\.error\(['"`]Usage:/);
expect(matches, `Found ${matches.length} legacy "Usage:" stubs:\n${
matches.map(m => ` L${m.line}: ${m.text}`).join('\n')
}\nReplace with failWithHelp('<cmd>', '<error>') so the full help block prints.`).toEqual([]);
});

it('no parseAssetArg() call sites — asset input is consumeAssetPair() everywhere', () => {
// Allow the helper to be referenced in comments/docs only.
const matches = findMatches(/parseAssetArg\(/);
const offenders = matches.filter(m => !/^\s*\/[/*]/.test(m.text));
expect(offenders, `Found ${offenders.length} parseAssetArg() calls — drop legacy quoted form, use consumeAssetPair(args, i + 1).`).toEqual([]);
});

it('every COMMAND_HELP key is reachable via the completion generator', () => {
// Parse top-level COMMAND_HELP keys + compound keys.
const helpKeys = new Set<string>();
const helpBlockStart = SOURCE.indexOf('const COMMAND_HELP');
const helpBlockEnd = SOURCE.indexOf('};\n\n/**\n * Print', helpBlockStart);
const helpBlock = SOURCE.slice(helpBlockStart, helpBlockEnd);
const helpRe = /^ {2}'([^']+)':\s*{/gm;
let hm: RegExpExecArray | null;
while ((hm = helpRe.exec(helpBlock))) helpKeys.add(hm[1]);

// Parse completion structure
const compStart = SOURCE.indexOf('function getCompletionCommands');
const compEnd = SOURCE.indexOf('function generateBash', compStart);
const compBlock = SOURCE.slice(compStart, compEnd);
// Collect every `name: '...'` — this captures both top-level and subcommands.
const compNames = new Set<string>();
const compRe = /name:\s*'([^']+)'/g;
let cm: RegExpExecArray | null;
while ((cm = compRe.exec(compBlock))) compNames.add(cm[1]);

// For every COMMAND_HELP key, ensure at least one component is present.
const missing: string[] = [];
for (const key of helpKeys) {
const parts = key.split(' ');
// Compound key "wallet create" satisfied if BOTH 'wallet' and 'create'
// appear (the second as a subcommand entry under the first).
const allPresent = parts.every(p => compNames.has(p));
if (!allPresent) missing.push(key);
}

expect(missing, `Completion generator is missing entries for:\n${
missing.map(k => ` - ${k}`).join('\n')
}\nAdd them to getCompletionCommands() so tab-completion stays in sync.`).toEqual([]);
});

it('formatOutput and failWithHelp helpers exist', () => {
expect(SOURCE).toMatch(/function formatOutput\(/);
expect(SOURCE).toMatch(/function failWithHelp\(/);
expect(SOURCE).toMatch(/function consumeAssetPair\(/);
});

it('--json global flag is wired in the early dispatch shim', () => {
expect(SOURCE).toMatch(/jsonMode = args\.includes\('--json'\)/);
});

it('--help / -h early dispatch shim is wired before the switch', () => {
expect(SOURCE).toMatch(/args\.includes\('--help'\)\s*\|\|\s*args\.includes\('-h'\)/);
});
});
Loading
Loading