regy 0.1.0

Private-by-default desktop agent for the Regy web interface
use std::{
    collections::BTreeMap,
    ffi::OsString,
    fs,
    path::{Path, PathBuf},
    time::Duration,
};

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::{
    config::app::ProviderId,
    dependencies::command::{CommandRunner, CommandSpec},
    domain::errors::{AgentError, AgentResult, ErrorCode},
    providers::descriptor::{ProviderDescriptor, descriptor},
};

const PROBE_TIMEOUT: Duration = Duration::from_secs(10);

#[derive(Clone, PartialEq, Eq)]
pub(crate) enum ToolLaunch {
    Direct { executable: PathBuf },
    ManagedNode { node: PathBuf, script: PathBuf },
}

impl ToolLaunch {
    pub(crate) fn direct(executable: impl Into<PathBuf>) -> Self {
        Self::Direct {
            executable: executable.into(),
        }
    }

    pub(crate) fn managed_node(node: impl Into<PathBuf>, script: impl Into<PathBuf>) -> Self {
        Self::ManagedNode {
            node: node.into(),
            script: script.into(),
        }
    }

    pub(crate) fn command(
        &self,
        args: &[&str],
        managed_claude: bool,
        cwd: &Path,
    ) -> AgentResult<CommandSpec> {
        self.validate()?;
        let (program, mut command_args) = match self {
            Self::Direct { executable } => (executable.clone(), Vec::new()),
            Self::ManagedNode { node, script } => {
                (node.clone(), vec![script.clone().into_os_string()])
            }
        };
        command_args.extend(args.iter().map(OsString::from));
        let env = if managed_claude && matches!(self, Self::ManagedNode { .. }) {
            BTreeMap::from([(OsString::from("DISABLE_AUTOUPDATER"), OsString::from("1"))])
        } else {
            BTreeMap::new()
        };
        Ok(CommandSpec {
            program,
            args: command_args,
            env,
            cwd: Some(cwd.to_path_buf()),
            timeout: PROBE_TIMEOUT,
        })
    }

    fn validate(&self) -> AgentResult<()> {
        let paths: &[&Path] = match self {
            Self::Direct { executable } => &[executable],
            Self::ManagedNode { node, script } => &[node, script],
        };
        if paths.iter().any(|path| !path.is_absolute()) {
            return Err(invariant_failed());
        }
        Ok(())
    }
}

#[derive(Clone)]
pub(crate) struct ProviderProbeRuntime {
    pub(crate) pi: ToolLaunch,
    pub(crate) claude: Option<ToolLaunch>,
    neutral_cwd: PathBuf,
}

impl ProviderProbeRuntime {
    pub(crate) fn new(
        pi: ToolLaunch,
        claude: Option<ToolLaunch>,
        neutral_cwd: impl Into<PathBuf>,
    ) -> Self {
        Self {
            pi,
            claude,
            neutral_cwd: neutral_cwd.into(),
        }
    }

    pub(crate) fn validated_cwd(&self) -> AgentResult<PathBuf> {
        self.pi.validate()?;
        if let Some(claude) = &self.claude {
            claude.validate()?;
        }
        canonical_neutral_cwd(&self.neutral_cwd)
    }
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub(crate) struct ProviderProbeResult {
    pub(crate) id: ProviderId,
    pub(crate) executable_ready: bool,
    pub(crate) extension_ready: bool,
    pub(crate) authenticated: bool,
    pub(crate) action: Option<String>,
}

impl ProviderProbeResult {
    pub(crate) fn ready(&self) -> bool {
        self.executable_ready && self.extension_ready && self.authenticated
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PiAuthState {
    Present,
    Absent,
    Expired,
}

#[async_trait]
pub(crate) trait PiAuthMetadata: Send + Sync {
    async fn state(&self, provider: ProviderId) -> AgentResult<PiAuthState>;
}

pub(crate) struct ProviderProbe<'a> {
    runtime: ProviderProbeRuntime,
    runner: &'a dyn CommandRunner,
    pi_auth: &'a dyn PiAuthMetadata,
}

impl<'a> ProviderProbe<'a> {
    pub(crate) fn new(
        runtime: ProviderProbeRuntime,
        runner: &'a dyn CommandRunner,
        pi_auth: &'a dyn PiAuthMetadata,
    ) -> Self {
        Self {
            runtime,
            runner,
            pi_auth,
        }
    }

