From cb79c1448fa0785173a87fbaa9cf1473a2488811 Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Tue, 7 Jul 2026 16:44:48 -0700 Subject: [PATCH] feat: integrate native auth manager Refs #63 Signed-off-by: Jason Matthew Suhari --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 39 +- config.example.toml | 10 + ...026-07-07-integrate-native-auth-manager.md | 34 + src/app.rs | 466 ++++++++++- src/auth.rs | 761 ++++++++++++++++++ src/config.rs | 24 +- src/main.rs | 1 + src/profiles.rs | 33 +- src/pty.rs | 6 + src/setup.rs | 35 +- src/ui.rs | 265 +++++- src/usage.rs | 47 ++ src/vibe.rs | 1 + 15 files changed, 1698 insertions(+), 26 deletions(-) create mode 100644 docs/devlogs/2026-07-07-integrate-native-auth-manager.md create mode 100644 src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 2c8e0d7..387c640 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -591,6 +591,7 @@ name = "gridbash" version = "0.1.5" dependencies = [ "anyhow", + "base64", "clap", "crossterm", "directories", diff --git a/Cargo.toml b/Cargo.toml index 037cad2..b6ff4ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ repository = "https://github.com/jasonsuhari/gridbash" [dependencies] anyhow = "1.0" +base64 = "0.22" clap = { version = "4.5", features = ["derive"] } crossterm = { version = "0.29", features = ["event-stream"] } directories = "6.0" diff --git a/README.md b/README.md index c0e9c70..34e1356 100644 --- a/README.md +++ b/README.md @@ -175,14 +175,38 @@ GridBash captures drag selection so selected text stays inside the pane where th | Alt+r | Rename the focused pane | | Alt+z | Put the focused pane to sleep; when multiple panes are selected, sleep the selected panes | | Hover sleeping pane | Wake the pane and make its terminal contents visible again | -| Alt+o | Open sample settings | +| Alt+o | Open settings | | Alt+q | Quit | Typing goes to selected panes whenever multiple panes are selected. With zero or one pane selected, input goes to the focused pane. Renamed pane headers replace the numeric prefix for the current session. Saving a blank name restores the default number. -The settings screen is currently a sample UI. Its switches, steppers, and choices can be changed, but they do not affect runtime behavior yet. +Settings includes a General tab for local runtime display controls and an Auth tab for GridBash-wide Claude/Codex auth defaults. + +## Auth Profiles + +GridBash can launch Claude and Codex with isolated auth/config directories. It discovers profiles from: + +```text +GRIDBASH_AUTH_HOME > CLAUDE_PROFILES_HOME > [auth].home > %USERPROFILE%\.claude-profiles +``` + +Claude profiles launch with `CLAUDE_CONFIG_DIR=`. Codex profiles launch with `CODEX_HOME=`. + +Auth settings controls: + +| Input | Action | +| --- | --- | +| Tab | Switch Settings tabs | +| Up / Down | Move through auth profiles | +| d | Set selected profile as the GridBash-wide default for its kind | +| n | Create a profile directory | +| l | Open the selected profile's login command | +| r | Refresh local account and usage status | +| Esc / q | Close settings | + +Usage status is best-effort. GridBash reads local auth metadata, masks account emails, and uses short-timeout `curl.exe` requests only while the Auth settings view is refreshed. ## Profiles @@ -197,7 +221,7 @@ GridBash resolves Windows `.exe` and `.cmd` shims before extensionless npm shims Optional config file: ```text -%APPDATA%\GridBash\config.toml +%APPDATA%\GridBash\config\config.toml ``` Example: @@ -206,10 +230,19 @@ Example: [defaults] profile = "powershell" +[auth] +home = "C:\\Users\\Jason\\.claude-profiles" +usage_status = true + +[auth.defaults] +claude = "claude-1" +codex = "codex-2" + [profiles.review] command = "codex" args = ["--model", "gpt-5.5"] title = "Codex Review" +agent_kind = "codex" ``` Then run: diff --git a/config.example.toml b/config.example.toml index 67c3030..041447c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,15 +1,25 @@ [defaults] profile = "git-bash" +[auth] +home = "C:\\Users\\Jason\\.claude-profiles" +usage_status = true + +[auth.defaults] +claude = "claude-1" +codex = "codex-2" + [profiles.codex-fast] command = "codex" args = ["--model", "gpt-5.4-mini"] title = "Codex Fast" +agent_kind = "codex" [profiles.claude-review] command = "claude" args = [] title = "Claude Review" +agent_kind = "claude" [profiles.git-worktree] command = "bash" diff --git a/docs/devlogs/2026-07-07-integrate-native-auth-manager.md b/docs/devlogs/2026-07-07-integrate-native-auth-manager.md new file mode 100644 index 0000000..c62180c --- /dev/null +++ b/docs/devlogs/2026-07-07-integrate-native-auth-manager.md @@ -0,0 +1,34 @@ +# Integrate native auth manager + +Date: 2026-07-07 +Release target: unreleased + +## Summary + +- Integrated the native Claude/Codex auth profile manager with current `main`. +- Preserved the existing live pane usage labels while making them prefer the auth directory applied to a pane. + +## What Changed + +- Added `[auth]` config with per-kind defaults and local auth profile discovery. +- Added an Auth tab to Settings for browsing profiles, creating profile directories, setting defaults, refreshing status, and launching login. +- Tagged built-in Claude/Codex profiles with `agent_kind` so auth defaults can set `CLAUDE_CONFIG_DIR` or `CODEX_HOME` at launch. +- Pane titles now include launch profile and auth profile metadata while keeping current usage labels. +- Usage monitoring now follows the applied native auth directory before falling back to existing profile/default auth lookup. + +## Why It Matters + +- Multiple Claude and Codex accounts can run through GridBash without manually exporting auth environment variables. +- Usage labels stay accurate for panes launched with native auth defaults. +- The Settings/Profile flow from current `main` remains the base behavior. + +## Validation + +- Ran `cargo fmt`. +- Ran `npm test` with 55 passing tests and 1 ignored interactive ConPTY smoke test after rebasing onto the latest `origin/main`. + +## Release Notes + +- Added native Claude/Codex auth profile management in Settings. +- Added GridBash-wide auth defaults for built-in and kind-tagged custom agent profiles. +- Pane headers now show launch profile/auth profile metadata alongside existing usage labels. diff --git a/src/app.rs b/src/app.rs index d141963..7da6549 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,10 @@ use std::{ collections::{BTreeMap, BTreeSet}, env, io::{self, Stdout, Write}, + path::PathBuf, + process::Command, sync::mpsc as std_mpsc, + thread, time::{Duration, Instant}, }; @@ -21,6 +24,7 @@ use tokio::sync::mpsc; use vt100::Screen; use crate::{ + auth::{self, AgentKind, AuthProfile}, cli::{Cli, GridMode}, composer::Composer, config::Config, @@ -41,6 +45,7 @@ const CONVERSATION_SUMMARY_MAX_CHARS: usize = 120; pub struct App { config: Config, + config_path: Option, worktrees: Option, launch_plan: Option, layout: GridLayout, @@ -55,6 +60,8 @@ pub struct App { mouse_enabled: bool, settings: SettingsState, rename: RenamePaneState, + auth_profiles: Vec, + auth_refresh_rx: Option, String>>>, status: String, next_pane_id: usize, event_tx: mpsc::UnboundedSender, @@ -66,10 +73,11 @@ pub struct App { last_activity_decay: Instant, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum KeyOutcome { Continue, Render, + AuthLogin(AuthProfile), Quit, } @@ -169,6 +177,18 @@ pub struct RenamePaneView { pub cursor: usize, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsTab { + General, + Auth, +} + +#[derive(Debug, Clone)] +pub struct AuthCreateState { + pub kind: AgentKind, + pub name: String, +} + #[derive(Debug, Clone, Default)] struct RenamePaneState { open: bool, @@ -251,7 +271,11 @@ impl RenamePaneState { #[derive(Debug, Clone)] struct SettingsState { open: bool, + tab: SettingsTab, cursor: usize, + auth_cursor: usize, + auth_refreshing: bool, + create_auth: Option, compact_titles: bool, activity_badges: bool, confirm_quit: bool, @@ -265,7 +289,11 @@ impl Default for SettingsState { fn default() -> Self { Self { open: false, + tab: SettingsTab::General, cursor: 0, + auth_cursor: 0, + auth_refreshing: false, + create_auth: None, compact_titles: false, activity_badges: true, confirm_quit: false, @@ -394,11 +422,15 @@ fn switch_value(enabled: bool) -> String { impl App { pub fn new(cli: Cli, config: Config) -> Result { + let config_path = cli.config.clone(); let worktrees = cli .worktrees .then(|| ManagedWorktreeOptions::new(cli.worktree_prefix.clone())) .transpose()?; - let launch_plan = resolve_direct_launch_plan(&cli, &config, worktrees.as_ref())?; + let mut launch_plan = resolve_direct_launch_plan(&cli, &config, worktrees.as_ref())?; + if let Some(plan) = launch_plan.as_mut() { + apply_auth_defaults(plan, &config)?; + } let mouse_enabled = !cli.no_mouse; let grid = launch_plan .as_ref() @@ -412,6 +444,7 @@ impl App { Ok(Self { config, + config_path, worktrees, launch_plan, layout: GridLayout::new(grid), @@ -426,6 +459,8 @@ impl App { mouse_enabled, settings: SettingsState::default(), rename: RenamePaneState::default(), + auth_profiles: Vec::new(), + auth_refresh_rx: None, status: if mouse_enabled { "Drag copies within pane | Alt+arrows move | Alt+Shift+arrows resize | Alt+r rename | Alt+x swap | Alt+z sleep | Alt+o settings" .into() @@ -458,7 +493,7 @@ impl App { let Some(plan) = composer.run(terminal, &self.config)? else { return Ok(()); }; - self.set_launch_plan(plan); + self.set_launch_plan(plan)?; } self.spawn_initial_panes()?; @@ -466,9 +501,11 @@ impl App { self.run_loop(terminal) } - fn set_launch_plan(&mut self, plan: LaunchPlan) { + fn set_launch_plan(&mut self, mut plan: LaunchPlan) -> Result<()> { + apply_auth_defaults(&mut plan, &self.config)?; self.layout = GridLayout::new(plan.grid); self.launch_plan = Some(plan); + Ok(()) } fn spawn_initial_panes(&mut self) -> Result<()> { @@ -501,6 +538,8 @@ impl App { .map(|spec| UsageTarget { profile_name: spec.profile_name.clone(), command: spec.command.command.clone(), + auth_kind: spec.auth_kind, + auth_dir: spec.auth_dir.clone(), }) .collect::>(); usage::spawn_usage_monitor(targets, self.usage_tx.clone()); @@ -515,6 +554,7 @@ impl App { 0, &launch.command, &launch.args, + &spec.env, &spec.cwd, self.event_tx.clone(), )?; @@ -529,6 +569,7 @@ impl App { loop { needs_render |= self.drain_pty_events(); needs_render |= self.drain_usage_events(); + needs_render |= self.drain_auth_refresh(); needs_render |= self.decay_activity(); if needs_render { @@ -548,6 +589,11 @@ impl App { match self.handle_key(key)? { KeyOutcome::Continue => {} KeyOutcome::Render => needs_render = true, + KeyOutcome::AuthLogin(profile) => { + self.run_auth_login(terminal, profile)?; + mouse_capture_enabled = false; + needs_render = true; + } KeyOutcome::Quit => break, } } @@ -775,6 +821,9 @@ impl App { } 'o' => { self.settings.open = true; + if self.settings.tab == SettingsTab::Auth { + self.start_auth_refresh(); + } self.status = "settings open".into(); Ok(Some(false)) } @@ -936,6 +985,23 @@ impl App { return Ok(KeyOutcome::Render); } + if self.settings.create_auth.is_some() { + return Ok(if self.handle_auth_create_key(key)? { + KeyOutcome::Render + } else { + KeyOutcome::Continue + }); + } + + if matches!(key.code, KeyCode::Tab | KeyCode::BackTab) { + self.toggle_settings_tab(); + return Ok(KeyOutcome::Render); + } + + if self.settings.tab == SettingsTab::Auth { + return self.handle_auth_settings_key(key); + } + let changed = match key.code { KeyCode::Esc | KeyCode::Char('q') => { self.settings.open = false; @@ -972,6 +1038,85 @@ impl App { }) } + fn handle_auth_settings_key(&mut self, key: KeyEvent) -> Result { + let changed = match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.settings.open = false; + self.status = "settings closed".into(); + true + } + KeyCode::Up | KeyCode::Char('k') => { + self.move_auth_cursor(-1); + true + } + KeyCode::Down | KeyCode::Char('j') => { + self.move_auth_cursor(1); + true + } + KeyCode::Char('r') | KeyCode::Char('R') => { + self.start_auth_refresh(); + true + } + KeyCode::Char('d') | KeyCode::Char('D') => { + self.set_selected_auth_default()?; + true + } + KeyCode::Char('n') | KeyCode::Char('N') => { + self.start_auth_create()?; + true + } + KeyCode::Char('l') | KeyCode::Char('L') => { + if let Some(profile) = self.selected_auth_profile().cloned() { + return Ok(KeyOutcome::AuthLogin(profile)); + } + self.status = "no auth profile selected".into(); + true + } + _ => false, + }; + + Ok(if changed { + KeyOutcome::Render + } else { + KeyOutcome::Continue + }) + } + + fn handle_auth_create_key(&mut self, key: KeyEvent) -> Result { + let changed = match key.code { + KeyCode::Esc => { + self.settings.create_auth = None; + self.status = "auth profile creation cancelled".into(); + true + } + KeyCode::Tab | KeyCode::Left | KeyCode::Right => { + self.toggle_create_auth_kind()?; + true + } + KeyCode::Enter => { + self.create_auth_profile()?; + true + } + KeyCode::Backspace => { + if let Some(create) = &mut self.settings.create_auth { + create.name.pop(); + } + true + } + KeyCode::Char(ch) if valid_auth_name_char(ch) => { + if let Some(create) = &mut self.settings.create_auth + && create.name.len() < 64 + { + create.name.push(ch); + } + true + } + _ => false, + }; + + Ok(changed) + } + fn handle_mouse(&mut self, mouse: MouseEvent, terminal: &mut Tui) -> Result { if !matches!( mouse.kind, @@ -1414,6 +1559,36 @@ impl App { self.settings.rows() } + pub fn settings_tab(&self) -> SettingsTab { + self.settings.tab + } + + pub fn auth_profiles(&self) -> &[AuthProfile] { + &self.auth_profiles + } + + pub fn auth_cursor(&self) -> usize { + self.settings.auth_cursor + } + + pub fn auth_refreshing(&self) -> bool { + self.settings.auth_refreshing + } + + pub fn auth_create(&self) -> Option<&AuthCreateState> { + self.settings.create_auth.as_ref() + } + + pub fn auth_default(&self, kind: AgentKind) -> Option<&str> { + self.config.auth.defaults.get(kind) + } + + pub fn auth_home_label(&self) -> String { + auth::resolve_home(&self.config.auth) + .map(|path| path.display().to_string()) + .unwrap_or_else(|error| format!("unresolved: {error:#}")) + } + pub fn pane_label(&self, index: usize) -> String { self.pane_names .get(index) @@ -1439,6 +1614,13 @@ impl App { .map(|pane| pane.folder_name.as_str()) } + pub fn pane_profile(&self, index: usize) -> Option<&str> { + self.launch_plan + .as_ref() + .and_then(|plan| plan.panes.get(index)) + .map(|pane| pane.profile_name.as_str()) + } + pub fn pane_worktree(&self, index: usize) -> Option<&str> { self.launch_plan .as_ref() @@ -1446,6 +1628,13 @@ impl App { .and_then(|pane| pane.worktree_name.as_deref()) } + pub fn pane_auth(&self, index: usize) -> Option<&str> { + self.launch_plan + .as_ref() + .and_then(|plan| plan.panes.get(index)) + .and_then(|pane| pane.auth_name.as_deref()) + } + pub fn pane_conversation_footer(&self, index: usize, max_chars: usize) -> Option { let label = self .launch_plan @@ -1499,6 +1688,168 @@ impl App { self.sync_pane_sizes(); Ok(()) } + + fn toggle_settings_tab(&mut self) { + self.settings.tab = match self.settings.tab { + SettingsTab::General => SettingsTab::Auth, + SettingsTab::Auth => SettingsTab::General, + }; + if self.settings.tab == SettingsTab::Auth && self.auth_profiles.is_empty() { + self.start_auth_refresh(); + } + } + + fn start_auth_refresh(&mut self) { + if self.settings.auth_refreshing { + self.status = "auth refresh already running".into(); + return; + } + + let auth_config = self.config.auth.clone(); + let (tx, rx) = std_mpsc::channel(); + self.auth_refresh_rx = Some(rx); + self.settings.auth_refreshing = true; + self.status = "refreshing auth profiles".into(); + + thread::spawn(move || { + let result = auth::discover_profiles_with_usage(&auth_config) + .map_err(|error| format!("{error:#}")); + let _ = tx.send(result); + }); + } + + fn drain_auth_refresh(&mut self) -> bool { + let Some(rx) = &self.auth_refresh_rx else { + return false; + }; + + let result = match rx.try_recv() { + Ok(result) => Some(result), + Err(std_mpsc::TryRecvError::Empty) => None, + Err(std_mpsc::TryRecvError::Disconnected) => Some(Err("auth refresh stopped".into())), + }; + + let Some(result) = result else { + return false; + }; + + self.auth_refresh_rx = None; + self.settings.auth_refreshing = false; + match result { + Ok(profiles) => { + self.auth_profiles = profiles; + self.settings.auth_cursor = self + .settings + .auth_cursor + .min(self.auth_profiles.len().saturating_sub(1)); + self.status = format!("loaded {} auth profiles", self.auth_profiles.len()); + } + Err(error) => self.status = format!("auth refresh failed: {error}"), + } + true + } + + fn move_auth_cursor(&mut self, delta: isize) { + if self.auth_profiles.is_empty() { + return; + } + + let len = self.auth_profiles.len() as isize; + self.settings.auth_cursor = + (self.settings.auth_cursor as isize + delta).clamp(0, len - 1) as usize; + } + + fn selected_auth_profile(&self) -> Option<&AuthProfile> { + self.auth_profiles.get(self.settings.auth_cursor) + } + + fn set_selected_auth_default(&mut self) -> Result<()> { + let Some(profile) = self.selected_auth_profile().cloned() else { + self.status = "no auth profile selected".into(); + return Ok(()); + }; + + self.config + .auth + .defaults + .set(profile.kind, profile.name.clone()); + let path = self.config.save(self.config_path.as_deref())?; + self.status = format!( + "{} default auth: {} ({})", + profile.kind.display_name(), + profile.name, + path.display() + ); + Ok(()) + } + + fn start_auth_create(&mut self) -> Result<()> { + let kind = self + .selected_auth_profile() + .map(|profile| profile.kind) + .unwrap_or(AgentKind::Claude); + let name = auth::next_profile_name(&self.config.auth, kind)?; + self.settings.create_auth = Some(AuthCreateState { kind, name }); + self.status = "creating auth profile".into(); + Ok(()) + } + + fn toggle_create_auth_kind(&mut self) -> Result<()> { + let Some(create) = &mut self.settings.create_auth else { + return Ok(()); + }; + let kind = create.kind.toggle(); + let name = auth::next_profile_name(&self.config.auth, kind)?; + *create = AuthCreateState { kind, name }; + Ok(()) + } + + fn create_auth_profile(&mut self) -> Result<()> { + let Some(create) = self.settings.create_auth.clone() else { + return Ok(()); + }; + let profile = auth::create_profile(&self.config.auth, create.kind, create.name.trim())?; + self.settings.create_auth = None; + self.auth_profiles = auth::discover_profiles(&self.config.auth)?; + if let Some(index) = self + .auth_profiles + .iter() + .position(|candidate| candidate.name == profile.name) + { + self.settings.auth_cursor = index; + } + self.status = format!( + "created {} auth profile {}", + create.kind.as_str(), + profile.name + ); + Ok(()) + } + + fn run_auth_login(&mut self, terminal: &mut Tui, profile: AuthProfile) -> Result<()> { + let launch = auth::login_command(&profile); + suspend_terminal(terminal)?; + let run_result = Command::new(&launch.command) + .args(&launch.args) + .envs(&launch.env) + .status() + .with_context(|| format!("failed to run {}", launch.command.display())); + let resume_result = resume_terminal(terminal); + resume_result?; + + match run_result { + Ok(status) if status.success() => { + self.status = format!("{} login completed", profile.name); + self.start_auth_refresh(); + } + Ok(status) => { + self.status = format!("{} login exited with {}", profile.name, status); + self.start_auth_refresh(); + } + Err(error) => self.status = format!("auth login failed: {error:#}"), + } + Ok(()) + } } fn resolve_grid(cli: &Cli) -> Result { @@ -1549,6 +1900,22 @@ fn resolve_direct_launch_plan( )?)) } +fn apply_auth_defaults(plan: &mut LaunchPlan, config: &Config) -> Result<()> { + for spec in &mut plan.panes { + let Some(kind) = spec.command.agent_kind else { + continue; + }; + let Some(auth_env) = auth::env_for_default(&config.auth, kind)? else { + continue; + }; + spec.env.extend(auth_env.env_map()); + spec.auth_name = Some(auth_env.name); + spec.auth_kind = Some(auth_env.kind); + spec.auth_dir = Some(auth_env.dir); + } + Ok(()) +} + fn uses_direct_launch(cli: &Cli) -> bool { cli.grid.is_some() || cli.count.is_some() @@ -1781,6 +2148,10 @@ fn pane_word(count: usize) -> &'static str { if count == 1 { "pane" } else { "panes" } } +fn valid_auth_name_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') +} + fn control_byte(ch: char) -> Option { let lower = ch.to_ascii_lowercase(); if lower.is_ascii_lowercase() { @@ -1853,7 +2224,35 @@ fn function_key_sequence(number: u8) -> Option<&'static [u8]> { #[cfg(test)] mod tests { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + use super::*; + use crate::profiles::Profile; + + struct TempHome { + path: PathBuf, + } + + impl TempHome { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let path = env::temp_dir().join(format!("gridbash-app-auth-test-{nonce}")); + fs::create_dir_all(&path).expect("temp home"); + Self { path } + } + } + + impl Drop for TempHome { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } fn selected(indices: &[usize]) -> BTreeSet { indices.iter().copied().collect() @@ -1949,6 +2348,43 @@ mod tests { vec![3] ); } + + #[test] + fn applies_auth_defaults_to_agent_launch_specs() { + let temp = TempHome::new(); + let codex_dir = temp.path.join("codex-2"); + fs::create_dir(&codex_dir).expect("codex dir"); + fs::write(codex_dir.join(".profile-kind"), "codex").expect("kind"); + let mut config = Config::default(); + config.auth.home = Some(temp.path.clone()); + config.auth.defaults.set(AgentKind::Codex, "codex-2"); + let cwd = env::current_dir().expect("cwd"); + let mut plan = LaunchPlan::legacy( + "codex".into(), + Profile { + command: "codex".into(), + args: vec![], + title: Some("codex".into()), + agent_kind: Some(AgentKind::Codex), + }, + cwd, + 1, + GridSize { + rows: 1, + columns: 1, + }, + ); + + apply_auth_defaults(&mut plan, &config).expect("apply auth"); + + assert_eq!( + plan.panes[0].env.get("CODEX_HOME"), + Some(&codex_dir.display().to_string()) + ); + assert_eq!(plan.panes[0].auth_name.as_deref(), Some("codex-2")); + assert_eq!(plan.panes[0].auth_kind, Some(AgentKind::Codex)); + assert_eq!(plan.panes[0].auth_dir.as_deref(), Some(codex_dir.as_path())); + } } fn conversation_summary(screen: &Screen) -> Option { @@ -2027,6 +2463,28 @@ fn teardown_terminal(terminal: &mut Tui, enable_mouse: bool) -> Result<()> { Ok(()) } +fn suspend_terminal(terminal: &mut Tui) -> Result<()> { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + DisableBracketedPaste, + DisableMouseCapture, + LeaveAlternateScreen + )?; + terminal.show_cursor()?; + Ok(()) +} + +fn resume_terminal(terminal: &mut Tui) -> Result<()> { + enable_raw_mode()?; + execute!( + terminal.backend_mut(), + EnterAlternateScreen, + EnableBracketedPaste + )?; + Ok(()) +} + #[cfg(test)] mod selection_tests { use super::*; diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..e6cb18a --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,761 @@ +use std::{ + collections::BTreeMap, + env, fs, + path::{Path, PathBuf}, + process::Command, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow}; +use base64::{Engine as _, engine::general_purpose}; +use directories::BaseDirs; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +const GRIDBASH_AUTH_HOME: &str = "GRIDBASH_AUTH_HOME"; +const CLAUDE_PROFILES_HOME: &str = "CLAUDE_PROFILES_HOME"; +const PROFILE_KIND_FILE: &str = ".profile-kind"; +const CLAUDE_USAGE_ENDPOINT: &str = "https://api.anthropic.com/api/oauth/usage"; +const CODEX_USAGE_ENDPOINT: &str = "https://chatgpt.com/backend-api/codex/usage"; +const USAGE_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AgentKind { + Claude, + Codex, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuthConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub home: Option, + #[serde(default, skip_serializing_if = "AuthDefaults::is_empty")] + pub defaults: AuthDefaults, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage_status: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuthDefaults { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claude: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthProfile { + pub name: String, + pub kind: AgentKind, + pub dir: PathBuf, + pub ready: bool, + pub account_label: Option, + pub account_detail: Option, + pub usage: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsageInfo { + pub five_hour: Option, + pub seven_day: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsageWindow { + pub available_percent: u8, + pub resets_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthEnv { + pub kind: AgentKind, + pub name: String, + pub dir: PathBuf, + pub env_var: &'static str, + pub env_value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthLaunchCommand { + pub command: PathBuf, + pub args: Vec, + pub env: BTreeMap, +} + +impl AgentKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + } + } + + pub fn display_name(self) -> &'static str { + match self { + Self::Claude => "Claude", + Self::Codex => "Codex", + } + } + + pub fn env_var(self) -> &'static str { + match self { + Self::Claude => "CLAUDE_CONFIG_DIR", + Self::Codex => "CODEX_HOME", + } + } + + pub fn default_command(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + } + } + + pub fn toggle(self) -> Self { + match self { + Self::Claude => Self::Codex, + Self::Codex => Self::Claude, + } + } +} + +impl AuthConfig { + pub fn is_empty(&self) -> bool { + self.home.is_none() && self.defaults.is_empty() && self.usage_status.is_none() + } + + pub fn usage_enabled(&self) -> bool { + self.usage_status.unwrap_or(true) + } +} + +impl AuthDefaults { + pub fn is_empty(&self) -> bool { + self.claude.is_none() && self.codex.is_none() + } + + pub fn get(&self, kind: AgentKind) -> Option<&str> { + match kind { + AgentKind::Claude => self.claude.as_deref(), + AgentKind::Codex => self.codex.as_deref(), + } + } + + pub fn set(&mut self, kind: AgentKind, name: impl Into) { + match kind { + AgentKind::Claude => self.claude = Some(name.into()), + AgentKind::Codex => self.codex = Some(name.into()), + } + } +} + +impl AuthProfile { + pub fn status_label(&self) -> &'static str { + if self.ready { "ready" } else { "login needed" } + } +} + +impl UsageInfo { + pub fn display_label(&self) -> String { + let five = self + .five_hour + .as_ref() + .map(|value| format!("5h {}%", value.available_percent)) + .unwrap_or_else(|| "5h n/a".into()); + let seven = self + .seven_day + .as_ref() + .map(|value| format!("7d {}%", value.available_percent)) + .unwrap_or_else(|| "7d n/a".into()); + format!("{five} | {seven}") + } +} + +impl AuthEnv { + pub fn env_map(&self) -> BTreeMap { + BTreeMap::from([(self.env_var.to_string(), self.env_value.clone())]) + } +} + +pub fn resolve_home(config: &AuthConfig) -> Result { + non_empty_env_path(GRIDBASH_AUTH_HOME) + .or_else(|| non_empty_env_path(CLAUDE_PROFILES_HOME)) + .or_else(|| config.home.clone()) + .or_else(|| BaseDirs::new().map(|dirs| dirs.home_dir().join(".claude-profiles"))) + .ok_or_else(|| anyhow!("failed to resolve auth profile home")) +} + +pub fn discover_profiles(config: &AuthConfig) -> Result> { + let home = resolve_home(config)?; + if !home.exists() { + return Ok(Vec::new()); + } + + let mut profiles = fs::read_dir(&home) + .with_context(|| format!("failed to read auth profile home {}", home.display()))? + .filter_map(|entry| entry.ok()) + .filter(|entry| is_profile_entry(entry.path(), &entry.file_name().to_string_lossy())) + .map(|entry| { + profile_from_dir( + entry.file_name().to_string_lossy().to_string(), + entry.path(), + ) + }) + .collect::>>()?; + profiles.sort_by(|left, right| { + left.kind + .as_str() + .cmp(right.kind.as_str()) + .then_with(|| left.name.cmp(&right.name)) + }); + Ok(profiles) +} + +pub fn discover_profiles_with_usage(config: &AuthConfig) -> Result> { + let mut profiles = discover_profiles(config)?; + if !config.usage_enabled() { + return Ok(profiles); + } + + for profile in &mut profiles { + profile.usage = fetch_usage(profile).unwrap_or(None); + } + Ok(profiles) +} + +pub fn create_profile(config: &AuthConfig, kind: AgentKind, name: &str) -> Result { + validate_profile_name(name)?; + let home = resolve_home(config)?; + fs::create_dir_all(&home) + .with_context(|| format!("failed to create auth profile home {}", home.display()))?; + let dir = home.join(name); + if dir.exists() { + return Err(anyhow!("auth profile already exists: {name}")); + } + + fs::create_dir(&dir) + .with_context(|| format!("failed to create auth profile {}", dir.display()))?; + fs::write(dir.join(PROFILE_KIND_FILE), kind.as_str()) + .with_context(|| format!("failed to write profile kind for {}", dir.display()))?; + fs::write(dir.join("PROFILE.md"), profile_readme(kind, name)) + .with_context(|| format!("failed to write profile readme for {}", dir.display()))?; + + profile_from_dir(name.to_string(), dir) +} + +pub fn next_profile_name(config: &AuthConfig, kind: AgentKind) -> Result { + let profiles = discover_profiles(config)?; + let names = profiles + .into_iter() + .map(|profile| profile.name) + .collect::>(); + for index in 1..=999 { + let candidate = format!("{}-{index}", kind.as_str()); + if !names.contains(&candidate) { + return Ok(candidate); + } + } + Err(anyhow!( + "failed to find available {} profile name", + kind.as_str() + )) +} + +pub fn env_for_default(config: &AuthConfig, kind: AgentKind) -> Result> { + let Some(name) = config.defaults.get(kind) else { + return Ok(None); + }; + env_for_profile(config, kind, name).map(Some) +} + +pub fn env_for_profile(config: &AuthConfig, kind: AgentKind, name: &str) -> Result { + validate_profile_name(name)?; + let dir = resolve_home(config)?.join(name); + if !dir.is_dir() { + return Err(anyhow!( + "configured {} auth profile not found: {}", + kind.as_str(), + name + )); + } + let actual_kind = profile_kind(&dir); + if actual_kind != kind { + return Err(anyhow!( + "configured auth profile '{}' is {}, not {}", + name, + actual_kind.as_str(), + kind.as_str() + )); + } + Ok(AuthEnv { + kind, + name: name.to_string(), + env_var: kind.env_var(), + env_value: dir.display().to_string(), + dir, + }) +} + +pub fn login_command(profile: &AuthProfile) -> AuthLaunchCommand { + let command = resolve_agent_executable(profile.kind) + .unwrap_or_else(|| PathBuf::from(profile.kind.default_command())); + let args = match profile.kind { + AgentKind::Claude => vec!["auth".into(), "login".into()], + AgentKind::Codex => vec!["login".into()], + }; + AuthLaunchCommand { + command, + args, + env: BTreeMap::from([( + profile.kind.env_var().to_string(), + profile.dir.display().to_string(), + )]), + } +} + +pub fn resolve_agent_executable(kind: AgentKind) -> Option { + match kind { + AgentKind::Claude => non_empty_env_path("CLAUDE_BIN") + .filter(|path| path.exists()) + .or_else(resolve_installed_claude) + .or_else(|| resolve_on_path("claude")), + AgentKind::Codex => non_empty_env_path("CODEX_BIN") + .filter(|path| path.exists()) + .or_else(resolve_installed_codex) + .or_else(|| resolve_on_path("codex")), + } +} + +fn non_empty_env_path(name: &str) -> Option { + env::var_os(name).and_then(|value| { + let path = PathBuf::from(value); + (!path.as_os_str().is_empty()).then_some(path) + }) +} + +fn resolve_installed_claude() -> Option { + let appdata = env::var_os("APPDATA")?; + let candidate = PathBuf::from(appdata) + .join("npm") + .join("node_modules") + .join("@anthropic-ai") + .join("claude-code") + .join("bin") + .join("claude.exe"); + candidate.is_file().then_some(candidate) +} + +fn resolve_installed_codex() -> Option { + let appdata = env::var_os("APPDATA")?; + let candidate = PathBuf::from(appdata).join("npm").join("codex.exe"); + candidate.is_file().then_some(candidate) +} + +fn resolve_on_path(command: &str) -> Option { + let path = env::var_os("PATH")?; + let pathext: Vec = env::var_os("PATHEXT") + .map(|value| { + env::split_paths(&value) + .filter_map(|p| p.to_str().map(|s| s.trim_start_matches('.').to_string())) + .collect() + }) + .unwrap_or_else(|| vec!["exe".into(), "cmd".into(), "bat".into(), "ps1".into()]); + + for dir in env::split_paths(&path) { + for ext in &pathext { + let candidate = dir.join(format!("{command}.{ext}")); + if candidate.is_file() { + return Some(candidate); + } + } + + let direct = dir.join(command); + if direct.is_file() { + return Some(direct); + } + } + None +} + +fn is_profile_entry(path: PathBuf, name: &str) -> bool { + path.is_dir() && !name.starts_with('.') && !name.ends_with(".lock") +} + +fn profile_from_dir(name: String, dir: PathBuf) -> Result { + let kind = profile_kind(&dir); + let ready = has_auth_files(&dir, kind); + let (account_label, account_detail) = read_account(&dir, kind); + Ok(AuthProfile { + name, + kind, + dir, + ready, + account_label, + account_detail, + usage: None, + }) +} + +fn profile_kind(dir: &Path) -> AgentKind { + match fs::read_to_string(dir.join(PROFILE_KIND_FILE)) { + Ok(raw) if raw.trim().eq_ignore_ascii_case("codex") => AgentKind::Codex, + _ => AgentKind::Claude, + } +} + +fn has_auth_files(dir: &Path, kind: AgentKind) -> bool { + match kind { + AgentKind::Claude => [".credentials.json", "accounts", ".claude.json"] + .iter() + .any(|name| dir.join(name).exists()), + AgentKind::Codex => dir.join("auth.json").exists(), + } +} + +fn read_account(dir: &Path, kind: AgentKind) -> (Option, Option) { + match kind { + AgentKind::Claude => read_claude_email(dir) + .map(|email| (Some(mask_email(&email)), None)) + .unwrap_or((None, None)), + AgentKind::Codex => { + let account = read_codex_account(dir); + ( + account.email.as_deref().map(mask_email), + account.plan.map(|plan| plan.to_ascii_lowercase()), + ) + } + } +} + +fn read_claude_email(dir: &Path) -> Option { + let data = read_json_file(&dir.join(".claude.json"))?; + data.pointer("/oauthAccount/emailAddress") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +#[derive(Debug, Default)] +struct CodexAccount { + email: Option, + plan: Option, +} + +fn read_codex_account(dir: &Path) -> CodexAccount { + let Some(auth) = read_json_file(&dir.join("auth.json")) else { + return CodexAccount::default(); + }; + let Some(token) = auth.pointer("/tokens/id_token").and_then(Value::as_str) else { + return CodexAccount::default(); + }; + let Some(claims) = decode_jwt_payload(token) else { + return CodexAccount::default(); + }; + let email = claims + .get("email") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let plan = claims + .get("https://api.openai.com/auth") + .and_then(|inner| inner.get("chatgpt_plan_type")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + CodexAccount { email, plan } +} + +fn decode_jwt_payload(token: &str) -> Option { + let encoded = token.split('.').nth(1)?; + let bytes = general_purpose::URL_SAFE_NO_PAD + .decode(encoded) + .or_else(|_| general_purpose::URL_SAFE.decode(encoded)) + .ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn fetch_usage(profile: &AuthProfile) -> Result> { + match profile.kind { + AgentKind::Claude => fetch_claude_usage(&profile.dir), + AgentKind::Codex => fetch_codex_usage(&profile.dir), + } +} + +fn fetch_claude_usage(dir: &Path) -> Result> { + let Some(creds) = read_json_file(&dir.join(".credentials.json")) else { + return Ok(None); + }; + let Some(token) = creds + .pointer("/claudeAiOauth/accessToken") + .and_then(Value::as_str) + else { + return Ok(None); + }; + let body = curl_json( + CLAUDE_USAGE_ENDPOINT, + &[ + ("Authorization", format!("Bearer {token}")), + ("anthropic-beta", "oauth-2025-04-20".into()), + ], + )?; + Ok(parse_usage_response(&body)) +} + +fn fetch_codex_usage(dir: &Path) -> Result> { + let Some(auth) = read_json_file(&dir.join("auth.json")) else { + return Ok(None); + }; + let Some(token) = auth.pointer("/tokens/access_token").and_then(Value::as_str) else { + return Ok(None); + }; + let mut headers = vec![ + ("Authorization", format!("Bearer {token}")), + ("User-Agent", "codex-cli".into()), + ]; + if let Some(account_id) = auth.pointer("/tokens/account_id").and_then(Value::as_str) { + headers.push(("chatgpt-account-id", account_id.to_string())); + } + let body = curl_json(CODEX_USAGE_ENDPOINT, &headers)?; + Ok(parse_usage_response(&body)) +} + +fn curl_json(url: &str, headers: &[(&str, String)]) -> Result { + let mut args = vec![ + "-s".to_string(), + "-m".to_string(), + USAGE_TIMEOUT.as_secs().to_string(), + ]; + for (name, value) in headers { + args.push("-H".into()); + args.push(format!("{name}: {value}")); + } + args.push(url.to_string()); + + let output = Command::new("curl.exe") + .args(args) + .output() + .context("failed to run curl.exe for usage status")?; + if !output.status.success() { + return Ok(Value::Null); + } + Ok(serde_json::from_slice(&output.stdout).unwrap_or(Value::Null)) +} + +fn parse_usage_response(value: &Value) -> Option { + let five_hour = parse_usage_window(value.get("five_hour")); + let seven_day = parse_usage_window(value.get("seven_day")); + (five_hour.is_some() || seven_day.is_some()).then_some(UsageInfo { + five_hour, + seven_day, + }) +} + +fn parse_usage_window(value: Option<&Value>) -> Option { + let value = value?; + let utilization = value.get("utilization").and_then(Value::as_f64)?; + let used_percent = if utilization <= 1.0 { + utilization * 100.0 + } else { + utilization + }; + let available_percent = (100.0 - used_percent).round().clamp(0.0, 100.0) as u8; + let resets_at = value + .get("resets_at") + .and_then(Value::as_str) + .map(str::to_string); + Some(UsageWindow { + available_percent, + resets_at, + }) +} + +fn read_json_file(path: &Path) -> Option { + let raw = fs::read_to_string(path).ok()?; + serde_json::from_str(&raw).ok() +} + +fn mask_email(email: &str) -> String { + let Some((local, domain)) = email.split_once('@') else { + return email.to_string(); + }; + if local.len() > 4 { + format!("{}...@{domain}", &local[..4]) + } else { + format!("{local}@{domain}") + } +} + +fn validate_profile_name(name: &str) -> Result<()> { + if name.is_empty() + || !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) + || !name + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_alphanumeric()) + { + return Err(anyhow!( + "invalid auth profile name '{name}'; use letters, numbers, dot, underscore, or dash" + )); + } + Ok(()) +} + +fn profile_readme(kind: AgentKind, name: &str) -> String { + let tool = match kind { + AgentKind::Claude => "Claude Code", + AgentKind::Codex => "Codex", + }; + format!( + "# {tool} profile: {name}\n\nThis directory is selected with {}.\n{tool} stores this profile's auth, settings, sessions, and history here.\n\nDo not commit credentials from this directory.\n", + kind.env_var() + ) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::*; + + struct TempHome { + path: PathBuf, + } + + impl TempHome { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let path = env::temp_dir().join(format!("gridbash-auth-test-{nonce}")); + fs::create_dir_all(&path).expect("temp home"); + Self { path } + } + + fn config(&self) -> AuthConfig { + AuthConfig { + home: Some(self.path.clone()), + defaults: AuthDefaults::default(), + usage_status: Some(false), + } + } + } + + impl Drop for TempHome { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + #[test] + fn discovers_claude_and_codex_profiles() { + let temp = TempHome::new(); + let claude = temp.path.join("claude-1"); + let codex = temp.path.join("codex-2"); + fs::create_dir(&claude).expect("claude dir"); + fs::create_dir(&codex).expect("codex dir"); + fs::write( + claude.join(".claude.json"), + r#"{"oauthAccount":{"emailAddress":"jason@example.com"}}"#, + ) + .expect("claude auth"); + fs::write(codex.join(PROFILE_KIND_FILE), "codex").expect("kind"); + fs::write(codex.join("auth.json"), r#"{"tokens":{}}"#).expect("codex auth"); + + let profiles = discover_profiles(&temp.config()).expect("profiles"); + + assert_eq!(profiles.len(), 2); + assert_eq!(profiles[0].kind, AgentKind::Claude); + assert_eq!( + profiles[0].account_label.as_deref(), + Some("jaso...@example.com") + ); + assert!(profiles[0].ready); + assert_eq!(profiles[1].kind, AgentKind::Codex); + assert!(profiles[1].ready); + } + + #[test] + fn builds_default_env_for_kind() { + let temp = TempHome::new(); + let dir = temp.path.join("codex-1"); + fs::create_dir(&dir).expect("dir"); + fs::write(dir.join(PROFILE_KIND_FILE), "codex").expect("kind"); + let mut config = temp.config(); + config.defaults.set(AgentKind::Codex, "codex-1"); + + let env = env_for_default(&config, AgentKind::Codex) + .expect("env") + .expect("present"); + + assert_eq!(env.env_var, "CODEX_HOME"); + assert_eq!(env.name, "codex-1"); + } + + #[test] + fn rejects_wrong_kind_default() { + let temp = TempHome::new(); + fs::create_dir(temp.path.join("claude-1")).expect("dir"); + let mut config = temp.config(); + config.defaults.set(AgentKind::Codex, "claude-1"); + + let error = env_for_default(&config, AgentKind::Codex).expect_err("wrong kind"); + + assert!(error.to_string().contains("not codex")); + } + + #[test] + fn creates_profile_with_marker_and_readme() { + let temp = TempHome::new(); + + let profile = create_profile(&temp.config(), AgentKind::Codex, "codex-1").expect("create"); + + assert_eq!(profile.kind, AgentKind::Codex); + assert_eq!( + fs::read_to_string(profile.dir.join(PROFILE_KIND_FILE)).expect("kind"), + "codex" + ); + assert!(profile.dir.join("PROFILE.md").is_file()); + } + + #[test] + fn decodes_codex_account_without_exposing_token() { + let temp = TempHome::new(); + let dir = temp.path.join("codex-1"); + fs::create_dir(&dir).expect("dir"); + let claims = serde_json::json!({ + "email": "longperson@example.com", + "https://api.openai.com/auth": {"chatgpt_plan_type": "Plus"} + }); + let payload = general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string()); + fs::write( + dir.join("auth.json"), + format!(r#"{{"tokens":{{"id_token":"header.{payload}.sig"}}}}"#), + ) + .expect("auth"); + + let (label, detail) = read_account(&dir, AgentKind::Codex); + + assert_eq!(label.as_deref(), Some("long...@example.com")); + assert_eq!(detail.as_deref(), Some("plus")); + } + + #[test] + fn formats_usage_response_as_available_percent() { + let value = serde_json::json!({ + "five_hour": {"utilization": 25.0, "resets_at": "2026-07-07T12:00:00Z"}, + "seven_day": {"utilization": 0.5} + }); + + let usage = parse_usage_response(&value).expect("usage"); + + assert_eq!(usage.five_hour.unwrap().available_percent, 75); + assert_eq!(usage.seven_day.unwrap().available_percent, 50); + } +} diff --git a/src/config.rs b/src/config.rs index cdd8254..3e4fe05 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,12 +8,14 @@ use anyhow::{Context, Result, anyhow}; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; -use crate::{profiles::Profile, setup::SavedSetup}; +use crate::{auth::AuthConfig, profiles::Profile, setup::SavedSetup}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Config { #[serde(default, skip_serializing_if = "Defaults::is_empty")] pub defaults: Defaults, + #[serde(default, skip_serializing_if = "AuthConfig::is_empty")] + pub auth: AuthConfig, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub profiles: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -99,6 +101,26 @@ mod tests { assert_eq!(config.defaults.profile.as_deref(), Some("powershell")); } + #[test] + fn parses_auth_defaults() { + let config: Config = toml::from_str( + r#" + [auth] + home = "C:\\Users\\Jason\\.claude-profiles" + usage_status = true + + [auth.defaults] + claude = "claude-1" + codex = "codex-2" + "#, + ) + .expect("parse config"); + + assert_eq!(config.auth.defaults.claude.as_deref(), Some("claude-1")); + assert_eq!(config.auth.defaults.codex.as_deref(), Some("codex-2")); + assert_eq!(config.auth.usage_status, Some(true)); + } + #[test] fn parses_named_setups() { let config: Config = toml::from_str( diff --git a/src/main.rs b/src/main.rs index 0c8dd71..8e99c39 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod auth; mod cli; mod composer; mod config; diff --git a/src/profiles.rs b/src/profiles.rs index ab6f61a..57ef5e4 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -7,7 +7,7 @@ use std::{ use anyhow::{Context, Result, anyhow}; use serde::{Deserialize, Serialize}; -use crate::config::Config; +use crate::{auth::AgentKind, config::Config}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Profile { @@ -16,6 +16,8 @@ pub struct Profile { pub args: Vec, #[serde(default)] pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_kind: Option, } #[derive(Debug, Clone)] @@ -31,7 +33,8 @@ pub const AGENT_PROFILE_NAMES: &[&str] = &[ impl Profile { pub fn resolved_command(&self) -> Result { - let exe = resolve_executable(&self.command) + let exe = self + .resolved_executable() .ok_or_else(|| anyhow!("profile command not found on PATH: {}", self.command))?; Ok(wrap_for_windows_script(exe, &self.args)) } @@ -39,6 +42,17 @@ impl Profile { pub fn display_name(&self, key: &str) -> String { self.title.clone().unwrap_or_else(|| key.to_string()) } + + fn resolved_executable(&self) -> Option { + self.native_agent_executable() + .or_else(|| resolve_executable(&self.command)) + } + + fn native_agent_executable(&self) -> Option { + let kind = self.agent_kind?; + (self.command == kind.default_command()) + .then(|| crate::auth::resolve_agent_executable(kind))? + } } fn wrap_for_windows_script(command: PathBuf, args: &[String]) -> LaunchCommand { @@ -109,7 +123,7 @@ pub fn available_profiles(config: &Config) -> Vec<(String, bool)> { all_profiles(config) .into_iter() .map(|(name, profile)| { - let available = resolve_executable(&profile.command).is_some(); + let available = profile.resolved_executable().is_some(); (name, available) }) .collect() @@ -136,6 +150,7 @@ fn builtin_profiles() -> BTreeMap { command: "bash".into(), args: vec!["--login".into(), "-i".into()], title: Some("Git Bash".into()), + agent_kind: None, }, ); profiles.insert( @@ -144,6 +159,7 @@ fn builtin_profiles() -> BTreeMap { command: "pwsh".into(), args: vec!["-NoLogo".into()], title: Some("PowerShell 7".into()), + agent_kind: None, }, ); profiles.insert( @@ -152,6 +168,7 @@ fn builtin_profiles() -> BTreeMap { command: "powershell".into(), args: vec!["-NoLogo".into()], title: Some("PowerShell".into()), + agent_kind: None, }, ); profiles.insert( @@ -160,6 +177,7 @@ fn builtin_profiles() -> BTreeMap { command: "cmd".into(), args: vec![], title: Some("cmd".into()), + agent_kind: None, }, ); @@ -170,6 +188,7 @@ fn builtin_profiles() -> BTreeMap { command: (*agent).into(), args: vec![], title: Some((*agent).into()), + agent_kind: builtin_agent_kind(agent), }, ); } @@ -177,6 +196,14 @@ fn builtin_profiles() -> BTreeMap { profiles } +fn builtin_agent_kind(agent: &str) -> Option { + match agent { + "codex" => Some(AgentKind::Codex), + "claude" => Some(AgentKind::Claude), + _ => None, + } +} + pub fn resolve_executable(command: &str) -> Option { let command_path = Path::new(command); if command_path.is_absolute() || command_path.components().count() > 1 { diff --git a/src/pty.rs b/src/pty.rs index ac774a2..034ecf2 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeMap, io::{Read, Write}, path::{Path, PathBuf}, sync::{Arc, Mutex}, @@ -53,6 +54,7 @@ impl PtyPane { generation: u64, command: &Path, args: &[String], + env: &BTreeMap, cwd: &Path, event_tx: mpsc::UnboundedSender, ) -> Result { @@ -73,6 +75,9 @@ impl PtyPane { command_builder.cwd(cwd); command_builder.env("TERM", "xterm-256color"); command_builder.env("COLORTERM", "truecolor"); + for (key, value) in env { + command_builder.env(key, value); + } let child = pair .slave @@ -314,6 +319,7 @@ mod tests { "/c".to_string(), "set /p GRIDBASH_IN= & echo GRIDBASH_READY:!GRIDBASH_IN!".to_string(), ], + &BTreeMap::new(), &cwd, event_tx, ) diff --git a/src/setup.rs b/src/setup.rs index 12cc739..98bc243 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeMap, path::{Path, PathBuf}, process::Command, }; @@ -7,6 +8,7 @@ use anyhow::{Context, Result, anyhow}; use serde::{Deserialize, Serialize}; use crate::{ + auth::AgentKind, layout::GridSize, profiles::{AGENT_PROFILE_NAMES, LaunchCommand, Profile}, worktrees::{ManagedWorktreeOptions, ensure_pane_worktrees}, @@ -34,12 +36,15 @@ pub struct LaunchPlan { #[derive(Debug, Clone)] pub struct PaneLaunchSpec { - #[allow(dead_code)] pub profile_name: String, pub command: Profile, + pub env: BTreeMap, pub cwd: PathBuf, pub folder_name: String, pub worktree_name: Option, + pub auth_name: Option, + pub auth_kind: Option, + pub auth_dir: Option, } impl SavedSetup { @@ -116,9 +121,13 @@ impl LaunchPlan { .map(|_| PaneLaunchSpec { profile_name: profile_name.clone(), command: command.clone(), + env: BTreeMap::new(), cwd: cwd.clone(), folder_name: folder_name.clone(), worktree_name: worktree_name.clone(), + auth_name: None, + auth_kind: None, + auth_dir: None, }) .collect(); @@ -138,9 +147,13 @@ impl LaunchPlan { .map(|worktree| PaneLaunchSpec { profile_name: profile_name.clone(), command: command.clone(), + env: BTreeMap::new(), cwd: worktree.cwd, folder_name: worktree.folder_name, worktree_name: Some(worktree.branch_name), + auth_name: None, + auth_kind: None, + auth_dir: None, }) .collect(); @@ -281,10 +294,15 @@ fn vibe_pane_spec(agent: &str, folder: &SetupFolder) -> PaneLaunchSpec { command: "vibe".into(), args: vec!["run".into(), agent.into(), "--".into()], title: Some(agent.into()), + agent_kind: None, }, + env: BTreeMap::new(), cwd: folder.path.clone(), folder_name: folder.name.clone(), worktree_name: git_worktree_name(&folder.path), + auth_name: None, + auth_kind: None, + auth_dir: None, } } @@ -354,10 +372,15 @@ mod tests { command: "vibe".into(), args: vec!["run".into(), "claude-1".into(), "--".into()], title: Some("claude-1".into()), + agent_kind: None, }, + env: BTreeMap::new(), cwd, folder_name: "repo".into(), worktree_name: None, + auth_name: None, + auth_kind: None, + auth_dir: None, }; assert_eq!(spec.agent_label().as_deref(), Some("claude-1")); @@ -372,10 +395,15 @@ mod tests { command: "codex".into(), args: vec!["--model".into(), "gpt-5.5".into()], title: Some("Codex Review".into()), + agent_kind: Some(AgentKind::Codex), }, + env: BTreeMap::new(), cwd, folder_name: "repo".into(), worktree_name: None, + auth_name: None, + auth_kind: None, + auth_dir: None, }; assert_eq!(spec.agent_label().as_deref(), Some("Codex Review")); @@ -390,10 +418,15 @@ mod tests { command: "bash".into(), args: vec!["--login".into()], title: Some("Git Bash".into()), + agent_kind: None, }, + env: BTreeMap::new(), cwd, folder_name: "repo".into(), worktree_name: None, + auth_name: None, + auth_kind: None, + auth_dir: None, }; assert_eq!(spec.agent_label(), None); diff --git a/src/ui.rs b/src/ui.rs index 4cac790..b15191f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,7 +8,10 @@ use ratatui::{ use std::path::Path; use vt100::Cell; -use crate::app::{App, PaneSelection, RenamePaneView, SettingsRow}; +use crate::{ + app::{App, PaneSelection, RenamePaneView, SettingsRow, SettingsTab}, + auth::{AgentKind, AuthProfile}, +}; const APP_BG: Color = Color::Rgb(11, 15, 20); const SETTINGS_BG: Color = Color::Rgb(9, 14, 19); @@ -56,6 +59,8 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { &app.pane_label(index), &folder, app.pane_worktree(index), + app.pane_profile(index), + app.pane_auth(index), usage.as_deref(), chrome.badge, ); @@ -124,7 +129,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { ); if app.settings_open() { - render_settings(frame, area, &app.settings_rows()); + render_settings(frame, area, app); } if let Some(rename) = rename_view.as_ref() { render_rename_pane(frame, area, rename); @@ -140,15 +145,26 @@ fn pane_title( label: &str, folder: &str, worktree: Option<&str>, + profile: Option<&str>, + auth: Option<&str>, usage: Option<&str>, badge: &str, ) -> String { - let usage = usage.map(|label| format!(" | {label}")).unwrap_or_default(); - if let Some(worktree) = worktree { - format!(" {label} | {folder} | {worktree}{usage}{badge} ") - } else { - format!(" {label} | {folder}{usage}{badge} ") + let mut parts = vec![label.to_string(), folder.to_string()]; + if let Some(worktree) = worktree.filter(|value| !value.is_empty()) { + parts.push(worktree.to_string()); + } + if let Some(profile) = profile.filter(|value| !value.is_empty()) { + parts.push(profile.to_string()); + } + if let Some(auth) = auth.filter(|value| !value.is_empty()) { + parts.push(auth.to_string()); } + if let Some(usage) = usage.filter(|value| !value.is_empty()) { + parts.push(usage.to_string()); + } + + format!(" {}{} ", parts.join(" | "), badge) } #[derive(Debug, PartialEq)] @@ -196,8 +212,8 @@ fn pane_chrome( } } -fn render_settings(frame: &mut Frame<'_>, area: Rect, rows: &[SettingsRow]) { - let modal = settings_modal_rect(area, rows.len()); +fn render_settings(frame: &mut Frame<'_>, area: Rect, app: &App) { + let modal = settings_modal_rect(area, settings_content_row_count(app)); let shadow = settings_shadow_rect(area, modal); if shadow != modal { @@ -222,7 +238,7 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, rows: &[SettingsRow]) { let inner = block.inner(modal); frame.render_widget(block, modal); frame.render_widget( - Paragraph::new(settings_lines(rows, inner.width)).style(settings_panel_style()), + Paragraph::new(settings_lines(app, inner.width)).style(settings_panel_style()), inner, ); } @@ -310,8 +326,49 @@ fn render_rename_pane(frame: &mut Frame<'_>, area: Rect, rename: &RenamePaneView } } -fn settings_lines(rows: &[SettingsRow], width: u16) -> Vec> { +fn settings_content_row_count(app: &App) -> usize { + match app.settings_tab() { + SettingsTab::General => app.settings_rows().len(), + SettingsTab::Auth => { + app.auth_profiles().len().max(1) + usize::from(app.auth_create().is_some()) * 3 + } + } +} + +fn settings_lines(app: &App, width: u16) -> Vec> { + match app.settings_tab() { + SettingsTab::General => general_settings_lines(&app.settings_rows(), width), + SettingsTab::Auth => auth_settings_lines(app, width), + } +} + +fn settings_tabs(active: SettingsTab) -> Line<'static> { + Line::from(vec![ + Span::raw(" "), + settings_tab("General", active == SettingsTab::General), + Span::raw(" "), + settings_tab("Auth", active == SettingsTab::Auth), + Span::raw(" "), + Span::styled("Tab switches", Style::default().fg(SETTINGS_MUTED)), + ]) +} + +fn settings_tab(label: &'static str, active: bool) -> Span<'static> { + let style = if active { + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::LightCyan).bg(SETTINGS_SURFACE) + }; + Span::styled(format!(" {label} "), style) +} + +fn general_settings_lines(rows: &[SettingsRow], width: u16) -> Vec> { let mut lines = vec![ + settings_tabs(SettingsTab::General), + Line::from(""), Line::from(vec![ Span::raw(" "), Span::styled( @@ -376,6 +433,158 @@ fn settings_lines(rows: &[SettingsRow], width: u16) -> Vec> { lines } +fn auth_settings_lines(app: &App, width: u16) -> Vec> { + let mut lines = vec![ + settings_tabs(SettingsTab::Auth), + Line::from(""), + settings_section( + "AUTH PROFILES", + if app.auth_refreshing() { + "refreshing local account and usage status" + } else { + "Claude and Codex defaults" + }, + width, + ), + Line::from(vec![ + Span::raw(" "), + Span::styled("home", Style::default().fg(SETTINGS_MUTED)), + Span::raw(" "), + Span::styled( + truncate_text(&app.auth_home_label(), width.saturating_sub(8) as usize), + Style::default().fg(Color::Gray), + ), + ]), + Line::from(""), + ]; + + if app.auth_profiles().is_empty() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("No auth profiles found.", Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::styled("n", Style::default().fg(Color::Yellow)), + Span::styled(" creates one", Style::default().fg(SETTINGS_MUTED)), + ])); + } else { + for (index, profile) in app.auth_profiles().iter().enumerate() { + lines.push(auth_profile_row( + profile, + index == app.auth_cursor(), + app.auth_default(profile.kind) == Some(profile.name.as_str()), + width, + )); + } + } + + if let Some(create) = app.auth_create() { + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("Create", Style::default().fg(SETTINGS_MUTED)), + Span::raw(" "), + Span::styled( + create.kind.display_name(), + Style::default().fg(kind_color(create.kind)), + ), + Span::raw(" "), + Span::styled(create.name.clone(), Style::default().fg(Color::Yellow)), + Span::styled("_", Style::default().fg(Color::Yellow)), + ])); + lines.push(auth_create_command_bar()); + } + + lines.push(Line::from("")); + lines.push(auth_command_bar(width)); + lines +} + +fn auth_profile_row( + profile: &AuthProfile, + selected: bool, + is_default: bool, + width: u16, +) -> Line<'static> { + let row_bg = selected.then_some(SETTINGS_ROW_ACTIVE); + let marker = if selected { "> " } else { " " }; + let default = if is_default { "default" } else { "" }; + let account = profile.account_label.as_deref().unwrap_or("no account"); + let detail = profile.account_detail.as_deref().unwrap_or(""); + let usage = profile + .usage + .as_ref() + .map(|usage| usage.display_label()) + .unwrap_or_else(|| "usage n/a".into()); + let summary = format!( + "{:<14} {:<7} {:<8} {:<12} {:<24} {:<8} {}", + profile.name, + profile.kind.as_str(), + default, + profile.status_label(), + account, + detail, + usage + ); + let available = width.saturating_sub(2) as usize; + + Line::from(vec![ + Span::styled(marker.to_string(), row_style(Color::Yellow, row_bg, false)), + Span::styled( + truncate_text(&summary, available), + row_style(SETTINGS_TEXT, row_bg, selected), + ), + ]) +} + +fn auth_command_bar(width: u16) -> Line<'static> { + if width < 58 { + return Line::from(vec![ + Span::raw(" "), + command_key("Up/Down"), + Span::styled(" move ", Style::default().fg(Color::Gray)), + command_key("d"), + Span::styled(" default ", Style::default().fg(Color::Gray)), + command_key("Esc"), + Span::styled(" close", Style::default().fg(Color::Gray)), + ]); + } + + Line::from(vec![ + Span::raw(" "), + command_key("Up/Down"), + Span::styled(" move ", Style::default().fg(Color::Gray)), + command_key("d"), + Span::styled(" default ", Style::default().fg(Color::Gray)), + command_key("n"), + Span::styled(" new ", Style::default().fg(Color::Gray)), + command_key("l"), + Span::styled(" login ", Style::default().fg(Color::Gray)), + command_key("r"), + Span::styled(" refresh ", Style::default().fg(Color::Gray)), + command_key("Esc"), + Span::styled(" close", Style::default().fg(Color::Gray)), + ]) +} + +fn auth_create_command_bar() -> Line<'static> { + Line::from(vec![ + Span::raw(" "), + command_key("Tab"), + Span::styled(" kind ", Style::default().fg(Color::Gray)), + command_key("Enter"), + Span::styled(" create ", Style::default().fg(Color::Gray)), + command_key("Esc"), + Span::styled(" cancel", Style::default().fg(Color::Gray)), + ]) +} + +fn kind_color(kind: AgentKind) -> Color { + match kind { + AgentKind::Claude => Color::Magenta, + AgentKind::Codex => Color::Cyan, + } +} + fn conversation_footer(summary: String, emphasized: bool) -> Line<'static> { let summary_style = if emphasized { Style::default() @@ -913,16 +1122,44 @@ mod tests { #[test] fn pane_title_uses_custom_label_in_number_slot() { assert_eq!( - pane_title("api", "gridbash/", Some("feat/rename-panes"), None, ""), + pane_title( + "api", + "gridbash/", + Some("feat/rename-panes"), + None, + None, + None, + "" + ), " api | gridbash/ | feat/rename-panes " ); assert_eq!( - pane_title("1", "gridbash/", None, None, " selected"), + pane_title("1", "gridbash/", None, None, None, None, " selected"), " 1 | gridbash/ selected " ); assert_eq!( - pane_title("2", "gridbash/", None, Some("5h 80% left"), " selected"), + pane_title( + "2", + "gridbash/", + None, + None, + None, + Some("5h 80% left"), + " selected" + ), " 2 | gridbash/ | 5h 80% left selected " ); + assert_eq!( + pane_title( + "api", + "gridbash/", + Some("main"), + Some("codex"), + Some("codex-2"), + Some("5h 80% left"), + " selected" + ), + " api | gridbash/ | main | codex | codex-2 | 5h 80% left selected " + ); } } diff --git a/src/usage.rs b/src/usage.rs index 3ce7d01..3ebc7da 100644 --- a/src/usage.rs +++ b/src/usage.rs @@ -11,6 +11,8 @@ use std::{ use serde::Deserialize; +use crate::auth::AgentKind; + const CLAUDE_USAGE_ENDPOINT: &str = "https://api.anthropic.com/api/oauth/usage"; const CODEX_USAGE_ENDPOINT: &str = "https://chatgpt.com/backend-api/codex/usage"; const OPENAI_COSTS_ENDPOINT: &str = "https://api.openai.com/v1/organization/costs"; @@ -20,6 +22,8 @@ const REFRESH_INTERVAL: Duration = Duration::from_secs(60); pub struct UsageTarget { pub profile_name: String, pub command: String, + pub auth_kind: Option, + pub auth_dir: Option, } #[derive(Debug, Clone, PartialEq)] @@ -39,6 +43,15 @@ enum AuthKind { Codex, } +impl From for AuthKind { + fn from(value: AgentKind) -> Self { + match value { + AgentKind::Claude => Self::Claude, + AgentKind::Codex => Self::Codex, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct UsageSource { profile_name: String, @@ -100,6 +113,16 @@ fn resolve_usage_sources(targets: &[UsageTarget]) -> Vec { } fn resolve_usage_source(target: &UsageTarget) -> Option { + if let (Some(kind), Some(dir)) = (target.auth_kind, target.auth_dir.as_ref()) + && dir.is_dir() + { + return Some(UsageSource { + profile_name: target.profile_name.clone(), + dir: dir.clone(), + kind: kind.into(), + }); + } + let vibe_dir = profiles_home().join(&target.profile_name); if vibe_dir.is_dir() { let kind = profile_kind(&vibe_dir) @@ -602,4 +625,28 @@ mod tests { Some("5h 30% left / 7d 80% left".into()) ); } + + #[test] + fn resolves_usage_from_applied_auth_dir_first() { + let temp = env::temp_dir().join(format!( + "gridbash-usage-source-test-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + fs::create_dir(&temp).expect("temp auth dir"); + + let source = resolve_usage_source(&UsageTarget { + profile_name: "codex".into(), + command: "codex".into(), + auth_kind: Some(AgentKind::Codex), + auth_dir: Some(temp.clone()), + }) + .expect("usage source"); + let _ = fs::remove_dir_all(&temp); + + assert_eq!(source.dir, temp); + assert_eq!(source.kind, AuthKind::Codex); + } } diff --git a/src/vibe.rs b/src/vibe.rs index ea3683d..53f7b99 100644 --- a/src/vibe.rs +++ b/src/vibe.rs @@ -17,6 +17,7 @@ pub fn load_profiles() -> Result> { command: "vibe".into(), args: vec!["profiles".into()], title: None, + agent_kind: None, } .resolved_command() .context("vibe is required for GridBash orchestration, but it was not found")?;