nils-claude-cli 1.21.9

CLI crate for nils-claude-cli in the nils-cli workspace.
Documentation
use nils_common::cli_contract::exit;
use nils_common::diag_output;
use nils_common::env as shared_env;
use serde::Serialize;
use std::path::PathBuf;

mod auth;
mod cache;
mod client;
mod render;
pub mod usage;

#[derive(Clone, Debug, Default)]
pub struct PromptSegmentOptions {
    pub ttl: Option<String>,
    pub time_format: Option<String>,
    pub refresh: bool,
}

const DEFAULT_TTL_SECONDS: u64 = 60;
const DEFAULT_TIME_FORMAT: &str = "%m-%d %H:%M";
const DEFAULT_STALE_SUFFIX: &str = " (stale)";
const PROMPT_SEGMENT_SCHEMA_VERSION: &str = "claude-cli.prompt-segment.v1";

pub fn run(options: &PromptSegmentOptions) -> i32 {
    let ttl_seconds = match resolve_ttl_seconds(options.ttl.as_deref()) {
        Ok(value) => value,
        Err(_) => {
            eprintln!("claude-cli prompt-segment: invalid --ttl");
            return exit::USAGE;
        }
    };

    let Some(cache_file) = cache::cache_file() else {
        return exit::SUCCESS;
    };

    let force_refresh = options.refresh || ttl_seconds == 0;
    let needs_refresh =
        force_refresh || !cache_file.is_file() || cache::cache_stale(&cache_file, ttl_seconds);
    let mut stale = false;

    if needs_refresh {
        match auth::resolve_access_token()
            .map(|token| client::fetch_usage(&token.value))
            .transpose()
        {
            Ok(Some(body)) => {
                if cache::write_cache_file(&cache_file, &body).is_err() {
                    stale = true;
                }
            }
            _ => {
                stale = true;
            }
        }
    }

    let Some(raw_cache) = cache::read_cache_file(&cache_file) else {
        return exit::SUCCESS;
    };

    let time_format = options
        .time_format
        .as_deref()
        .unwrap_or(DEFAULT_TIME_FORMAT);
    let stale_suffix = resolve_stale_suffix();
    if let Some(line) = render::render_usage_json(&raw_cache, time_format, stale, &stale_suffix)
        && !line.trim().is_empty()
    {
        println!("{line}");
    }

    exit::SUCCESS
}

pub fn check() -> i32 {
    if auth::resolve_access_token().is_some() {
        exit::SUCCESS
    } else {
        exit::RUNTIME
    }
}

pub fn status(output_json: bool) -> i32 {
    let result = PromptSegmentStatusResult::inspect();

    if output_json {
        if diag_output::emit_success_result(
            PROMPT_SEGMENT_SCHEMA_VERSION,
            "prompt-segment status",
            &result,
        )
        .is_err()
        {
            return exit::RUNTIME;
        }
    } else {
        println!(
            "claude: prompt-segment status authenticated={} would_render={} reason={}",
            result.authenticated, result.would_render, result.reason
        );
    }

    exit::SUCCESS
}

fn resolve_ttl_seconds(cli_ttl: Option<&str>) -> Result<u64, ()> {
    if let Some(raw) = cli_ttl {
        return parse_ttl_seconds(raw).ok_or(());
    }

    for key in ["CLAUDE_PROMPT_SEGMENT_TTL", "CLAUDE_PROMPT_TTL"] {
        if let Ok(raw) = std::env::var(key)
            && let Some(value) = parse_ttl_seconds(&raw)
        {
            return Ok(value);
        }
    }

    Ok(DEFAULT_TTL_SECONDS)
}

fn parse_ttl_seconds(raw: &str) -> Option<u64> {
    let raw = raw.trim();
    if raw == "0" || raw.eq_ignore_ascii_case("0s") {
        return Some(0);
    }
    shared_env::parse_duration_seconds(raw)
}

fn resolve_stale_suffix() -> String {
    std::env::var("CLAUDE_PROMPT_SEGMENT_STALE_SUFFIX")
        .ok()
        .or_else(|| std::env::var("CLAUDE_PROMPT_STALE_SUFFIX").ok())
        .unwrap_or_else(|| DEFAULT_STALE_SUFFIX.to_string())
}

#[derive(Debug, Clone, Serialize)]
struct PromptSegmentStatusResult {
    authenticated: bool,
    auth_source: Option<String>,
    cache_file: Option<String>,
    cache_exists: bool,
    cache_stale: bool,
    would_render: bool,
    reason: String,
}

impl PromptSegmentStatusResult {
    fn inspect() -> Self {
        let token = auth::resolve_access_token();
        let cache_file = cache::cache_file();
        let ttl_seconds = resolve_ttl_seconds(None).unwrap_or(DEFAULT_TTL_SECONDS);
        let (cache_exists, cache_stale, would_render) =
            inspect_cache(cache_file.as_ref(), ttl_seconds);

        let authenticated = token.is_some();
        let reason = if authenticated && would_render && !cache_stale {
            "ready"
        } else if authenticated && would_render {
            "cache-stale"
        } else if !authenticated {
            "access-token-missing"
        } else if !cache_exists {
            "cache-missing"
        } else {
            "cache-empty-or-invalid"
        };

        Self {
            authenticated,
            auth_source: token.map(|token| match token.source {
                auth::TokenSource::AccessTokenEnv => "access-token-env".to_string(),
                auth::TokenSource::CredentialsJsonEnv => "credentials-json-env".to_string(),
                auth::TokenSource::Keychain => "keychain".to_string(),
            }),
            cache_file: cache_file.map(display_path),
            cache_exists,
            cache_stale,
            would_render,
            reason: reason.to_string(),
        }
    }
}

fn inspect_cache(cache_file: Option<&PathBuf>, ttl_seconds: u64) -> (bool, bool, bool) {
    let Some(cache_file) = cache_file else {
        return (false, false, false);
    };
    if !cache_file.is_file() {
        return (false, false, false);
    }

    let cache_stale = cache::cache_stale(cache_file, ttl_seconds);
    let would_render = cache::read_cache_file(cache_file)
        .as_deref()
        .and_then(|raw| render::render_usage_json(raw, DEFAULT_TIME_FORMAT, false, ""))
        .map(|line| !line.trim().is_empty())
        .unwrap_or(false);

    (true, cache_stale, would_render)
}

fn display_path(path: PathBuf) -> String {
    path.to_string_lossy().to_string()
}

#[cfg(test)]
mod tests {
    use super::{parse_ttl_seconds, resolve_ttl_seconds};
    use nils_test_support::{EnvGuard, GlobalStateLock};

    #[test]
    fn parse_ttl_seconds_accepts_zero_for_legacy_force_refresh() {
        assert_eq!(parse_ttl_seconds("0"), Some(0));
        assert_eq!(parse_ttl_seconds("0s"), Some(0));
        assert_eq!(parse_ttl_seconds("60"), Some(60));
        assert_eq!(parse_ttl_seconds("2m"), Some(120));
    }

    #[test]
    fn resolve_ttl_prefers_segment_env_before_legacy_env() {
        let lock = GlobalStateLock::new();
        let _segment = EnvGuard::set(&lock, "CLAUDE_PROMPT_SEGMENT_TTL", "2m");
        let _legacy = EnvGuard::set(&lock, "CLAUDE_PROMPT_TTL", "30");
        assert_eq!(resolve_ttl_seconds(None), Ok(120));
    }
}