diff --git a/Cargo.lock b/Cargo.lock index 4c81448..f34da23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -647,6 +647,7 @@ name = "gridbash" version = "0.1.5" dependencies = [ "anyhow", + "base64", "clap", "crossterm", "directories", diff --git a/Cargo.toml b/Cargo.toml index caffac7..0b50ebe 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 cd7abbe..9797f2c 100644 --- a/README.md +++ b/README.md @@ -204,10 +204,36 @@ Typing goes to selected panes whenever multiple panes are selected. With zero or Renamed pane headers replace the numeric prefix for the current session. Saving a blank name restores the default number. +Settings includes a General tab for local runtime display controls and an Auth tab for GridBash-wide Claude/Codex auth defaults. + Pane titles add a small quiet-output marker after roughly three seconds without output. The marker means a pane produced output and then went idle; it does not mean the process exited or completed its task. The settings screen includes sample controls plus live color controls for the accent, focus, selected, quiet, and exited grid roles. Palette changes apply immediately for the current run. +## 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 Built-in profile keys: @@ -221,7 +247,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: @@ -230,10 +256,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 62e19bd..0dc02a5 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, @@ -45,6 +49,7 @@ const OUTPUT_QUIET_AFTER: Duration = Duration::from_secs(3); pub struct App { config: Config, + config_path: Option, worktrees: Option, launch_plan: Option, layout: GridLayout, @@ -62,6 +67,8 @@ pub struct App { image_overlay: Option, settings: SettingsState, rename: RenamePaneState, + auth_profiles: Vec, + auth_refresh_rx: Option, String>>>, status: String, next_pane_id: usize, event_tx: mpsc::UnboundedSender, @@ -73,10 +80,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, } @@ -346,6 +354,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, @@ -428,7 +448,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, @@ -442,7 +466,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, @@ -602,11 +630,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() @@ -642,6 +674,7 @@ impl App { Ok(Self { config, + config_path, worktrees, launch_plan, layout: GridLayout::new(grid), @@ -659,6 +692,8 @@ impl App { image_overlay: None, settings: SettingsState::default(), rename: RenamePaneState::default(), + auth_profiles: Vec::new(), + auth_refresh_rx: None, status, next_pane_id: 0, event_tx, @@ -685,7 +720,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()?; @@ -693,9 +728,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<()> { @@ -728,6 +765,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()); @@ -744,6 +783,7 @@ impl App { 0, &launch.command, &launch.args, + &spec.env, &spec.cwd, &extra_env, self.event_tx.clone(), @@ -774,6 +814,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.drain_control_events(); needs_render |= self.decay_activity(); @@ -794,6 +835,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, } } @@ -1174,6 +1220,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)) } @@ -1335,6 +1384,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; @@ -1371,6 +1437,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, @@ -1817,6 +1962,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 activity_badges_enabled(&self) -> bool { self.settings.activity_badges } @@ -1850,6 +2025,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() @@ -1857,6 +2039,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 @@ -1910,6 +2099,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 { @@ -1960,6 +2311,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() @@ -2192,6 +2559,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 pane_number_list(indices: &[usize]) -> String { indices .iter() @@ -2272,7 +2643,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() @@ -2369,6 +2768,43 @@ mod tests { ); } + #[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())); + } + #[test] fn palette_color_cycles_in_both_directions() { assert_eq!(PaletteColor::Cyan.adjust(1), PaletteColor::Sky); @@ -2468,6 +2904,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 b3d402b..6dd4351 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; +use crate::{auth::AuthConfig, profiles::Profile}; #[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, } @@ -92,6 +94,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 ignores_legacy_setups_table() { let config: Config = toml::from_str( diff --git a/src/main.rs b/src/main.rs index dd9823c..f1435de 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 a501340..ea3426c 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}, @@ -89,6 +90,7 @@ impl PtyPane { generation: u64, command: &Path, args: &[String], + env: &BTreeMap, cwd: &Path, extra_env: &[(String, String)], event_tx: mpsc::UnboundedSender, @@ -110,6 +112,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); + } for (key, value) in extra_env { command_builder.env(key, value); } @@ -389,6 +394,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 aab61bd..5da76fc 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeMap, path::{Path, PathBuf}, process::Command, }; @@ -6,6 +7,7 @@ use std::{ use anyhow::Result; use crate::{ + auth::AgentKind, layout::GridSize, profiles::{AGENT_PROFILE_NAMES, LaunchCommand, Profile}, worktrees::{ManagedWorktreeOptions, ensure_pane_worktrees}, @@ -19,12 +21,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 LaunchPlan { @@ -56,9 +61,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(); @@ -78,9 +87,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(); @@ -207,10 +220,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")); @@ -225,10 +243,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")); @@ -243,10 +266,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 705b644..78f5809 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,7 +9,8 @@ use std::path::Path; use vt100::Cell; use crate::{ - app::{App, GridPalette, PaneSelection, RenamePaneView, SettingsRow}, + app::{App, GridPalette, PaneSelection, RenamePaneView, SettingsRow, SettingsTab}, + auth::{AgentKind, AuthProfile}, image_preview::ImagePreview, }; @@ -73,6 +74,8 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { chrome.quiet_marker, &folder, app.pane_worktree(index), + app.pane_profile(index), + app.pane_auth(index), usage.as_deref(), chrome.badge, ); @@ -141,7 +144,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { ); if app.settings_open() { - render_settings(frame, area, &app.settings_rows(), palette); + render_settings(frame, area, app, palette); } if let Some(rename) = rename_view.as_ref() { render_rename_pane(frame, area, rename); @@ -156,21 +159,32 @@ pub fn draw(frame: &mut Frame<'_>, app: &App) -> DrawState { } } +#[allow(clippy::too_many_arguments)] fn pane_title( label: &str, quiet_marker: &str, folder: &str, worktree: Option<&str>, + profile: Option<&str>, + auth: Option<&str>, usage: Option<&str>, badge: &str, ) -> String { - let label = format!("{label}{quiet_marker}"); - 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![format!("{label}{quiet_marker}"), 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)] @@ -231,8 +245,8 @@ fn pane_chrome( } } -fn render_settings(frame: &mut Frame<'_>, area: Rect, rows: &[SettingsRow], palette: &GridPalette) { - let modal = settings_modal_rect(area, rows.len()); +fn render_settings(frame: &mut Frame<'_>, area: Rect, app: &App, palette: &GridPalette) { + let modal = settings_modal_rect(area, settings_content_row_count(app)); let shadow = settings_shadow_rect(area, modal); if shadow != modal { @@ -257,7 +271,7 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, rows: &[SettingsRow], pale 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, ); } @@ -345,6 +359,45 @@ fn render_rename_pane(frame: &mut Frame<'_>, area: Rect, rename: &RenamePaneView } } +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 render_image_overlay(frame: &mut Frame<'_>, area: Rect, image: &ImagePreview) { let modal = image_modal_rect(area, image); frame.render_widget(Clear, modal); @@ -445,8 +498,10 @@ fn image_modal_rect(area: Rect, image: &ImagePreview) -> Rect { } } -fn settings_lines(rows: &[SettingsRow], width: u16) -> Vec> { +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( @@ -511,6 +566,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() @@ -1048,23 +1255,54 @@ 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 " + ); } #[test] fn pane_title_keeps_quiet_marker_with_custom_label() { assert_eq!( - pane_title("api", QUIET_MARKER, "gridbash/", None, None, ""), + pane_title("api", QUIET_MARKER, "gridbash/", None, None, None, None, ""), " api * | gridbash/ " ); } 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")?;