diff --git a/CHANGELOG.md b/CHANGELOG.md
index 895f5e07..a4d8c83a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,26 @@
All notable changes to the Toolpath workspace are documented here.
+## `path auth login --token` — streamlined, browser-free sign-in — 2026-07-01
+
+Adds a second way to authenticate the CLI that skips the browser code
+exchange entirely. The existing flow (`path auth login` → open
+`/auth/cli` → copy an 8-character code → paste it back → redeem)
+still works unchanged; `--token` is an alternative for when you're already
+signed in on the Pathbase site, which can then hand you a single
+ready-to-paste command.
+
+- **`path auth login --token `** (path-cli 0.15.0). The token *is*
+ the bearer credential — there's no redeem round-trip. The CLI validates
+ it against `GET /api/v1/users/me` before writing anything, so a bad or
+ expired token fails fast and never leaves a half-written session on disk;
+ on success it stores the same `credentials.json` (0600) the code flow
+ produces. Server URL resolves as usual (`--url` → `$PATHBASE_URL` →
+ `https://pathbase.dev`).
+- `--token` **conflicts with** `--code`. The interactive prompt now also
+ points at the one-line token command for users already signed in on the
+ site.
+
## Token usage: once per message, with per-step attribution + kind v1.1.0 — 2026-06-17
Fixes token over-counting in derived documents (~3× output-token
diff --git a/CLAUDE.md b/CLAUDE.md
index 01c70e9f..9b9d1733 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -134,6 +134,7 @@ cargo run -p path-cli -- show claude --project /path/to/project --session # streamlined: paste a token from Pathbase, no browser round-trip
cargo run -p path-cli -- auth status
cargo run -p path-cli -- auth whoami
cargo run -p path-cli -- auth logout
@@ -156,6 +157,14 @@ writes to `~/.toolpath/credentials.json` (0600, parent dir 0700) and sends as
overrides the credentials directory. Server URL comes from `--url`, then
`$PATHBASE_URL`, then `https://pathbase.dev`.
+`path auth login --token ` is the streamlined alternative — no browser
+code exchange. When you're already signed in on the Pathbase site it hands you
+a ready-to-paste `path auth login --token ` command; the CLI validates
+that token with `GET /api/v1/users/me` (so a bad/expired token fails fast and
+never gets written), then stores the same `credentials.json` session the code
+flow produces. `--token` conflicts with `--code`. The token *is* the bearer
+credential — there's no redeem step. See `login_with_token` in `cmd_auth.rs`.
+
The CLI redeem endpoint (`POST /api/v1/auth/cli/redeem`) is real and works
in production but is **not listed in `schema/pathbase-openapi.json`** — the
OpenAPI spec only covers the documented surface. Don't be surprised that
diff --git a/Cargo.lock b/Cargo.lock
index 77e86508..55187785 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2308,7 +2308,7 @@ dependencies = [
[[package]]
name = "path-cli"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"anyhow",
"assert_cmd",
diff --git a/Cargo.toml b/Cargo.toml
index b03c2d21..7fbe44d2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -35,7 +35,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" }
toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" }
toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" }
toolpath-pi = { version = "0.6.0", path = "crates/toolpath-pi" }
-path-cli = { version = "0.14.0", path = "crates/path-cli" }
+path-cli = { version = "0.15.0", path = "crates/path-cli" }
pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" }
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] }
diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml
index 01eea174..7a6fa876 100644
--- a/crates/path-cli/Cargo.toml
+++ b/crates/path-cli/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "path-cli"
-version = "0.14.0"
+version = "0.15.0"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
diff --git a/crates/path-cli/src/cmd_auth.rs b/crates/path-cli/src/cmd_auth.rs
index 621dbc4a..92494dc0 100644
--- a/crates/path-cli/src/cmd_auth.rs
+++ b/crates/path-cli/src/cmd_auth.rs
@@ -1,15 +1,15 @@
-use anyhow::{Result, anyhow};
+use anyhow::{Context, Result, anyhow, bail};
use clap::Subcommand;
use std::path::Path;
use crate::cmd_pathbase::{
- StoredSession, api_logout, api_me, api_redeem, clear_session, credentials_path, load_session,
- prompt_line, resolve_url, store_session,
+ StoredSession, User, api_logout, api_me, api_redeem, clear_session, credentials_path,
+ load_session, prompt_line, resolve_url, store_session,
};
#[derive(Subcommand, Debug)]
pub enum AuthOp {
- /// Log in by opening a browser to Pathbase and pasting the displayed code
+ /// Log in to Pathbase — paste a code from the browser, or a token
Login {
/// Pathbase server URL (defaults to $PATHBASE_URL or https://pathbase.dev)
#[arg(long)]
@@ -18,6 +18,13 @@ pub enum AuthOp {
/// Paste the code directly instead of prompting
#[arg(long)]
code: Option,
+
+ /// Log in with a token straight from Pathbase, skipping the browser
+ /// code exchange. When you're already signed in on the Pathbase site
+ /// it hands you a ready-to-paste `path auth login --token `
+ /// command; this validates that token and stores the session.
+ #[arg(long, conflicts_with = "code")]
+ token: Option,
},
/// Log out and clear the stored session
Logout,
@@ -30,17 +37,26 @@ pub enum AuthOp {
pub fn run(op: AuthOp) -> Result<()> {
let path = credentials_path()?;
match op {
- AuthOp::Login { url, code } => login(&path, url, code),
+ AuthOp::Login { url, code, token } => login(&path, url, code, token),
AuthOp::Logout => logout(&path),
AuthOp::Status => status(&path),
AuthOp::Whoami => whoami(&path),
}
}
-fn login(path: &Path, url: Option, code_arg: Option) -> Result<()> {
+fn login(
+ path: &Path,
+ url: Option,
+ code_arg: Option,
+ token_arg: Option,
+) -> Result<()> {
let base_url = resolve_url(url);
- let auth_url = format!("{base_url}/auth/cli");
+ if let Some(raw) = token_arg {
+ return login_with_token(path, &base_url, raw);
+ }
+
+ let auth_url = format!("{base_url}/auth/cli");
let code = match code_arg {
Some(c) => c,
None => {
@@ -50,15 +66,41 @@ fn login(path: &Path, url: Option, code_arg: Option) -> Result<(
println!(" 2. Sign in if prompted");
println!(" 3. Copy the 8-character code shown on that page");
println!();
+ println!(" (already signed in there? paste the one-line");
+ println!(" `path auth login --token ` command instead)");
+ println!();
prompt_line("Paste code: ")?
}
};
let (token, user) = api_redeem(&base_url, &code)?;
+ finish_login(path, &base_url, token, user)
+}
+
+/// Sign in with a token issued directly by Pathbase, no browser round-trip.
+///
+/// The token is used as-is as the bearer credential; we validate it (and
+/// resolve the account it belongs to) with a `GET /users/me` before writing
+/// it to disk, so a bad or expired token fails here instead of silently at
+/// the next upload.
+fn login_with_token(path: &Path, base_url: &str, raw: String) -> Result<()> {
+ let token = raw.trim().to_string();
+ if token.is_empty() {
+ bail!("--token was empty; paste the token from {base_url}/auth/cli");
+ }
+
+ let user = api_me(base_url, &token)
+ .with_context(|| format!("failed to sign in to {base_url} with the provided token"))?;
+ finish_login(path, base_url, token, user)
+}
+
+/// Persist the session and print the "logged in as …" confirmation. Shared
+/// by the code-exchange and token login flows.
+fn finish_login(path: &Path, base_url: &str, token: String, user: User) -> Result<()> {
store_session(
path,
&StoredSession {
- url: base_url.clone(),
+ url: base_url.to_string(),
token,
user: user.clone(),
},
diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs
index 27c53d8a..ec68aa0b 100644
--- a/crates/path-cli/tests/integration.rs
+++ b/crates/path-cli/tests/integration.rs
@@ -412,6 +412,103 @@ fn auth_login_against_unreachable_url_errors() {
.stderr(predicate::str::contains("127.0.0.1"));
}
+/// One-shot mock of `GET /api/v1/users/me`. `path auth login --token`
+/// validates the pasted token against this endpoint before storing it.
+fn spawn_me_mock(status_line: &'static str, body: String) -> (u16, std::thread::JoinHandle<()>) {
+ use std::io::{Read, Write};
+ use std::net::TcpListener;
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let port = listener.local_addr().unwrap().port();
+ let handle = std::thread::spawn(move || {
+ let (mut stream, _) = listener.accept().unwrap();
+ let mut buf = [0u8; 4096];
+ let _ = stream.read(&mut buf);
+ let resp = format!(
+ "{status_line}\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{body}",
+ body.len()
+ );
+ let _ = stream.write_all(resp.as_bytes());
+ });
+ (port, handle)
+}
+
+/// Minimal `User` payload — the fields the generated pathbase client
+/// requires to decode `/users/me` successfully.
+fn me_body(username: &str) -> String {
+ format!(
+ r#"{{"id":"00000000-0000-0000-0000-000000000001","username":"{username}","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}}"#
+ )
+}
+
+#[test]
+fn auth_login_help_mentions_token() {
+ cmd()
+ .args(["auth", "login", "--help"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("--token"));
+}
+
+#[test]
+fn auth_login_token_conflicts_with_code() {
+ cmd()
+ .args(["auth", "login", "--token", "tok", "--code", "BCDFGHJK"])
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("cannot be used with"));
+}
+
+#[test]
+fn auth_login_with_token_validates_and_stores_session() {
+ let (port, server) = spawn_me_mock("HTTP/1.1 200 OK", me_body("alice"));
+ let cfg = tempfile::tempdir().unwrap();
+ let url = format!("http://127.0.0.1:{port}");
+
+ // Token login hits /users/me, resolves the account, writes credentials.
+ cmd()
+ .env("TOOLPATH_CONFIG_DIR", cfg.path())
+ .args(["auth", "login", "--token", "secret-token", "--url"])
+ .arg(&url)
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("Logged in to"))
+ .stdout(predicate::str::contains("alice"));
+ server.join().unwrap();
+
+ // The session is on disk — `status` reads it back with no network call.
+ cmd()
+ .env("TOOLPATH_CONFIG_DIR", cfg.path())
+ .args(["auth", "status"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("alice"))
+ .stdout(predicate::str::contains(&url));
+}
+
+#[test]
+fn auth_login_with_rejected_token_errors() {
+ let (port, server) = spawn_me_mock("HTTP/1.1 401 Unauthorized", "{}".to_string());
+ let cfg = tempfile::tempdir().unwrap();
+ let url = format!("http://127.0.0.1:{port}");
+
+ cmd()
+ .env("TOOLPATH_CONFIG_DIR", cfg.path())
+ .args(["auth", "login", "--token", "bad-token", "--url"])
+ .arg(&url)
+ .assert()
+ .failure()
+ .stderr(predicate::str::contains("failed to sign in"));
+ server.join().unwrap();
+
+ // A rejected token must not leave a half-written session behind.
+ cmd()
+ .env("TOOLPATH_CONFIG_DIR", cfg.path())
+ .args(["auth", "status"])
+ .assert()
+ .success()
+ .stdout(predicate::str::contains("Not logged in"));
+}
+
// ── Import / export / cache ─────────────────────────────────────────
#[test]
diff --git a/site/_data/crates.json b/site/_data/crates.json
index abf80299..c9335061 100644
--- a/site/_data/crates.json
+++ b/site/_data/crates.json
@@ -105,7 +105,7 @@
},
{
"name": "path-cli",
- "version": "0.14.0",
+ "version": "0.15.0",
"description": "Unified CLI (binary: path)",
"docs": "https://docs.rs/path-cli",
"crate": "https://crates.io/crates/path-cli",