nornir 0.5.4

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! 🤖 **Claude "SDK"** — nornir CONSUMES the shared `nornir-claude` (`ln`) leaf.
//!
//! There is no official Anthropic Rust SDK, so the transport/wire/error/consts +
//! the offline stub live ONCE in `edda/crates/nornir-claude` (LAW #5) — the SAME
//! leaf facett-claude and dwarves consume. nornir was the lone holdout hand-rolling
//! a byte-parallel copy (`ClaudeMessage`/`ClaudeRequest`/`ClaudeError`/`HttpTransport`/
//! `FakeTransport` + `MESSAGES_ENDPOINT`/`ANTHROPIC_VERSION`/`DEFAULT_MODEL`/`MODELS`/
//! `MAX_TOKENS`). That twin is deleted; this module now RE-EXPORTS the leaf's types
//! (`WireMsg` as [`ClaudeMessage`], [`ClaudeRequest`], [`ClaudeError`],
//! [`ClaudeTransport`], `OfflineTransport` as [`FakeTransport`], the consts) so every
//! existing `crate::viz::claude::*` path keeps resolving.
//!
//! What STAYS here is nornir DOMAIN (L5a — conductor, not leaf): the Anthropic-key
//! **file** source `<home>/.nornir/anthropic-key` and the env→file→session resolution
//! ladder ([`resolve_api_key`]/[`api_key_present`]) the headless funnel executor,
//! `funnel decompose`, and `funnel prompt --llm` rely on — the leaf's own ladder is
//! env→`ANTHROPIC_AUTH_TOKEN`→session and knows nothing of nornir's home dir. nornir's
//! runtime [`HttpTransport`] resolves via THIS ladder and hands the resolved key to
//! `ln::send_request` (the leaf's POST primitive), so the funnel's Claude calls make
//! the byte-identical request they did before while the reqwest twin is gone.

use std::sync::Arc;

// ── The leaf's shared client surface, re-exported under nornir's historical names
// so `crate::viz::claude::*` call sites (claude_tab, warehouse generator, bin/nornir,
// robot_viz) resolve unchanged. `WireMsg`≡`ClaudeMessage` (same `role`/`content`
// fields + `user()`/`assistant()`); `OfflineTransport`≡`FakeTransport` (same
// `replying()`/`failing()`/`requests()`); `ClaudeRequest` gains `session_key`
// (formerly `api_key`) + `with_session_key()`.
pub use nornir_claude::{
    assemble_text, credential_present, resolve_credential, ClaudeError, ClaudeRequest,
    ClaudeTransport, Credential, OfflineTransport as FakeTransport, WireMsg as ClaudeMessage,
    ANTHROPIC_VERSION, DEFAULT_MODEL, MAX_TOKENS, MESSAGES_ENDPOINT, MODELS,
};

/// The home-derived fallback file that holds the Anthropic API key when
/// `ANTHROPIC_API_KEY` is not exported — `<home>/.nornir/anthropic-key`, mode
/// 0600, key on the first line (trailing whitespace/newline trimmed). This is a
/// SEPARATE secret from the server bearer `<home>/.nornir/token`; the two are
/// never conflated. Put your `sk-ant-…` key here to make `funnel prompt --llm`,
/// `funnel decompose`, the executor, and the 🤖 Claude tab work headlessly.
///
/// This is nornir DOMAIN — the shared `ln` leaf has no notion of a nornir home dir,
/// so this file source (and the [`resolve_api_key`] ladder over it) stays here and
/// feeds the resolved key into `ln::send_request` via [`HttpTransport`].
pub fn anthropic_key_file() -> std::path::PathBuf {
    crate::config::nornir_home().join("anthropic-key")
}

/// Read the Anthropic key from [`anthropic_key_file`] if present and non-empty.
/// Best-effort: any read error (missing file, no permission) yields `None` so
/// the caller falls through to the next source rather than erroring.
pub fn key_from_file() -> Option<String> {
    let path = anthropic_key_file();
    let raw = std::fs::read_to_string(&path).ok()?;
    let key = raw.lines().next().unwrap_or("").trim().to_string();
    (!key.is_empty()).then_some(key)
}

/// Resolve the Anthropic API key from nornir's full precedence chain, first hit
/// wins:
///
///   1. env `ANTHROPIC_API_KEY` (export it for CI / headless runs)
///   2. the `<home>/.nornir/anthropic-key` file (0600) — the durable place a
///      human drops the key so `funnel --llm`/decompose/executor + the tab work
///   3. `session` — a per-session token pasted into the 🤖 Claude tab (N8)
///
/// Returns `None` when every source is empty; callers surface a "no credential"
/// error. This ladder is PRESERVED verbatim from the pre-leaf transport so the
/// funnel executor + warehouse generator resolve exactly as before (the leaf's own
/// env→`ANTHROPIC_AUTH_TOKEN`→session ladder would drop the file source).
pub fn resolve_api_key(session: Option<&str>) -> Option<String> {
    let env = std::env::var("ANTHROPIC_API_KEY").ok();
    resolve_api_key_from(env.as_deref(), key_from_file().as_deref(), session)
}

