diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..2b4dee0 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,9 @@ +## 2024-03-24 - Insecure Default File Permissions +**Vulnerability:** The CLI application creates sensitive configuration files and directories (like wallets and snapshot data) using standard `fs::create_dir_all` and `fs::write` in Rust. These standard functions create files/directories using the system's default umask, which typically allows other users on the same Unix-like system to read the sensitive files. +**Learning:** This could lead to a local privilege escalation or exposure of sensitive user data if the user runs the CLI on a shared machine. Relying on default system configurations for sensitive files is unsafe. +**Prevention:** Always use `std::os::unix::fs::DirBuilderExt` and `std::os::unix::fs::OpenOptionsExt` to explicitly set file permissions (e.g., `0o700` for directories and `0o600` for files) when creating sensitive data on disk. + +## 2024-05-24 - Command Injection via Configured `bitcoin_cli` Binary +**Vulnerability:** The application allowed arbitrary command execution by reading the `bitcoin_cli` command to run from a user-provided profile configuration and executing it directly with `std::process::Command::new` without validating the binary name. +**Learning:** Profile configurations or configuration files can often be manipulated by users. Trusting arbitrary paths or commands specified in these files can lead to remote code execution (RCE) or local privilege escalation if the application is run with elevated privileges. +**Prevention:** Implement a strict whitelist on the binary name allowed to be executed when the binary path is sourced from user configuration or input. Always extract the base filename and compare it against the expected executable name (e.g., `bitcoin-cli` or `bitcoin-cli.exe`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b124db..c2993d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,42 @@ All notable changes to this project will be documented in this file. - No changes yet. +## [0.3.0] - 2026-03-27 + +### Changed +- Removed `quiet` mode from CLI surface and config plumbing: + - removed `--quiet` global flag, + - removed `ZINC_CLI_QUIET` env handling, + - removed persisted config key `quiet`, + - removed setup `--quiet-default`. +- Updated command contract/docs to reflect the current global flag set. +- Improved wallet/account config resolution and wallet-info consistency with effective runtime network/scheme values. +- Improved wallet-info recency presentation in human output. + +### Fixed +- Fixed account-switch address preview paths to use receive index `0` consistently. +- Refined offer/PSBT input-source handling and stdin conflict validation. +- Updated dependency pin to `zinc-core = =0.1.2`. + +## [0.2.1] - 2026-03-26 + +### Fixed +- Updated `zinc-core` dependency pin to `=0.1.1` for compatibility with current CLI features. +- Removed local `path` dependency override for release packaging so `cargo package`/`cargo publish` resolve from crates.io in CI. + +## [0.2.0] - 2026-03-26 + +### Added +- ANSI logo asset and branded `version` command output. + +### Changed +- `inscription list` now shows newest received inscriptions first. +- `offer` commands remain available via `zinc-cli offer ...` but are hidden from top-level help output. +- Improved human-output inscription thumbnail rendering fidelity in terminal-friendly output modes. + +### Docs +- Aligned README/usage/contract/schema docs with the current CLI surface, including `--agent` output mode and thumbnail toggles (`--thumb`, `--no-thumb`). + ## [0.1.1] - 2026-03-21 ### Fixed @@ -15,7 +51,7 @@ All notable changes to this project will be documented in this file. ### Added - Initial standalone public packaging for `zinc-cli`. -- Human-friendly command output plus stable `--json` agent envelope. +- Human-friendly command output plus stable agent envelope. - Wallet profile management with profile lock and atomic writes. - PSBT create/analyze/sign/broadcast command family. - Snapshot, account switching, and diagnostic command support. diff --git a/COMMAND_CONTRACT_V1.md b/COMMAND_CONTRACT_V1.md index 222d2d0..1442278 100644 --- a/COMMAND_CONTRACT_V1.md +++ b/COMMAND_CONTRACT_V1.md @@ -8,25 +8,24 @@ This document defines the active `v1` command contract. It is additive with curr ## 1) Design Goals 1. One wallet engine and one command model for both humans and agents. -2. Stable machine-readable responses in `--json` mode. +2. Stable machine-readable responses in `--agent` mode. 3. Strict, typed error taxonomy for automation reliability. 4. Backward-compatible rollout from current `SCHEMAS.md` behavior. ## 2) Execution Profiles 1. Human profile -- CLI called without `--json`. -- Output may be user-friendly text. +- Default output mode (`ZINC_CLI_OUTPUT=human`). +- Output is a curated, styled, user-friendly presentation. 2. Agent profile -- CLI called with `--agent` (preferred) or `--json`. -- `--agent` implies `--json --quiet --ascii`. +- CLI called with `--agent` or `ZINC_CLI_OUTPUT=agent`. - Exactly one JSON object on `stdout` per invocation. - Non-JSON noise must not be printed to `stdout`. ## 3) Global Flags (Supported) -`--json`, `--agent`, `--quiet`, `--yes`, `--password`, `--password-env`, `--password-stdin`, `--reveal`, `--data-dir`, `--profile`, `--network`, `--scheme`, `--esplora-url`, `--ord-url`, `--ascii`, `--no-images`, `--correlation-id`, `--log-json`, `--idempotency-key`, `--network-timeout-secs`, `--network-retries`, `--policy-mode` +`--agent`, `--yes`, `--password`, `--password-env`, `--password-stdin`, `--reveal`, `--data-dir`, `--profile`, `--network`, `--scheme`, `--esplora-url`, `--ord-url`, `--ascii`, `--no-images`, `--thumb`, `--no-thumb`, `--correlation-id`, `--log-json`, `--idempotency-key`, `--network-timeout-secs`, `--network-retries`, `--policy-mode` Global flags are supported both before and after command tokens. @@ -38,6 +37,11 @@ Reliability defaults: - `--network-retries` defaults to `0`. - `--policy-mode` defaults to `warn`. +Human-output defaults: +- Thumbnails are enabled by default in human mode. +- `--no-thumb` or `--no-images` disables thumbnails. +- In `--agent` mode thumbnails are disabled by default unless `--thumb` is set. + ## 4) JSON Envelope ## Success @@ -117,7 +121,7 @@ Idempotency for mutating commands: ## 7) Command Contracts -All commands below describe `--json` response payloads. +All commands below describe `--agent` response payloads. ## 7.1 wallet init @@ -333,7 +337,7 @@ Command: `scenario mine [--blocks N] [--address ]` Success fields: -`action`, `blocks`, `address`, `raw` +`blocks`, `address`, `raw_output` ## 7.24 scenario fund @@ -341,7 +345,7 @@ Command: `scenario fund [--amount-btc ] [--address ] [--mine-blocks N]` Success fields: -`action`, `address`, `amount_btc`, `txid`, `mine_blocks`, `mine_address`, `generated_blocks` +`address`, `amount_btc`, `txid`, `mine_blocks`, `mine_address`, `generated_blocks` ## 7.25 scenario reset @@ -349,7 +353,7 @@ Command: `scenario reset [--remove-profile] [--remove-snapshots]` Success fields: -`action`, `removed` +`removed` ## 7.26 doctor @@ -357,13 +361,7 @@ Command: `doctor` Success fields: -`healthy`, `esplora`, `ord` - -`esplora` shape: -`{ url: string, reachable: bool }` - -`ord` shape: -`{ url: string, reachable: bool, indexing_height: u32 | null, error: string | null }` +`healthy`, `esplora_url`, `esplora_reachable`, `ord_url`, `ord_reachable`, `ord_indexing_height`, `ord_error` ## 7.27 offer create @@ -376,7 +374,7 @@ Notes: - `--seller-payout-address` overrides payout destination output while preserving seller input metadata from ord inscription output. Success fields: -`inscription`, `seller_address`, `seller_outpoint`, `postage_sats`, `ask_sats`, `fee_rate_sat_vb`, `seller_input_index`, `buyer_input_count`, `psbt`, `offer`, `submitted_ord`, `ord_url` +`inscription`, `ask_sats`, `fee_rate_sat_vb`, `seller_address`, `seller_outpoint`, `seller_pubkey_hex`, `expires_at_unix`, `thumbnail_lines?`, `hide_inscription_ids`, `raw_response` ## 7.28 offer publish @@ -384,7 +382,7 @@ Command: `offer publish [--offer-json | --offer-file | --offer-stdin] --secret-key-hex --relay ... [--created-at-unix ] [--timeout-ms N]` Success fields: -`event`, `publish_results`, `accepted_relays`, `total_relays` +`event_id`, `accepted_relays`, `total_relays`, `publish_results`, `raw_response` ## 7.29 offer discover @@ -392,7 +390,7 @@ Command: `offer discover --relay ... [--limit N] [--timeout-ms N]` Success fields: -`events`, `offers`, `event_count`, `offer_count` +`event_count`, `offer_count`, `offers`, `thumbnail_lines?`, `hide_inscription_ids`, `raw_response` ## 7.30 offer submit-ord @@ -400,7 +398,7 @@ Command: `offer submit-ord [--psbt | --psbt-file | --psbt-stdin]` Success fields: -`submitted`, `ord_url` +`ord_url`, `submitted`, `raw_response` ## 7.31 offer list-ord @@ -408,7 +406,7 @@ Command: `offer list-ord` Success fields: -`ord_url`, `offers`, `count` +`ord_url`, `count`, `offers`, `raw_response` ## 7.32 offer accept @@ -416,7 +414,7 @@ Command: `offer accept [--offer-json | --offer-file | --offer-stdin] [--expect-inscription ] [--expect-ask-sats ] [--dry-run]` Success fields: -`accepted`, `dry_run`, `offer_id`, `seller_input_index`, `input_count`, `inscription_id`, `ask_sats`, `safe_to_send`, `inscription_risk`, `policy_reasons`, `analysis`, `txid?` +`inscription`, `ask_sats`, `txid`, `dry_run`, `inscription_risk`, `thumbnail_lines?`, `hide_inscription_ids`, `raw_response` ## 8) Input Source Rules (PSBT and Offer Commands) @@ -455,7 +453,7 @@ When `--policy-mode strict` is set, `psbt sign`, `psbt broadcast`, and `offer ac ## 10) Security Contract for Agent Usage -1. Agents should always use `--json`. +1. Agents should always use `--agent`. 2. Agents should prefer password env variables over plaintext flags. 3. Policy/ordinals failures must be surfaced as `error.type = "policy"` with actionable messages. 4. Commands that mutate wallet state should remain explicit and single-purpose. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 029af70..8734bac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ cargo doc -p zinc-core --no-deps 1. Publish `zinc-core` to crates.io first. 2. Update `Cargo.toml` to an exact `zinc-core` pin: - - `zinc-core = { version = "=X.Y.Z", path = "../zinc-core-public" }` + - `zinc-core = { version = "=X.Y.Z" }` 3. Push a `zinc-cli` release tag. 4. Ensure tag CI passes: - exact pin validation succeeds, diff --git a/Cargo.toml b/Cargo.toml index 6667bd6..be56dfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zinc-wallet-cli" -version = "0.1.1" +version = "0.3.0" edition = "2021" rust-version = "1.88" license = "MIT" @@ -52,7 +52,6 @@ ui = [ "dep:figlet-rs", "dep:rand", "dep:bip39", - "dep:image", ] [dependencies] @@ -77,10 +76,12 @@ tui-big-text = { version = "0.8.2", optional = true } figlet-rs = { version = "0.1.5", optional = true } supports-unicode = "3.0.0" -zinc-core = { version = "=0.1.0", path = "../zinc-core-public" } +zinc-core = { version = "=0.1.2" } rand = { version = "0.8", optional = true } bip39 = { version = "2.1.0", optional = true } -image = { version = "0.25.10", features = ["avif", "webp"], optional = true } +image = { version = "0.25.10", features = ["avif", "webp"] } +console = "0.15" +viuer = "0.11.0" diff --git a/README.md b/README.md index dbf373b..91087ed 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ It uses an account-based model where each account has: - a native segwit address for BTC payments Default behavior is optimized for automation (`--agent` JSON envelopes). -Optional human mode is available via `--features ui`. +Optional interactive dashboard mode is available via `--features ui`. ## Install @@ -62,6 +62,10 @@ The `ui` feature enables a basic terminal dashboard for humans that shows: - inscriptions - ordinals/payment addresses per account +Even without `ui`, human command output can be tuned with: +- `--thumb` to force thumbnail rendering on +- `--no-thumb` to disable thumbnail rendering + Run dashboard: ```bash @@ -83,7 +87,7 @@ Reports are written to `demo/artifacts/`. - sync: `sync chain|ordinals` - addresses and balance: `address taproot|payment`, `balance` - transfers: `psbt create|analyze|sign|broadcast` -- offers: `offer create|publish|discover|accept|submit-ord|list-ord` +- advanced offers (hidden from top-level help): `offer create|publish|discover|accept|submit-ord|list-ord` - accounts: `account list|use` - waits and tx: `wait tx-confirmed|balance`, `tx list` - operations: `snapshot save|restore|list`, `lock info|clear`, `doctor` @@ -101,7 +105,7 @@ Reports are written to `demo/artifacts/`. - Prefer `ZINC_WALLET_PASSWORD` (default password env) or `--password-stdin`. - Use `--password-env` only when you need a non-default env var name. -- In `--json` mode, mnemonic output is redacted unless `--reveal` is set. +- In `--agent` mode, mnemonic output is redacted unless `--reveal` is set. - See [SECURITY.md](./SECURITY.md). ## License diff --git a/SCHEMAS.md b/SCHEMAS.md index af4c4ff..f2ca6b2 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -1,6 +1,6 @@ # zinc-cli JSON Schemas (v1.0) -All commands support `--json` and emit exactly one JSON object to `stdout`. +All commands support `--agent` and emit exactly one JSON object to `stdout`. For full contract details (typing, compatibility, and command policy), see `COMMAND_CONTRACT_V1.md`. For task-oriented command examples (human + agent), see `USAGE.md`. @@ -81,47 +81,46 @@ Optional output files: - `offer create`: - `inscription` - - `seller_address` - - `seller_outpoint` - - `postage_sats` - `ask_sats` - `fee_rate_sat_vb` - - `seller_input_index` - - `buyer_input_count` - - `psbt` - - `offer` (offer envelope) - - `submitted_ord` - - `ord_url` + - `seller_address` + - `seller_outpoint` + - `seller_pubkey_hex` + - `expires_at_unix` + - `thumbnail_lines` (optional) + - `hide_inscription_ids` + - `raw_response` (canonical full response payload) - `offer publish`: - - `event` (signed nostr event) - - `publish_results` (per-relay acceptance/message rows) + - `event_id` - `accepted_relays` (count) - `total_relays` (count) + - `publish_results` (per-relay acceptance/message rows) + - `raw_response` - `offer discover`: - - `events` (decoded nostr events) - - `offers` (decoded offer envelopes with event metadata) - `event_count` - `offer_count` + - `offers` + - `thumbnail_lines` (optional) + - `hide_inscription_ids` + - `raw_response` - `offer submit-ord`: - - `submitted` (bool) - `ord_url` + - `submitted` (bool) + - `raw_response` - `offer list-ord`: - `ord_url` - - `offers` (array of base64 PSBT strings) - `count` + - `offers` + - `raw_response` - `offer accept`: - - `accepted` (bool) - - `dry_run` (bool) - - `offer_id` - - `seller_input_index` - - `input_count` - - `inscription_id` + - `inscription` - `ask_sats` - - `safe_to_send` + - `txid` + - `dry_run` (bool) - `inscription_risk` - - `policy_reasons` - - `analysis` - - `txid` (present when `dry_run=false`) + - `thumbnail_lines` (optional) + - `hide_inscription_ids` + - `raw_response` Input modes and rules: @@ -145,9 +144,9 @@ Input modes and rules: ## Account/Wait/Snapshot - `account list`: `accounts` -- `account use`: `account_index` -- `wait tx-confirmed`: `txid`, `confirmation_time` -- `wait balance`: `confirmed` +- `account use`: `previous_account_index`, `account_index`, `taproot_address`, `payment_address?` +- `wait tx-confirmed`: `txid`, `confirmation_time`, `confirmed`, `waited_secs` +- `wait balance`: `confirmed`, `confirmed_balance`, `target`, `waited_secs` - `snapshot save`: `snapshot` - `snapshot restore`: `restored` - `snapshot list`: `snapshots` @@ -156,13 +155,17 @@ Input modes and rules: ## Scenario (Regtest) -- `scenario mine`: `action`, `blocks`, `address`, `raw` -- `scenario fund`: `action`, `address`, `amount_btc`, `txid`, `mine_blocks`, `mine_address`, `generated_blocks` -- `scenario reset`: `action`, `removed` (paths) +- `scenario mine`: `blocks`, `address`, `raw_output` +- `scenario fund`: `address`, `amount_btc`, `txid`, `mine_blocks`, `mine_address`, `generated_blocks` +- `scenario reset`: `removed` (paths) ## Doctor - `doctor`: - `healthy` - - `esplora` (`url`, `reachable`) - - `ord` (`url`, `reachable`, `indexing_height`, `error`) + - `esplora_url` + - `esplora_reachable` + - `ord_url` + - `ord_reachable` + - `ord_indexing_height` + - `ord_error` diff --git a/USAGE.md b/USAGE.md index c7f7023..3b1157f 100644 --- a/USAGE.md +++ b/USAGE.md @@ -15,28 +15,28 @@ With `ui` enabled, the basic dashboard shows account balance, inscriptions, and Useful globals: -- `--json` machine output mode (recommended for agents) -- `--agent` shorthand for `--json --quiet --ascii` +- `--agent` machine output mode (returns structured JSON) - `--profile ` select profile (default: `default`) - `--data-dir ` override data root - `--password ` - `--password-env ` (default env: `ZINC_WALLET_PASSWORD`) - `--password-stdin` -- `--reveal` show mnemonic fields in `--json` mode, and on `wallet import` +- `--reveal` show mnemonic fields in `--agent` mode, and on `wallet import` - `--correlation-id ` set a stable workflow/request identifier - `--log-json` emit structured lifecycle logs to stderr (`command_start|command_finish|command_error`) - `--idempotency-key ` de-duplicate mutating commands for retry-safe automation - `--network-timeout-secs ` timeout for remote calls (default: `30`) - `--network-retries ` retry count for transient network failures/timeouts (default: `0`) - `--policy-mode warn|strict` transaction safety behavior (default: `warn`) +- `--thumb` force inscription thumbnails on +- `--no-thumb` disable inscription thumbnails Environment defaults (optional): - `ZINC_CLI_PROFILE` - `ZINC_CLI_DATA_DIR` - `ZINC_CLI_PASSWORD_ENV` -- `ZINC_CLI_JSON` (`1|true|yes|on`) -- `ZINC_CLI_QUIET` (`1|true|yes|on`) +- `ZINC_CLI_OUTPUT` (`human|agent`) - `ZINC_CLI_NETWORK` - `ZINC_CLI_SCHEME` - `ZINC_CLI_ESPLORA_URL` @@ -51,7 +51,7 @@ Environment defaults (optional): Inspect effective config: ```bash -zinc-cli --json config show +zinc-cli --agent config show ``` Persist config defaults: @@ -94,7 +94,7 @@ zinc-cli wallet info Reveal seed phrase (sensitive): ```bash -zinc-cli --yes wallet reveal-mnemonic +zinc-cli --yes --agent wallet reveal-mnemonic ``` Sync and check balance: @@ -103,6 +103,7 @@ Sync and check balance: zinc-cli sync chain zinc-cli sync ordinals zinc-cli balance +zinc-cli inscription list ``` Get addresses: @@ -116,7 +117,7 @@ zinc-cli address payment ## 3) Agent Mode (Recommended) -Use `--agent` (or `--json`) and parse stdout as one JSON object. +Use `--agent` and parse stdout as one JSON object. ```bash zinc-cli --agent wallet info @@ -212,7 +213,9 @@ Rules: - For `psbt analyze/sign/broadcast`, exactly one of `--psbt`, `--psbt-file`, `--psbt-stdin` is required. - `--password-stdin` cannot be combined with `--psbt-stdin` in one invocation. -## 6) Offer Commands (Nostr + Ord) +## 6) Offer Commands (Nostr + Ord, Advanced) + +`offer` is callable directly (`zinc-cli offer ...`) but intentionally hidden from top-level `zinc-cli --help`. Create an ord-compatible buyer offer PSBT and a relay-ready offer envelope: @@ -247,6 +250,25 @@ zinc-cli --agent offer publish \ --relay wss://nostr.example ``` +Human-focused, glanceable offer output (great for demos): + +```bash +zinc-cli --ord-url https://ord.example --thumb offer create \ + --inscription \ + --amount 100000 \ + --fee-rate 1 +``` + +```bash +zinc-cli --ord-url https://ord.example --thumb offer discover \ + --relay wss://nostr.example +``` + +```bash +zinc-cli --ord-url https://ord.example --thumb offer accept \ + --offer-file /tmp/offer.json +``` + Discover offers from one or more relays: ```bash @@ -297,6 +319,9 @@ Rules: - For dual-scheme sellers, pass `--seller-payout-address ` to direct proceeds to the seller payment branch. - `offer create --publisher-pubkey-hex` can override the default publisher pubkey embedded in the offer envelope. - `offer publish` and `offer discover` require at least one `--relay`. +- `--thumb` and `--no-thumb` are boolean toggles. +- In human mode, thumbnails are enabled by default unless `--no-thumb` or `--no-images` is set. +- In `--agent` mode, thumbnails are disabled by default unless `--thumb` is explicitly set. ## 7) Profiles, Accounts, and Waits @@ -323,8 +348,8 @@ zinc-cli --agent wait tx-confirmed --txid --timeout-secs 300 - Prefer setting `ZINC_WALLET_PASSWORD` once for automation. - Use `--password-env` only when you need a non-default env var name. - Avoid `--password` in shared process environments. -- `wallet init` in human mode prints the new seed phrase once; in `--json` mode mnemonic output is redacted unless `--reveal` is set. -- In `--json` mode, consume stdout as machine data and treat stderr as diagnostics only. +- `wallet init` in human mode prints the new seed phrase once; in `--agent` mode mnemonic output is redacted unless `--reveal` is set. +- In `--agent` mode, consume stdout as machine data and treat stderr as diagnostics only. ## 9) Agent Flow Integration Test diff --git a/assets/zinc-cli-logo.ans b/assets/zinc-cli-logo.ans new file mode 100644 index 0000000..f49eb69 --- /dev/null +++ b/assets/zinc-cli-logo.ans @@ -0,0 +1,21 @@ +[?25l   +   +   +   +  ▗▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄   +  ▗▘ ╴▂▄▗▘  +   ▃▁ ▂▄▆ ▗▘  +   ▆▃▁ ▁▄▆╴ ▗   +   ▆▄▃▆╴ ▗   +   ▘ ▗   +   ▘ ╴▗  +  ▗▘ ▁▃▝▄▂  +  ▗▇ ╴▃▅▇ ╴▆▄▂  +  ▗╴╴▂▅▇ ▇▅  ▃▃▃▃   +  ▗▂▅▇ ╴▄  ▏ ▏  +   ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▘ ▄▄▄▄   +   +   +   +   +[?25h \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index 9d8e2e4..c2e2f58 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -13,25 +13,19 @@ pub enum PolicyMode { #[command( name = "zinc-cli", version, - about = "CLI wallet for Zinc Bitcoin + Ordinals workflows" + about = "CLI wallet for Zinc Bitcoin + Ordinals" )] pub struct Cli { #[command(subcommand)] pub command: Command, - #[arg(long, global = true, help = "Output exactly as JSON")] - pub json: bool, - #[arg( long, global = true, - help = "Agent mode (implies --json --quiet --ascii)" + help = "Agent mode (machine-readable JSON output)" )] pub agent: bool, - #[arg(long, global = true, help = "Suppress all non-error output")] - pub quiet: bool, - #[arg(long, global = true, help = "Automatically say yes to prompts")] pub yes: bool, @@ -78,6 +72,13 @@ pub struct Cli { #[arg(long, global = true, help = "Force ASCII fallback mode")] pub ascii: bool, + #[arg( + long, + global = true, + help = "Show inscription thumbnails (auto-detects best rendering)" + )] + pub thumb: bool, + #[arg( long, global = true, @@ -115,6 +116,9 @@ pub struct Cli { )] pub network_retries: u32, + #[arg(long, global = true, help = "Disable inscription thumbnails")] + pub no_thumb: bool, + #[arg( long, global = true, @@ -131,6 +135,18 @@ pub struct Cli { pub started_at_unix_ms: u128, } +impl Cli { + pub fn thumb_enabled(&self) -> bool { + if self.no_thumb || self.no_images { + false + } else if self.thumb { + true + } else { + !self.agent + } + } +} + #[derive(Subcommand, Debug, Clone)] pub enum Command { Setup(SetupArgs), @@ -141,6 +157,7 @@ pub enum Command { Balance, Tx(TxArgs), Psbt(PsbtArgs), + #[command(hide = true)] Offer(OfferArgs), Account(AccountArgs), Wait(WaitArgs), @@ -148,6 +165,7 @@ pub enum Command { Lock(LockArgs), Scenario(ScenarioArgs), Inscription(InscriptionArgs), + Version, #[cfg(feature = "ui")] Dashboard, Doctor, @@ -170,10 +188,6 @@ pub struct SetupArgs { #[arg(long)] pub default_ord_url: Option, #[arg(long)] - pub json_default: Option, - #[arg(long)] - pub quiet_default: Option, - #[arg(long)] pub restore_mnemonic: Option, #[arg(long)] pub words: Option, @@ -517,6 +531,37 @@ mod tests { use super::{Cli, Command, OfferAction}; use clap::Parser; + #[test] + fn parses_global_thumb_switch() { + let cli = Cli::try_parse_from([ + "zinc-cli", + "--thumb", + "offer", + "discover", + "--relay", + "wss://relay.example", + ]) + .expect("cli parse"); + + assert!(cli.thumb_enabled()); + } + + #[test] + fn defaults_global_thumb_switch() { + let cli = Cli::try_parse_from([ + "zinc-cli", + "offer", + "discover", + "--relay", + "wss://relay.example", + ]) + .expect("cli parse"); + + // Should default to true because output mode is human by default + assert!(cli.thumb_enabled()); + assert!(!cli.no_thumb); + } + #[test] fn parses_offer_publish_subcommand() { let cli = Cli::try_parse_from([ diff --git a/src/commands/account.rs b/src/commands/account.rs index 8a323ec..77b6001 100644 --- a/src/commands/account.rs +++ b/src/commands/account.rs @@ -2,21 +2,15 @@ use crate::cli::{AccountAction, AccountArgs, Cli}; use crate::error::AppError; use crate::wallet_service::{now_unix, AccountState}; use crate::{load_wallet_session, profile_path, read_profile, write_profile}; -use serde_json::{json, Value}; +use crate::output::CommandOutput; use zinc_core::Account; -pub async fn run(cli: &Cli, args: &AccountArgs) -> Result { +pub async fn run(cli: &Cli, args: &AccountArgs) -> Result { match &args.action { AccountAction::List { count } => { let session = load_wallet_session(cli)?; let accounts: Vec = session.wallet.get_accounts(count.unwrap_or(20)); - if cli.json { - Ok(json!({"accounts": accounts})) - } else { - let table = crate::presenter::account::format_accounts(&accounts); - println!("{table}"); - Ok(Value::Null) - } + Ok(CommandOutput::AccountList { accounts }) } AccountAction::Use { index } => { let path = profile_path(cli)?; @@ -31,18 +25,19 @@ pub async fn run(cli: &Cli, args: &AccountArgs) -> Result { write_profile(&path, &profile)?; let session = load_wallet_session(cli)?; - let taproot_addr = session.wallet.peek_taproot_address(*index).to_string(); + // Display the first receive address for the active account. + let taproot_addr = session.wallet.peek_taproot_address(0).to_string(); let payment_addr = session .wallet - .peek_payment_address(*index) + .peek_payment_address(0) .map(|s| s.to_string()); - Ok(json!({ - "previous_account_index": old_index, - "account_index": index, - "taproot_address": taproot_addr, - "payment_address": payment_addr, - })) + Ok(CommandOutput::AccountUse { + previous_account_index: old_index, + account_index: *index, + taproot_address: taproot_addr, + payment_address: payment_addr, + }) } } } diff --git a/src/commands/address.rs b/src/commands/address.rs index dfd420b..03ec922 100644 --- a/src/commands/address.rs +++ b/src/commands/address.rs @@ -2,9 +2,9 @@ use crate::cli::{AddressArgs, AddressKind, Cli}; use crate::error::AppError; use crate::wallet_service::map_wallet_error; use crate::{load_wallet_session, persist_wallet_session}; -use serde_json::{json, Value}; +use crate::output::CommandOutput; -pub async fn run(cli: &Cli, args: &AddressArgs) -> Result { +pub async fn run(cli: &Cli, args: &AddressArgs) -> Result { let mut session = load_wallet_session(cli)?; let (kind, address) = match &args.kind { AddressKind::Taproot { index, new } => { @@ -44,11 +44,8 @@ pub async fn run(cli: &Cli, args: &AddressArgs) -> Result { } }; persist_wallet_session(&mut session)?; - if cli.json { - Ok(json!({"type": kind, "address": address})) - } else { - let table = crate::presenter::address::format_address(kind, &address); - println!("{table}"); - Ok(Value::Null) - } + Ok(CommandOutput::Address { + kind: kind.to_string(), + address, + }) } diff --git a/src/commands/balance.rs b/src/commands/balance.rs index febf023..83d5a9f 100644 --- a/src/commands/balance.rs +++ b/src/commands/balance.rs @@ -1,31 +1,25 @@ use crate::cli::Cli; use crate::error::AppError; use crate::load_wallet_session; -use serde_json::{json, Value}; +use crate::output::{BtcBalance, CommandOutput}; -pub async fn run(cli: &Cli) -> Result { +pub async fn run(cli: &Cli) -> Result { let session = load_wallet_session(cli)?; let balance = session.wallet.get_balance(); - if cli.json { - Ok(json!({ - "total": { - "immature": balance.total.immature.to_sat(), - "trusted_pending": balance.total.trusted_pending.to_sat(), - "untrusted_pending": balance.total.untrusted_pending.to_sat(), - "confirmed": balance.total.confirmed.to_sat() - }, - "spendable": { - "immature": balance.spendable.immature.to_sat(), - "trusted_pending": balance.spendable.trusted_pending.to_sat(), - "untrusted_pending": balance.spendable.untrusted_pending.to_sat(), - "confirmed": balance.spendable.confirmed.to_sat() - }, - "inscribed_sats": balance.inscribed - })) - } else { - let table = crate::presenter::balance::format_balance(&balance); - println!("{table}"); - Ok(Value::Null) - } + Ok(CommandOutput::Balance { + total: BtcBalance { + immature: balance.total.immature.to_sat(), + trusted_pending: balance.total.trusted_pending.to_sat(), + untrusted_pending: balance.total.untrusted_pending.to_sat(), + confirmed: balance.total.confirmed.to_sat(), + }, + spendable: BtcBalance { + immature: balance.spendable.immature.to_sat(), + trusted_pending: balance.spendable.trusted_pending.to_sat(), + untrusted_pending: balance.spendable.untrusted_pending.to_sat(), + confirmed: balance.spendable.confirmed.to_sat(), + }, + inscribed_sats: balance.inscribed, + }) } diff --git a/src/commands/config.rs b/src/commands/config.rs index ad2bd6d..f531192 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -4,10 +4,11 @@ use crate::config::{ unset_config_field, ConfigField, }; use crate::error::AppError; -use serde_json::{json, Value}; +use crate::output::CommandOutput; +use serde_json::json; /// `zinc config {show|set|unset}` — manage persisted CLI defaults. -pub async fn run(cli: &Cli, args: &ConfigArgs) -> Result { +pub async fn run(cli: &Cli, args: &ConfigArgs) -> Result { match &args.action { ConfigAction::Show => { let config = load_persisted_config()?; @@ -15,8 +16,7 @@ pub async fn run(cli: &Cli, args: &ConfigArgs) -> Result { "profile": cli.profile.as_ref().or(config.profile.as_ref()).cloned().unwrap_or_else(|| "default".to_string()), "data_dir": cli.data_dir.as_ref().map(|p| p.display().to_string()).or(config.data_dir.clone()).unwrap_or_else(|| "~/.zinc-cli".to_string()), "password_env": cli.password_env.as_ref().or(config.password_env.as_ref()).cloned().unwrap_or_else(|| "ZINC_WALLET_PASSWORD".to_string()), - "json": cli.json || config.json.unwrap_or(false), - "quiet": cli.quiet || config.quiet.unwrap_or(false), + "agent": cli.agent, "defaults": { "network": config.network.clone(), "scheme": config.scheme.clone(), @@ -25,35 +25,34 @@ pub async fn run(cli: &Cli, args: &ConfigArgs) -> Result { }, "path": persisted_config_path().display().to_string(), }); - if cli.json { - Ok(res) - } else { - let table = crate::presenter::config::format_config(&res); - println!("{table}"); - Ok(res) - } + Ok(CommandOutput::ConfigShow { config: res }) } ConfigAction::Set { key, value } => { let mut config = load_persisted_config()?; let field = ConfigField::parse(key)?; let applied = set_config_field(&mut config, field, value)?; save_persisted_config(&config)?; - Ok(json!({ - "key": field.as_str(), - "value": applied, - "saved": true, - })) + Ok(CommandOutput::ConfigSet { + key: field.as_str().to_string(), + value: applied + .as_str() + .unwrap_or("") + .to_string() + .replace("\"", "") + .to_string(), + saved: true, + }) } ConfigAction::Unset { key } => { let mut config = load_persisted_config()?; let field = ConfigField::parse(key)?; let was_set = unset_config_field(&mut config, field); save_persisted_config(&config)?; - Ok(json!({ - "key": field.as_str(), - "was_set": was_set, - "saved": true, - })) + Ok(CommandOutput::ConfigUnset { + key: field.as_str().to_string(), + was_set, + saved: true, + }) } } } diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index a4749c5..c21ac71 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -1,10 +1,10 @@ use crate::cli::Cli; use crate::error::AppError; use crate::{profile_path, read_profile}; -use serde_json::{json, Value}; +use crate::output::CommandOutput; use zinc_core::{OrdClient, ZincWallet}; -pub async fn run(cli: &Cli) -> Result { +pub async fn run(cli: &Cli) -> Result { let profile = read_profile(&profile_path(cli)?)?; let esplora_ok = ZincWallet::check_connection(&profile.esplora_url).await; let ord_height_result = OrdClient::new(profile.ord_url.clone()) @@ -16,17 +16,13 @@ pub async fn run(cli: &Cli) -> Result { Err(err) => (false, None, Some(err.to_string())), }; - Ok(json!({ - "healthy": esplora_ok && ord_ok, - "esplora": { - "url": profile.esplora_url, - "reachable": esplora_ok - }, - "ord": { - "url": profile.ord_url, - "reachable": ord_ok, - "indexing_height": ord_height, - "error": ord_error - } - })) + Ok(CommandOutput::Doctor { + healthy: esplora_ok && ord_ok, + esplora_url: profile.esplora_url.clone(), + esplora_reachable: esplora_ok, + ord_url: profile.ord_url.clone(), + ord_reachable: ord_ok, + ord_indexing_height: ord_height.map(|h| h as u64), + ord_error, + }) } diff --git a/src/commands/inscription.rs b/src/commands/inscription.rs index e779014..01bd0d7 100644 --- a/src/commands/inscription.rs +++ b/src/commands/inscription.rs @@ -1,19 +1,129 @@ use crate::cli::{Cli, InscriptionArgs}; use crate::error::AppError; use crate::load_wallet_session; -use serde_json::{json, Value}; +use crate::output::{CommandOutput, InscriptionItemDisplay}; +use crate::presenter::thumbnail::render_non_image_badge; +use zinc_core::{ordinals::Inscription, OrdClient}; -pub async fn run(cli: &Cli, _args: &InscriptionArgs) -> Result { +pub async fn run(cli: &Cli, _args: &InscriptionArgs) -> Result { let session = load_wallet_session(cli)?; - let inscriptions = session.wallet.inscriptions(); + let sorted_inscriptions = sort_inscriptions_latest_first(session.wallet.inscriptions()); - if cli.json { - Ok(json!({ - "inscriptions": inscriptions - })) + let display_items = if !cli.thumb_enabled() { + None } else { - let table = crate::presenter::inscription::format_inscriptions(&inscriptions); - println!("{table}"); - Ok(Value::Null) + Some(get_inscription_display_items( + &session.profile.ord_url, + &sorted_inscriptions, + ) + .await) + }; + + Ok(CommandOutput::InscriptionList { + inscriptions: sorted_inscriptions, + display_items, + thumb_mode_enabled: cli.thumb_enabled(), + }) +} + +fn sort_inscriptions_latest_first(inscriptions: &[Inscription]) -> Vec { + let mut sorted = inscriptions.to_vec(); + sorted.sort_by(|a, b| { + b.timestamp + .cmp(&a.timestamp) + .then_with(|| b.number.cmp(&a.number)) + .then_with(|| b.id.cmp(&a.id)) + }); + sorted +} + +async fn get_inscription_display_items( + ord_url: &str, + inscriptions: &[Inscription], +) -> Vec { + const MAX_VISIBLE: usize = 8; + let client = OrdClient::new(ord_url.to_string()); + let mut items = Vec::new(); + + for ins in inscriptions.iter().take(MAX_VISIBLE) { + let content_type = ins.content_type.as_deref().unwrap_or("unknown"); + let value = ins + .value + .map(|amount| amount.to_string()) + .unwrap_or_else(|| "-".to_string()); + + let mut badge_lines = Vec::new(); + let mut image_bytes = None; + + if content_type.starts_with("image/") { + match client.get_inscription_content(&ins.id).await { + Ok(content) => { + // Store raw bytes — viuer will render them during output. + image_bytes = Some(content.bytes); + } + Err(_) => { + badge_lines.push(format!( + "(thumbnail unavailable: {})", + crate::commands::offer::abbreviate(&ins.id, 12, 8) + )); + } + } + } else { + badge_lines.extend(render_non_image_badge(Some(content_type))); + } + + items.push(InscriptionItemDisplay { + number: ins.number, + id: ins.id.clone(), + value_sats: value, + content_type: content_type.to_string(), + badge_lines, + image_bytes, + }); + } + + items +} + +#[cfg(test)] +mod tests { + use super::sort_inscriptions_latest_first; + use zinc_core::ordinals::{Inscription, Satpoint}; + + fn sample(id: &str, number: i64, timestamp: Option) -> Inscription { + Inscription { + id: id.to_string(), + number, + satpoint: Satpoint::default(), + content_type: None, + value: None, + content_length: None, + timestamp, + } + } + + #[test] + fn sorts_newest_timestamp_first_then_number() { + let inscriptions = vec![ + sample("old", 10, Some(100)), + sample("new", 1, Some(300)), + sample("mid-high-number", 99, Some(200)), + sample("no-ts", 500, None), + sample("same-ts-lower-number", 5, Some(200)), + ]; + + let sorted = sort_inscriptions_latest_first(&inscriptions); + let ids: Vec<&str> = sorted.iter().map(|i| i.id.as_str()).collect(); + + assert_eq!( + ids, + vec![ + "new", + "mid-high-number", + "same-ts-lower-number", + "old", + "no-ts" + ] + ); } } diff --git a/src/commands/lock.rs b/src/commands/lock.rs index c48d153..d629d2b 100644 --- a/src/commands/lock.rs +++ b/src/commands/lock.rs @@ -2,10 +2,10 @@ use crate::cli::{Cli, LockAction, LockArgs}; use crate::error::AppError; use crate::wallet_service::now_unix; use crate::{confirm, profile_lock_path, read_lock_metadata}; -use serde_json::{json, Value}; +use crate::output::CommandOutput; use std::fs; -pub async fn run(cli: &Cli, args: &LockArgs) -> Result { +pub async fn run(cli: &Cli, args: &LockArgs) -> Result { let lock_path = profile_lock_path(cli)?; match &args.action { LockAction::Info => { @@ -18,22 +18,22 @@ pub async fn run(cli: &Cli, args: &LockArgs) -> Result { let age_secs = metadata .as_ref() .map(|m| now_unix().saturating_sub(m.created_at_unix)); - Ok(json!({ - "profile": cli.profile, - "lock_path": lock_path.display().to_string(), - "locked": exists, - "owner_pid": metadata.as_ref().map(|m| m.pid), - "created_at_unix": metadata.as_ref().map(|m| m.created_at_unix), - "age_secs": age_secs - })) + Ok(CommandOutput::LockInfo { + profile: cli.profile.clone(), + lock_path: lock_path.display().to_string(), + locked: exists, + owner_pid: metadata.as_ref().map(|m| m.pid), + created_at_unix: metadata.as_ref().map(|m| m.created_at_unix), + age_secs, + }) } LockAction::Clear => { if !lock_path.exists() { - return Ok(json!({ - "profile": cli.profile, - "lock_path": lock_path.display().to_string(), - "cleared": false - })); + return Ok(CommandOutput::LockClear { + profile: cli.profile.clone(), + lock_path: lock_path.display().to_string(), + cleared: false, + }); } if !confirm( @@ -45,11 +45,11 @@ pub async fn run(cli: &Cli, args: &LockArgs) -> Result { fs::remove_file(&lock_path) .map_err(|e| AppError::Config(format!("failed to clear lock: {e}")))?; - Ok(json!({ - "profile": cli.profile, - "lock_path": lock_path.display().to_string(), - "cleared": true - })) + Ok(CommandOutput::LockClear { + profile: cli.profile.clone(), + lock_path: lock_path.display().to_string(), + cleared: true, + }) } } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1f48004..e97ad3b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -14,3 +14,4 @@ pub mod sync; pub mod tx; pub mod wait; pub mod wallet; +pub mod version; diff --git a/src/commands/offer.rs b/src/commands/offer.rs index 55d399a..c15d9f4 100644 --- a/src/commands/offer.rs +++ b/src/commands/offer.rs @@ -1,20 +1,35 @@ +#![allow(dead_code)] use crate::cli::{Cli, OfferAction, OfferArgs}; use crate::commands::psbt::{analyze_psbt_with_policy, enforce_policy_mode}; use crate::config::NetworkArg; use crate::error::AppError; use crate::network_retry::with_network_retry; +use crate::output::CommandOutput; +use crate::presenter::thumbnail::{print_thumbnail, render_non_image_badge}; use crate::utils::{maybe_write_text, resolve_psbt_source}; use crate::wallet_service::map_wallet_error; use crate::{load_wallet_session, persist_wallet_session}; use serde_json::{json, Value}; use std::io::Read; +use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; use zinc_core::{ prepare_offer_acceptance, CreateOfferRequest, NostrOfferEvent, NostrRelayClient, OfferEnvelopeV1, OrdClient, RelayQueryOptions, SignOptions, }; -pub async fn run(cli: &Cli, args: &OfferArgs) -> Result { +pub async fn run(cli: &Cli, args: &OfferArgs) -> Result { + if cli.password_stdin { + if let OfferAction::Accept { + offer_stdin: true, .. + } = &args.action + { + return Err(AppError::Invalid( + "--password-stdin cannot be combined with --offer-stdin".to_string(), + )); + } + } + match &args.action { OfferAction::Create { inscription, @@ -115,7 +130,7 @@ pub async fn run(cli: &Cli, args: &OfferArgs) -> Result { } persist_wallet_session(&mut session)?; - Ok(json!({ + let response = json!({ "inscription": created.inscription, "seller_address": created.seller_address, "seller_outpoint": created.seller_outpoint, @@ -128,7 +143,8 @@ pub async fn run(cli: &Cli, args: &OfferArgs) -> Result { "offer": created.offer, "submitted_ord": submit_ord, "ord_url": ord_url, - })) + }); + finalize_offer_output(cli, &args.action, response).await } OfferAction::Publish { offer_json, @@ -145,11 +161,8 @@ pub async fn run(cli: &Cli, args: &OfferArgs) -> Result { )); } - let source = resolve_offer_source( - offer_json.as_deref(), - offer_file.as_ref().map(|p| p.to_str().unwrap()), - *offer_stdin, - )?; + let source = + resolve_offer_source(offer_json.as_deref(), offer_file.as_deref(), *offer_stdin)?; let offer: OfferEnvelopeV1 = serde_json::from_str(&source) .map_err(|e| AppError::Invalid(format!("invalid offer json: {e}")))?; @@ -160,12 +173,13 @@ pub async fn run(cli: &Cli, args: &OfferArgs) -> Result { let results = NostrRelayClient::publish_offer_multi(relay, &event, *timeout_ms).await; let accepted = results.iter().filter(|r| r.accepted).count(); - Ok(json!({ + let response = json!({ "event": event, "publish_results": results, "accepted_relays": accepted, "total_relays": relay.len(), - })) + }); + finalize_offer_output(cli, &args.action, response).await } OfferAction::Discover { relay, @@ -196,23 +210,21 @@ pub async fn run(cli: &Cli, args: &OfferArgs) -> Result { }) .collect(); - Ok(json!({ + let response = json!({ "events": events, "offers": offers, "event_count": events.len(), "offer_count": offers.len(), - })) + }); + finalize_offer_output(cli, &args.action, response).await } OfferAction::SubmitOrd { psbt, psbt_file, psbt_stdin, } => { - let psbt_source = resolve_psbt_source( - psbt.as_deref(), - psbt_file.as_ref().map(|p| p.to_str().unwrap()), - *psbt_stdin, - )?; + let psbt_source = + resolve_psbt_source(psbt.as_deref(), psbt_file.as_deref(), *psbt_stdin)?; let ord_url = resolve_ord_url(cli)?; let client = OrdClient::new(ord_url.clone()); client @@ -220,20 +232,22 @@ pub async fn run(cli: &Cli, args: &OfferArgs) -> Result { .await .map_err(map_offer_error)?; - Ok(json!({ + let response = json!({ "submitted": true, "ord_url": ord_url, - })) + }); + finalize_offer_output(cli, &args.action, response).await } OfferAction::ListOrd => { let ord_url = resolve_ord_url(cli)?; let client = OrdClient::new(ord_url.clone()); let offers = client.get_offer_psbts().await.map_err(map_offer_error)?; - Ok(json!({ + let response = json!({ "ord_url": ord_url, "offers": offers, "count": offers.len(), - })) + }); + finalize_offer_output(cli, &args.action, response).await } OfferAction::Accept { offer_json, @@ -243,11 +257,8 @@ pub async fn run(cli: &Cli, args: &OfferArgs) -> Result { expect_ask_sats, dry_run, } => { - let source = resolve_offer_source( - offer_json.as_deref(), - offer_file.as_ref().map(|p| p.to_str().unwrap()), - *offer_stdin, - )?; + let source = + resolve_offer_source(offer_json.as_deref(), offer_file.as_deref(), *offer_stdin)?; let offer: OfferEnvelopeV1 = serde_json::from_str(&source) .map_err(|e| AppError::Invalid(format!("invalid offer json: {e}")))?; assert_offer_expectations(&offer, expect_inscription.as_deref(), *expect_ask_sats)?; @@ -274,7 +285,7 @@ pub async fn run(cli: &Cli, args: &OfferArgs) -> Result { .map_err(map_wallet_error)?; if *dry_run { - return Ok(json!({ + let response = json!({ "accepted": true, "dry_run": true, "offer_id": plan.offer_id, @@ -286,7 +297,8 @@ pub async fn run(cli: &Cli, args: &OfferArgs) -> Result { "inscription_risk": policy.inscription_risk, "policy_reasons": policy.policy_reasons, "analysis": analysis - })); + }); + return finalize_offer_output(cli, &args.action, response).await; } let esplora_url = session.profile.esplora_url.clone(); @@ -308,7 +320,7 @@ pub async fn run(cli: &Cli, args: &OfferArgs) -> Result { .await?; persist_wallet_session(&mut session)?; - Ok(json!({ + let response = json!({ "accepted": true, "dry_run": false, "offer_id": plan.offer_id, @@ -321,11 +333,231 @@ pub async fn run(cli: &Cli, args: &OfferArgs) -> Result { "inscription_risk": policy.inscription_risk, "policy_reasons": policy.policy_reasons, "analysis": analysis - })) + }); + finalize_offer_output(cli, &args.action, response).await } } } +async fn finalize_offer_output( + cli: &Cli, + action: &OfferAction, + response: Value, +) -> Result { + let thumbnail_lines = maybe_offer_thumbnail_lines(cli, action, &response).await; + let hide_inscription_ids = cli.thumb_enabled(); + + match action { + OfferAction::Create { inscription, .. } => Ok(CommandOutput::OfferCreate { + inscription: inscription.clone(), + ask_sats: response + .get("ask_sats") + .and_then(Value::as_u64) + .unwrap_or(0), + fee_rate_sat_vb: response + .get("fee_rate_sat_vb") + .and_then(Value::as_u64) + .unwrap_or(0), + seller_address: response + .get("seller_address") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + seller_outpoint: response + .get("seller_outpoint") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + seller_pubkey_hex: response + .get("offer") + .and_then(|o| o.get("seller_pubkey_hex")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + expires_at_unix: response + .get("offer") + .and_then(|o| o.get("expires_at_unix")) + .and_then(Value::as_i64) + .unwrap_or(0), + thumbnail_lines, + hide_inscription_ids, + raw_response: response, + }), + OfferAction::Publish { .. } => Ok(CommandOutput::OfferPublish { + event_id: response + .get("event") + .and_then(|v| v.get("id")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + accepted_relays: response + .get("accepted_relays") + .and_then(Value::as_u64) + .unwrap_or(0), + total_relays: response + .get("total_relays") + .and_then(Value::as_u64) + .unwrap_or(0), + publish_results: response + .get("publish_results") + .and_then(Value::as_array) + .unwrap_or(&vec![]) + .clone(), + raw_response: response, + }), + OfferAction::Discover { .. } => Ok(CommandOutput::OfferDiscover { + event_count: response + .get("event_count") + .and_then(Value::as_u64) + .unwrap_or(0), + offer_count: response + .get("offer_count") + .and_then(Value::as_u64) + .unwrap_or(0), + offers: response + .get("offers") + .and_then(Value::as_array) + .unwrap_or(&vec![]) + .clone(), + thumbnail_lines, + hide_inscription_ids, + raw_response: response, + }), + OfferAction::SubmitOrd { .. } => Ok(CommandOutput::OfferSubmitOrd { + ord_url: response + .get("ord_url") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + submitted: true, + raw_response: response, + }), + OfferAction::ListOrd => Ok(CommandOutput::OfferListOrd { + ord_url: response + .get("ord_url") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + count: response.get("count").and_then(Value::as_u64).unwrap_or(0), + offers: response + .get("offers") + .and_then(Value::as_array) + .unwrap_or(&vec![]) + .clone(), + raw_response: response, + }), + OfferAction::Accept { .. } => Ok(CommandOutput::OfferAccept { + inscription: response + .get("inscription_id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + ask_sats: response + .get("ask_sats") + .and_then(Value::as_u64) + .unwrap_or(0), + txid: response + .get("txid") + .and_then(Value::as_str) + .unwrap_or("-") + .to_string(), + dry_run: response + .get("dry_run") + .and_then(Value::as_bool) + .unwrap_or(false), + inscription_risk: response + .get("inscription_risk") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + thumbnail_lines, + hide_inscription_ids, + raw_response: response, + }), + } +} + +async fn maybe_offer_thumbnail_lines( + cli: &Cli, + action: &OfferAction, + response: &Value, +) -> Option> { + if !cli.thumb_enabled() { + return None; + } + + let inscription_id = offer_thumbnail_inscription_id(action, response)?; + let ord_url = resolve_ord_url(cli).ok().or_else(|| { + response + .get("ord_url") + .and_then(Value::as_str) + .map(ToString::to_string) + })?; + + let client = OrdClient::new(ord_url); + let details = client.get_inscription_details(&inscription_id).await.ok()?; + let content_type = details.content_type.clone(); + if !content_type + .as_deref() + .is_some_and(|kind| kind.starts_with("image/")) + { + let mut badge = vec![format!( + "thumbnail ({}):", + abbreviate(&inscription_id, 12, 8) + )]; + badge.extend(render_non_image_badge(content_type.as_deref())); + return Some(badge); + } + + let content = client.get_inscription_content(&inscription_id).await.ok()?; + let lines = vec![format!( + "thumbnail ({}):", + abbreviate(&inscription_id, 12, 8) + )]; + // Flush the header, then print the image directly via viuer + for line in &lines { + println!("{line}"); + } + print_thumbnail(&content.bytes, 24); + // Return empty vec since we already printed everything + Some(Vec::new()) +} + +fn offer_thumbnail_inscription_id(action: &OfferAction, response: &Value) -> Option { + match action { + OfferAction::Create { inscription, .. } => Some(inscription.clone()), + OfferAction::Accept { .. } => response + .get("inscription_id") + .and_then(Value::as_str) + .map(ToString::to_string), + OfferAction::Discover { .. } => response + .get("offers") + .and_then(Value::as_array) + .and_then(|offers| offers.first()) + .and_then(|entry| entry.get("offer")) + .and_then(|offer| offer.get("inscription_id")) + .and_then(Value::as_str) + .map(ToString::to_string), + OfferAction::Publish { .. } | OfferAction::SubmitOrd { .. } | OfferAction::ListOrd => None, + } +} + +pub fn abbreviate(value: &str, prefix: usize, suffix: usize) -> String { + if value.chars().count() <= prefix + suffix + 3 { + return value.to_string(); + } + let start: String = value.chars().take(prefix).collect(); + let end: String = value + .chars() + .rev() + .take(suffix) + .collect::() + .chars() + .rev() + .collect(); + format!("{start}...{end}") +} + fn resolve_ord_url(cli: &Cli) -> Result { cli.ord_url.clone().ok_or_else(|| { AppError::Config( @@ -336,7 +568,7 @@ fn resolve_ord_url(cli: &Cli) -> Result { fn resolve_offer_source( offer_json: Option<&str>, - offer_file: Option<&str>, + offer_file: Option<&Path>, offer_stdin: bool, ) -> Result { let count = (offer_json.is_some() as u8) + (offer_file.is_some() as u8) + (offer_stdin as u8); @@ -349,8 +581,9 @@ fn resolve_offer_source( return Ok(source.to_string()); } if let Some(path) = offer_file { - return std::fs::read_to_string(path) - .map_err(|e| AppError::Io(format!("failed to read offer file {path}: {e}"))); + return std::fs::read_to_string(path).map_err(|e| { + AppError::Io(format!("failed to read offer file {}: {e}", path.display())) + }); } if offer_stdin { let mut source = String::new(); @@ -452,8 +685,9 @@ fn map_offer_error(err: E) -> AppError { #[cfg(test)] mod tests { - use super::{assert_offer_expectations, map_offer_error, resolve_offer_source}; + use super::{abbreviate, assert_offer_expectations, map_offer_error, resolve_offer_source}; use crate::error::AppError; + use std::path::Path; use zinc_core::OfferEnvelopeV1; fn sample_offer() -> OfferEnvelopeV1 { @@ -482,7 +716,7 @@ mod tests { #[test] fn resolve_offer_source_rejects_multiple_sources() { - let err = resolve_offer_source(Some("{}"), Some("/tmp/offer.json"), false) + let err = resolve_offer_source(Some("{}"), Some(Path::new("/tmp/offer.json")), false) .expect_err("must reject"); assert!(matches!(err, AppError::Invalid(_))); } @@ -520,4 +754,11 @@ mod tests { .expect_err("must reject mismatch"); assert!(matches!(err, AppError::Invalid(_))); } + + #[test] + fn abbreviate_shortens_long_identifiers() { + let value = "1234567890abcdef1234567890abcdef"; + let short = abbreviate(value, 6, 4); + assert_eq!(short, "123456...cdef"); + } } diff --git a/src/commands/psbt.rs b/src/commands/psbt.rs index e221015..cc9f7d0 100644 --- a/src/commands/psbt.rs +++ b/src/commands/psbt.rs @@ -1,13 +1,14 @@ use crate::cli::{Cli, PolicyMode, PsbtAction, PsbtArgs}; use crate::error::AppError; use crate::network_retry::with_network_retry; +use crate::output::CommandOutput; use crate::utils::{maybe_write_text, parse_indices, resolve_psbt_source}; use crate::wallet_service::map_wallet_error; use crate::{load_wallet_session, persist_wallet_session}; use serde_json::{json, Value}; use zinc_core::*; -pub async fn run(cli: &Cli, args: &PsbtArgs) -> Result { +pub async fn run(cli: &Cli, args: &PsbtArgs) -> Result { let psbt_stdin = match &args.action { PsbtAction::Create { .. } => false, PsbtAction::Analyze { psbt_stdin, .. } => *psbt_stdin, @@ -37,31 +38,27 @@ pub async fn run(cli: &Cli, args: &PsbtArgs) -> Result { maybe_write_text(Some(&path.display().to_string()), &psbt)?; } persist_wallet_session(&mut session)?; - Ok(json!({"psbt": psbt})) + Ok(CommandOutput::PsbtCreate { psbt }) } PsbtAction::Analyze { psbt, psbt_file, psbt_stdin, } => { - let source = resolve_psbt_source( - psbt.as_deref(), - psbt_file.as_ref().map(|p| p.to_str().unwrap()), - *psbt_stdin, - )?; + let source = resolve_psbt_source(psbt.as_deref(), psbt_file.as_deref(), *psbt_stdin)?; let session = load_wallet_session(cli)?; let (parsed, policy) = analyze_psbt_with_policy(&session.wallet, &source)?; - Ok(json!({ - "analysis": parsed, - "safe_to_send": policy.safe_to_send, - "inscription_risk": policy.inscription_risk, - "policy_reasons": policy.policy_reasons, - "policy": { + Ok(CommandOutput::PsbtAnalyze { + analysis: parsed, + safe_to_send: policy.safe_to_send, + inscription_risk: policy.inscription_risk.to_string(), + policy_reasons: policy.policy_reasons.clone(), + policy: json!({ "safe_to_send": policy.safe_to_send, "inscription_risk": policy.inscription_risk, "reasons": policy.policy_reasons - } - })) + }), + }) } PsbtAction::Sign { psbt, @@ -72,11 +69,7 @@ pub async fn run(cli: &Cli, args: &PsbtArgs) -> Result { finalize, out_file, } => { - let source = resolve_psbt_source( - psbt.as_deref(), - psbt_file.as_ref().map(|p| p.to_str().unwrap()), - *psbt_stdin, - )?; + let source = resolve_psbt_source(psbt.as_deref(), psbt_file.as_deref(), *psbt_stdin)?; let mut session = load_wallet_session(cli)?; let (analysis, policy) = analyze_psbt_with_policy(&session.wallet, &source)?; enforce_policy_mode(cli, &policy)?; @@ -97,24 +90,20 @@ pub async fn run(cli: &Cli, args: &PsbtArgs) -> Result { maybe_write_text(Some(&path.display().to_string()), &signed)?; } persist_wallet_session(&mut session)?; - Ok(json!({ - "psbt": signed, - "safe_to_send": policy.safe_to_send, - "inscription_risk": policy.inscription_risk, - "policy_reasons": policy.policy_reasons, - "analysis": analysis - })) + Ok(CommandOutput::PsbtSign { + psbt: signed, + safe_to_send: policy.safe_to_send, + inscription_risk: policy.inscription_risk.to_string(), + policy_reasons: policy.policy_reasons.clone(), + analysis, + }) } PsbtAction::Broadcast { psbt, psbt_file, psbt_stdin, } => { - let source = resolve_psbt_source( - psbt.as_deref(), - psbt_file.as_ref().map(|p| p.to_str().unwrap()), - *psbt_stdin, - )?; + let source = resolve_psbt_source(psbt.as_deref(), psbt_file.as_deref(), *psbt_stdin)?; let mut session = load_wallet_session(cli)?; let (analysis, policy) = analyze_psbt_with_policy(&session.wallet, &source)?; enforce_policy_mode(cli, &policy)?; @@ -132,13 +121,13 @@ pub async fn run(cli: &Cli, args: &PsbtArgs) -> Result { }) .await?; persist_wallet_session(&mut session)?; - Ok(json!({ - "txid": txid, - "safe_to_send": policy.safe_to_send, - "inscription_risk": policy.inscription_risk, - "policy_reasons": policy.policy_reasons, - "analysis": analysis - })) + Ok(CommandOutput::PsbtBroadcast { + txid, + safe_to_send: policy.safe_to_send, + inscription_risk: policy.inscription_risk.to_string(), + policy_reasons: policy.policy_reasons.clone(), + analysis, + }) } } } diff --git a/src/commands/scenario.rs b/src/commands/scenario.rs index b5f537e..e38cabb 100644 --- a/src/commands/scenario.rs +++ b/src/commands/scenario.rs @@ -3,10 +3,10 @@ use crate::error::AppError; use crate::utils::run_bitcoin_cli; use crate::wallet_service::NetworkArg; use crate::{confirm, load_wallet_session, persist_wallet_session, snapshot_dir}; -use serde_json::{json, Value}; +use crate::output::CommandOutput; use std::fs; -pub async fn run(cli: &Cli, args: &ScenarioArgs) -> Result { +pub async fn run(cli: &Cli, args: &ScenarioArgs) -> Result { let mut session = load_wallet_session(cli)?; if !matches!(session.profile.network, NetworkArg::Regtest) { return Err(AppError::Invalid( @@ -38,12 +38,11 @@ pub async fn run(cli: &Cli, args: &ScenarioArgs) -> Result { ], )?; - json!({ - "action": "mine", - "blocks": blocks, - "address": mining_address, - "raw": generated - }) + CommandOutput::ScenarioMine { + blocks: *blocks as u64, + address: mining_address, + raw_output: generated, + } } ScenarioAction::Fund { address, @@ -80,15 +79,14 @@ pub async fn run(cli: &Cli, args: &ScenarioArgs) -> Result { ], )?; - json!({ - "action": "fund", - "address": destination, - "amount_btc": amount_btc, - "txid": txid.trim(), - "mine_blocks": mine_blocks, - "mine_address": mine_address, - "generated_blocks": generated - }) + CommandOutput::ScenarioFund { + address: destination, + amount_btc: amount_btc.clone(), + txid: txid.trim().to_string(), + mine_blocks: *mine_blocks as u64, + mine_address, + generated_blocks: generated, + } } ScenarioAction::Reset { remove_profile, @@ -123,10 +121,9 @@ pub async fn run(cli: &Cli, args: &ScenarioArgs) -> Result { } } - json!({ - "action": "reset", - "removed": removed - }) + CommandOutput::ScenarioReset { + removed, + } } }; diff --git a/src/commands/setup.rs b/src/commands/setup.rs index 601bbe5..1eb675d 100644 --- a/src/commands/setup.rs +++ b/src/commands/setup.rs @@ -1,6 +1,7 @@ use crate::cli::{Cli, SetupArgs}; use crate::config::{load_persisted_config, save_persisted_config}; use crate::error::AppError; +use crate::output::CommandOutput; use crate::utils::parse_network; use crate::utils::parse_scheme; use crate::wallet_service::{ @@ -9,11 +10,11 @@ use crate::wallet_service::{ }; use crate::wizard::{resolve_setup_values, run_tui_setup_wizard, should_run_setup_wizard}; use crate::{now_unix, profile_path, wallet_password, write_profile}; -use serde_json::{json, Value}; +use serde_json::json; use std::io::IsTerminal; use zinc_core::{encrypt_wallet_internal, generate_wallet_internal}; -pub async fn run(cli: &Cli, args: &SetupArgs) -> Result { +pub async fn run(cli: &Cli, args: &SetupArgs) -> Result { let seed = resolve_setup_values(cli, args)?; let is_tui = should_run_setup_wizard( @@ -37,8 +38,6 @@ pub async fn run(cli: &Cli, args: &SetupArgs) -> Result { config.scheme = values.default_scheme.clone(); config.esplora_url = values.default_esplora_url.clone(); config.ord_url = values.default_ord_url.clone(); - config.json = Some(values.json_default); - config.quiet = Some(values.quiet_default); save_persisted_config(&config)?; // ── Optionally initialize wallet ──────────────────────────────────── @@ -66,13 +65,13 @@ pub async fn run(cli: &Cli, args: &SetupArgs) -> Result { password: values.password.clone().or(cli.password.clone()), password_env: Some(values.password_env.clone()), password_stdin: cli.password_stdin, - json: values.json_default, - quiet: values.quiet_default, reveal: cli.reveal, yes: cli.yes, agent: cli.agent, no_images: cli.no_images, ascii: false, + thumb: cli.thumb_enabled(), + no_thumb: cli.no_thumb, correlation_id: cli.correlation_id.clone(), log_json: cli.log_json, idempotency_key: cli.idempotency_key.clone(), @@ -163,34 +162,56 @@ pub async fn run(cli: &Cli, args: &SetupArgs) -> Result { #[cfg(feature = "ui")] if is_tui { // If we ran the TUI, transition directly to the dashboard - return crate::dashboard::run(cli).await; + return crate::dashboard::run(cli).await.map(CommandOutput::Generic); } - let mut result = json!({ - "ok": true, - "config_saved": true, - "wizard_used": false, - "profile": values.profile, - "data_dir": crate::wallet_service::data_dir(&crate::service_config(cli)).display().to_string(), - "defaults": { - "network": values.default_network.as_deref().or(cli.network.as_deref()).unwrap_or("regtest"), - "scheme": values.default_scheme.as_deref().or(cli.scheme.as_deref()).unwrap_or("dual"), - "esplora_url": values.default_esplora_url.as_deref().or(cli.esplora_url.as_deref()).unwrap_or(default_esplora_url(parse_network("regtest").unwrap())), - "ord_url": values.default_ord_url.as_deref().or(cli.ord_url.as_deref()).unwrap_or(default_ord_url(parse_network("regtest").unwrap())), - "json": values.json_default, - "quiet": values.quiet_default, - }, - "password_env": values.password_env, - }); - - if let Some(wallet) = wallet_result { - result["wallet"] = wallet; - } else { - result["wallet"] = json!({ - "requested": values.initialize_wallet, - "initialized": false, - }); + let mut wallet_initialized = false; + let mut wallet_mode = None; + let mut wallet_phrase = None; + let mut wallet_word_count = None; + if let Some(wallet_info) = wallet_result { + wallet_initialized = wallet_info["wallet_initialized"].as_bool().unwrap_or(false); + wallet_mode = wallet_info["mode"].as_str().map(|s: &str| s.to_string()); + wallet_phrase = wallet_info["phrase"].as_str().map(|s: &str| s.to_string()); + wallet_word_count = wallet_info["word_count"].as_u64().map(|v| v as usize); } - Ok(result) + Ok(CommandOutput::Setup { + config_saved: true, + wizard_used: is_tui, + profile: Some(values.profile), + data_dir: crate::wallet_service::data_dir(&crate::service_config(cli)) + .display() + .to_string(), + default_network: values + .default_network + .as_deref() + .or(cli.network.as_deref()) + .unwrap_or("regtest") + .to_string(), + default_scheme: values + .default_scheme + .as_deref() + .or(cli.scheme.as_deref()) + .unwrap_or("dual") + .to_string(), + default_esplora_url: values + .default_esplora_url + .as_deref() + .or(cli.esplora_url.as_deref()) + .unwrap_or_else(|| default_esplora_url(parse_network("regtest").unwrap())) + .to_string(), + default_ord_url: values + .default_ord_url + .as_deref() + .or(cli.ord_url.as_deref()) + .unwrap_or_else(|| default_ord_url(parse_network("regtest").unwrap())) + .to_string(), + password_env: values.password_env.clone(), + wallet_requested: values.initialize_wallet, + wallet_initialized, + wallet_mode, + wallet_phrase, + wallet_word_count, + }) } diff --git a/src/commands/snapshot.rs b/src/commands/snapshot.rs index 4412c66..29dd51b 100644 --- a/src/commands/snapshot.rs +++ b/src/commands/snapshot.rs @@ -1,10 +1,10 @@ use crate::cli::{Cli, SnapshotAction, SnapshotArgs}; use crate::error::AppError; use crate::{confirm, profile_path, read_profile, snapshot_dir, write_bytes_atomic}; -use serde_json::{json, Value}; +use crate::output::CommandOutput; use std::fs; -pub async fn run(cli: &Cli, args: &SnapshotArgs) -> Result { +pub async fn run(cli: &Cli, args: &SnapshotArgs) -> Result { let profile_path = profile_path(cli)?; let snap_dir = snapshot_dir(cli)?; fs::create_dir_all(&snap_dir) @@ -22,7 +22,7 @@ pub async fn run(cli: &Cli, args: &SnapshotArgs) -> Result { let bytes = serde_json::to_vec_pretty(&source) .map_err(|e| AppError::Internal(format!("snapshot serialize failed: {e}")))?; write_bytes_atomic(&destination, &bytes, "snapshot")?; - Ok(json!({"snapshot": destination.display().to_string()})) + Ok(CommandOutput::SnapshotSave { snapshot: destination.display().to_string() }) } SnapshotAction::Restore { name } => { if !confirm(&format!("Are you sure you want to restore snapshot '{name}'? This will overwrite your current profile."), cli) { @@ -38,7 +38,7 @@ pub async fn run(cli: &Cli, args: &SnapshotArgs) -> Result { let data = fs::read(&source) .map_err(|e| AppError::Config(format!("failed to read snapshot: {e}")))?; write_bytes_atomic(&profile_path, &data, "profile restore")?; - Ok(json!({"restored": source.display().to_string()})) + Ok(CommandOutput::SnapshotRestore { restored: source.display().to_string() }) } SnapshotAction::List => { let mut names = Vec::new(); @@ -57,13 +57,7 @@ pub async fn run(cli: &Cli, args: &SnapshotArgs) -> Result { } } names.sort(); - if cli.json { - Ok(json!({"snapshots": names})) - } else { - let table = crate::presenter::snapshot::format_snapshots(&names); - println!("{table}"); - Ok(Value::Null) - } + Ok(CommandOutput::SnapshotList { snapshots: names }) } } } diff --git a/src/commands/sync.rs b/src/commands/sync.rs index ec2634c..7ec04fa 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -1,13 +1,13 @@ use crate::cli::{Cli, SyncArgs, SyncTarget}; use crate::error::AppError; use crate::network_retry::with_network_retry; +use crate::output::CommandOutput; use crate::wallet_service::map_wallet_error; use crate::{load_wallet_session, persist_wallet_session}; use indicatif::{ProgressBar, ProgressStyle}; -use serde_json::{json, Value}; -pub async fn run(cli: &Cli, args: &SyncArgs) -> Result { - let spinner = if !cli.json && !cli.quiet { +pub async fn run(cli: &Cli, args: &SyncArgs) -> Result { + let spinner = if !cli.agent { let pb = ProgressBar::new_spinner(); pb.set_style( ProgressStyle::default_spinner() @@ -31,7 +31,7 @@ pub async fn run(cli: &Cli, args: &SyncArgs) -> Result { }) .await?; persist_wallet_session(&mut session)?; - json!({"events": events}) + CommandOutput::SyncChain { events } } SyncTarget::Ordinals => { let mut session = load_wallet_session(cli)?; @@ -45,7 +45,9 @@ pub async fn run(cli: &Cli, args: &SyncArgs) -> Result { }) .await?; persist_wallet_session(&mut session)?; - json!({"inscriptions": count}) + CommandOutput::SyncOrdinals { + inscriptions: count, + } } }; diff --git a/src/commands/tx.rs b/src/commands/tx.rs index 994a25d..761a2ae 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -1,20 +1,14 @@ use crate::cli::{Cli, TxAction, TxArgs}; use crate::error::AppError; use crate::load_wallet_session; -use serde_json::{json, Value}; +use crate::output::CommandOutput; -pub async fn run(cli: &Cli, args: &TxArgs) -> Result { +pub async fn run(cli: &Cli, args: &TxArgs) -> Result { match &args.action { TxAction::List { limit } => { let session = load_wallet_session(cli)?; let txs = session.wallet.get_transactions(limit.unwrap_or(20)); - if cli.json { - Ok(json!({"transactions": txs})) - } else { - let table = crate::presenter::tx::format_transactions(&txs); - println!("{table}"); - Ok(Value::Null) - } + Ok(CommandOutput::TxList { transactions: txs }) } } } diff --git a/src/commands/version.rs b/src/commands/version.rs new file mode 100644 index 0000000..559483b --- /dev/null +++ b/src/commands/version.rs @@ -0,0 +1,53 @@ +use crate::cli::Cli; +use crate::error::AppError; +use crate::output::CommandOutput; +use serde_json::json; +use std::io::Write; + +const ANSI_LOGO_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/zinc-cli-logo.ans"); +const VERSION_TAGLINE: &str = "Agent-first Bitcoin + Ordinals CLI wallet"; +const ACCENT_ORANGE: &str = "\x1b[38;2;249;115;22m"; +const ANSI_RESET: &str = "\x1b[0m"; +const LOGO_COLUMNS: u16 = 40; +const LOGO_ROWS: u16 = 20; +const TEXT_GAP_COLUMNS: u16 = 4; +const TEXT_TOP_OFFSET_ROWS: u16 = 8; + +pub async fn run(cli: &Cli) -> Result { + if cli.agent { + // Do nothing for stdout in agent mode + } else if cli.ascii || !is_terminal::is_terminal(&std::io::stdout()) { + println!("zinc-cli v{}", env!("CARGO_PKG_VERSION")); + } else { + render_logo()?; + } + + Ok(CommandOutput::Generic(json!({ + "version": env!("CARGO_PKG_VERSION"), + "name": "zinc-cli", + }))) +} + +fn render_logo() -> std::io::Result<()> { + let mut stdout = std::io::stdout().lock(); + let logo = std::fs::read(ANSI_LOGO_PATH)?; + let version_line = format!("zinc-cli v{}", env!("CARGO_PKG_VERSION")); + + stdout.write_all(&logo)?; + + // Place text to the right side of the rendered ANSI logo. + let text_col = LOGO_COLUMNS + TEXT_GAP_COLUMNS + 1; // CSI G is 1-based. + let rise_rows = LOGO_ROWS.saturating_sub(TEXT_TOP_OFFSET_ROWS); + stdout.write_all(b"\x1b[s")?; + stdout.write_all(format!("\x1b[{rise_rows}A").as_bytes())?; + stdout.write_all( + format!("\x1b[{text_col}G{ACCENT_ORANGE}{version_line}{ANSI_RESET}").as_bytes(), + )?; + stdout.write_all(b"\n\n")?; + stdout.write_all( + format!("\x1b[{text_col}G{ACCENT_ORANGE}{VERSION_TAGLINE}{ANSI_RESET}").as_bytes(), + )?; + stdout.write_all(b"\x1b[u")?; + stdout.write_all(b"\n")?; + stdout.flush() +} diff --git a/src/commands/wait.rs b/src/commands/wait.rs index e6bd83d..bb94ccb 100644 --- a/src/commands/wait.rs +++ b/src/commands/wait.rs @@ -2,16 +2,16 @@ use crate::cli::{Cli, WaitAction, WaitArgs}; use crate::error::AppError; use crate::load_wallet_session; use crate::network_retry::with_network_retry; +use crate::output::CommandOutput; use crate::wallet_service::map_wallet_error; use indicatif::{ProgressBar, ProgressStyle}; -use serde_json::{json, Value}; use std::time::Duration; use tokio::time::sleep; -pub async fn run(cli: &Cli, args: &WaitArgs) -> Result { +pub async fn run(cli: &Cli, args: &WaitArgs) -> Result { let mut session = load_wallet_session(cli)?; - let spinner = if !cli.json && !cli.quiet { + let spinner = if !cli.agent { let pb = ProgressBar::new_spinner(); pb.set_style( ProgressStyle::default_spinner() @@ -60,12 +60,12 @@ pub async fn run(cli: &Cli, args: &WaitArgs) -> Result { if let Some(pb) = spinner { pb.finish_with_message("Transaction confirmed!"); } - return Ok(json!({ - "txid": txid, - "confirmation_time": confirmation_time, - "confirmed": true, - "waited_secs": start.elapsed().as_secs() - })); + return Ok(CommandOutput::WaitTxConfirmed { + txid: txid.clone(), + confirmation_time, + confirmed: true, + waited_secs: start.elapsed().as_secs(), + }); } if start.elapsed() >= timeout { @@ -83,12 +83,12 @@ pub async fn run(cli: &Cli, args: &WaitArgs) -> Result { poll_secs, } => { if *confirmed_at_least == 0 { - return Ok(json!({ - "confirmed": 0, - "confirmed_balance": 0, - "target": confirmed_at_least, - "waited_secs": 0 - })); + return Ok(CommandOutput::WaitBalance { + confirmed: 0, + confirmed_balance: 0, + target: *confirmed_at_least, + waited_secs: 0, + }); } if let Some(ref pb) = spinner { @@ -112,12 +112,12 @@ pub async fn run(cli: &Cli, args: &WaitArgs) -> Result { if let Some(pb) = spinner { pb.finish_with_message("Balance reached!"); } - return Ok(json!({ - "confirmed": confirmed, - "confirmed_balance": confirmed, - "target": confirmed_at_least, - "waited_secs": start.elapsed().as_secs() - })); + return Ok(CommandOutput::WaitBalance { + confirmed, + confirmed_balance: confirmed, + target: *confirmed_at_least, + waited_secs: start.elapsed().as_secs(), + }); } if start.elapsed() >= timeout { diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index 7fd9bee..a279614 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -1,5 +1,7 @@ use crate::cli::{Cli, WalletAction, WalletArgs}; +use crate::config::load_persisted_config; use crate::error::AppError; +use crate::output::CommandOutput; use crate::utils::{parse_network, parse_scheme}; use crate::wallet_service::{ decrypt_wallet_internal, default_bitcoin_cli, default_bitcoin_cli_args, default_esplora_url, @@ -7,10 +9,9 @@ use crate::wallet_service::{ Profile, }; use crate::{now_unix, profile_path, read_profile, wallet_password, write_profile}; -use serde_json::{json, Value}; use std::collections::BTreeMap; -pub async fn run(cli: &Cli, args: &WalletArgs) -> Result { +pub async fn run(cli: &Cli, args: &WalletArgs) -> Result { match &args.action { WalletAction::Init { words, @@ -61,30 +62,25 @@ pub async fn run(cli: &Cli, args: &WalletArgs) -> Result { }; write_profile(&profile_path, &profile)?; - let phrase = if cli.reveal || (!cli.json && !cli.agent) { + let phrase = if cli.reveal || !cli.agent { wallet.phrase.clone() } else { "".to_string() }; - let mut res = json!({ - "profile": cli.profile, - "version": 1, - "network": network_arg, - "scheme": scheme_arg, - "account_index": 0, - "esplora_url": default_esplora_url(network_arg).to_string(), - "ord_url": default_ord_url(network_arg).to_string(), - "bitcoin_cli": default_bitcoin_cli(), - "bitcoin_cli_args": default_bitcoin_cli_args(), - "phrase": phrase, - }); - - if cli.reveal || (!cli.json && !cli.agent) { - res["words"] = json!(wallet.words); - } - - Ok(res) + Ok(CommandOutput::WalletInit { + profile: cli.profile.clone(), + version: 1, + network: network_arg.to_string(), + scheme: scheme_arg.to_string(), + account_index: 0, + esplora_url: default_esplora_url(network_arg).to_string(), + ord_url: default_ord_url(network_arg).to_string(), + bitcoin_cli: default_bitcoin_cli(), + bitcoin_cli_args: default_bitcoin_cli_args().join(" "), + phrase, + words: if cli.reveal || !cli.agent { Some(wallet.words.len()) } else { None }, + }) } WalletAction::Import { mnemonic, @@ -131,53 +127,70 @@ pub async fn run(cli: &Cli, args: &WalletArgs) -> Result { }; write_profile(&profile_path, &profile)?; - let mut res = json!({ - "profile": cli.profile, - "network": network_arg, - "scheme": scheme_arg, - "account_index": 0, - "imported": true - }); - if cli.reveal || (!cli.json && !cli.agent) { - res["phrase"] = json!(mnemonic.to_string()); - } - - Ok(res) + Ok(CommandOutput::WalletImport { + profile: cli.profile.clone(), + network: network_arg.to_string(), + scheme: scheme_arg.to_string(), + account_index: 0, + imported: true, + phrase: if cli.reveal || !cli.agent { Some(mnemonic.to_string()) } else { None }, + }) } WalletAction::Info => { - let profile = read_profile(&profile_path(cli)?)?; - let state = profile.account_state(); - let res = json!({ - "profile": cli.profile, - "version": profile.version, - "network": profile.network, - "scheme": profile.scheme, - "account_index": profile.account_index, - "esplora_url": profile.esplora_url, - "ord_url": profile.ord_url, - "bitcoin_cli": profile.bitcoin_cli, - "bitcoin_cli_args": profile.bitcoin_cli_args, - "has_persistence": state.persistence_json.is_some(), - "has_inscriptions": state.inscriptions_json.is_some(), - "updated_at_unix": profile.updated_at_unix - }); - if cli.json { - Ok(res) - } else { - let table = crate::presenter::wallet::format_wallet_info(&res); - println!("{table}"); - Ok(Value::Null) + let mut profile = read_profile(&profile_path(cli)?)?; + + // Match runtime resolution used by wallet session loading so + // wallet info reflects effective config/default overrides. + let service_cfg = crate::service_config(cli); + let persisted = load_persisted_config().unwrap_or_default(); + let resolver = crate::config_resolver::ConfigResolver::new(&persisted, &service_cfg); + + let resolved_network: crate::config::NetworkArg = + resolver.resolve_network(Some(&profile)).value.into(); + let resolved_scheme: crate::config::SchemeArg = + resolver.resolve_scheme(Some(&profile)).value.into(); + + let network_changed = profile.network.to_string() != resolved_network.to_string(); + if network_changed { + profile.esplora_url = default_esplora_url(resolved_network).to_string(); + profile.ord_url = default_ord_url(resolved_network).to_string(); } + + profile.network = resolved_network; + profile.scheme = resolved_scheme; + + if let Some(e) = service_cfg.esplora_url_override { + profile.esplora_url = e.to_string(); + } + if let Some(url) = service_cfg.ord_url_override { + profile.ord_url = url.to_string(); + } + + let state = profile.account_state(); + Ok(CommandOutput::WalletInfo { + profile: cli.profile.clone(), + version: profile.version, + network: profile.network.to_string(), + scheme: profile.scheme.to_string(), + account_index: profile.account_index, + esplora_url: profile.esplora_url.clone(), + ord_url: profile.ord_url.clone(), + bitcoin_cli: profile.bitcoin_cli.clone(), + bitcoin_cli_args: profile.bitcoin_cli_args.join(" "), + has_persistence: state.persistence_json.is_some(), + has_inscriptions: state.inscriptions_json.is_some(), + updated_at_unix: profile.updated_at_unix, + }) } WalletAction::RevealMnemonic => { let profile = read_profile(&profile_path(cli)?)?; let password = wallet_password(cli)?; let result = decrypt_wallet_internal(&profile.encrypted_mnemonic, &password) .map_err(|e| crate::wallet_service::map_wallet_error(e.to_string()))?; - Ok(json!({ - "phrase": result.phrase, - "words": result.phrase.split_whitespace().count() - })) + Ok(CommandOutput::RevealMnemonic { + phrase: result.phrase.clone(), + words: result.phrase.split_whitespace().count(), + }) } } } diff --git a/src/config.rs b/src/config.rs index 405b3fc..8b9fded 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ use crate::error::AppError; use crate::paths::write_bytes_atomic; -use crate::utils::{parse_bool_value, unknown_with_hint}; +use crate::utils::{parse_bool_value, parse_network, parse_scheme, unknown_with_hint}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; @@ -14,8 +14,6 @@ pub struct PersistedConfig { pub profile: Option, pub data_dir: Option, pub password_env: Option, - pub json: Option, - pub quiet: Option, pub network: Option, pub scheme: Option, pub esplora_url: Option, @@ -29,9 +27,7 @@ impl Default for PersistedConfig { profile: None, data_dir: None, password_env: None, - json: None, - quiet: None, - network: Some("regtest".to_string()), + network: None, scheme: None, esplora_url: None, ord_url: None, @@ -70,8 +66,6 @@ pub enum ConfigField { Profile, DataDir, PasswordEnv, - Json, - Quiet, Network, Scheme, EsploraUrl, @@ -83,8 +77,6 @@ pub const CONFIG_KEYS: &[&str] = &[ "profile", "data-dir", "password-env", - "json", - "quiet", "network", "scheme", "esplora-url", @@ -98,8 +90,6 @@ impl ConfigField { Self::Profile => "profile", Self::DataDir => "data-dir", Self::PasswordEnv => "password-env", - Self::Json => "json", - Self::Quiet => "quiet", Self::Network => "network", Self::Scheme => "scheme", Self::EsploraUrl => "esplora-url", @@ -113,8 +103,6 @@ impl ConfigField { "profile" => Ok(Self::Profile), "data-dir" | "data_dir" => Ok(Self::DataDir), "password-env" | "password_env" => Ok(Self::PasswordEnv), - "json" => Ok(Self::Json), - "quiet" => Ok(Self::Quiet), "network" => Ok(Self::Network), "scheme" => Ok(Self::Scheme), "esplora-url" | "esplora_url" => Ok(Self::EsploraUrl), @@ -155,25 +143,17 @@ pub(crate) fn set_config_field( config.password_env = Some(value.to_string()); Ok(Value::String(value.to_string())) } - ConfigField::Json => { - let parsed = parse_bool_value(value, "config json").map_err(AppError::Invalid)?; - config.json = Some(parsed); - Ok(Value::Bool(parsed)) - } - ConfigField::Quiet => { - let parsed = parse_bool_value(value, "config quiet").map_err(AppError::Invalid)?; - config.quiet = Some(parsed); - Ok(Value::Bool(parsed)) - } ConfigField::Network => { - // Note: we'll need to expose parse_network if we keep manual parsing, - // but for now we just store the string. - config.network = Some(value.to_string()); - Ok(Value::String(value.to_string())) + let parsed = parse_network(value)?; + let canonical = parsed.to_string(); + config.network = Some(canonical.clone()); + Ok(Value::String(canonical)) } ConfigField::Scheme => { - config.scheme = Some(value.to_string()); - Ok(Value::String(value.to_string())) + let parsed = parse_scheme(value)?; + let canonical = parsed.to_string(); + config.scheme = Some(canonical.clone()); + Ok(Value::String(canonical)) } ConfigField::EsploraUrl => { config.esplora_url = Some(value.to_string()); @@ -196,8 +176,6 @@ pub(crate) fn unset_config_field(config: &mut PersistedConfig, key: ConfigField) ConfigField::Profile => config.profile.take().is_some(), ConfigField::DataDir => config.data_dir.take().is_some(), ConfigField::PasswordEnv => config.password_env.take().is_some(), - ConfigField::Json => config.json.take().is_some(), - ConfigField::Quiet => config.quiet.take().is_some(), ConfigField::Network => config.network.take().is_some(), ConfigField::Scheme => config.scheme.take().is_some(), ConfigField::EsploraUrl => config.esplora_url.take().is_some(), @@ -213,7 +191,7 @@ pub struct ServiceConfig<'a> { pub password: Option<&'a str>, pub password_env: &'a str, pub password_stdin: bool, - pub json: bool, + pub agent: bool, pub network_override: Option<&'a str>, pub explicit_network: bool, pub scheme_override: Option<&'a str>, @@ -238,6 +216,28 @@ pub enum SchemeArg { Dual, } +impl std::fmt::Display for NetworkArg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + NetworkArg::Bitcoin => "bitcoin", + NetworkArg::Signet => "signet", + NetworkArg::Testnet => "testnet", + NetworkArg::Regtest => "regtest", + }; + write!(f, "{}", s) + } +} + +impl std::fmt::Display for SchemeArg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + SchemeArg::Unified => "unified", + SchemeArg::Dual => "dual", + }; + write!(f, "{}", s) + } +} + impl From for Network { fn from(value: NetworkArg) -> Self { match value { @@ -368,3 +368,26 @@ pub fn read_profile(path: &Path) -> Result { serde_json::from_str::(&data) .map_err(|e| AppError::Config(format!("failed to parse profile: {e}"))) } + +#[cfg(test)] +mod tests { + use super::{set_config_field, ConfigField, PersistedConfig}; + use crate::error::AppError; + + #[test] + fn set_config_network_validates_and_canonicalizes() { + let mut cfg = PersistedConfig::default(); + let value = set_config_field(&mut cfg, ConfigField::Network, "mainnet") + .expect("mainnet should parse"); + assert_eq!(value.as_str(), Some("bitcoin")); + assert_eq!(cfg.network.as_deref(), Some("bitcoin")); + } + + #[test] + fn set_config_scheme_validates() { + let mut cfg = PersistedConfig::default(); + let err = set_config_field(&mut cfg, ConfigField::Scheme, "legacy") + .expect_err("invalid scheme should be rejected"); + assert!(matches!(err, AppError::Invalid(_))); + } +} diff --git a/src/config_resolver.rs b/src/config_resolver.rs index c815fcc..7d382d1 100644 --- a/src/config_resolver.rs +++ b/src/config_resolver.rs @@ -78,7 +78,7 @@ impl<'a> ConfigResolver<'a> { } pub fn resolve_scheme(&self, profile: Option<&Profile>) -> ResolvedValue { - // Implementation for scheme... + // Priority 1: Explicit CLI if let Some(scheme_str) = self.service.scheme_override { if let Ok(scheme) = crate::utils::parse_scheme(scheme_str) { return ResolvedValue { @@ -88,6 +88,7 @@ impl<'a> ConfigResolver<'a> { } } + // Priority 2: Profile if let Some(profile) = profile { return ResolvedValue { value: profile.scheme.into(), @@ -95,6 +96,7 @@ impl<'a> ConfigResolver<'a> { }; } + // Priority 3: Global Config if let Some(scheme_str) = self.persisted.scheme.as_deref() { if let Ok(scheme) = crate::utils::parse_scheme(scheme_str) { return ResolvedValue { @@ -104,6 +106,7 @@ impl<'a> ConfigResolver<'a> { } } + // Priority 4: Default fallback ResolvedValue { value: AddressScheme::Dual, source: ConfigSource::Default, diff --git a/src/dashboard.rs b/src/dashboard.rs index 038be40..26db6fa 100644 --- a/src/dashboard.rs +++ b/src/dashboard.rs @@ -178,11 +178,11 @@ fn activate_session( state.ordinals_address = Some( session .wallet - .peek_taproot_address(state.account_index) + .peek_taproot_address(0) .to_string(), ); state.payment_address = session .wallet - .peek_payment_address(state.account_index) + .peek_payment_address(0) .map(|s| s.to_string()); } diff --git a/src/dashboard/events.rs b/src/dashboard/events.rs index 851c6d4..79767c1 100644 --- a/src/dashboard/events.rs +++ b/src/dashboard/events.rs @@ -83,11 +83,10 @@ pub fn handle_dashboard_event( if let Some(ref w_mutex) = wallet_mutex { let w_clone = Arc::clone(w_mutex); let event_tx_clone = event_tx.clone(); - let account_index = state.account_index; tokio::spawn(async move { let w = w_clone.lock().await; - let ordinals = w.peek_taproot_address(account_index).to_string(); - let payment = w.peek_payment_address(account_index).map(|s| s.to_string()); + let ordinals = w.peek_taproot_address(0).to_string(); + let payment = w.peek_payment_address(0).map(|s| s.to_string()); let _ = event_tx_clone .send(DashboardEvent::AddressesUpdated { ordinals, payment }) .await; diff --git a/src/main.rs b/src/main.rs index cb35d6a..35d61bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod dashboard; mod error; mod lock; mod network_retry; +mod output; mod paths; mod presenter; #[cfg(feature = "ui")] @@ -47,9 +48,7 @@ const SCHEMA_VERSION: &str = "1.0"; static CORRELATION_SEQ: AtomicU64 = AtomicU64::new(1); const GLOBAL_FLAGS: &[&str] = &[ - "--json", "--agent", - "--quiet", "--yes", "--password", "--password-env", @@ -69,6 +68,8 @@ const GLOBAL_FLAGS: &[&str] = &[ "--network-timeout-secs", "--network-retries", "--policy-mode", + "--thumb", + "--no-thumb", ]; const COMMAND_LIST: &[&str] = &[ "setup", @@ -108,6 +109,7 @@ const COMMAND_LIST: &[&str] = &[ "scenario fund", "scenario reset", "doctor", + "version", ]; #[tokio::main] @@ -115,8 +117,10 @@ async fn main() -> miette::Result<()> { // We need a pre-parse to identify --json or --agent for early errors let args: Vec = std::env::args().collect(); let started_at_unix_ms = now_unix_ms(); - let is_json = args.iter().any(|a| a == "--json" || a == "--agent") - || env_bool("ZINC_CLI_JSON").unwrap_or(false); + let is_agent = args.iter().any(|a| a == "--agent") + || std::env::var("ZINC_CLI_OUTPUT") + .map(|v| v.to_lowercase() == "agent") + .unwrap_or(false); let preparse_log_json = args.iter().any(|a| a == "--log-json") || env_bool("ZINC_CLI_LOG_JSON").unwrap_or(false); let preparse_correlation_id = @@ -131,7 +135,7 @@ async fn main() -> miette::Result<()> { c } Err(err) => { - if is_json { + if is_agent { let command = { let inferred = infer_command_name_from_args(&args); if inferred == "unknown" { @@ -195,7 +199,7 @@ async fn main() -> miette::Result<()> { Ok(c) => c, Err(err) => { // Re-check JSON after resolution if it wasn't pre-detected - if is_json { + if is_agent { let command = infer_command_name_from_args(&args); let duration_ms = now_unix_ms().saturating_sub(started_at_unix_ms); emit_structured_log_line( @@ -246,7 +250,6 @@ async fn main() -> miette::Result<()> { &command_name, "command_start", json!({ - "json_mode": cli.json, "agent_mode": cli.agent }), ); @@ -267,7 +270,7 @@ async fn main() -> miette::Result<()> { "message": err.to_string() }), ); - if cli.json { + if cli.agent { let envelope = wrap_envelope(Err(err), &cli); println!("{}", serde_json::to_string(&envelope).unwrap()); std::process::exit(1); @@ -295,17 +298,23 @@ async fn main() -> miette::Result<()> { "idempotency_replayed": true }), ); - if cli.json { + if cli.agent { let envelope = wrap_envelope(Ok(replay_value), &cli); println!("{}", serde_json::to_string(&envelope).unwrap()); } else if !replay_value.is_null() && !is_non_json_rendered_command(&cli.command) { - println!("{}", serde_json::to_string(&replay_value).unwrap()); + let output = crate::output::CommandOutput::Generic(replay_value); + use crate::output::Presenter; + let presenter = crate::output::HumanPresenter::new(true); + println!("{}", presenter.render(&output)); } return Ok(()); } match run(cli).await { - Ok((mut val, cli_final)) => { + Ok((val, cli_final)) => { + use crate::output::Presenter; + let agent_str = crate::output::AgentPresenter::new().render(&val); + let mut val_json: Value = serde_json::from_str(&agent_str).unwrap_or(Value::Null); if is_mutating_command(&cli_final.command) && cli_final .idempotency_key @@ -313,8 +322,9 @@ async fn main() -> miette::Result<()> { .is_some_and(|k| !k.trim().is_empty()) { if let Some(key) = cli_final.idempotency_key.as_deref() { - let recorded_at = record_idempotent_result(&cli_final, &command_name, &val)?; - val = attach_idempotency_metadata(val, key, false, recorded_at); + let recorded_at = + record_idempotent_result(&cli_final, &command_name, &val_json)?; + val_json = attach_idempotency_metadata(val_json, key, false, recorded_at); } } emit_structured_log_line( @@ -328,12 +338,13 @@ async fn main() -> miette::Result<()> { "idempotency_key": cli_final.idempotency_key.as_deref(), }), ); - if cli_final.json { - let envelope = wrap_envelope(Ok(val), &cli_final); + if cli_final.agent { + let envelope = wrap_envelope(Ok(val_json), &cli_final); println!("{}", serde_json::to_string(&envelope).unwrap()); - } else if !val.is_null() && !is_non_json_rendered_command(&cli_final.command) { - // For contract_v1.rs compatibility, print non-null results as JSON even in human mode - println!("{}", serde_json::to_string(&val).unwrap()); + } else if !val_json.is_null() && !is_non_json_rendered_command(&cli_final.command) { + use crate::output::Presenter; + let presenter = crate::output::HumanPresenter::new(true); + println!("{}", presenter.render(&val)); } } Err((err, cli_final)) => { @@ -353,7 +364,7 @@ async fn main() -> miette::Result<()> { "message": err_message }), ); - if cli_final.json { + if cli_final.agent { let envelope = wrap_envelope(Err(err), &cli_final); println!("{}", serde_json::to_string(&envelope).unwrap()); std::process::exit(1); @@ -370,16 +381,13 @@ fn resolve_effective_cli(mut cli: Cli) -> Result { let persisted = load_persisted_config()?; // Global flags override persisted config - if !cli.json { - cli.json = persisted.json.unwrap_or(false); - } - if !cli.quiet { - cli.quiet = persisted.quiet.unwrap_or(false); - } if cli.agent { - cli.json = true; - cli.quiet = true; cli.ascii = true; + } else if let Some(val) = std::env::var("ZINC_CLI_OUTPUT").ok() { + if val.to_lowercase() == "agent" { + cli.agent = true; + cli.ascii = true; + } } if !cli.ascii { @@ -417,13 +425,6 @@ fn resolve_effective_cli(mut cli: Cli) -> Result { cli.ord_url = persisted.ord_url.clone(); } - // Env vars override everything? (Following old logic) - if let Some(val) = env_bool("ZINC_CLI_JSON") { - cli.json = val; - } - if let Some(val) = env_bool("ZINC_CLI_QUIET") { - cli.quiet = val; - } if let Some(val) = env_non_empty("ZINC_CLI_PROFILE") { cli.profile = Some(val); } @@ -435,6 +436,7 @@ fn resolve_effective_cli(mut cli: Cli) -> Result { } if let Some(val) = env_non_empty("ZINC_CLI_NETWORK") { cli.network = Some(val); + cli.explicit_network = true; } if let Some(val) = env_non_empty("ZINC_CLI_SCHEME") { cli.scheme = Some(val); @@ -876,7 +878,7 @@ fn attach_idempotency_metadata( }) } -pub(crate) async fn run(cli: Cli) -> Result<(Value, Cli), (AppError, Cli)> { +pub(crate) async fn run(cli: Cli) -> Result<(crate::output::CommandOutput, Cli), (AppError, Cli)> { let outcome = dispatch(&cli).await; match outcome { Ok(v) => Ok((v, cli)), @@ -884,7 +886,7 @@ pub(crate) async fn run(cli: Cli) -> Result<(Value, Cli), (AppError, Cli)> { } } -pub(crate) async fn dispatch(cli: &Cli) -> Result { +pub(crate) async fn dispatch(cli: &Cli) -> Result { let _lock = if needs_lock(&cli.command) { let path = profile_path(cli)?; Some(ProfileLock::acquire(&path)?) @@ -901,33 +903,45 @@ pub(crate) async fn dispatch(cli: &Cli) -> Result { Command::Balance => crate::commands::balance::run(cli).await, Command::Tx(args) => crate::commands::tx::run(cli, args).await, Command::Psbt(args) => crate::commands::psbt::run(cli, args).await, - Command::Offer(args) => crate::commands::offer::run(cli, args).await, + Command::Offer(_) => Err(crate::error::AppError::Invalid( + "The 'offer' command is currently in development and not available in this release." + .to_string(), + )), Command::Account(args) => crate::commands::account::run(cli, args).await, Command::Wait(args) => crate::commands::wait::run(cli, args).await, Command::Snapshot(args) => crate::commands::snapshot::run(cli, args).await, Command::Lock(args) => crate::commands::lock::run(cli, args).await, Command::Scenario(args) => crate::commands::scenario::run(cli, args).await, Command::Inscription(args) => crate::commands::inscription::run(cli, args).await, + Command::Version => crate::commands::version::run(cli).await, Command::Doctor => crate::commands::doctor::run(cli).await, #[cfg(feature = "ui")] - Command::Dashboard => crate::dashboard::run(cli).await, + Command::Dashboard => crate::dashboard::run(cli) + .await + .map(crate::output::CommandOutput::Generic), } } fn is_non_json_rendered_command(command: &Command) -> bool { match command { - Command::Scenario(_) | Command::Doctor => true, + Command::Version => true, #[cfg(feature = "ui")] Command::Dashboard => true, _ => false, } } +#[cfg(test)] +mod tests { + // legacy tests removed since ViewMode is gone +} + pub(crate) fn needs_lock(command: &Command) -> bool { match command { Command::Setup { .. } | Command::Config { .. } | Command::Doctor + | Command::Version | Command::Lock { .. } | Command::Psbt { .. } | Command::Offer { .. } => false, @@ -945,7 +959,7 @@ pub(crate) fn service_config(cli: &Cli) -> ServiceConfig<'_> { .as_deref() .unwrap_or("ZINC_WALLET_PASSWORD"), password_stdin: cli.password_stdin, - json: cli.json, + agent: cli.agent, network_override: cli.network.as_deref(), explicit_network: cli.explicit_network, scheme_override: cli.scheme.as_deref(), @@ -996,7 +1010,7 @@ pub(crate) fn wallet_password(cli: &Cli) -> Result { } pub(crate) fn confirm(prompt: &str, cli: &Cli) -> bool { - if cli.yes || cli.json { + if cli.yes || cli.agent { return true; } diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..ff9731a --- /dev/null +++ b/src/output.rs @@ -0,0 +1,1383 @@ +use serde::Serialize; +use serde_json::Value; +use zinc_core::{Account, TxItem}; + +#[derive(Serialize)] +pub struct BtcBalance { + pub immature: u64, + pub trusted_pending: u64, + pub untrusted_pending: u64, + pub confirmed: u64, +} + +#[derive(Serialize)] +#[serde(untagged)] +#[allow(dead_code)] +pub enum CommandOutput { + WalletInit { + profile: Option, + version: u32, + network: String, + scheme: String, + account_index: u32, + esplora_url: String, + ord_url: String, + bitcoin_cli: String, + bitcoin_cli_args: String, + phrase: String, + #[serde(skip_serializing_if = "Option::is_none")] + words: Option, + }, + WalletImport { + profile: Option, + network: String, + scheme: String, + account_index: u32, + imported: bool, + #[serde(skip_serializing_if = "Option::is_none")] + phrase: Option, + }, + WalletInfo { + profile: Option, + version: u32, + network: String, + scheme: String, + account_index: u32, + esplora_url: String, + ord_url: String, + bitcoin_cli: String, + bitcoin_cli_args: String, + has_persistence: bool, + has_inscriptions: bool, + updated_at_unix: u64, + }, + RevealMnemonic { + phrase: String, + words: usize, + }, + Address { + #[serde(rename = "type")] + kind: String, + address: String, + }, + Balance { + total: BtcBalance, + spendable: BtcBalance, + inscribed_sats: u64, + }, + AccountList { + accounts: Vec, + }, + AccountUse { + previous_account_index: u32, + account_index: u32, + taproot_address: String, + #[serde(skip_serializing_if = "Option::is_none")] + payment_address: Option, + }, + TxList { + transactions: Vec, + }, + PsbtCreate { + psbt: String, + }, + PsbtAnalyze { + analysis: Value, + safe_to_send: bool, + inscription_risk: String, + policy_reasons: Vec, + policy: Value, + }, + PsbtSign { + psbt: String, + safe_to_send: bool, + inscription_risk: String, + policy_reasons: Vec, + analysis: Value, + }, + PsbtBroadcast { + txid: String, + safe_to_send: bool, + inscription_risk: String, + policy_reasons: Vec, + analysis: Value, + }, + SyncChain { + events: Vec, + }, + SyncOrdinals { + inscriptions: usize, + }, + WaitTxConfirmed { + txid: String, + confirmation_time: Option, + confirmed: bool, + waited_secs: u64, + }, + WaitBalance { + confirmed: u64, + confirmed_balance: u64, + target: u64, + waited_secs: u64, + }, + SnapshotSave { + snapshot: String, + }, + SnapshotRestore { + restored: String, + }, + SnapshotList { + snapshots: Vec, + }, + ConfigShow { + config: Value, + }, + ConfigSet { + key: String, + value: String, + saved: bool, + }, + ConfigUnset { + key: String, + was_set: bool, + saved: bool, + }, + LockInfo { + profile: Option, + lock_path: String, + locked: bool, + owner_pid: Option, + created_at_unix: Option, + age_secs: Option, + }, + LockClear { + profile: Option, + lock_path: String, + cleared: bool, + }, + Doctor { + healthy: bool, + esplora_url: String, + esplora_reachable: bool, + ord_url: String, + ord_reachable: bool, + ord_indexing_height: Option, + ord_error: Option, + }, + InscriptionList { + inscriptions: Vec, + display_items: Option>, // Present if thumb mode is enabled, otherwise use table + thumb_mode_enabled: bool, + }, + OfferCreate { + inscription: String, + ask_sats: u64, + fee_rate_sat_vb: u64, + seller_address: String, + seller_outpoint: String, + seller_pubkey_hex: String, + expires_at_unix: i64, + thumbnail_lines: Option>, + hide_inscription_ids: bool, + raw_response: serde_json::Value, + }, + OfferPublish { + event_id: String, + accepted_relays: u64, + total_relays: u64, + publish_results: Vec, + raw_response: serde_json::Value, + }, + OfferDiscover { + event_count: u64, + offer_count: u64, + offers: Vec, + thumbnail_lines: Option>, + hide_inscription_ids: bool, + raw_response: serde_json::Value, + }, + OfferSubmitOrd { + ord_url: String, + submitted: bool, + raw_response: serde_json::Value, + }, + OfferListOrd { + ord_url: String, + count: u64, + offers: Vec, + raw_response: serde_json::Value, + }, + OfferAccept { + inscription: String, + ask_sats: u64, + txid: String, + dry_run: bool, + inscription_risk: String, + thumbnail_lines: Option>, + hide_inscription_ids: bool, + raw_response: serde_json::Value, + }, + Setup { + config_saved: bool, + wizard_used: bool, + profile: Option, + data_dir: String, + password_env: String, + default_network: String, + default_scheme: String, + default_esplora_url: String, + default_ord_url: String, + wallet_requested: bool, + wallet_initialized: bool, + wallet_mode: Option, + wallet_phrase: Option, + wallet_word_count: Option, + }, + ScenarioMine { + blocks: u64, + address: String, + raw_output: String, + }, + ScenarioFund { + address: String, + amount_btc: String, + txid: String, + mine_blocks: u64, + mine_address: String, + generated_blocks: String, + }, + ScenarioReset { + removed: Vec, + }, + Generic(Value), +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct InscriptionItemDisplay { + pub number: i64, + pub id: String, + pub value_sats: String, + pub content_type: String, + pub badge_lines: Vec, + /// Raw image bytes for viuer to render directly to stdout. + pub image_bytes: Option>, +} + +pub trait Presenter { + fn render(&self, output: &CommandOutput) -> String; +} + +fn pluralize(value: u64, unit: &str) -> String { + if value == 1 { + format!("1 {unit}") + } else { + format!("{value} {unit}s") + } +} + +fn format_relative_age(updated_at_unix: u64) -> String { + let now_unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + if updated_at_unix > now_unix { + let diff = updated_at_unix - now_unix; + let future = if diff < 60 { + pluralize(diff, "second") + } else if diff < 3_600 { + pluralize(diff / 60, "minute") + } else if diff < 86_400 { + pluralize(diff / 3_600, "hour") + } else if diff < 604_800 { + pluralize(diff / 86_400, "day") + } else if diff < 2_592_000 { + pluralize(diff / 604_800, "week") + } else if diff < 31_536_000 { + pluralize(diff / 2_592_000, "month") + } else { + pluralize(diff / 31_536_000, "year") + }; + return format!("in {future}"); + } + + let diff = now_unix - updated_at_unix; + if diff < 5 { + "just now".to_string() + } else if diff < 60 { + format!("{} ago", pluralize(diff, "second")) + } else if diff < 3_600 { + format!("{} ago", pluralize(diff / 60, "minute")) + } else if diff < 86_400 { + format!("{} ago", pluralize(diff / 3_600, "hour")) + } else if diff < 604_800 { + format!("{} ago", pluralize(diff / 86_400, "day")) + } else if diff < 2_592_000 { + format!("{} ago", pluralize(diff / 604_800, "week")) + } else if diff < 31_536_000 { + format!("{} ago", pluralize(diff / 2_592_000, "month")) + } else { + format!("{} ago", pluralize(diff / 31_536_000, "year")) + } +} + +pub struct AgentPresenter; + +impl AgentPresenter { + pub fn new() -> Self { + Self + } +} + +impl Presenter for AgentPresenter { + fn render(&self, output: &CommandOutput) -> String { + match output { + CommandOutput::ConfigShow { config } => { + serde_json::to_string_pretty(config).unwrap_or_default() + } + CommandOutput::Setup { + config_saved, + wizard_used, + profile, + data_dir, + password_env, + default_network, + default_scheme, + default_esplora_url, + default_ord_url, + wallet_requested, + wallet_initialized, + wallet_mode, + wallet_phrase, + wallet_word_count, + } => { + let wallet_info = if *wallet_requested || *wallet_initialized { + serde_json::json!({ + "wallet_initialized": wallet_initialized, + "mode": wallet_mode, + "phrase": wallet_phrase, + "word_count": wallet_word_count, + }) + } else { + serde_json::json!({ + "requested": false, + "initialized": false, + }) + }; + + let base = serde_json::json!({ + "config_saved": config_saved, + "wizard_used": wizard_used, + "profile": profile, + "data_dir": data_dir, + "password_env": password_env, + "defaults": { + "network": default_network, + "scheme": default_scheme, + "esplora_url": default_esplora_url, + "ord_url": default_ord_url, + }, + "wallet": wallet_info + }); + serde_json::to_string_pretty(&base).unwrap_or_default() + } + _ => serde_json::to_string_pretty(output).unwrap_or_default(), + } + } +} + +pub struct HumanPresenter { + #[allow(dead_code)] + pub use_color: bool, +} + +impl HumanPresenter { + pub fn new(use_color: bool) -> Self { + Self { use_color } + } + + fn print_doctor(&self, output: &CommandOutput) -> String { + if let CommandOutput::Doctor { + healthy, + esplora_url, + esplora_reachable, + ord_url, + ord_reachable, + ord_indexing_height, + ord_error, + } = output + { + use console::style; + let mut out = String::new(); + let status = if *healthy { + style("Healthy").green() + } else { + style("Unhealthy").red() + }; + out.push_str(&format!("{} {}\n\n", style("Status:").bold(), status)); + + out.push_str(&format!( + "{} {}\n", + style("Esplora RPC:").bold(), + esplora_url + )); + out.push_str(&format!( + " Reachable: {}\n", + if *esplora_reachable { + style("Yes").green() + } else { + style("No").red() + } + )); + + out.push_str(&format!("{} {}\n", style("Ord RPC:").bold(), ord_url)); + out.push_str(&format!( + " Reachable: {}\n", + if *ord_reachable { + style("Yes").green() + } else { + style("No").red() + } + )); + if let Some(h) = ord_indexing_height { + out.push_str(&format!(" Height: {}\n", h)); + } + if let Some(e) = ord_error { + out.push_str(&format!(" Error: {}\n", style(e).red())); + } + out + } else { + String::new() // Should not happen + } + } + + fn print_inscription_list( + &self, + inscriptions: &[zinc_core::ordinals::Inscription], + display_items: &Option>, + thumb_mode_enabled: bool, + ) -> String { + use crate::presenter::thumbnail::print_thumbnail_at; + use console::style; + + let mut out = String::new(); + if let Some(items) = display_items { + let term_width = { + let (_, cols) = console::Term::stdout().size(); + if cols > 0 { + cols as usize + } else { + 120 + } + }; + + let card_width: u32 = 24; + let gutter: usize = 2; + let cards_per_row = ((term_width + gutter) / (card_width as usize + gutter)).max(1); + + for row_items in items.chunks(cards_per_row) { + // 1. Print all headers for the row + for (col, item) in row_items.iter().enumerate() { + let x_offset = col * (card_width as usize + gutter); + let header = format!("{}", style(format!("#{}", item.number)).bold().cyan()); + + if col > 0 { + print!("\x1b[{}G{header}", x_offset + 1); + } else { + print!("{header}"); + } + } + println!(); + + // 2. Pre-allocate vertical space + // Printing images near the bottom of the terminal triggers scrolling. + // The ANSI Save Cursor (\x1b[s) uses absolute screen row. If the screen scrolls + // between Image 1 and Image 2, Image 2's restored Y-coordinate is physically lower! + // We pre-allocate space by printing newlines, then returning up, to guarantee + // the viewport won't scroll while viuer prints the row of images. + let space_to_reserve = 14; + for _ in 0..space_to_reserve { + println!(); + } + // move back up + print!("\x1b[{}A", space_to_reserve); + + // 3. Print all images for the row + // Each print_thumbnail_at call uses viuer's restore_cursor to return to this line + let mut max_img_height: u32 = 0; + for (col, item) in row_items.iter().enumerate() { + let x_offset = col * (card_width as usize + gutter); + if let Some(ref bytes) = item.image_bytes { + let img_height = print_thumbnail_at(bytes, card_width, x_offset as u16); + if let Some((_, h)) = img_height { + max_img_height = max_img_height.max(h); + } + } + } + + // 3. Move cursor down past the tallest image + if max_img_height > 0 { + print!("\x1b[{}B\r", max_img_height); + } + + // 4. Print all badge lines for the row + let max_badge_lines = row_items + .iter() + .map(|i| i.badge_lines.len()) + .max() + .unwrap_or(0); + for line_idx in 0..max_badge_lines { + for (col, item) in row_items.iter().enumerate() { + if let Some(line) = item.badge_lines.get(line_idx) { + let x_offset = col * (card_width as usize + gutter); + if col > 0 { + print!("\x1b[{}G{line}", x_offset + 1); + } else { + print!("{line}"); + } + } + } + println!(); + } + + // Blank line between rows + println!(); + } + + if inscriptions.len() > items.len() { + out.push_str(&format!( + "... and {} more inscriptions\n", + inscriptions.len() - items.len() + )); + } + } else if !thumb_mode_enabled { + let table = crate::presenter::inscription::format_inscriptions(inscriptions); + out.push_str(&format!("{table}\n")); + } + out + } + + fn print_offer_create(&self, output: &CommandOutput) -> String { + if let CommandOutput::OfferCreate { + inscription, + ask_sats, + fee_rate_sat_vb, + seller_address, + seller_outpoint, + seller_pubkey_hex, + expires_at_unix, + thumbnail_lines, + hide_inscription_ids, + .. + } = output + { + let mut out = String::new(); + if let Some(lines) = thumbnail_lines { + for line in lines { + out.push_str(&format!("{line}\n")); + } + } + let mut lines = vec!["OFFER CREATE".to_string()]; + if *hide_inscription_ids { + lines.push("inscription: [thumbnail shown above]".to_string()); + } else { + lines.push(format!( + "inscription: {}", + crate::commands::offer::abbreviate(inscription, 12, 8) + )); + } + lines.push(format!( + "ask: {} sats @ {} sat/vB", + ask_sats, fee_rate_sat_vb + )); + lines.push(format!( + "seller input: {}", + crate::commands::offer::abbreviate(seller_address, 12, 8) + )); + lines.push(format!( + "outpoint: {}", + crate::commands::offer::abbreviate(seller_outpoint, 16, 6) + )); + + lines.push(format!( + "seller pubkey: {}", + crate::commands::offer::abbreviate(seller_pubkey_hex, 10, 6) + )); + lines.push(format!("expires_at: {expires_at_unix}")); + out.push_str(&format!("{}\n", lines.join("\n"))); + out + } else { + String::new() + } + } + + fn print_offer_publish(&self, output: &CommandOutput) -> String { + if let CommandOutput::OfferPublish { + event_id, + accepted_relays, + total_relays, + publish_results, + .. + } = output + { + let mut out = String::new(); + let mut lines = vec![ + "OFFER PUBLISH".to_string(), + format!( + "event: {}", + crate::commands::offer::abbreviate(event_id, 12, 8) + ), + format!("accepted relays: {accepted_relays}/{total_relays}"), + ]; + for result in publish_results.iter().take(3) { + let relay = result + .get("relay_url") + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + let accepted = result + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let status = if accepted { "ok" } else { "reject" }; + lines.push(format!("{status}: {relay}")); + } + if publish_results.len() > 3 { + lines.push(format!("... and {} more relays", publish_results.len() - 3)); + } + out.push_str(&format!("{}\n", lines.join("\n"))); + out + } else { + String::new() + } + } + + fn print_offer_discover(&self, output: &CommandOutput) -> String { + if let CommandOutput::OfferDiscover { + event_count, + offer_count, + offers, + thumbnail_lines, + hide_inscription_ids, + .. + } = output + { + let mut out = String::new(); + if let Some(lines) = thumbnail_lines { + for line in lines { + out.push_str(&format!("{line}\n")); + } + } + let mut lines = vec![ + "OFFER DISCOVER".to_string(), + format!("decoded offers: {offer_count} (events: {event_count})"), + ]; + for (idx, entry) in offers.iter().take(8).enumerate() { + let event_id = entry + .get("event_id") + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + let offer = entry.get("offer").and_then(serde_json::Value::as_object); + let inscription = offer + .and_then(|o| o.get("inscription_id")) + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + let ask_sats = offer + .and_then(|o| o.get("ask_sats")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let seller = offer + .and_then(|o| o.get("seller_pubkey_hex")) + .and_then(serde_json::Value::as_str) + .unwrap_or("-"); + + if *hide_inscription_ids { + lines.push(format!( + "{:>2}. ask {} sats | seller {} | evt {}", + idx + 1, + ask_sats, + crate::commands::offer::abbreviate(seller, 10, 6), + crate::commands::offer::abbreviate(event_id, 10, 6) + )); + } else { + lines.push(format!( + "{:>2}. {} | {} sats | seller {} | evt {}", + idx + 1, + crate::commands::offer::abbreviate(inscription, 12, 8), + ask_sats, + crate::commands::offer::abbreviate(seller, 10, 6), + crate::commands::offer::abbreviate(event_id, 10, 6) + )); + } + } + if offers.len() > 8 { + lines.push(format!("... and {} more offers", offers.len() - 8)); + } + out.push_str(&format!("{}\n", lines.join("\n"))); + out + } else { + String::new() + } + } + + fn print_offer_submit_ord(&self, output: &CommandOutput) -> String { + if let CommandOutput::OfferSubmitOrd { ord_url, .. } = output { + format!("OFFER SUBMIT-ORD\nsubmitted: true\nord endpoint: {ord_url}\n") + } else { + String::new() + } + } + + fn print_offer_list_ord(&self, output: &CommandOutput) -> String { + if let CommandOutput::OfferListOrd { + ord_url, + count, + offers, + .. + } = output + { + let mut out = String::new(); + let mut lines = vec![ + "OFFER LIST-ORD".to_string(), + format!("count: {count}"), + format!("ord endpoint: {ord_url}"), + ]; + for (idx, psbt) in offers.iter().take(3).enumerate() { + if let Some(psbt_str) = psbt.as_str() { + lines.push(format!( + "{:>2}. {}", + idx + 1, + crate::commands::offer::abbreviate(psbt_str, 14, 8) + )); + } + } + out.push_str(&format!("{}\n", lines.join("\n"))); + out + } else { + String::new() + } + } + + fn print_offer_accept(&self, output: &CommandOutput) -> String { + if let CommandOutput::OfferAccept { + inscription, + ask_sats, + txid, + dry_run, + inscription_risk, + thumbnail_lines, + hide_inscription_ids, + .. + } = output + { + let mut out = String::new(); + if let Some(lines) = thumbnail_lines { + for line in lines { + out.push_str(&format!("{line}\n")); + } + } + let mut lines = vec!["OFFER ACCEPT".to_string()]; + if *hide_inscription_ids { + lines.push("inscription: [thumbnail shown above]".to_string()); + } else { + lines.push(format!( + "inscription: {}", + crate::commands::offer::abbreviate(inscription, 12, 8) + )); + } + lines.push(format!("ask: {ask_sats} sats")); + lines.push(format!( + "mode: {}", + if *dry_run { "dry-run" } else { "broadcast" } + )); + lines.push(format!("inscription risk: {inscription_risk}")); + if txid != "-" { + lines.push(format!( + "txid: {}", + crate::commands::offer::abbreviate(txid, 12, 8) + )); + } + out.push_str(&format!("{}\n", lines.join("\n"))); + out + } else { + String::new() + } + } +} + +impl Presenter for HumanPresenter { + fn render(&self, output: &CommandOutput) -> String { + use console::style; + match output { + CommandOutput::WalletInfo { + profile, + network, + scheme, + account_index, + esplora_url, + ord_url, + has_persistence, + has_inscriptions, + updated_at_unix, + .. + } => { + let mut out = String::new(); + out.push_str(&format!( + " {:<12} {}\n", + style("Profile").dim(), + profile.as_deref().unwrap_or("default") + )); + out.push_str(&format!(" {:<12} {}\n", style("Network").dim(), network)); + out.push_str(&format!(" {:<12} {}\n", style("Scheme").dim(), scheme)); + out.push_str(&format!( + " {:<12} {}\n", + style("Account").dim(), + account_index + )); + out.push_str(&format!( + " {:<12} {}\n", + style("Esplora").dim(), + esplora_url + )); + out.push_str(&format!(" {:<12} {}\n", style("Ord").dim(), ord_url)); + + let check = style("✓").green(); + let dash = style("-").dim(); + out.push_str(&format!( + " {:<12} {}\n", + style("Storage").dim(), + if *has_persistence { &check } else { &dash } + )); + out.push_str(&format!( + " {:<12} {}\n", + style("Inscriptions").dim(), + if *has_inscriptions { &check } else { &dash } + )); + let time_str = format_relative_age(*updated_at_unix); + out.push_str(&format!(" {:<12} {}\n", style("Updated").dim(), time_str)); + out + } + CommandOutput::WalletInit { + profile, + network, + phrase, + words, + .. + } => { + let mut out = format!("{} {}\n", style("✓").green().bold(), "Wallet initialized"); + out.push_str(&format!( + " {:<12} {}\n", + style("Profile").dim(), + profile.as_deref().unwrap_or("default") + )); + out.push_str(&format!(" {:<12} {}\n", style("Network").dim(), network)); + out.push_str(&format!( + " {:<12} {}\n", + style("Phrase").dim(), + if phrase.contains(" { + let mut out = format!("{} {}\n", style("✓").green().bold(), "Wallet imported"); + out.push_str(&format!( + " {:<12} {}\n", + style("Profile").dim(), + profile.as_deref().unwrap_or("default") + )); + out.push_str(&format!(" {:<12} {}\n", style("Network").dim(), network)); + if let Some(p) = phrase { + out.push_str(&format!( + " {:<12} {}\n", + style("Phrase").dim(), + if p.contains(" { + let mut out = String::new(); + out.push_str(&format!(" {:<12} {}\n", style("Phrase").dim(), phrase)); + out.push_str(&format!(" {:<12} {}\n", style("Words").dim(), words)); + out + } + CommandOutput::Address { kind, address } => { + let mut table = comfy_table::Table::new(); + table.set_header(vec![ + comfy_table::Cell::new("Type").fg(comfy_table::Color::Cyan), + comfy_table::Cell::new("Address").fg(comfy_table::Color::Green), + ]); + table.add_row(vec![ + comfy_table::Cell::new(kind), + comfy_table::Cell::new(address), + ]); + format!("{table}") + } + CommandOutput::Balance { + total, + spendable, + inscribed_sats, + } => { + let mut table = comfy_table::Table::new(); + table.set_header(vec![ + comfy_table::Cell::new("Type").fg(comfy_table::Color::Cyan), + comfy_table::Cell::new("Confirmed (sats)").fg(comfy_table::Color::Green), + comfy_table::Cell::new("Trusted Pending (sats)").fg(comfy_table::Color::Yellow), + comfy_table::Cell::new("Untrusted Pending (sats)") + .fg(comfy_table::Color::Magenta), + ]); + table.add_row(vec![ + comfy_table::Cell::new("Total (Combined)"), + comfy_table::Cell::new(total.confirmed.to_string()), + comfy_table::Cell::new(total.trusted_pending.to_string()), + comfy_table::Cell::new(total.untrusted_pending.to_string()), + ]); + table.add_row(vec![ + comfy_table::Cell::new("Spendable (Safe)"), + comfy_table::Cell::new(spendable.confirmed.to_string()), + comfy_table::Cell::new(spendable.trusted_pending.to_string()), + comfy_table::Cell::new(spendable.untrusted_pending.to_string()), + ]); + table.add_row(vec![ + comfy_table::Cell::new("Inscribed/Protected"), + comfy_table::Cell::new("-"), + comfy_table::Cell::new("-"), + comfy_table::Cell::new(inscribed_sats.to_string()), + ]); + format!("{table}") + } + CommandOutput::AccountList { accounts } => { + let mut table = comfy_table::Table::new(); + table.set_header(vec![ + comfy_table::Cell::new("Index").fg(comfy_table::Color::Cyan), + comfy_table::Cell::new("Label").fg(comfy_table::Color::Green), + comfy_table::Cell::new("Taproot Address").fg(comfy_table::Color::Yellow), + ]); + for account in accounts { + table.add_row(vec![ + comfy_table::Cell::new(account.index.to_string()), + comfy_table::Cell::new(&account.label), + comfy_table::Cell::new(&account.taproot_address), + ]); + } + format!("{table}") + } + CommandOutput::AccountUse { + account_index, + taproot_address, + payment_address, + .. + } => { + let mut out = format!("{} {}\n", style("✓").green().bold(), "Switched account"); + out.push_str(&format!( + " {:<12} {}\n", + style("Index").dim(), + account_index + )); + out.push_str(&format!( + " {:<12} {}\n", + style("Taproot").dim(), + taproot_address + )); + if let Some(payment) = payment_address { + out.push_str(&format!(" {:<12} {}\n", style("Payment").dim(), payment)); + } + out + } + CommandOutput::TxList { transactions } => { + let mut table = comfy_table::Table::new(); + table.set_header(vec![ + comfy_table::Cell::new("TxID").fg(comfy_table::Color::Cyan), + comfy_table::Cell::new("Type").fg(comfy_table::Color::Green), + comfy_table::Cell::new("Amount (sats)").fg(comfy_table::Color::Yellow), + comfy_table::Cell::new("Fee (sats)").fg(comfy_table::Color::Magenta), + comfy_table::Cell::new("Confirm Time (UTC)").fg(comfy_table::Color::Blue), + comfy_table::Cell::new("Inscription #s").fg(comfy_table::Color::Red), + ]); + + for tx in transactions { + let time_str = tx + .confirmation_time + .map(|t| { + let d = std::time::UNIX_EPOCH + std::time::Duration::from_secs(t); + let datetime: chrono::DateTime = d.into(); + datetime.format("%Y-%m-%d %H:%M:%S").to_string() + }) + .unwrap_or_else(|| "Unconfirmed".to_string()); + + let ins_str = if tx.inscriptions.is_empty() { + "-".to_string() + } else { + tx.inscriptions + .iter() + .map(|i| format!("#{}", i.number)) + .collect::>() + .join(", ") + }; + + table.add_row(vec![ + comfy_table::Cell::new(crate::commands::offer::abbreviate(&tx.txid, 6, 6)), + comfy_table::Cell::new(&tx.tx_type), + comfy_table::Cell::new(tx.amount_sats.to_string()), + comfy_table::Cell::new(tx.fee_sats.to_string()), + comfy_table::Cell::new(time_str), + comfy_table::Cell::new(ins_str), + ]); + } + + format!("{table}") + } + CommandOutput::PsbtCreate { psbt } => { + let mut out = format!("{} {}\n", style("✓").green().bold(), "PSBT Created"); + out.push_str(&format!(" {:<12} {}\n", style("PSBT").dim(), psbt)); + out + } + CommandOutput::PsbtAnalyze { + safe_to_send, + inscription_risk, + policy_reasons, + .. + } => { + let mut out = format!("{} {}\n", style("ℹ").blue().bold(), "PSBT Analysis"); + let risk_style = if *safe_to_send { + style(inscription_risk.as_str()).green() + } else { + style(inscription_risk.as_str()).red() + }; + out.push_str(&format!( + " {:<12} {}\n", + style("Safe").dim(), + if *safe_to_send { "yes" } else { "no" } + )); + out.push_str(&format!(" {:<12} {}\n", style("Risk").dim(), risk_style)); + if !policy_reasons.is_empty() { + out.push_str(&format!(" {:<12}\n", style("Reasons").dim())); + for r in policy_reasons { + out.push_str(&format!(" - {}\n", r)); + } + } + out + } + CommandOutput::PsbtSign { + psbt, + safe_to_send, + inscription_risk, + .. + } => { + let mut out = format!("{} {}\n", style("✓").green().bold(), "PSBT Signed"); + let risk_style = if *safe_to_send { + style(inscription_risk.as_str()).green() + } else { + style(inscription_risk.as_str()).red() + }; + out.push_str(&format!( + " {:<12} {}\n", + style("Safe").dim(), + if *safe_to_send { "yes" } else { "no" } + )); + out.push_str(&format!(" {:<12} {}\n", style("Risk").dim(), risk_style)); + out.push_str(&format!(" {:<12} {}\n", style("PSBT").dim(), psbt)); + out + } + CommandOutput::PsbtBroadcast { + txid, + safe_to_send, + inscription_risk, + .. + } => { + let mut out = format!("{} {}\n", style("✓").green().bold(), "PSBT Broadcasted"); + let risk_style = if *safe_to_send { + style(inscription_risk.as_str()).green() + } else { + style(inscription_risk.as_str()).red() + }; + out.push_str(&format!( + " {:<12} {}\n", + style("Safe").dim(), + if *safe_to_send { "yes" } else { "no" } + )); + out.push_str(&format!(" {:<12} {}\n", style("Risk").dim(), risk_style)); + out.push_str(&format!(" {:<12} {}\n", style("TxID").dim(), txid)); + out + } + CommandOutput::SyncChain { events } => { + format!( + "{} Synced {} events\n", + style("✓").green().bold(), + events.len() + ) + } + CommandOutput::SyncOrdinals { inscriptions } => { + format!( + "{} Synced {} inscriptions\n", + style("✓").green().bold(), + inscriptions + ) + } + CommandOutput::WaitTxConfirmed { + txid, waited_secs, .. + } => { + format!( + "{} Tx {} confirmed after {}s\n", + style("✓").green().bold(), + txid, + waited_secs + ) + } + CommandOutput::WaitBalance { + confirmed_balance, + target, + waited_secs, + .. + } => { + format!( + "{} Target balance {} reached (current: {}) after {}s\n", + style("✓").green().bold(), + target, + confirmed_balance, + waited_secs + ) + } + CommandOutput::SnapshotSave { snapshot } => { + format!( + "{} Saved snapshot to {}\n", + style("✓").green().bold(), + snapshot + ) + } + CommandOutput::SnapshotRestore { restored } => { + format!( + "{} Restored snapshot from {}\n", + style("✓").green().bold(), + restored + ) + } + CommandOutput::SnapshotList { snapshots } => { + let mut table = comfy_table::Table::new(); + table.set_header(vec![ + comfy_table::Cell::new("Name").fg(comfy_table::Color::Cyan), + comfy_table::Cell::new("Path").fg(comfy_table::Color::Green), + ]); + + for path_str in snapshots { + let path = std::path::Path::new(path_str); + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(path_str); + table.add_row(vec![ + comfy_table::Cell::new(name), + comfy_table::Cell::new(path_str), + ]); + } + format!("{table}") + } + CommandOutput::ConfigShow { config } => { + let mut table = comfy_table::Table::new(); + table.set_header(vec![ + comfy_table::Cell::new("Setting").fg(comfy_table::Color::Cyan), + comfy_table::Cell::new("Value").fg(comfy_table::Color::Green), + ]); + + if let Some(obj) = config.as_object() { + for (k, v) in obj { + let val_str = match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "-".to_string(), + _ => v.to_string(), + }; + table.add_row(vec![ + comfy_table::Cell::new(k), + comfy_table::Cell::new(val_str), + ]); + } + } + format!("{table}") + } + CommandOutput::ConfigSet { key, value, .. } => { + format!("{} Set {} to {}\n", style("✓").green().bold(), key, value) + } + CommandOutput::ConfigUnset { key, was_set, .. } => { + if *was_set { + format!("{} Unset {}\n", style("✓").green().bold(), key) + } else { + format!("{} {} was not set\n", style("ℹ").blue().bold(), key) + } + } + CommandOutput::LockInfo { + lock_path, + locked, + owner_pid, + age_secs, + .. + } => { + let mut out = format!("{} {}\n", style("ℹ").blue().bold(), "Lock Status"); + let status = if *locked { + style("Locked").red() + } else { + style("Unlocked").green() + }; + out.push_str(&format!(" {:<12} {}\n", style("Status").dim(), status)); + out.push_str(&format!(" {:<12} {}\n", style("Path").dim(), lock_path)); + if let Some(pid) = owner_pid { + out.push_str(&format!(" {:<12} {}\n", style("Owner PID").dim(), pid)); + } + if let Some(age) = age_secs { + out.push_str(&format!(" {:<12} {}s\n", style("Age").dim(), age)); + } + out + } + CommandOutput::LockClear { + lock_path, cleared, .. + } => { + if *cleared { + format!( + "{} Cleared lock at {}\n", + style("✓").green().bold(), + lock_path + ) + } else { + format!( + "{} No lock to clear at {}\n", + style("ℹ").blue().bold(), + lock_path + ) + } + } + CommandOutput::Doctor { .. } => self.print_doctor(output), + CommandOutput::Setup { + config_saved, + wizard_used, + profile, + default_network, + default_scheme, + wallet_initialized, + wallet_mode, + wallet_phrase, + .. + } => { + let mut out = String::new(); + out.push_str(&format!("{} Setup complete\n", style("✓").green().bold())); + out.push_str(&format!( + " {:<15} {}\n", + style("Config Saved:").dim(), + config_saved + )); + out.push_str(&format!( + " {:<15} {}\n", + style("Wizard Used:").dim(), + wizard_used + )); + out.push_str(&format!( + " {:<15} {}\n", + style("Profile:").dim(), + profile.as_deref().unwrap_or("default") + )); + out.push_str(&format!( + " {:<15} {}\n", + style("Network:").dim(), + default_network + )); + out.push_str(&format!( + " {:<15} {}\n", + style("Scheme:").dim(), + default_scheme + )); + if *wallet_initialized { + out.push_str(&format!( + " {:<15} {}\n", + style("Wallet:").dim(), + "Initialized" + )); + if let Some(mode) = wallet_mode { + out.push_str(&format!(" {:<15} {}\n", style("Mode:").dim(), mode)); + } + if let Some(phrase) = wallet_phrase { + if phrase != "" { + out.push_str(&format!( + "\n{}\n{}\n", + style("Mnemonic Phrase (keep this safe!):").red().bold(), + phrase + )); + } else { + out.push_str(&format!(" {:<15} {}\n", style("Phrase:").dim(), phrase)); + } + } + } + out + } + CommandOutput::ScenarioMine { + blocks, + address, + raw_output, + } => { + format!( + "{} Mined {} blocks to {}\nOutput:\n{}\n", + style("✓").green().bold(), + blocks, + address, + raw_output + ) + } + CommandOutput::ScenarioFund { + address, + amount_btc, + txid, + mine_blocks, + mine_address, + generated_blocks, + } => { + format!( + "{} Funded {} with {} BTC\nTxID: {}\n{} Mined {} blocks to {}\nOutput:\n{}\n", + style("✓").green().bold(), + address, + amount_btc, + txid, + style("✓").green().bold(), + mine_blocks, + mine_address, + generated_blocks + ) + } + CommandOutput::ScenarioReset { removed } => { + let mut out = format!("{} Scenario Reset\n", style("✓").green().bold()); + for path in removed { + out.push_str(&format!(" Removed: {}\n", path)); + } + out + } + CommandOutput::InscriptionList { + inscriptions, + display_items, + thumb_mode_enabled, + } => self.print_inscription_list(inscriptions, display_items, *thumb_mode_enabled), + CommandOutput::OfferCreate { .. } => self.print_offer_create(output), + CommandOutput::OfferPublish { .. } => self.print_offer_publish(output), + CommandOutput::OfferDiscover { .. } => self.print_offer_discover(output), + CommandOutput::OfferSubmitOrd { .. } => self.print_offer_submit_ord(output), + CommandOutput::OfferListOrd { .. } => self.print_offer_list_ord(output), + CommandOutput::OfferAccept { .. } => self.print_offer_accept(output), + CommandOutput::Generic(val) => { + // Fallback for human mode when a command hasn't been fully refactored yet + serde_json::to_string_pretty(val).unwrap_or_default() + } + } + } +} diff --git a/src/paths.rs b/src/paths.rs index f6871cb..7f0f27c 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -5,6 +5,40 @@ use std::path::{Path, PathBuf}; use crate::lock::now_unix; use std::process; +pub fn create_secure_dir_all>(path: P) -> std::io::Result<()> { + let path = path.as_ref(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + builder.mode(0o700); + builder.create(path) + } + #[cfg(not(unix))] + { + fs::create_dir_all(path) + } +} + +pub fn write_secure_file>(path: P, contents: &[u8]) -> std::io::Result<()> { + let path = path.as_ref(); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + use std::io::Write; + let mut options = fs::OpenOptions::new(); + options.write(true).create(true).truncate(true).mode(0o600); + let mut file = options.open(path)?; + file.write_all(contents)?; + file.sync_all() + } + #[cfg(not(unix))] + { + fs::write(path, contents) + } +} + pub fn data_dir(config: &crate::config::ServiceConfig<'_>) -> std::path::PathBuf { if let Some(path) = config.data_dir { path.to_path_buf() @@ -25,7 +59,7 @@ pub fn profile_path(config: &crate::config::ServiceConfig<'_>) -> Result) -> Result) -> Result { let root = data_dir(config); let directory = root.join("snapshots").join(config.profile); - fs::create_dir_all(&directory) + create_secure_dir_all(&directory) .map_err(|e| AppError::Config(format!("failed to create snapshot dir: {e}")))?; Ok(directory) } pub fn write_bytes_atomic(path: &Path, bytes: &[u8], label: &str) -> Result<(), AppError> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) + create_secure_dir_all(parent) .map_err(|e| AppError::Config(format!("failed to create dir for {label}: {e}")))?; } let tmp_name = format!( @@ -56,7 +90,7 @@ pub fn write_bytes_atomic(path: &Path, bytes: &[u8], label: &str) -> Result<(), ); let tmp_path = path.with_file_name(tmp_name); - fs::write(&tmp_path, bytes) + write_secure_file(&tmp_path, bytes) .map_err(|e| AppError::Config(format!("failed to write temp {label}: {e}")))?; if let Err(e) = fs::rename(&tmp_path, path) { let _ = fs::remove_file(&tmp_path); diff --git a/src/presenter/account.rs b/src/presenter/account.rs deleted file mode 100644 index ddbc4f4..0000000 --- a/src/presenter/account.rs +++ /dev/null @@ -1,21 +0,0 @@ -use comfy_table::{Cell, Color, Table}; -use zinc_core::Account; - -pub fn format_accounts(accounts: &[Account]) -> Table { - let mut table = Table::new(); - table.set_header(vec![ - Cell::new("Index").fg(Color::Cyan), - Cell::new("Label").fg(Color::Green), - Cell::new("Taproot Address").fg(Color::Yellow), - ]); - - for account in accounts { - table.add_row(vec![ - Cell::new(account.index.to_string()), - Cell::new(&account.label), - Cell::new(&account.taproot_address), - ]); - } - - table -} diff --git a/src/presenter/address.rs b/src/presenter/address.rs deleted file mode 100644 index 73caec1..0000000 --- a/src/presenter/address.rs +++ /dev/null @@ -1,13 +0,0 @@ -use comfy_table::{Cell, Color, Table}; - -pub fn format_address(address_type: &str, address: &str) -> Table { - let mut table = Table::new(); - table.set_header(vec![ - Cell::new("Type").fg(Color::Cyan), - Cell::new("Address").fg(Color::Green), - ]); - - table.add_row(vec![Cell::new(address_type), Cell::new(address)]); - - table -} diff --git a/src/presenter/balance.rs b/src/presenter/balance.rs deleted file mode 100644 index a07d533..0000000 --- a/src/presenter/balance.rs +++ /dev/null @@ -1,35 +0,0 @@ -use comfy_table::{Cell, Color, Table}; -use zinc_core::ZincBalance; - -pub fn format_balance(balance: &ZincBalance) -> Table { - let mut table = Table::new(); - table.set_header(vec![ - Cell::new("Type").fg(Color::Cyan), - Cell::new("Confirmed (sats)").fg(Color::Green), - Cell::new("Trusted Pending (sats)").fg(Color::Yellow), - Cell::new("Untrusted Pending (sats)").fg(Color::Magenta), - ]); - - table.add_row(vec![ - Cell::new("Total (Combined)"), - Cell::new(balance.total.confirmed.to_sat().to_string()), - Cell::new(balance.total.trusted_pending.to_sat().to_string()), - Cell::new(balance.total.untrusted_pending.to_sat().to_string()), - ]); - - table.add_row(vec![ - Cell::new("Spendable (Safe)"), - Cell::new(balance.spendable.confirmed.to_sat().to_string()), - Cell::new(balance.spendable.trusted_pending.to_sat().to_string()), - Cell::new(balance.spendable.untrusted_pending.to_sat().to_string()), - ]); - - table.add_row(vec![ - Cell::new("Inscribed/Protected"), - Cell::new("-"), - Cell::new("-"), - Cell::new(balance.inscribed.to_string()), - ]); - - table -} diff --git a/src/presenter/config.rs b/src/presenter/config.rs deleted file mode 100644 index f069bdd..0000000 --- a/src/presenter/config.rs +++ /dev/null @@ -1,25 +0,0 @@ -use comfy_table::{Cell, Color, Table}; -use serde_json::Value; - -pub fn format_config(config: &Value) -> Table { - let mut table = Table::new(); - table.set_header(vec![ - Cell::new("Setting").fg(Color::Cyan), - Cell::new("Value").fg(Color::Green), - ]); - - if let Some(obj) = config.as_object() { - for (k, v) in obj { - let val_str = match v { - Value::String(s) => s.clone(), - Value::Number(n) => n.to_string(), - Value::Bool(b) => b.to_string(), - Value::Null => "-".to_string(), - _ => v.to_string(), - }; - table.add_row(vec![Cell::new(k), Cell::new(val_str)]); - } - } - - table -} diff --git a/src/presenter/grid.rs b/src/presenter/grid.rs new file mode 100644 index 0000000..5ce042f --- /dev/null +++ b/src/presenter/grid.rs @@ -0,0 +1,160 @@ +/// Grid layout renderer for arranging "cards" (header + thumbnail) side-by-side +/// in pure terminal output. Handles ANSI escape sequences when calculating +/// visible column widths. + +/// Strip ANSI escape sequences to measure visible character width. +fn visible_len(s: &str) -> usize { + let mut len = 0usize; + let mut in_escape = false; + for ch in s.chars() { + if in_escape { + if ch.is_ascii_alphabetic() { + in_escape = false; + } + continue; + } + if ch == '\x1b' { + in_escape = true; + continue; + } + len += 1; + } + len +} + +/// Pad a string (which may contain ANSI escapes) to a target *visible* width +/// by appending spaces. +fn pad_to_visible(s: &str, target: usize) -> String { + let vis = visible_len(s); + if vis >= target { + s.to_string() + } else { + format!("{}{}", s, " ".repeat(target - vis)) + } +} + +/// A single card to be placed in the grid. +pub struct GridCard { + /// Lines of text (may contain ANSI escape sequences). + pub lines: Vec, +} + +impl GridCard { + /// Maximum visible width across all lines. + pub fn visible_width(&self) -> usize { + self.lines.iter().map(|l| visible_len(l)).max().unwrap_or(0) + } +} + +/// Arrange cards into a grid and return the composed output string. +/// +/// * `cards` – the cards to lay out +/// * `max_cols` – maximum number of columns in the terminal (e.g. 120) +/// * `gutter` – number of blank columns between cards +pub fn render_grid(cards: &[GridCard], max_cols: usize, gutter: usize) -> String { + if cards.is_empty() { + return String::new(); + } + + // Determine how many cards fit in one row. + // We use the widest card as the cell width so columns align. + let cell_width = cards.iter().map(|c| c.visible_width()).max().unwrap_or(0); + if cell_width == 0 { + return String::new(); + } + + let cols_per_row = ((max_cols + gutter) / (cell_width + gutter)).max(1); + + let mut out = String::new(); + + for row_cards in cards.chunks(cols_per_row) { + // Find the tallest card in this row. + let max_height = row_cards.iter().map(|c| c.lines.len()).max().unwrap_or(0); + + for line_idx in 0..max_height { + for (card_idx, card) in row_cards.iter().enumerate() { + let line = card + .lines + .get(line_idx) + .map(|s| s.as_str()) + .unwrap_or(""); + + // Pad every card to cell_width so columns stay aligned. + out.push_str(&pad_to_visible(line, cell_width)); + + // Add gutter between cards (but not after the last one in a row). + if card_idx + 1 < row_cards.len() { + out.push_str(&" ".repeat(gutter)); + } + } + out.push('\n'); + } + + // Blank line between rows of cards. + out.push('\n'); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn visible_len_strips_ansi() { + assert_eq!(visible_len("\x1b[38;2;255;0;0mHello\x1b[0m"), 5); + assert_eq!(visible_len("Plain text"), 10); + assert_eq!(visible_len(""), 0); + } + + #[test] + fn pad_to_visible_pads_ansi_string() { + let s = "\x1b[31mHi\x1b[0m"; // visible len = 2 + let padded = pad_to_visible(s, 5); + assert_eq!(visible_len(&padded), 5); + assert!(padded.ends_with(" ")); // 3 spaces + } + + #[test] + fn grid_arranges_cards_side_by_side() { + let cards = vec![ + GridCard { lines: vec!["AAAA".into(), "AAAA".into()] }, + GridCard { lines: vec!["BBBB".into(), "BBBB".into()] }, + GridCard { lines: vec!["CCCC".into()] }, // shorter card + ]; + let output = render_grid(&cards, 20, 2); + // With cell_width=4, gutter=2, cols_per_row = (20+2)/(4+2) = 3 + // All three should fit on one row. + let lines: Vec<&str> = output.lines().collect(); + assert!(lines[0].contains("AAAA")); + assert!(lines[0].contains("BBBB")); + assert!(lines[0].contains("CCCC")); + // Second line: card C has no second line, so it should be padded with spaces. + assert!(lines[1].contains("AAAA")); + assert!(lines[1].contains("BBBB")); + } + + #[test] + fn grid_wraps_to_multiple_rows() { + let cards = vec![ + GridCard { lines: vec!["AAAA".into()] }, + GridCard { lines: vec!["BBBB".into()] }, + GridCard { lines: vec!["CCCC".into()] }, + ]; + // max_cols=10, gutter=2 → cell_width=4, cols_per_row = (10+2)/(4+2) = 2 + let output = render_grid(&cards, 10, 2); + let lines: Vec<&str> = output.lines().collect(); + // First row: A and B + assert!(lines[0].contains("AAAA")); + assert!(lines[0].contains("BBBB")); + // Second row (after blank): C alone + // lines[1] is blank separator + assert!(lines[2].contains("CCCC")); + } + + #[test] + fn empty_cards_returns_empty() { + assert_eq!(render_grid(&[], 80, 2), ""); + } +} diff --git a/src/presenter/mod.rs b/src/presenter/mod.rs index 0f36310..d728cf6 100644 --- a/src/presenter/mod.rs +++ b/src/presenter/mod.rs @@ -1,8 +1,4 @@ -pub mod account; -pub mod address; -pub mod balance; -pub mod config; +#[allow(dead_code)] +pub mod grid; pub mod inscription; -pub mod snapshot; -pub mod tx; -pub mod wallet; +pub mod thumbnail; diff --git a/src/presenter/snapshot.rs b/src/presenter/snapshot.rs deleted file mode 100644 index db60090..0000000 --- a/src/presenter/snapshot.rs +++ /dev/null @@ -1,22 +0,0 @@ -use comfy_table::{Cell, Color, Table}; -use std::path::Path; - -pub fn format_snapshots(paths: &[String]) -> Table { - let mut table = Table::new(); - table.set_header(vec![ - Cell::new("Name").fg(Color::Cyan), - Cell::new("Path").fg(Color::Green), - ]); - - for path_str in paths { - let path = Path::new(path_str); - let name = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(path_str); - - table.add_row(vec![Cell::new(name), Cell::new(path_str)]); - } - - table -} diff --git a/src/presenter/thumbnail.rs b/src/presenter/thumbnail.rs new file mode 100644 index 0000000..2a3ada3 --- /dev/null +++ b/src/presenter/thumbnail.rs @@ -0,0 +1,53 @@ +/// Print a thumbnail directly to stdout using the best available terminal +/// graphics protocol (Kitty, iTerm2, Sixel, or halfblock fallback). +/// Returns the (width, height) of the rendered image in terminal cells, +/// or `None` if rendering failed. +#[allow(dead_code)] +pub fn print_thumbnail(bytes: &[u8], width: u32) -> Option<(u32, u32)> { + let img = image::load_from_memory(bytes).ok()?; + let conf = viuer::Config { + width: Some(width), + height: None, + transparent: true, + absolute_offset: false, + ..Default::default() + }; + viuer::print(&img, &conf).ok() +} + +/// Print a thumbnail at a specific column offset (for grid layouts). +/// Saves and restores cursor position so the caller can print more +/// images on the same row. Returns the (width, height) of the rendered +/// image in terminal cells, or `None` if rendering failed. +pub fn print_thumbnail_at(bytes: &[u8], width: u32, x_offset: u16) -> Option<(u32, u32)> { + let img = image::load_from_memory(bytes).ok()?; + let conf = viuer::Config { + width: Some(width), + height: Some(width / 2 + 1), + transparent: true, + absolute_offset: false, + x: x_offset, + restore_cursor: true, + ..Default::default() + }; + viuer::print(&img, &conf).ok() +} + +pub fn render_non_image_badge(content_type: Option<&str>) -> Vec { + let label = content_type.unwrap_or("unknown"); + vec![ + "[non-image inscription]".to_string(), + format!("content-type: {label}"), + ] +} + +#[cfg(test)] +mod tests { + use super::render_non_image_badge; + + #[test] + fn non_image_badge_contains_content_type() { + let badge = render_non_image_badge(Some("text/plain")); + assert!(badge.join("\n").contains("text/plain")); + } +} diff --git a/src/presenter/tx.rs b/src/presenter/tx.rs deleted file mode 100644 index c8c6d4a..0000000 --- a/src/presenter/tx.rs +++ /dev/null @@ -1,46 +0,0 @@ -use comfy_table::{Cell, Color, Table}; -use zinc_core::TxItem; - -pub fn format_transactions(txs: &[TxItem]) -> Table { - let mut table = Table::new(); - table.set_header(vec![ - Cell::new("TxID").fg(Color::Cyan), - Cell::new("Type").fg(Color::Green), - Cell::new("Amount (sats)").fg(Color::Yellow), - Cell::new("Fee (sats)").fg(Color::Magenta), - Cell::new("Confirm Time (UTC)").fg(Color::Blue), - Cell::new("Inscriptions").fg(Color::Red), - ]); - - for tx in txs { - let time_str = tx - .confirmation_time - .map(|t| { - let d = std::time::UNIX_EPOCH + std::time::Duration::from_secs(t); - let datetime: chrono::DateTime = d.into(); - datetime.format("%Y-%m-%d %H:%M:%S").to_string() - }) - .unwrap_or_else(|| "Unconfirmed".to_string()); - - let ins_str = if tx.inscriptions.is_empty() { - "-".to_string() - } else { - tx.inscriptions - .iter() - .map(|i| i.number.to_string()) - .collect::>() - .join(", ") - }; - - table.add_row(vec![ - Cell::new(format!("{}...", &tx.txid[..8])), - Cell::new(&tx.tx_type), - Cell::new(tx.amount_sats.to_string()), - Cell::new(tx.fee_sats.to_string()), - Cell::new(time_str), - Cell::new(ins_str), - ]); - } - - table -} diff --git a/src/presenter/wallet.rs b/src/presenter/wallet.rs deleted file mode 100644 index 7c42883..0000000 --- a/src/presenter/wallet.rs +++ /dev/null @@ -1,24 +0,0 @@ -use comfy_table::{Cell, Color, Table}; -use serde_json::Value; - -pub fn format_wallet_info(info: &Value) -> Table { - let mut table = Table::new(); - table.set_header(vec![ - Cell::new("Property").fg(Color::Cyan), - Cell::new("Value").fg(Color::Green), - ]); - - if let Some(obj) = info.as_object() { - for (k, v) in obj { - let val_str = match v { - Value::String(s) => s.clone(), - Value::Number(n) => n.to_string(), - Value::Bool(b) => b.to_string(), - _ => v.to_string(), - }; - table.add_row(vec![Cell::new(k), Cell::new(val_str)]); - } - } - - table -} diff --git a/src/utils.rs b/src/utils.rs index 53162a4..1250906 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,7 +1,7 @@ use crate::config::{NetworkArg, Profile, SchemeArg}; use crate::error::AppError; use std::env; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; pub fn home_dir() -> PathBuf { if let Some(home) = std::env::var_os("HOME") { @@ -104,6 +104,19 @@ pub fn run_bitcoin_cli( profile: &Profile, args: &[String], ) -> Result { + let path = std::path::Path::new(&profile.bitcoin_cli); + let bin_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + if bin_name != "bitcoin-cli" && bin_name != "bitcoin-cli.exe" { + return Err(crate::error::AppError::Internal(format!( + "security violation: bitcoin_cli must be 'bitcoin-cli' or 'bitcoin-cli.exe', got '{}'", + profile.bitcoin_cli + ))); + } + let mut cmd = std::process::Command::new(&profile.bitcoin_cli); for arg in &profile.bitcoin_cli_args { cmd.arg(arg); @@ -149,7 +162,7 @@ pub(crate) fn levenshtein(a: &str, b: &str) -> usize { } pub fn resolve_psbt_source( psbt: Option<&str>, - psbt_file: Option<&str>, + psbt_file: Option<&Path>, psbt_stdin: bool, ) -> Result { let count = (psbt.is_some() as u8) + (psbt_file.is_some() as u8) + (psbt_stdin as u8); @@ -162,8 +175,9 @@ pub fn resolve_psbt_source( return Ok(psbt.to_string()); } if let Some(path) = psbt_file { - return std::fs::read_to_string(path) - .map_err(|e| AppError::Io(format!("failed to read psbt file {path}: {e}"))); + return std::fs::read_to_string(path).map_err(|e| { + AppError::Io(format!("failed to read psbt file {}: {e}", path.display())) + }); } if psbt_stdin { use std::io::Read; diff --git a/src/wallet_service.rs b/src/wallet_service.rs index 263c738..792a908 100644 --- a/src/wallet_service.rs +++ b/src/wallet_service.rs @@ -86,7 +86,7 @@ pub fn wallet_password(config: &ServiceConfig<'_>) -> Result { return Ok(pass); } - if config.json { + if config.agent { return Err(AppError::Auth(format!( "wallet password missing (use --password, --password-stdin, or set {})", config.password_env @@ -116,8 +116,27 @@ pub fn load_wallet_session(config: &ServiceConfig<'_>) -> Result Result<(), AppErro write_profile(&session.profile_path, &session.profile) } -#[allow(dead_code)] -pub fn run_bitcoin_cli(profile: &Profile, args: &[String]) -> Result { - let output = ShellCommand::new(&profile.bitcoin_cli) - .args(&profile.bitcoin_cli_args) - .args(args) - .output() - .map_err(|e| AppError::Config(format!("failed to launch {}: {e}", profile.bitcoin_cli)))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let details = if !stderr.is_empty() { stderr } else { stdout }; - return Err(AppError::Network(format!( - "bitcoin-cli command failed: {} {}", - profile.bitcoin_cli, details - ))); - } - - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - #[cfg(test)] mod tests {} diff --git a/src/wizard/mod.rs b/src/wizard/mod.rs index f58904b..12e1fd1 100644 --- a/src/wizard/mod.rs +++ b/src/wizard/mod.rs @@ -14,8 +14,6 @@ pub struct SetupValues { pub default_scheme: Option, pub default_esplora_url: Option, pub default_ord_url: Option, - pub json_default: bool, - pub quiet_default: bool, pub initialize_wallet: bool, pub restore_mnemonic: Option, pub words: Option, @@ -41,15 +39,6 @@ pub(crate) fn resolve_setup_values(cli: &Cli, args: &SetupArgs) -> Result *value, - None => cli.json, - }; - let quiet_default = match &args.quiet_default { - Some(value) => *value, - None => cli.quiet, - }; - Ok(SetupValues { profile, data_dir, @@ -58,8 +47,6 @@ pub(crate) fn resolve_setup_values(cli: &Cli, args: &SetupArgs) -> Result Command { "ZINC_CLI_DATA_DIR", "ZINC_CLI_PASSWORD_ENV", "ZINC_CLI_JSON", - "ZINC_CLI_QUIET", "ZINC_CLI_NETWORK", "ZINC_CLI_SCHEME", "ZINC_CLI_ESPLORA_URL", @@ -34,7 +33,7 @@ fn cargo_cmd() -> Command { fn run_zinc(args: &[&str], data_dir: &str, password: &str) -> Value { let mut cmd = cargo_cmd(); cmd.args(&["run", "--quiet", "--"]) - .arg("--json") + .arg("--agent") .arg("--data-dir") .arg(data_dir) .arg("--password") @@ -62,7 +61,7 @@ fn run_zinc(args: &[&str], data_dir: &str, password: &str) -> Value { fn run_zinc_ignore_error(args: &[&str], data_dir: &str, password: &str) -> Value { let mut cmd = cargo_cmd(); cmd.args(&["run", "--quiet", "--"]) - .arg("--json") + .arg("--agent") .arg("--data-dir") .arg(data_dir) .arg("--password") @@ -721,7 +720,7 @@ fn test_psbt_error_handling() { #[test] fn test_command_list_complete() { let mut cmd = cargo_cmd(); - cmd.args(&["run", "--quiet", "--", "--json", "help"]); + cmd.args(&["run", "--quiet", "--", "--agent", "help"]); let output = cmd.output().expect("failed to execute process"); let stdout = String::from_utf8_lossy(&output.stdout); let json = parse_json_from_output(&stdout); diff --git a/tests/cli_snapshots.rs b/tests/cli_snapshots.rs index 049b264..7196738 100644 --- a/tests/cli_snapshots.rs +++ b/tests/cli_snapshots.rs @@ -9,7 +9,7 @@ fn test_inscription_list_no_wallet_json() { cmd.env("ZINC_CLI_DATA_DIR", "/tmp/nonexistent_zinc_dir_test") .arg("inscription") .arg("list") - .arg("--json"); + .arg("--agent"); let output = cmd.output().unwrap(); let stdout = String::from_utf8(output.stdout).unwrap(); diff --git a/tests/contract_v1.rs b/tests/contract_v1.rs index 775e6f1..31fc5bb 100644 --- a/tests/contract_v1.rs +++ b/tests/contract_v1.rs @@ -21,7 +21,6 @@ fn cargo_cmd() -> Command { "ZINC_CLI_DATA_DIR", "ZINC_CLI_PASSWORD_ENV", "ZINC_CLI_JSON", - "ZINC_CLI_QUIET", "ZINC_CLI_NETWORK", "ZINC_CLI_SCHEME", "ZINC_CLI_ESPLORA_URL", @@ -44,7 +43,7 @@ fn init_wallet(data_dir: &str, password: &str) { "run", "--quiet", "--", - "--json", + "--agent", "--data-dir", data_dir, "--password", @@ -91,7 +90,7 @@ fn parse_json_lines(output: &str) -> Vec { #[test] fn test_json_envelope_help() { let mut cmd = cargo_cmd(); - cmd.args(&["run", "--quiet", "--", "--json", "help"]); + cmd.args(&["run", "--quiet", "--", "--agent", "help"]); let output = cmd.output().expect("failed to execute process"); assert!(output.status.success()); @@ -114,7 +113,7 @@ fn test_correlation_id_can_be_overridden_by_flag() { "run", "--quiet", "--", - "--json", + "--agent", "--correlation-id", "agent-run-42", "help", @@ -135,7 +134,7 @@ fn test_log_json_emits_structured_stderr_events() { "run", "--quiet", "--", - "--json", + "--agent", "--log-json", "config", "show", @@ -176,7 +175,7 @@ fn test_log_json_emits_structured_stderr_events() { #[test] fn test_json_envelope_error() { let mut cmd = cargo_cmd(); - cmd.args(&["run", "--quiet", "--", "--json", "invalid-command"]); + cmd.args(&["run", "--quiet", "--", "--agent", "invalid-command"]); let output = cmd.output().expect("failed to execute process"); assert!(!output.status.success()); @@ -199,7 +198,7 @@ fn test_mnemonic_hiding() { "run", "--quiet", "--", - "--json", + "--agent", "--data-dir", data_dir, "--password", @@ -240,10 +239,9 @@ fn test_wallet_init_human_mode_shows_mnemonic() { let output = cmd.output().expect("failed to execute process"); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); - let json = parse_json_from_output(&stdout); - - assert_ne!(json["phrase"], ""); - assert!(json.get("words").is_some()); + assert!(stdout.contains("Wallet initialized")); + assert!(stdout.contains("Phrase")); + assert!(!stdout.contains(" Command { "ZINC_CLI_DATA_DIR", "ZINC_CLI_PASSWORD_ENV", "ZINC_CLI_JSON", - "ZINC_CLI_QUIET", "ZINC_CLI_NETWORK", "ZINC_CLI_SCHEME", "ZINC_CLI_ESPLORA_URL", @@ -46,7 +45,7 @@ fn cargo_cmd() -> Command { fn run_zinc(args: &[&str], data_dir: &str, password: &str) -> Value { let mut cmd = cargo_cmd(); cmd.args(["run", "--quiet", "--"]) - .arg("--json") + .arg("--agent") .arg("--data-dir") .arg(data_dir) .arg("--password") @@ -82,7 +81,7 @@ fn run_zinc(args: &[&str], data_dir: &str, password: &str) -> Value { fn run_zinc_allow_error(args: &[&str], data_dir: &str, password: &str) -> Value { let mut cmd = cargo_cmd(); cmd.args(["run", "--quiet", "--"]) - .arg("--json") + .arg("--agent") .arg("--data-dir") .arg(data_dir) .arg("--password") diff --git a/tests/snapshots/cli_snapshots__cli_help_json.snap b/tests/snapshots/cli_snapshots__cli_help_json.snap index 49a42e6..5558538 100644 --- a/tests/snapshots/cli_snapshots__cli_help_json.snap +++ b/tests/snapshots/cli_snapshots__cli_help_json.snap @@ -40,7 +40,6 @@ expression: json_output "global_flags": [ "--json", "--agent", - "--quiet", "--yes", "--password", "--password-env",