Skip to content

feat(cli): cashctl-style errors and help, a network spinner, and ncli profile - #55

Merged
naliyi merged 4 commits into
mainfrom
worktree-cli-ux-cashctl
Sep 23, 2026
Merged

naliyi merged 4 commits into
mainfrom
worktree-cli-ux-cashctl

Conversation

@naliyi

@naliyi naliyi commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Brings ncli's command UX in line with cashctl, and adds ncli profile.

1. Failures read like cashctl

Three text shapes instead of a timestamped zerolog line plus a help dump on the wrong stream:

invocation before after
ncli decode (missing arg) help on stdout, 2026-09-23T12:27:33Z ERR accepts 1 arg(s), received 0 on stderr, exit 2 help on stderr, no error line, exit 2
ncli miner mnie (typo) help on stdout + timestamped ERR Error: unknown command "mnie", blank line, help — all stderr, exit 2
ncli decode --bogusflag reported twice, exit 1 one report, exit 2
ncli decode notavalid ERR ... Error: ... alone, no help, exit 3

Help moved off stdout, where it contradicted AGENTS.md's "a command's result goes to stdout only" and could pollute a pipe into jq. Error: is red only on a real terminal, and never under NO_COLOR — which ncli honoured nowhere before.

Exit codes are unchanged. A bare group command still exits 2, so the friendlier output never turns a mistyped subcommand into apparent success, and --json still emits exactly one structured line and never help. The three skills that said a bare group was "not a silent help dump" are reworded: it does print help now, and still exits 2.

Help is opt-in, mirroring cashctl: UsageError stays help-free for a usage-coded runtime refusal (a relay answering 501 because membership is off; a vault password prompt with no TTY), InvocationError adds error-then-help, HelpError prints help alone. InvocationOrHelp picks between the last two from the args a validator already has.

Two contract violations fixed on the way

  • An unknown flag was reported twice — cobra's own Error: + usage dump, with ncli's line stacked under it — and exited 1. Silencing cobra on the root and classifying ExecuteC's own errors in main fixes that, and with it every unwrapped MarkFlagRequired.
  • bunker sessions revoke-grant with no --method exited 1 as internal instead of 2 as usage — the one command with MarkFlagRequired and no ValidateRequiredFlags.

2. Spinner on network-facing commands

Log records go through a writer that wipes the in-flight frame first, so the existing per-relay narration still prints and the next tick redraws beneath it — the spinner doesn't cost you the narration.

Off under --json, -q, NO_COLOR and a non-terminal stderr (verified: zero ANSI bytes on captured stderr). Never wired into the commands that own the terminal — apply, ping --tui, bunker, the delegate wizard — or into the long-running relay server.

3. ncli profile <identifier>

 ncli profile

  jack
  no state is the best state

  Identity
    nip-05       ✔ jack@primal.net
    npub         npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m
    pubkey       82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2
    lightning    ⚡ jack@primal.net
    website      — not set

  Following      — not published

  Relays (6)
    ↕  wss://relay.damus.io
    ↕  wss://relay.primal.net
    ...

  Blossom        — not published (no kind:10063)

  queried 1 relay(s) · profile updated 2026-06-04

One subscription over kind:0, kind:3 (following count), kind:10002 (NIP-65) and kind:10063 (Blossom). Takes npub, hex, nprofile, a nip-05 address, or a vault label.

Two deliberate differences from find:

  • Aggregates across every target rather than stopping at the first match (QueryTargets, not Find) — a relay list very often lives on a different relay than the profile.
  • An identity that published nothing renders sparse at exit 0, not not_found. Every section still prints as "not published", so absent stays distinguishable from zero; in --json those fields are omitted rather than zeroed.

nip-05 is verified against its domain by default; a lookup that can't complete reports as unverifiable (⚠) rather than as a failed verification. --no-verify skips it.

Verification

  • go build ./..., go vet ./..., go test -short ./... all clean (GOWORK=off).
  • New tests: the three HelpMode shapes with stdout asserted empty, --json never dumping help, errorPrefix/NO_COLOR, IsBareInvocation, and the spinner (frames written, nothing when disabled, goroutine joined before return, no nesting, error propagated).
  • Two test failures during development caught two real bugs: IsBareInvocation used Visit instead of VisitAll+Changed, which made every flags-only invocation look bare.
  • ncli profile verified end to end against relay.primal.net — real metadata, a genuine nip-05 HTTPS verification, a six-relay NIP-65 list.

Note

origin/worktree-trim-id-help is no longer a conflict: satoshi is already the vault-label placeholder on main, contrary to an earlier reading.

Align ncli's command UX with cashctl's. A failure now takes one of three
text shapes instead of a timestamped zerolog line plus a help dump on the
wrong stream:

  bare invocation      help alone, no error line
  wrong invocation     "Error: <msg>", blank line, then help
  runtime failure      "Error: <msg>" alone

