nils-macos-agent 0.3.0

CLI crate for nils-macos-agent in the nils-cli workspace.
Documentation
use std::path::{Path, PathBuf};

use serde::Serialize;

use crate::backend::process::ProcessRunner;
use crate::backend::{AutoAxBackend, AxBackendAdapter};
use crate::cli::{DebugBundleArgs, ImageFormat, ListAppsArgs, ListWindowsArgs, OutputFormat};
use crate::commands::ax_common::build_target;
use crate::commands::{emit_json_success, reject_tsv_for_list_only};
use crate::error::CliError;
use crate::model::{
    DebugBundleArtifactEntry, DebugBundleResult, ListAppsResult, ListWindowsResult, WindowRow,
};
use crate::run::ActionPolicy;
use crate::targets::{self, TargetSelector};
use crate::test_mode;

pub fn run_windows_list(format: OutputFormat, args: &ListWindowsArgs) -> Result<(), CliError> {
    let windows = targets::list_windows(args)?;
    match format {
        OutputFormat::Json => {
            emit_json_success("windows.list", ListWindowsResult { windows })?;
        }
        OutputFormat::Text | OutputFormat::Tsv => {
            for row in windows {
                println!("{}", row.tsv_line());
            }
        }
    }

    Ok(())
}

pub fn run_apps_list(format: OutputFormat, _args: &ListAppsArgs) -> Result<(), CliError> {
    let apps = targets::list_apps()?;
    match format {
        OutputFormat::Json => {
            emit_json_success("apps.list", ListAppsResult { apps })?;
        }
        OutputFormat::Text | OutputFormat::Tsv => {
            for row in apps {
                println!("{}", row.tsv_line());
            }
        }
    }

    Ok(())
}

pub fn run_debug_bundle(
    format: OutputFormat,
    args: &DebugBundleArgs,
    policy: ActionPolicy,
    runner: &dyn ProcessRunner,
) -> Result<(), CliError> {
    let output_dir = resolve_debug_output_dir(args);
    std::fs::create_dir_all(&output_dir).map_err(|err| {
        CliError::runtime(format!(
            "failed to create debug bundle output directory `{}`: {err}",
            output_dir.display()
        ))
        .with_operation("debug.bundle")
    })?;

    let selector = target_selector_from_debug_args(args);
    let mut artifacts = Vec::new();
    let mut resolved_window = None;

    let target_window_path = output_dir.join("01-target-window.json");
    match targets::resolve_window(&selector) {
        Ok(window) => {
            resolved_window = Some(window.clone());
            push_artifact_json(
                &mut artifacts,
                "target-window",
                &target_window_path,
                &WindowRow::from(&window),
            );
        }
        Err(err) => {
            push_artifact_error(&mut artifacts, "target-window", &target_window_path, &err);
        }
    }

    let windows_list_path = output_dir.join("02-windows-list.json");
    match targets::list_windows(&ListWindowsArgs {
        app: None,
        window_name: None,
        on_screen_only: false,
    }) {
        Ok(windows) => {
            push_artifact_json(&mut artifacts, "windows-list", &windows_list_path, &windows);
        }
        Err(err) => {
            push_artifact_error(&mut artifacts, "windows-list", &windows_list_path, &err);
        }
    }

    let screenshot_path = output_dir.join("03-active-window.png");
    match resolved_window.as_ref() {
        Some(window) => {
            match targets::capture_screenshot(&screenshot_path, window, ImageFormat::Png) {
                Ok(()) => {
                    push_artifact_ok(&mut artifacts, "active-window-screenshot", &screenshot_path)
                }
                Err(err) => {
                    push_artifact_error(
                        &mut artifacts,
                        "active-window-screenshot",
                        &screenshot_path,
                        &err,
                    );
                }
            }
        }
        None => push_artifact_error(
            &mut artifacts,
            "active-window-screenshot",
            &screenshot_path,
            &CliError::runtime("target window was not resolved"),
        ),
    }

    let backend = AutoAxBackend::default();
    let ax_app = args.app.clone().or_else(|| {
        resolved_window
            .as_ref()
            .map(|window| window.owner_name.clone())
    });
    let ax_target = build_target(
        None,
        ax_app,
        None,
        if args.app.is_some() {
            args.window_name.clone()
        } else {
            None
        },
    )?;

    capture_ax_role_artifact(
        &mut artifacts,
        &output_dir.join("04-ax-links.json"),
        "ax-links",
        "AXLink",
        &backend,
        runner,
        &ax_target,
        policy.timeout_ms,
    );
    capture_ax_role_artifact(
        &mut artifacts,
        &output_dir.join("05-ax-buttons.json"),
        "ax-buttons",
        "AXButton",
        &backend,
        runner,
        &ax_target,
        policy.timeout_ms,
    );
    capture_ax_role_artifact(
        &mut artifacts,
        &output_dir.join("06-ax-textfields.json"),
        "ax-textfields",
        "AXTextField",
        &backend,
        runner,
        &ax_target,
        policy.timeout_ms,
    );

    let focused_path = output_dir.join("07-ax-focused.json");
    match backend.list(
        runner,
        &crate::model::AxListRequest {
            target: ax_target.clone(),
            focused: Some(true),
            limit: Some(1),
            ..crate::model::AxListRequest::default()
        },
        policy.timeout_ms.max(1),
    ) {
        Ok(result) => push_artifact_json(&mut artifacts, "ax-focused", &focused_path, &result),
        Err(err) => push_artifact_error(&mut artifacts, "ax-focused", &focused_path, &err),
    }

    let artifact_index_path = output_dir.join("artifact-index.json");
    let result = DebugBundleResult {
        output_dir: output_dir.display().to_string(),
        artifact_index_path: artifact_index_path.display().to_string(),
        partial_failure: artifacts.iter().any(|artifact| !artifact.ok),
        artifacts,
    };
    write_json_file(&artifact_index_path, &result).map_err(|err| {
        CliError::runtime(format!(
            "failed to write debug bundle artifact index `{}`: {err}",
            artifact_index_path.display()
        ))
        .with_operation("debug.bundle")
    })?;

    match format {
        OutputFormat::Json => {
            emit_json_success("debug.bundle", result)?;
        }
        OutputFormat::Text => {
            println!(
                "debug.bundle\toutput_dir={}\tartifact_index_path={}\tpartial_failure={}",
                result.output_dir, result.artifact_index_path, result.partial_failure
            );
            for artifact in &result.artifacts {
                println!(
                    "debug.bundle.artifact\tid={}\tok={}\tpath={}\terror={}",
                    artifact.id,
                    artifact.ok,
                    artifact.path,
                    artifact.error.clone().unwrap_or_default()
                );
            }
        }
        OutputFormat::Tsv => {
            return reject_tsv_for_list_only();
        }
    }

    Ok(())
}