/// Pure precedence core of [`resolve_api_key`] — env → file → session, first
/// non-blank wins. Split out so the ordering is unit-testable without touching
/// process-global env or the home directory.
pub fn resolve_api_key_from(
    env: Option<&str>,
    file: Option<&str>,
    session: Option<&str>,
) -> Option<String> {
    [env, file, session]
        .into_iter()
        .flatten()
        .map(str::trim)
        .find(|k| !k.is_empty())
        .map(str::to_string)
}

/// Is an Anthropic key available from a NON-interactive source (env or the
/// `<home>/.nornir/anthropic-key` file)? Read by the tab so its `state_json`
/// can decide whether to show the "paste a token" field — without leaking the
/// value. A per-session tab token is tracked separately by the tab itself.
pub fn api_key_present() -> bool {
    std::env::var("ANTHROPIC_API_KEY").map(|k| !k.trim().is_empty()).unwrap_or(false)
        || key_from_file().is_some()
}

/// The production transport used at runtime when the `claude` feature is on:
/// resolve the key via nornir's env→file→session ladder ([`resolve_api_key`]),
/// then POST through the shared leaf ([`nornir_claude::send_request`]). No bespoke
/// reqwest client lives here anymore — only the domain key resolution.
#[cfg(feature = "claude")]
pub struct HttpTransport;

#[cfg(feature = "claude")]
impl ClaudeTransport for HttpTransport {
    fn send(&self, req: &ClaudeRequest) -> Result<String, ClaudeError> {
        // Unified token resolution (N8): env `ANTHROPIC_API_KEY` → the
        // `<home>/.nornir/anthropic-key` file → the per-session tab token
        // (`req.session_key`). Absent all three → the leaf's `NoCredential` hint.
        let key = resolve_api_key(req.session_key.as_deref()).ok_or(ClaudeError::NoCredential)?;
        nornir_claude::send_request(&Credential::ApiKey(key), req)
    }
}

/// The default runtime transport: the real HTTP client with the `claude`
/// feature, otherwise a stub that reports the feature is disabled.
pub fn default_transport() -> Arc<dyn ClaudeTransport> {
    #[cfg(feature = "claude")]
    {
        Arc::new(HttpTransport)
    }
    #[cfg(not(feature = "claude"))]
    {
        Arc::new(DisabledTransport)
    }
}

/// Stand-in transport when the `claude` feature is compiled out.
#[cfg(not(feature = "claude"))]
pub struct DisabledTransport;

#[cfg(not(feature = "claude"))]
impl ClaudeTransport for DisabledTransport {
    fn send(&self, _req: &ClaudeRequest) -> Result<String, ClaudeError> {
        Err(ClaudeError::FeatureDisabled)
    }
}

#[cfg(test)]
mod key_resolution_tests {
    use super::*;

    #[test]
    fn precedence_is_env_then_file_then_session() {
        // env wins over everything.
        assert_eq!(
            resolve_api_key_from(Some("env-key"), Some("file-key"), Some("sess-key")).as_deref(),
            Some("env-key"),
        );
        // file wins when env is absent/blank.
        assert_eq!(
            resolve_api_key_from(None, Some("file-key"), Some("sess-key")).as_deref(),
            Some("file-key"),
        );
        assert_eq!(
            resolve_api_key_from(Some("  "), Some("file-key"), Some("sess-key")).as_deref(),
            Some("file-key"),
        );
        // session is the last resort.
        assert_eq!(
            resolve_api_key_from(None, None, Some("sess-key")).as_deref(),
            Some("sess-key"),
        );
        // all blank/absent ⇒ None (caller surfaces NoCredential). RED-when-broken:
        // a ladder that returned Some here would surface a bogus empty-key 401.
        assert_eq!(resolve_api_key_from(None, Some(""), Some("   ")), None);
        assert_eq!(resolve_api_key_from(None, None, None), None);
    }

    #[test]
    fn key_file_is_under_nornir_home_and_is_not_the_bearer_token() {
        let p = anthropic_key_file();
        assert!(p.ends_with(".nornir/anthropic-key"), "path: {}", p.display());
        // Must never collide with the server bearer token file.
        assert!(!p.ends_with(".nornir/token"), "must not reuse the bearer token file");
    }

    #[test]
    fn request_built_from_domain_ladder_hits_the_leaf_offline_transport() {
        // Behaviour-identity proof: nornir builds a `ClaudeRequest` (the leaf type)
        // exactly as the funnel/generator do — model + user turn + the tab's
        // per-session key — and the leaf transport echoes the last user turn. A
        // broken re-point (dropped session key / wrong wire shape) would fail here.
        let req = ClaudeRequest::new(DEFAULT_MODEL, vec![ClaudeMessage::user("2+2?")])
            .with_session_key(Some("sk-ant-session".into()));
        let reply = FakeTransport::echoing().send(&req).unwrap();
        assert_eq!(reply, format!("[offline · {DEFAULT_MODEL}] 2+2?"));
    }
}