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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,16 @@ Displays which Anthropic account the active Claude Code session is signed in to
| `{tier}` | Organization rate-limit tier (e.g. `default_claude_max_5x`) |
| `{type}` | Organization type (e.g. `claude_team`, `personal`) |

**Launcher-provided account (`CSHIP_ACCOUNT`):** Before the OAuth fetch, the module checks the `CSHIP_ACCOUNT` environment variable. When set, it is parsed as compact JSON with the same fields the profile exposes — `organization_name`, `organization_tier`, `organization_type`, `account_display_name`, `account_email` (all optional, non-secret) — and rendered directly, skipping the keychain read and the network call. When absent or unparseable, the module falls back to the OAuth fetch above.

This lets a multi-account launcher supply the account for a session whose token the module can't use: Claude Code strips `CLAUDE_CODE_OAUTH_TOKEN` from the status subprocess, so a launcher that injects a per-session token would otherwise be reported as the last interactive login. The launcher resolves the account at launch (where it holds the token) and passes the non-secret result here — a tool-agnostic contract; a token is never placed in the variable.

Because field values render verbatim, a launcher can also embed ANSI color in them (a different color per profile, or per plan tier) when one fixed `style` isn't enough.

```
CSHIP_ACCOUNT='{"organization_name":"Acme Corp","organization_tier":"Team","account_display_name":"work"}'
```

**Prerequisites:** Requires an OAuth token in the OS credential store (the same credential used by `usage_limits`). On Linux/WSL2, install `libsecret-tools` and store your token with `secret-tool`. If the module renders nothing, run `cship explain cship.account` for a diagnosis (missing credential, expired token, or unreachable API).