fn target_selector_from_debug_args(args: &DebugBundleArgs) -> TargetSelector {
    if args.window_id.is_none() && !args.active_window && args.app.is_none() {
        return TargetSelector {
            window_id: None,
            active_window: true,
            app: None,
            window_name: None,
        };
    }

    TargetSelector {
        window_id: args.window_id,
        active_window: args.active_window,
        app: args.app.clone(),
        window_name: args.window_name.clone(),
    }
}

#[allow(clippy::too_many_arguments)]
fn capture_ax_role_artifact(
    artifacts: &mut Vec<DebugBundleArtifactEntry>,
    path: &Path,
    id: &str,
    role: &str,
    backend: &AutoAxBackend,
    runner: &dyn ProcessRunner,
    target: &crate::model::AxTarget,
    timeout_ms: u64,
) {
    match backend.list(
        runner,
        &crate::model::AxListRequest {
            target: target.clone(),
            role: Some(role.to_string()),
            ..crate::model::AxListRequest::default()
        },
        timeout_ms.max(1),
    ) {
        Ok(result) => push_artifact_json(artifacts, id, path, &result),
        Err(err) => push_artifact_error(artifacts, id, path, &err),
    }
}

fn resolve_debug_output_dir(args: &DebugBundleArgs) -> PathBuf {
    if let Some(path) = args.output_dir.clone() {
        return path;
    }
    codex_out_dir().join(format!(
        "macos-agent-debug-bundle-{}",
        test_mode::timestamp_token()
    ))
}

fn codex_out_dir() -> PathBuf {
    if let Ok(codex_home) = std::env::var("CODEX_HOME") {
        return PathBuf::from(codex_home).join("out");
    }
    if let Some(home) = std::env::var_os("HOME") {
        return PathBuf::from(home).join(".codex").join("out");
    }
    PathBuf::from(".codex").join("out")
}

fn write_json_file<T>(path: &Path, value: &T) -> Result<(), std::io::Error>
where
    T: Serialize,
{
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let body = serde_json::to_vec_pretty(value).map_err(std::io::Error::other)?;
    std::fs::write(path, body)
}

fn push_artifact_json<T>(
    artifacts: &mut Vec<DebugBundleArtifactEntry>,
    id: &str,
    path: &Path,
    value: &T,
) where
    T: Serialize,
{
    match write_json_file(path, value) {
        Ok(()) => push_artifact_ok(artifacts, id, path),
        Err(err) => push_artifact_error(
            artifacts,
            id,
            path,
            &CliError::runtime(format!("failed to write artifact file: {err}")),
        ),
    }
}

fn push_artifact_ok(artifacts: &mut Vec<DebugBundleArtifactEntry>, id: &str, path: &Path) {
    artifacts.push(DebugBundleArtifactEntry {
        id: id.to_string(),
        path: path.display().to_string(),
        ok: true,
        error: None,
    });
}

fn push_artifact_error(
    artifacts: &mut Vec<DebugBundleArtifactEntry>,
    id: &str,
    path: &Path,
    error: &CliError,
) {
    artifacts.push(DebugBundleArtifactEntry {
        id: id.to_string(),
        path: path.display().to_string(),
        ok: false,
        error: Some(error.message().to_string()),
    });
}