    pub(crate) async fn probe(&self, id: ProviderId) -> AgentResult<ProviderProbeResult> {
        let cwd = self.runtime.validated_cwd()?;
        let provider = descriptor(id);
        let executable_ready = provider.executable.is_none() || self.runtime.claude.is_some();
        let extension_ready = self.extension_ready(&provider, &cwd).await;
        let authenticated = if id == ProviderId::ClaudeCode {
            self.claude_authenticated(&cwd).await
        } else {
            matches!(self.pi_auth.state(id).await, Ok(PiAuthState::Present))
        };
        let action = action_for(id, executable_ready, extension_ready, authenticated);
        Ok(ProviderProbeResult {
            id,
            executable_ready,
            extension_ready,
            authenticated,
            action: action.map(str::to_owned),
        })
    }

    async fn extension_ready(&self, provider: &ProviderDescriptor, cwd: &Path) -> bool {
        let Some(requirement) = provider.extension else {
            return true;
        };
        let Ok(spec) = self.runtime.pi.command(&["list"], false, cwd) else {
            return false;
        };
        let Ok(output) = self.runner.output(&spec).await else {
            return false;
        };
        let expected = requirement.package.exact();
        exact_list_field(&output.stdout, &expected)
    }

    async fn claude_authenticated(&self, cwd: &Path) -> bool {
        let Some(claude) = &self.runtime.claude else {
            return false;
        };
        let Ok(spec) = claude.command(&["auth", "status", "--json"], true, cwd) else {
            return false;
        };
        let Ok(output) = self.runner.output(&spec).await else {
            return false;
        };
        let parsed = serde_json::from_slice::<ClaudeAuthStatus>(&output.stdout)
            .map(|status| status.logged_in)
            .unwrap_or(false);
        drop(output);
        parsed
    }
}

#[derive(Deserialize)]
struct ClaudeAuthStatus {
    #[serde(rename = "loggedIn", alias = "logged_in")]
    logged_in: bool,
}

fn exact_list_field(output: &[u8], expected: &str) -> bool {
    std::str::from_utf8(output).ok().is_some_and(|output| {
        output
            .lines()
            .flat_map(|line| line.split_whitespace())
            .any(|field| field == expected)
    })
}

fn action_for(
    id: ProviderId,
    executable_ready: bool,
    extension_ready: bool,
    authenticated: bool,
) -> Option<&'static str> {
    if !executable_ready {
        return Some("Install the Claude Code executable.");
    }
    if !extension_ready {
        return Some(match id {
            ProviderId::ClaudeCode => "Install the reviewed Claude Code Pi extension.",
            ProviderId::Antigravity => "Install the reviewed Antigravity Pi extension.",
            ProviderId::OpenaiCodex => "Configure the OpenAI Codex provider.",
        });
    }
    if !authenticated {
        return Some(match id {
            ProviderId::OpenaiCodex => "Log in to OpenAI Codex through Pi.",
            ProviderId::ClaudeCode => "Log in to Claude Code.",
            ProviderId::Antigravity => "Log in to Antigravity through Pi.",
        });
    }
    None
}

fn invariant_failed() -> AgentError {
    AgentError::new(
        ErrorCode::InvalidMessage,
        "provider executable paths must be absolute",
    )
}

fn canonical_neutral_cwd(path: &Path) -> AgentResult<PathBuf> {
    if !path.is_absolute() {
        return Err(neutral_cwd_invariant_failed());
    }
    let metadata = fs::symlink_metadata(path).map_err(|_| neutral_cwd_invariant_failed())?;
    if metadata.file_type().is_symlink() || !metadata.is_dir() {
        return Err(neutral_cwd_invariant_failed());
    }
    let canonical = fs::canonicalize(path).map_err(|_| neutral_cwd_invariant_failed())?;
    if !fs::metadata(&canonical)
        .map_err(|_| neutral_cwd_invariant_failed())?
        .is_dir()
    {
        return Err(neutral_cwd_invariant_failed());
    }
    Ok(canonical)
}

fn neutral_cwd_invariant_failed() -> AgentError {
    AgentError::new(
        ErrorCode::InvalidMessage,
        "provider neutral cwd must be an existing absolute non-symlink directory",
    )
}