```toml
Expand Down
132 changes: 96 additions & 36 deletions src/modules/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@
//!
//! Render flow:
//! 1. Check `disabled` flag → silent `None`
//! 2. Read `transcript_path` for cache keying
//! 3. Read OAuth token up front → compute fingerprint for cache identity
//! 4. Cache hit (fingerprint must match) → render immediately
//! 5. Cache miss → fetch via spawned thread with 2s timeout
//! 6. On timeout, fall back to stale cache (still fingerprint-gated)
//! 7. Format output (default or user-defined format string)
//! 8. Apply style
//! 2. Resolve the account profile via [`resolve_profile`] — preferring a
//! launcher-provided account from the `CSHIP_ACCOUNT` env var, else the
//! keychain token → OAuth `/api/oauth/profile` fetch (fingerprint-gated and cached)
//! 3. Format output (default or user-defined format string)
//! 4. Apply style
//!
//! The `CSHIP_ACCOUNT` path exists because a multi-account launcher can
//! authenticate the session with an injected token that Claude Code strips from
//! this statusline subprocess. The keychain path then reports the wrong
//! (last-interactive) account. Such a launcher instead resolves the account at
//! launch and passes it in `CSHIP_ACCOUNT` (compact non-secret JSON of the same
//! shape as [`AccountProfile`]); this module simply renders it. Never a token.
//!
//! The OAuth token is never written to disk, stdout, or cache (NFR-S1/S3).

Expand All @@ -21,6 +26,10 @@ use crate::cache;
use crate::config::{AccountConfig, CshipConfig};
use crate::context::Context;

/// Env var a launcher may set to the current session's account as compact JSON
/// (the [`AccountProfile`] shape). Preferred over the keychain/OAuth path.
const ACCOUNT_ENV_VAR: &str = "CSHIP_ACCOUNT";

/// Default format string — renders the resolved label (org name or mapped alias).
const DEFAULT_FORMAT: &str = "{label}";

Expand All @@ -36,11 +45,61 @@ pub fn render(ctx: &Context, cfg: &CshipConfig) -> Option<String> {
return None;
}

// Step 2: transcript_path is required for cache keying
// Step 2: resolve the account (CSHIP_ACCOUNT env var first, else keychain/OAuth).
let profile = resolve_profile(ctx, account_cfg)?;

// Step 3: build formatted output
let default_cfg = AccountConfig::default();
let cfg_ref = account_cfg.unwrap_or(&default_cfg);
let fmt = cfg_ref.format.as_deref().unwrap_or(DEFAULT_FORMAT);
let content = format_output(fmt, &profile, cfg_ref)?;

// Step 4: apply style (threshold styling not meaningful for account names)
let symbol = cfg_ref.symbol.as_deref().unwrap_or("");
let styled = crate::ansi::apply_style(&format!("{symbol}{content}"), cfg_ref.style.as_deref());
Some(styled)
}

/// Parse the launcher-provided account from [`ACCOUNT_ENV_VAR`], if present and
/// well-formed. Returns `None` when the var is unset, empty, or not valid JSON —
/// so the caller cleanly falls back to the keychain/OAuth path. The value is
/// non-secret account identity only (never a token).
fn account_from_env() -> Option<AccountProfile> {
parse_account_env(&std::env::var(ACCOUNT_ENV_VAR).ok()?)
}

/// Parse the `CSHIP_ACCOUNT` payload. Split from env access so the JSON contract
/// is unit-testable. `None` for empty or malformed input (→ keychain fallback).
fn parse_account_env(raw: &str) -> Option<AccountProfile> {
if raw.trim().is_empty() {
return None;
}
match serde_json::from_str::<AccountProfile>(raw) {
Ok(profile) => Some(profile),
Err(e) => {
tracing::warn!("cship.account: {ACCOUNT_ENV_VAR} set but not parseable: {e}");
None
}
}
}

/// Resolve the account profile to display, in preference order:
///
/// 1. The **`CSHIP_ACCOUNT` env var** — a launcher that injected a session token
/// this subprocess cannot see resolves the account at launch and passes it here
/// as compact JSON. No keychain read, no network call.
/// 2. The **keychain token → OAuth `/api/oauth/profile`** fetch (fingerprint-gated
/// and cached), which is correct for a plain `claude` with no launcher.
fn resolve_profile(ctx: &Context, account_cfg: Option<&AccountConfig>) -> Option<AccountProfile> {
// Preference 1: launcher-provided account via env (tool-agnostic contract).
if let Some(profile) = account_from_env() {
return Some(profile);
}

// Preference 2: keychain token → OAuth fetch, with fingerprint-gated cache.
let transcript_str = ctx.transcript_path.as_deref()?;
let transcript_path = std::path::Path::new(transcript_str);

// Step 3: read OAuth token up front for fingerprint (cache identity check)
let token = match crate::platform::get_oauth_token() {
Ok(t) => t,
Err(e) => {
Expand All @@ -50,34 +109,20 @@ pub fn render(ctx: &Context, cfg: &CshipConfig) -> Option<String> {
};
let fp = crate::platform::token_fingerprint(&token);

// Step 4: cache hit (fingerprint must match) → render immediately
let profile =
if let Some(cached) = cache::read_account_profile(transcript_path, false, Some(&fp)) {
cached
} else {
// Step 5: cache miss → OAuth fetch with timeout
let ttl_secs = account_cfg.and_then(|c| c.ttl).unwrap_or(DEFAULT_TTL_SECS);
match super::fetch_with_timeout("cship.account", move || {
crate::account::fetch_account_profile(&token)
}) {
Some(fresh) => {
cache::write_account_profile(transcript_path, &fresh, ttl_secs, Some(&fp));
fresh
}
None => cache::read_account_profile(transcript_path, true, Some(&fp))?,
}
};

// Step 6: build formatted output
let default_cfg = AccountConfig::default();
let cfg_ref = account_cfg.unwrap_or(&default_cfg);
let fmt = cfg_ref.format.as_deref().unwrap_or(DEFAULT_FORMAT);
let content = format_output(fmt, &profile, cfg_ref)?;
if let Some(cached) = cache::read_account_profile(transcript_path, false, Some(&fp)) {
return Some(cached);
}

// Step 7: apply style (threshold styling not meaningful for account names)
let symbol = cfg_ref.symbol.as_deref().unwrap_or("");
let styled = crate::ansi::apply_style(&format!("{symbol}{content}"), cfg_ref.style.as_deref());
Some(styled)
let ttl_secs = account_cfg.and_then(|c| c.ttl).unwrap_or(DEFAULT_TTL_SECS);
match super::fetch_with_timeout("cship.account", move || {
crate::account::fetch_account_profile(&token)
}) {
Some(fresh) => {
cache::write_account_profile(transcript_path, &fresh, ttl_secs, Some(&fp));
Some(fresh)
}
None => cache::read_account_profile(transcript_path, true, Some(&fp)),
}
}

/// Substitute placeholders in `fmt` using fields from `profile` and optional labels map.
Expand Down Expand Up @@ -154,6 +199,21 @@ mod tests {
assert_eq!(out, "Fulcrum Genomics");
}

#[test]
fn test_parse_account_env_reads_launcher_json() {
let raw = r#"{"organization_name":"FG Partners","organization_tier":"tier_x","organization_type":"claude_team","account_display_name":"partners"}"#;
let parsed = parse_account_env(raw).expect("valid CSHIP_ACCOUNT parses");
assert_eq!(parsed.organization_name.as_deref(), Some("FG Partners"));
assert_eq!(parsed.organization_tier.as_deref(), Some("tier_x"));
}

#[test]
fn test_parse_account_env_rejects_empty_and_malformed() {
assert!(parse_account_env("").is_none());
assert!(parse_account_env(" ").is_none());
assert!(parse_account_env("{not json").is_none());
}

#[test]
fn test_labels_map_overrides_organization_name() {
let mut labels = BTreeMap::new();
Expand Down