oy-cli 0.8.3

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
use anyhow::Result;
use clap::Args;
use std::io::IsTerminal as _;
use std::path::{Path, PathBuf};

use crate::config;
use crate::model;
use crate::session::{self, Session};

#[derive(Debug, Args, Clone)]
pub(super) struct SharedModeArgs {
    #[arg(
        long,
        alias = "agent",
        default_value = "default",
        help = "Safety mode (default: balanced): plan, ask, edit, or auto"
    )]
    pub(super) mode: config::SafetyMode,
    #[arg(
        long = "continue-session",
        default_value_t = false,
        help = "Resume the most recent saved session"
    )]
    pub(super) continue_session: bool,
    #[arg(
        long,
        default_value = "",
        value_name = "NAME_OR_NUMBER",
        help = "Resume a named or numbered saved session"
    )]
    pub(super) resume: String,
}

#[derive(Debug, Args, Clone)]
pub(super) struct RunArgs {
    #[command(flatten)]
    pub(super) shared: SharedModeArgs,
    #[arg(
        long,
        value_name = "PATH",
        help = "Write the final answer to a workspace file"
    )]
    pub(super) out: Option<PathBuf>,
    #[arg(
        value_name = "PROMPT",
        help = "Task prompt; omitted means read stdin or start chat in a TTY"
    )]
    pub(super) task: Vec<String>,
}

#[derive(Debug, Args, Clone)]
pub(super) struct ChatArgs {
    #[command(flatten)]
    pub(super) shared: SharedModeArgs,
}

pub(super) async fn run_command(args: RunArgs) -> Result<i32> {
    let task = collect_task(&args.task)?;
    if task.trim().is_empty() {
        return chat_command(ChatArgs {
            shared: args.shared,
        })
        .await;
    }
    let mut session = load_or_new(
        false,
        args.shared.mode,
        args.shared.continue_session,
        &args.shared.resume,
    )?;
    print_session_intro("run", &session, Some(&task));
    let answer = session::run_prompt(&mut session, &task).await?;
    if crate::ui::is_json() {
        print_run_json(&session, &answer)?;
    } else if let Some(path) = args.out {
        write_workspace_file(&session.root, &path, &answer)?;
        crate::ui::success(format_args!("wrote {}", path.display()));
    } else if !answer.is_empty() {
        crate::ui::markdown(&format!("{answer}\n"));
    }
    Ok(0)
}

fn print_run_json(session: &Session, answer: &str) -> Result<()> {
    let status = session.context_status();
    let payload = serde_json::json!({
        "answer": answer,
        "model": session.model,
        "mode": session.mode,
        "workspace": session.root,
        "tokens": status.estimate,
        "context": status,
        "messages": status.estimate.messages,
        "todos": session.todos,
    });
    crate::ui::line(serde_json::to_string_pretty(&payload)?);
    Ok(())
}

pub(super) async fn chat_command(args: ChatArgs) -> Result<i32> {
    let mut session = load_or_new(
        true,
        args.shared.mode,
        args.shared.continue_session,
        &args.shared.resume,
    )?;
    print_session_intro("chat", &session, None);
    crate::chat::run_chat(&mut session).await
}

fn load_or_new(
    interactive: bool,
    mode: config::SafetyMode,
    continue_session: bool,
    resume: &str,
) -> Result<Session> {
    let policy = config::tool_policy(mode);
    if continue_session || !resume.is_empty() {
        let name = if continue_session { None } else { Some(resume) };
        if let Some(session) = session::load_saved(name, interactive, mode, policy)? {
            return Ok(session);
        }
    }
    let root = config::oy_root()?;
    let model = model::resolve_model(None)?;
    Ok(Session::new(root, model, interactive, mode, policy))
}

fn collect_task(parts: &[String]) -> Result<String> {
    if !parts.is_empty() {
        return Ok(parts.join(" "));
    }
    if std::io::stdin().is_terminal() {
        return Ok(String::new());
    }
    let mut input = String::new();
    use std::io::Read as _;
    std::io::stdin().read_to_string(&mut input)?;
    Ok(input.trim().to_string())
}

fn print_session_intro(mode: &str, session: &Session, prompt: Option<&str>) {
    if crate::ui::is_quiet() {
        return;
    }
    crate::ui::section(mode);
    crate::ui::kv("workspace", session.root.display());
    crate::ui::kv("model", &session.model);
    crate::ui::kv("mode", session.mode.name());
    crate::ui::kv("risk", config::policy_risk_label(&session.policy));
    if let Some(prompt) = prompt {
        crate::ui::kv("prompt", crate::ui::compact_preview(prompt, 100));
    }
}

fn write_workspace_file(root: &Path, requested: &Path, body: &str) -> Result<()> {
    let path = config::resolve_workspace_output_path(root, requested)?;
    let mut out = body.trim_end().to_string();
    out.push('\n');
    config::write_workspace_file(&path, out.as_bytes())
}