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
31 changes: 25 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ that priority order.
| `ncli apply -f <spec.yaml>` | Run a `stream`/`sync`/`inspect` workflow from a YAML spec |
| `ncli dump -o <out.json>` | Export events to JSON from a relay, `.db` file, or prefs relays |
| `ncli find <id>` / `-t <targets.yaml>` | Look up events by ID (hex/note/nevent) or author (npub/nprofile/nip-05), and/or filter, across targets |
| `ncli profile <identifier>` | Readable profile card for one identity — metadata, following count, relay list, Blossom servers, lightning address — aggregated across every relay (not stopping at the first hit like `find`) |
| `ncli ping <relay...>` / `-t <targets.yaml>` | Probe whether targets are reachable (connect + subscribe), no events fetched |
| `ncli prefs relays add/list/remove/clear` | Manage the default relay list `find`/`dump`/`ping`/`miner check` fall back to |
| `ncli relay --config <relay.yaml>` | Run the relay server; `agent_auth` block enables NIP-AA (an agent key gains virtual membership from its owner's NIP-43 membership via a NIP-OA credential, no separate enrollment) |
Expand Down Expand Up @@ -68,12 +69,23 @@ means "queried successfully, found nothing," never "couldn't check."
and logged like it is elsewhere.

**Failures**: exactly one top-level error report, always on stderr, never
stdout — a plain timestamped line by default, or `{"error", "code",
"retryable", "input"}` with `--json`:
stdout. In text mode it takes one of three shapes, and `--json` replaces
all three with a single `{"error", "code", "retryable", "input"}` line:

| shape | when | looks like |
|---|---|---|
| help alone, no error line | the command was invoked bare — no arguments and none of its own flags (`ncli decode`, `ncli miner`) | the command's `--help` text, on stderr |
| `Error: <msg>`, blank line, then help | something *was* supplied and it was wrong — bad arg count, unknown flag or subcommand, conflicting flags | `Error:` in red on a TTY, then the help |
| `Error: <msg>` alone | the invocation was fine and the operation failed (`ncli ping` with no relays, a bad identifier) | one line; help wouldn't help |

The first shape still exits non-zero with its usual code — help is shown
instead of scolding, **not** instead of failing, so a mistyped subcommand
is never mistaken for success. `Error:` is red only on a real terminal,
and never when `NO_COLOR` is set or stderr is piped.

| `code` | exit | retryable | meaning |
|---|---|---|---|
| `usage` | 2 | no | bad/missing/conflicting flags/args/config, a relay-side feature that isn't turned on (e.g. `relay members ...` against a relay with `membership.enabled: false`), or a group command (`relay members`/`invites`/`roles`/`reindex`/`clear`, `prefs`, `prefs relays`, `miner`) invoked without one of its own subcommands |
| `usage` | 2 | no | bad/missing/conflicting flags/args/config, a relay-side feature that isn't turned on (e.g. `relay members ...` against a relay with `membership.enabled: false`), or a group command (`relay members`/`invites`/`roles`/`reindex`/`clear`, `prefs`, `prefs relays`, `miner`, `blossom`, `blossom servers`, `bunker sessions`) invoked without one of its own subcommands |
| `invalid_input` | 3 | no | a supplied value failed validation/parsing (bad identifier, URL, key, duration, kind, ...) |
| `not_found` | 4 | no | the referenced thing doesn't exist (vault entry, configured relay, ...) |
| `conflict` | 5 | yes | collides with existing state (vault label taken, reindex already running) |
Expand All @@ -88,9 +100,16 @@ omitted when there's no one clean value, or when the value is private-key
material (never echoed, even malformed). `retryable` lets an agent decide
whether to back off and retry (`network`/`conflict`) or fix the input and
try again (everything else) without string-matching the message. No
command double-reports the same failure in two shapes, and a usage mistake
in `--json` mode skips the human-readable help dump (which would otherwise
land on stdout) in favor of the structured error alone.
command double-reports the same failure in two shapes, and `--json` never
prints help at all — just the one structured line, whichever of the three
text shapes the failure would otherwise have taken.

**Waiting**: any command that blocks on the network (`find`, `dump`,
`ping`, `publish`, `profile`, `miner check`, every `blossom` and `relay`
admin subcommand) animates a spinner on stderr while it waits, with the
per-relay progress narration still printed above it. The spinner is off
under `--json`, under `-q/--quiet`, when `NO_COLOR` is set, and whenever
stderr isn't a terminal — so captured output is byte-identical to before.

`--json` also switches *every* other stderr log line (progress narration,
warnings, a partial/recoverable failure like one unreachable target in a
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,23 @@
`circlehub1...` connections. Pairing secrets are never shown.
`cashhub1...` is recognized and rejected.
- Every command's `--help` has an `Example:`.
- `ncli profile <identifier>` prints a readable profile card for one
identity -- metadata, following count, relay list, Blossom servers and
lightning address -- from a single query, aggregated across every relay
rather than stopping at the first hit. `--json` for the structured
shape; `--no-verify` skips the nip-05 check. (#55)
- A spinner on stderr while any command waits on the network. Off under
`--json`, `-q/--quiet`, `NO_COLOR`, and whenever stderr isn't a
terminal. (#55)

### Changed

- Failures now read like `cashctl`: a bare invocation prints help with no
error line, a wrong one prints `Error: <msg>` (red on a TTY) above the
help, and a runtime failure prints the error alone. All three go to
**stderr** -- help used to land on stdout -- and exit codes are
unchanged, so a bare group command still exits 2, never 0. `--json` is
untouched: one structured line, never help. (#55)
- A local flow's `ensure` now defaults to `create` (was `exists`), so a
missing store path is created instead of failing.
- The Age column shows days and weeks (`2d4h`, `1w3d`) instead of
Expand All @@ -20,6 +34,10 @@

### Fixed

- An unknown flag was reported twice (cobra's own `Error:` plus ncli's
own line) and exited 1 instead of 2. (#55)
- `ncli bunker sessions revoke-grant` with no `--method` exited 1 as
`internal` instead of 2 as `usage`. (#55)
- Wallet transfers reused a stale client on their second call.
- NWC responses dropped the `circle_hub`/`circle_wallet` fee fields.
- Two `Example:` commands failed when run as written.
Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ events.**
- [`ncli relay stats/reindex/clear`](#relay-statsreindexclear) — Manage a running relay over NIP-98 HTTP
- [`ncli apply`](#apply) — Stream, sync, or inspect events, with a live TUI and hot-reloading config
- [`ncli find`](#find) — Query events
- [`ncli profile`](#profile) — Look up one identity as a readable card
- [`ncli ping`](#ping) — Check if relays/targets are reachable
- [`ncli dump`](#dump) — Export events to JSON
- [`ncli publish`](#publish) — Publish signed events to one or more relays
Expand Down Expand Up @@ -442,6 +443,56 @@ spec:
limit: 5
```

## `profile`

To look someone *up*, reach for `ncli profile` rather than `find -k 0` —
one query, four records, printed as a card instead of raw JSON:

```sh
ncli profile npub1...
ncli profile jack@primal.net
ncli profile mykey # a vault label works too
ncli profile npub1... --json # structured, for scripts
```

```
ncli profile

jack
Bitcoin, Nostr, and open protocols.

Identity
nip-05 ✔ jack@example.com
npub npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m
pubkey 82129f882b6eab9a95d3f8f7c8556c873c1d2bc55cf7a250993e9553efdf3543
lightning ⚡ jack@example.com
website https://example.com

Following 1,284

Relays (3)
↕ wss://relay.example.com
↓ wss://read.example.com
↑ wss://write.example.com

Blossom (2)
• https://blossom.example.com
• https://cdn.example.org

queried 4 relay(s) · profile updated 2026-09-21
```

It gathers kind:0 (metadata), kind:3 (contacts → the following count),
kind:10002 (NIP-65 relays, `↕` read+write / `↓` read / `↑` write) and
kind:10063 (Blossom servers) in a single subscription, and **aggregates
across every relay** instead of stopping at the first match the way
`find` does — a relay list often lives somewhere other than the profile.

The claimed nip-05 is checked against its domain (`✔` verified, `✘`
mismatch, `⚠` unreachable); `--no-verify` skips that round trip. An
identity that published nothing still renders, with each section reading
`not published`, and exits 0.

## `ping`

Test relay connectivity: confirms a relay actually speaks the protocol,
Expand Down
15 changes: 7 additions & 8 deletions cli/blossom/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,13 @@ func NewBlossomCommand() *cobra.Command {
Long: `A client for the Blossom protocol (BUD-01..12): content-addressed blob
storage authenticated with a Nostr identity instead of a login.

Every write (upload, rm, mirror) targets every server from --server, or
the default list from "ncli blossom servers add" -- reporting a result
per (item, server) pair, and exiting non-zero if any pair failed.
"download" tries the configured servers in order, stopping at the first
that answers; "list" queries one server by default, or every server with
--all.`,
Example: ` ncli blossom upload ./photo.jpg --identity satoshi`,
RunE: common.RequireSubcommand,
Writes (upload, rm, mirror) fan out to every configured server and exit
non-zero if any one failed; download tries them in order until one
answers.`,
Example: ` ncli blossom upload ./photo.jpg --identity satoshi
ncli blossom download <hash> -o photo.jpg
ncli blossom servers list`,
RunE: common.RequireSubcommand,
}

cmd.PersistentFlags().String("identity", "", "Identity to sign with -- vault label, nsec, npub, hex, nprofile, or nip-05")
Expand Down
9 changes: 8 additions & 1 deletion cli/blossom/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package blossom
import (
"fmt"
"io"
"net/http"
"os"
"os/signal"
"regexp"
Expand Down Expand Up @@ -69,7 +70,13 @@ omitted, or streams to stdout with "-o -" (suppressing the summary line).`,
timeout, _ := cmd.Flags().GetDuration("timeout")
hc := newHTTPClient(timeout)

resp, usedServer, err := hc.GetFromServers(ctx, servers, hash, bclient.GetOptions{Ext: ext, Auth: auth})
var resp *http.Response
var usedServer string
err = common.WithSpinner(cmd, fmt.Sprintf("downloading %s", shortHash(hash)), func() error {
var gErr error
resp, usedServer, gErr = hc.GetFromServers(ctx, servers, hash, bclient.GetOptions{Ext: ext, Auth: auth})
return gErr
})
if err != nil {
return classifyHTTPError(cmd, hash, err)
}
Expand Down
32 changes: 20 additions & 12 deletions cli/blossom/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,14 @@ func newListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list [identifier]",
Short: "List blobs stored under a pubkey",
Long: `Query one Blossom server's GET /list/<pubkey> (--server, or the first
configured server), or every configured server with --all, merged and
deduped by hash.
Long: `List the blobs one pubkey has stored, on the first configured server or
on every one with --all, merged and deduped by hash.

identifier may be a vault label, nsec, npub, hex pubkey, nprofile, or
nip-05 address, resolved to a hex pubkey; defaults to --identity's
resolved pubkey when omitted.`,
identifier accepts a vault label, npub, hex pubkey, nprofile or nip-05
address, and defaults to --identity's pubkey when omitted.`,
Example: ` ncli blossom list --identity satoshi
ncli blossom list --identity satoshi --all`,
ncli blossom list --identity satoshi --all
ncli blossom list name@example.com`,
Args: common.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
Expand Down Expand Up @@ -66,7 +65,7 @@ resolved pubkey when omitted.`,
}
}
if pubKeyHex == "" {
return common.UsageError(cmd, fmt.Errorf("a pubkey argument or --identity is required"))
return common.InvocationOrHelp(cmd, args, fmt.Errorf("a pubkey argument or --identity is required"))
}

servers, err := resolveServers(cmd)
Expand All @@ -84,11 +83,20 @@ resolved pubkey when omitted.`,
hc := newHTTPClient(timeout)

var descriptors []nipB7.BlobDescriptor
if all, _ := cmd.Flags().GetBool("all"); all {
descriptors, err = listAllServers(ctx, hc, servers, pubKeyHex, query, auth)
} else {
descriptors, err = hc.List(ctx, servers[0], pubKeyHex, query, auth)
all, _ := cmd.Flags().GetBool("all")
message := fmt.Sprintf("listing blobs on %s", servers[0])
if all {
message = fmt.Sprintf("listing blobs on %d server(s)", len(servers))
}
err = common.WithSpinner(cmd, message, func() error {
var lErr error
if all {
descriptors, lErr = listAllServers(ctx, hc, servers, pubKeyHex, query, auth)
} else {
descriptors, lErr = hc.List(ctx, servers[0], pubKeyHex, query, auth)
}
return lErr
})
if err != nil {
return classifyListError(cmd, pubKeyHex, err)
}
Expand Down
51 changes: 27 additions & 24 deletions cli/blossom/mirror.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,18 @@ import (
func newMirrorCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "mirror <source-url>",
Short: "Ask your Blossom server(s) to fetch and store a blob from a URL",
Short: "Mirror a blob from a URL onto your Blossom server(s)",
Long: `Sign a BUD-11 authorization and PUT /mirror to every target server
(--server, or the configured default list) -- each server fetches
source-url itself; no bytes pass through ncli. Reports a result per
server.`,
Example: ` ncli blossom mirror https://example.com/file.jpg --identity satoshi`,
Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return common.UsageError(cmd, fmt.Errorf("exactly one source URL is required"))
return common.InvocationOrHelp(cmd, args, fmt.Errorf("exactly one source URL is required"))
}
if identity, _ := cmd.Flags().GetString("identity"); identity == "" {
return common.UsageError(cmd, fmt.Errorf("--identity is required"))
return common.InvocationOrHelp(cmd, args, fmt.Errorf("--identity is required"))
}
return nil
},
Expand Down Expand Up @@ -67,30 +67,33 @@ server.`,
hc := newHTTPClient(timeout)

report := &fanoutReport{}
for _, server := range servers {
res := serverResult{Item: sourceURL, Server: server}
_ = common.WithSpinner(cmd, fmt.Sprintf("mirroring to %d server(s)", len(servers)), func() error {
for _, server := range servers {
res := serverResult{Item: sourceURL, Server: server}

// Signed fresh per server, not once for the whole batch --
// see the identical comment in upload.go.
auth, err := buildAuth(privKeyHex, pubKeyHex, nipB7.VerbUpload, hashes, ttl)
if err != nil {
res.Error = err.Error()
report.add(res)
continue
}
// Signed fresh per server, not once for the whole batch --
// see the identical comment in upload.go.
auth, err := buildAuth(privKeyHex, pubKeyHex, nipB7.VerbUpload, hashes, ttl)
if err != nil {
res.Error = err.Error()
report.add(res)
continue
}

descriptor, err := hc.Mirror(ctx, server, sourceURL, auth)
if err != nil {
res.Error = describeError(err) + uploadErrorHint
} else {
res.OK = true
res.URL = descriptor.URL
res.Sha256 = descriptor.Sha256
res.Size = descriptor.Size
res.Type = descriptor.Type
descriptor, err := hc.Mirror(ctx, server, sourceURL, auth)
if err != nil {
res.Error = describeError(err) + uploadErrorHint
} else {
res.OK = true
res.URL = descriptor.URL
res.Sha256 = descriptor.Sha256
res.Size = descriptor.Size
res.Type = descriptor.Type
}
report.add(res)
}
report.add(res)
}
return nil
})

printFanoutReport(jsonMode, report)
if !report.allSucceeded() {
Expand Down
11 changes: 7 additions & 4 deletions cli/blossom/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,16 @@ server: --server, or the first configured default.`,
Example: ` ncli blossom report <hash> --identity satoshi`,
Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return common.UsageError(cmd, fmt.Errorf("exactly one hash is required"))
return common.InvocationOrHelp(cmd, args, fmt.Errorf("exactly one hash is required"))
}
if !nipB7.IsSHA256Hex(args[0]) {
return common.InvalidInputError(cmd, args[0], fmt.Errorf("not a valid sha256 hash"))
}
if err := cmd.ValidateRequiredFlags(); err != nil {
return common.UsageError(cmd, err)
return common.InvocationOrHelp(cmd, args, err)
}
if identity, _ := cmd.Flags().GetString("identity"); identity == "" {
return common.UsageError(cmd, fmt.Errorf("--identity is required"))
return common.InvocationOrHelp(cmd, args, fmt.Errorf("--identity is required"))
}
return nil
},
Expand Down Expand Up @@ -65,7 +65,10 @@ server: --server, or the first configured default.`,
timeout, _ := cmd.Flags().GetDuration("timeout")
hc := newHTTPClient(timeout)

if err := hc.Report(ctx, server, event); err != nil {
err = common.WithSpinner(cmd, fmt.Sprintf("reporting %s to %s", shortHash(hash), server), func() error {
return hc.Report(ctx, server, event)
})
if err != nil {
return classifyHTTPError(cmd, server, err)
}

Expand Down
Loading
Loading