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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/remote-wallet-no-browser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
tempo-wallet: patch
---

Add a remote-host-friendly `--no-browser` path for `tempo wallet login` and `tempo wallet fund`, including CLI guidance that agents can relay to a user approving wallet actions from another device.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Tempo Wallet is a CLI that lets you create a wallet, manage keys, and make HTTP
3. The browser authorizes a session key for the CLI and redirects back.
4. The CLI stores the authorized key locally. All subsequent signing happens locally — no browser needed.

If the agent is running on a remote host while you are using a different device, use `tempo wallet login --no-browser` instead. The CLI will print the auth URL and verification code for you to open on your device, and you may need to return to your CLI or agent session after passkey creation or after funding. A second authorization round may still be needed before the host is ready.

## Goals

1. **Zero-config payments**: `tempo request <url>` handles the full 402 flow — challenge, sign, pay, retry — in a single command.
Expand Down Expand Up @@ -60,11 +62,17 @@ npx skills@latest add tempoxyz/wallet --global
# Log in with your passkey (opens browser)
tempo wallet login

# Remote-host login when the human is on another device
tempo wallet login --no-browser

# Check your wallet
tempo wallet whoami

# Fund your wallet
tempo wallet fund

# Remote-host funding when the human is on another device
tempo wallet fund --no-browser
```

### One-Shot Payment (Charge)
Expand Down
6 changes: 5 additions & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ Run these commands in order. Do not skip steps. Do not search for other install

**Step 1 — Install:** `curl -fsSL https://tempo.xyz/install | bash`

**Step 2 — Login:** `"$HOME/.tempo/bin/tempo" wallet login` — requires user browser/passkey action. Prompt user, wait for confirmation, then continue. Do not loop login attempts without user confirmation. When run by agents, use a long command timeout (at least 16 minutes).
**Step 2 — Login:** `"$HOME/.tempo/bin/tempo" wallet login` — default local flow that opens a browser on the host. Prompt user, wait for confirmation, then continue. Do not loop login attempts without user confirmation. When run by agents, use a long command timeout (at least 16 minutes).

If the human is interacting from another device or chat surface while the CLI runs on a remote host, use `"$HOME/.tempo/bin/tempo" wallet login --no-browser` instead. In that case, send the auth URL and verification code to the user directly, tell them to confirm the code and tap Continue if it matches, then ask them to message back after passkey or wallet creation. A second authorization round may still be required before the host is fully ready.

**Step 3 — Confirm readiness:** `"$HOME/.tempo/bin/tempo" wallet -t whoami`

Expand All @@ -32,6 +34,8 @@ Provide:
- Installation location and version (`$HOME/.tempo/bin/tempo --version`).
- Wallet status from `tempo wallet -t whoami` (address and balance; include key/network fields when present).
- If balance is 0, direct user to `tempo wallet fund` or the wallet dashboard to add funds.
- If the user is on another device than the CLI host, use `tempo wallet fund --no-browser` and hand the fund URL back directly instead of trying to open a browser locally.
- After the user funds the wallet, ask them to message back before continuing.
- 2-3 simple starter prompts tailored to currently available services.

To generate starter prompts, list available services and pick useful beginner examples:
Expand Down
13 changes: 13 additions & 0 deletions crates/tempo-wallet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ Wallet identity and custody extension for the Tempo CLI. Manages authentication,
| Command | Description |
|---------|-------------|
| `tempo wallet login` | Connect via browser passkey authentication |
| `tempo wallet login --no-browser` | Remote-host login when the human is on another device |
| `tempo wallet refresh` | Renew your access key without logging out |
| `tempo wallet logout` | Disconnect your wallet |
| `tempo wallet whoami` | Show wallet address, balances, keys, and readiness |
| `tempo wallet keys` | List keys with balance and spending limit details |
| `tempo wallet fund` | Fund your wallet (opens browser) |
| `tempo wallet fund --no-browser` | Remote-host funding when the human is on another device |
| `tempo wallet sessions list` | List payment sessions |
| `tempo wallet sessions close` | Close by origin or channel ID, or batch close/finalize/orphaned |
| `tempo wallet sessions sync` | Reconcile local sessions against on-chain state |
Expand All @@ -27,10 +29,21 @@ curl -fsSL https://tempo.xyz/install | bash
# Connect your wallet
tempo wallet login