All three go to stderr; help used to land on stdout, which contradicted
"a command's result goes to stdout only" and could pollute a pipe into
jq. "Error:" is red only on a real terminal, and never under NO_COLOR --
which ncli did not honour anywhere before.

Exit codes are unchanged. A bare group command still exits 2, so the
friendlier output never turns a mistyped subcommand into apparent
success, and --json still emits exactly one structured line and never
help.

Help is opt-in, mirroring cashctl's split: UsageError stays help-free for
a usage-coded runtime refusal (a relay answering 501 because membership
is off, a vault password prompt that can't run), InvocationError adds the
error-then-help shape, and HelpError prints help alone.
InvocationOrHelp picks between the last two from the args a validator
already has, since the same check fires both for "you gave me nothing"
and "you gave me the wrong thing".

Fixes two contract violations found while verifying:

- An unknown flag was reported twice -- cobra's own "Error:" plus usage
  dump, and ncli's own line stacked under it -- and exited 1 instead of
  2. Silencing cobra on the root and classifying ExecuteC's own errors in
  main fixes that, and with it every unwrapped MarkFlagRequired.
- "bunker sessions revoke-grant" with no --method exited 1 as internal
  rather than 2 as usage; it was the one command with MarkFlagRequired
  and no ValidateRequiredFlags.

Commands that block on the network now animate a spinner on stderr.
Log records go through a writer that wipes the in-flight frame first, so
the existing per-relay narration still prints and the next tick redraws
beneath it. Off under --json, -q, NO_COLOR and a non-terminal stderr, and
never wired into the commands that own the terminal (apply, ping --tui,
bunker, the delegate wizard) or into the long-running relay server.

EmitError keeps recording failures to ncli.log through a file-only
logger, since it no longer prints via zerolog.

Help text: trimmed the bulkiest Long blocks and tightened the Short lines
over 60 characters. That is not cosmetic any more -- help is printed on
every mis-invocation now, so find's 17-line Long was about to become the
common case (find --help: 43 lines -> 35).
Looking someone up was "ncli find npub1..." returning raw JSON. "ncli
profile <identifier>" answers the same question as a readable card, from
a single subscription covering the four records that describe an
identity: kind:0 metadata, kind:3 (the following count), kind:10002
(NIP-65 relays, with read/write markers) and kind:10063 (Blossom
servers). It shows the lightning address from lud16, falling back to
lud06.

It takes whatever ResolveIdentifier already takes -- npub, hex,
nprofile, a nip-05 address, or a vault label, so "ncli profile <label>"
shows your own.

Two things it deliberately does differently from find:

- It aggregates across every target rather than stopping at the first
  one with a match, using QueryTargets rather than Find. A relay list
  very often lives on a different relay than the profile, so find's
  first-match-wins would miss it.
- An identity that published nothing is a successful, sparse render at
  exit 0, not not_found. The identity exists; it just published nothing.
  Every section still prints, reading "not published", so the card's
  shape never moves and absent stays distinguishable from zero -- in
  --json those fields are omitted rather than zeroed.

The claimed nip-05 is verified against its domain by default, one HTTPS
round trip: a lookup that can't complete reports as unverifiable rather
than as a failed verification, since an unreachable domain says nothing
either way. --no-verify skips it.

The kind:0 content document moves to cli/common as ProfileMetadata,
replacing bunker's private three-field copy, so both agree on what a
profile is. NIP-65 entries are re-serialized through a local struct
because nip65.RelayEntry carries no JSON tags and would otherwise put Go
field names in the --json contract.

Verified end to end against relay.primal.net: real metadata, a genuine
nip-05 verification, and a six-relay NIP-65 list.

Docs: AGENTS.md's failure section rewritten for the three new shapes and
the spinner, plus the three skills that claimed a bare group command was
"not a silent help dump" -- it now does print help, and still exits 2.
Two CI failures from the spinner commit:

- errcheck flagged the three fmt.Fprint/Fprintf calls that draw and erase
  the spinner frame. Discard the returns explicitly, as the rest of the
  tree already does for best-effort writes -- there is nothing useful to
  do if writing a progress frame to stderr fails.
- IsBareInvocation imports spf13/pflag directly for the VisitAll callback
  signature, which promotes it from an indirect to a direct requirement;
  go.mod hadn't been re-tidied to match.

Verified against CI's own steps: gofmt, build, vet, tidy, `go test -short
-race ./...`, and golangci-lint v2.13.2 (the version the workflow pins)
now reports 0 issues.
@naliyi
naliyi marked this pull request as ready for review September 23, 2026 14:27
@naliyi
naliyi merged commit f9807a9 into main Sep 23, 2026
4 checks passed
@naliyi
naliyi deleted the worktree-cli-ux-cashctl branch September 23, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant