terminal-info 1.3.1

An extensible terminal information CLI and developer toolbox
Documentation
use std::collections::HashSet;
use std::path::PathBuf;

use sysinfo::{Pid, System};

use crate::ai::adapters::AgentAdapterKind;
use crate::config::{AiAgentCliConfig, Config};

#[derive(Debug, Clone)]
pub struct DiscoveredAgent {
    pub pid: u32,
    pub parent_pid: Option<u32>,
    pub adapter: AgentAdapterKind,
    pub command: String,
    pub args: Vec<String>,
    pub cwd: Option<String>,
    pub display_name: String,
}

pub fn discover_agents() -> Vec<DiscoveredAgent> {
    let config = Config::load_or_create().ok();
    let configured = configured_commands(config.as_ref());
    let mut system = System::new_all();
    system.refresh_all();

    let mut discovered = system
        .processes()
        .values()
        .filter_map(|process| {
            let process_name = process.name().to_string_lossy().to_ascii_lowercase();
            let command = process
                .exe()
                .map(|path| path.to_string_lossy().to_string())
                .or_else(|| process.cmd().first().map(|value| value.to_string_lossy().to_string()))
                .unwrap_or_else(|| process.name().to_string_lossy().to_string());
            let command_name = command_name(&command);
            let adapter = match_agent(&command_name, &process_name, &configured)?;
            let args = process
                .cmd()
                .iter()
                .skip(1)
                .map(|value| value.to_string_lossy().to_string())
                .collect::<Vec<_>>();
            let cwd = process.cwd().map(|path| path.to_string_lossy().to_string());

            Some(DiscoveredAgent {
                pid: pid_to_u32(process.pid()),
                parent_pid: process.parent().map(pid_to_u32),
                adapter,
                command,
                args,
                cwd,
                display_name: display_name(adapter).to_string(),
            })
        })
        .collect::<Vec<_>>();

    discovered.sort_by_key(|agent| (adapter_sort_key(agent.adapter), agent.pid));
    discovered
}

pub fn discovered_agent_id(agent: &DiscoveredAgent) -> String {
    format!("external-{}-{}", agent.adapter.label(), agent.pid)
}

pub fn attach_discovered_agent(pid: u32) -> Result<String, String> {
    let discovered = discover_agents()
        .into_iter()
        .find(|agent| agent.pid == pid)
        .ok_or_else(|| format!("No supported external agent process with pid {pid} was found."))?;

    let mut config = Config::load_or_create()?;
    let name = unique_agent_name(&config, discovered.adapter, discovered.pid);
    config.ai.agents.insert(
        name.clone(),
        AiAgentCliConfig {
            enabled: true,
            auto_start: false,
            adapter: Some(discovered.adapter.label().to_string()),
            command: discovered.command.clone(),
            args: discovered.args.clone(),
            cwd: discovered.cwd.clone(),
            env: Default::default(),
        },
    );
    config.save()?;
    Ok(name)
}

fn configured_commands(config: Option<&Config>) -> HashSet<String> {
    let mut commands = HashSet::new();
    if let Some(config) = config {
        for command in [
            config.ai.adapters.codex.command.as_str(),
            config.ai.adapters.claude_code.command.as_str(),
            config.ai.adapters.gemini.command.as_str(),
        ] {
            if !command.trim().is_empty() {
                commands.insert(command_name(command));
            }
        }
        for agent in config.ai.agents.values() {
            if !agent.command.trim().is_empty() {
                commands.insert(command_name(&agent.command));
            }
        }
    }
    commands
}

fn match_agent(
    command_name: &str,
    process_name: &str,
    _configured: &HashSet<String>,
) -> Option<AgentAdapterKind> {
    if matches!(command_name, "codex" | "codex.exe")
        || process_name == "codex"
    {
        return Some(AgentAdapterKind::Codex);
    }
    if matches!(
        command_name,
        "claude" | "claude.exe" | "claude-code" | "claude_code"
    ) || matches!(process_name, "claude" | "claude-code" | "claude_code")
    {
        return Some(AgentAdapterKind::ClaudeCode);
    }
    if matches!(command_name, "gemini" | "gemini.exe" | "gemini-cli")
        || matches!(process_name, "gemini" | "gemini-cli")
    {
        return Some(AgentAdapterKind::Gemini);
    }
    None
}

fn command_name(command: &str) -> String {
    PathBuf::from(command)
        .file_name()
        .and_then(|value| value.to_str())
        .unwrap_or(command)
        .to_ascii_lowercase()
}

fn unique_agent_name(config: &Config, adapter: AgentAdapterKind, pid: u32) -> String {
    let base = format!("{}-{}", adapter.label(), pid);
    if !config.ai.agents.contains_key(&base) {
        return base;
    }
    let mut index = 2usize;
    loop {
        let candidate = format!("{base}-{index}");
        if !config.ai.agents.contains_key(&candidate) {
            return candidate;
        }
        index += 1;
    }
}

fn display_name(adapter: AgentAdapterKind) -> &'static str {
    match adapter {
        AgentAdapterKind::Codex => "Codex CLI",
        AgentAdapterKind::ClaudeCode => "Claude Code",
        AgentAdapterKind::Gemini => "Gemini CLI",
        AgentAdapterKind::Generic => "Generic Agent CLI",
    }
}

fn adapter_sort_key(adapter: AgentAdapterKind) -> u8 {
    match adapter {
        AgentAdapterKind::Codex => 0,
        AgentAdapterKind::ClaudeCode => 1,
        AgentAdapterKind::Gemini => 2,
        AgentAdapterKind::Generic => 3,
    }
}

fn pid_to_u32(pid: Pid) -> u32 {
    pid.as_u32()
}