# Remote-host login
tempo wallet login --no-browser

# Check status
tempo wallet whoami

# Fund your wallet
tempo wallet fund

# Remote-host funding
tempo wallet fund --no-browser
```

If you use the remote-host funding path, return to your CLI or agent session once funding is complete so the workflow can continue.

## License

Dual-licensed under [Apache 2.0](../../LICENSE-APACHE) and [MIT](../../LICENSE-MIT).
9 changes: 6 additions & 3 deletions crates/tempo-wallet/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@ pub(crate) async fn run(mut cli: Cli) -> Result<(), TempoError> {
|ctx| async move {
let cmd_name = command_name(&command);
let result = match command {
Commands::Login => login::run(&ctx).await,
Commands::Login { no_browser } => login::run(&ctx, no_browser).await,
Commands::Refresh => refresh::run(&ctx).await,
Commands::Logout { yes } => logout::run(&ctx, yes),
Commands::Completions { shell } => completions::run(&ctx, shell),
Commands::Fund { address } => fund::run(&ctx, address).await,
Commands::Fund {
address,
no_browser,
} => fund::run(&ctx, address, no_browser).await,
Commands::Whoami => whoami::run(&ctx).await,
Commands::Keys => keys::run(&ctx).await,
Commands::Sessions { command } => {
Expand Down Expand Up @@ -63,7 +66,7 @@ pub(crate) async fn run(mut cli: Cli) -> Result<(), TempoError> {
/// Derive a short analytics-friendly name from a parsed command.
const fn command_name(command: &Commands) -> &'static str {
match command {
Commands::Login => "login",
Commands::Login { .. } => "login",
Commands::Refresh => "refresh",
Commands::Logout { .. } => "logout",
Commands::Completions { .. } => "completions",
Expand Down
9 changes: 8 additions & 1 deletion crates/tempo-wallet/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ pub(crate) struct Cli {
pub(crate) enum Commands {
/// Sign up or log in to your Tempo wallet
#[command(display_order = 1)]
Login,
Login {
/// Do not attempt to open a browser
#[arg(long)]
no_browser: bool,
},
/// Refresh your access key without logging out
#[command(display_order = 2)]
Refresh,
Expand Down Expand Up @@ -74,6 +78,9 @@ Examples:
/// Wallet address to fund (defaults to current wallet)
#[arg(long)]
address: Option<String>,
/// Do not attempt to open a browser
#[arg(long)]
no_browser: bool,
},
/// Manage payment sessions
#[command(display_order = 8, name = "sessions")]
Expand Down
67 changes: 63 additions & 4 deletions crates/tempo-wallet/src/commands/auth.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,69 @@
//! Shared authentication and browser utilities.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BrowserLaunchStatus {
Opened,
Skipped,
Failed,
}

/// Attempt to open a URL in the default browser.
/// Prints a fallback message if it fails.
pub(crate) fn try_open_browser(url: &str) {
if let Err(e) = webbrowser::open(url) {
eprintln!("Failed to open browser: {e}");
eprintln!("Open this URL manually: {url}");
fn try_open_browser_with(
url: &str,
no_browser: bool,
opener: impl FnOnce(&str) -> Result<(), String>,
) -> BrowserLaunchStatus {
if no_browser {
return BrowserLaunchStatus::Skipped;
}
match opener(url) {
Ok(_) => BrowserLaunchStatus::Opened,
Err(err) => {
eprintln!("Failed to open browser: {err}");
eprintln!("Open this URL manually: {url}");
BrowserLaunchStatus::Failed
}
}
}

pub(crate) fn try_open_browser(url: &str, no_browser: bool) -> BrowserLaunchStatus {
try_open_browser_with(url, no_browser, |url| {
webbrowser::open(url).map_err(|err| err.to_string())
})
}

#[cfg(test)]
mod tests {
use super::{try_open_browser_with, BrowserLaunchStatus};
use std::{cell::RefCell, rc::Rc};

#[test]
fn browser_launch_is_skipped_when_no_browser_is_true() {
let status = try_open_browser_with(
"https://wallet.tempo.xyz/cli-auth?code=ANMGE375",
true,
|_url| Err("should not be called".to_string()),
);
assert_eq!(status, BrowserLaunchStatus::Skipped);
}

#[test]
fn browser_launch_attempts_open_when_no_browser_is_false() {
let seen_url = Rc::new(RefCell::new(None::<String>));
let captured_url = Rc::clone(&seen_url);
let status = try_open_browser_with(
"https://wallet.tempo.xyz/cli-auth?code=ANMGE375",
false,
|url| {
*captured_url.borrow_mut() = Some(url.to_string());
Err("synthetic open failure".to_string())
},
);
assert_eq!(status, BrowserLaunchStatus::Failed);
assert_eq!(
seen_url.borrow().as_deref(),
Some("https://wallet.tempo.xyz/cli-auth?code=ANMGE375")
);
}
}
55 changes: 46 additions & 9 deletions crates/tempo-wallet/src/commands/fund/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,23 @@ const CALLBACK_TIMEOUT_SECS: u64 = 900;
// Entry point
// ---------------------------------------------------------------------------

pub(crate) async fn run(ctx: &Context, address: Option<String>) -> Result<(), TempoError> {
let method = "browser";
pub(crate) async fn run(
ctx: &Context,
address: Option<String>,
no_browser: bool,
) -> Result<(), TempoError> {
let method = fund_method(no_browser);
track_fund_start(ctx, method);
let result = run_inner(ctx, address).await;
let result = run_inner(ctx, address, no_browser).await;
track_fund_result(ctx, method, &result);
result
}

async fn run_inner(ctx: &Context, address: Option<String>) -> Result<(), TempoError> {
async fn run_inner(
ctx: &Context,
address: Option<String>,
no_browser: bool,
) -> Result<(), TempoError> {
let wallet_address = resolve_address(address, &ctx.keys)?;

let before = query_all_balances(&ctx.config, ctx.network, &wallet_address).await;
Expand All @@ -48,14 +56,19 @@ async fn run_inner(ctx: &Context, address: Option<String>) -> Result<(), TempoEr
})?;
let base_url = parsed_url.origin().ascii_serialization();
let fund_url = format!("{base_url}/?action=fund");
let show_status = no_browser || ctx.output_format == OutputFormat::Text;

if ctx.output_format == OutputFormat::Text {
if show_status {
eprintln!("Fund URL: {fund_url}");
}

super::auth::try_open_browser(&fund_url);
super::auth::try_open_browser(&fund_url, no_browser);

if ctx.output_format == OutputFormat::Text {
if no_browser {
show_remote_fund_prompt(&fund_url);
}

if show_status {
eprintln!("Waiting for funding...");
}

Expand All @@ -65,7 +78,7 @@ async fn run_inner(ctx: &Context, address: Option<String>) -> Result<(), TempoEr

loop {
if start.elapsed() >= timeout {
if ctx.output_format == OutputFormat::Text {
if show_status {
eprintln!(
"Timed out waiting for funding after {} minutes.",
CALLBACK_TIMEOUT_SECS / 60
Expand All @@ -79,7 +92,7 @@ async fn run_inner(ctx: &Context, address: Option<String>) -> Result<(), TempoEr
let current = query_all_balances(&ctx.config, ctx.network, &wallet_address).await;

if has_balance_changed(&before, &current) {
if ctx.output_format == OutputFormat::Text {
if show_status {
eprintln!("\nFunding received!");
render_balance_diff(&before, &current);
}
Expand Down Expand Up @@ -133,6 +146,19 @@ fn render_balance_diff(before: &[TokenBalance], after: &[TokenBalance]) {
}
}

fn fund_method(no_browser: bool) -> &'static str {
if no_browser {
"manual"
} else {
"browser"
}
}

fn show_remote_fund_prompt(fund_url: &str) {
eprintln!("Open this link on your device: {fund_url}");
eprintln!("After funding is complete, return here to continue.");
}

// ---------------------------------------------------------------------------
// Analytics
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -170,3 +196,14 @@ fn track_fund_result(ctx: &Context, method: &str, result: &Result<(), TempoError
}
}
}

#[cfg(test)]
mod tests {
use super::fund_method;

#[test]
fn fund_method_uses_manual_only_when_no_browser_is_true() {
assert_eq!(fund_method(true), "manual");
assert_eq!(fund_method(false), "browser");
}
}
Loading
Loading