Skip to content
Closed
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<base>/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 <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
Expand Down
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ cargo run -p path-cli -- show claude --project /path/to/project --session <sessi
cargo run -p path-cli -- p track init --file src/main.rs --actor "human:alex"
cargo run -p path-cli -- p validate --input doc.json
cargo run -p path-cli -- auth login
cargo run -p path-cli -- auth login --token <token> # 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
Expand All @@ -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 <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 <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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
2 changes: 1 addition & 1 deletion crates/path-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
58 changes: 50 additions & 8 deletions crates/path-cli/src/cmd_auth.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand All @@ -18,6 +18,13 @@ pub enum AuthOp {
/// Paste the code directly instead of prompting
#[arg(long)]
code: Option<String>,

/// 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 <token>`
/// command; this validates that token and stores the session.
#[arg(long, conflicts_with = "code")]
token: Option<String>,
},
/// Log out and clear the stored session
Logout,
Expand All @@ -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<String>, code_arg: Option<String>) -> Result<()> {
fn login(
path: &Path,
url: Option<String>,
code_arg: Option<String>,
token_arg: Option<String>,
) -> 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 => {
Expand All @@ -50,15 +66,41 @@ fn login(path: &Path, url: Option<String>, code_arg: Option<String>) -> 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 <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(),
},
Expand Down
97 changes: 97 additions & 0 deletions crates/path-cli/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion site/_data/crates.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading