Skip to main content

agentctl/diag/
mod.rs

1pub mod capabilities;
2pub mod doctor;
3
4use clap::{Args, Subcommand, ValueEnum};
5use nils_common::env as shared_env;
6use serde::Serialize;
7
8pub const DIAG_SCHEMA_VERSION: &str = "agentctl.diag.v1";
9pub const EXIT_OK: i32 = 0;
10pub const EXIT_RUNTIME_ERROR: i32 = 1;
11pub const EXIT_USAGE: i32 = 64;
12
13pub const MACOS_AGENT_TEST_MODE_ENV: &str = "CODEX_MACOS_AGENT_TEST_MODE";
14pub const SCREEN_RECORD_TEST_MODE_ENV: &str = "CODEX_SCREEN_RECORD_TEST_MODE";
15
16#[derive(Debug, Args)]
17pub struct DiagArgs {
18    #[command(subcommand)]
19    pub command: Option<DiagSubcommand>,
20}
21
22#[derive(Debug, Subcommand)]
23pub enum DiagSubcommand {
24    /// Run provider and automation readiness diagnostics
25    Doctor(doctor::DoctorArgs),
26    /// Report provider and automation capability inventory
27    Capabilities(capabilities::CapabilitiesArgs),
28}
29
30pub fn run(command: DiagSubcommand) -> i32 {
31    match command {
32        DiagSubcommand::Doctor(args) => doctor::run(args),
33        DiagSubcommand::Capabilities(args) => capabilities::run(args),
34    }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
38pub enum OutputFormat {
39    #[default]
40    Text,
41    Json,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
45pub enum ProbeModeArg {
46    #[default]
47    Auto,
48    Live,
49    Test,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "kebab-case")]
54pub enum ProbeMode {
55    Live,
56    Test,
57}
58
59impl ProbeMode {
60    pub const fn as_str(self) -> &'static str {
61        match self {
62            Self::Live => "live",
63            Self::Test => "test",
64        }
65    }
66}
67
68pub fn resolve_probe_mode(mode: ProbeModeArg) -> ProbeMode {
69    match mode {
70        ProbeModeArg::Auto => {
71            if shared_env::env_truthy(MACOS_AGENT_TEST_MODE_ENV)
72                || shared_env::env_truthy(SCREEN_RECORD_TEST_MODE_ENV)
73            {
74                ProbeMode::Test
75            } else {
76                ProbeMode::Live
77            }
78        }
79        ProbeModeArg::Live => ProbeMode::Live,
80        ProbeModeArg::Test => ProbeMode::Test,
81    }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
85#[serde(rename_all = "kebab-case")]
86pub enum Component {
87    Provider,
88    Automation,
89}
90
91impl Component {
92    pub const fn as_str(self) -> &'static str {
93        match self {
94            Self::Provider => "provider",
95            Self::Automation => "automation",
96        }
97    }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
101#[serde(rename_all = "kebab-case")]
102pub enum CheckStatus {
103    Ready,
104    Degraded,
105    NotReady,
106    Unknown,
107}
108
109impl CheckStatus {
110    pub const fn as_str(self) -> &'static str {
111        match self {
112            Self::Ready => "ready",
113            Self::Degraded => "degraded",
114            Self::NotReady => "not-ready",
115            Self::Unknown => "unknown",
116        }
117    }
118
119    const fn severity(self) -> u8 {
120        match self {
121            Self::Ready => 0,
122            Self::Unknown => 1,
123            Self::Degraded => 2,
124            Self::NotReady => 3,
125        }
126    }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
130#[serde(rename_all = "kebab-case")]
131pub enum FailureHintCategory {
132    MissingDependency,
133    Permission,
134    PlatformLimitation,
135    Unknown,
136}
137
138impl FailureHintCategory {
139    pub const fn as_str(self) -> &'static str {
140        match self {
141            Self::MissingDependency => "missing-dependency",
142            Self::Permission => "permission",
143            Self::PlatformLimitation => "platform-limitation",
144            Self::Unknown => "unknown",
145        }
146    }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
150pub struct FailureHint {
151    pub category: FailureHintCategory,
152    pub message: String,
153}
154
155#[derive(Debug, Clone, Serialize)]
156pub struct ReadinessCheck {
157    pub id: String,
158    pub component: Component,
159    pub subject: String,
160    pub probe: String,
161    pub status: CheckStatus,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub summary: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub hint: Option<FailureHint>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub details: Option<serde_json::Value>,
168    pub probe_mode: ProbeMode,
169}
170
171#[derive(Debug, Clone, Serialize)]
172pub struct ReadinessSummary {
173    pub total_checks: usize,
174    pub ready: usize,
175    pub degraded: usize,
176    pub not_ready: usize,
177    pub unknown: usize,
178}
179
180impl ReadinessSummary {
181    pub fn from_checks(checks: &[ReadinessCheck]) -> Self {
182        let mut ready = 0_usize;
183        let mut degraded = 0_usize;
184        let mut not_ready = 0_usize;
185        let mut unknown = 0_usize;
186
187        for check in checks {
188            match check.status {
189                CheckStatus::Ready => ready += 1,
190                CheckStatus::Degraded => degraded += 1,
191                CheckStatus::NotReady => not_ready += 1,
192                CheckStatus::Unknown => unknown += 1,
193            }
194        }
195
196        Self {
197            total_checks: checks.len(),
198            ready,
199            degraded,
200            not_ready,
201            unknown,
202        }
203    }
204}
205
206#[derive(Debug, Clone, Serialize)]
207pub struct ReadinessSection {
208    pub overall_status: CheckStatus,
209    pub summary: ReadinessSummary,
210    pub checks: Vec<ReadinessCheck>,
211}
212
213impl ReadinessSection {
214    pub fn new(checks: Vec<ReadinessCheck>) -> Self {
215        let overall_status = checks
216            .iter()
217            .map(|check| check.status)
218            .max_by_key(|status| status.severity())
219            .unwrap_or(CheckStatus::Unknown);
220        let summary = ReadinessSummary::from_checks(&checks);
221
222        Self {
223            overall_status,
224            summary,
225            checks,
226        }
227    }
228}
229
230pub fn emit_json<T: Serialize>(value: &T) -> i32 {
231    match serde_json::to_string_pretty(value) {
232        Ok(encoded) => {
233            println!("{encoded}");
234            EXIT_OK
235        }
236        Err(error) => {
237            eprintln!("agentctl diag: failed to render json output: {error}");
238            EXIT_RUNTIME_ERROR
239        }
240    }
241}
242
243pub fn classify_hint_category(text: &str) -> FailureHintCategory {
244    let lower = text.to_ascii_lowercase();
245
246    if lower.contains("permission")
247        || lower.contains("not granted")
248        || lower.contains("not allowed")
249        || lower.contains("denied")
250        || lower.contains("accessibility")
251        || lower.contains("automation")
252        || lower.contains("screen recording")
253        || lower.contains("tcc")
254    {
255        return FailureHintCategory::Permission;
256    }
257
258    if lower.contains("unsupported platform")
259        || lower.contains("only supported on")
260        || lower.contains("not supported on this platform")
261        || lower.contains("platform is unsupported")
262    {
263        return FailureHintCategory::PlatformLimitation;
264    }
265
266    if lower.contains("missing dependency")
267        || lower.contains("not found in path")
268        || lower.contains("command not found")
269        || lower.contains("no such file or directory")
270        || lower.contains("binary is missing")
271        || lower.contains("is not available on path")
272    {
273        return FailureHintCategory::MissingDependency;
274    }
275
276    FailureHintCategory::Unknown
277}
278
279pub fn current_platform() -> &'static str {
280    std::env::consts::OS
281}
282
283#[derive(Debug, Clone, Copy)]
284pub(crate) struct AutomationToolSpec {
285    pub id: &'static str,
286    pub command: &'static str,
287    pub probe_args: &'static [&'static str],
288    pub supported_platforms: &'static [&'static str],
289    pub test_mode_env: Option<&'static str>,
290    pub install_hint: &'static str,
291    pub capabilities: &'static [&'static str],
292}
293
294const MACOS_AGENT_CAPABILITIES: &[&str] = &[
295    "preflight",
296    "windows.list",
297    "apps.list",
298    "window.activate",
299    "input.click",
300    "input.type",
301    "observe.screenshot",
302];
303const SCREEN_RECORD_CAPABILITIES: &[&str] = &[
304    "preflight",
305    "request-permission",
306    "list-windows",
307    "list-displays",
308    "list-apps",
309    "record",
310    "screenshot",
311];
312const IMAGE_PROCESSING_CAPABILITIES: &[&str] = &[
313    "info",
314    "auto-orient",
315    "convert",
316    "resize",
317    "rotate",
318    "crop",
319    "pad",
320    "flip",
321    "flop",
322    "optimize",
323];
324const FZF_CLI_CAPABILITIES: &[&str] = &[
325    "file",
326    "directory",
327    "git-status",
328    "git-commit",
329    "git-checkout",
330    "git-branch",
331    "git-tag",
332    "process",
333    "port",
334    "history",
335    "env",
336    "alias",
337    "function",
338    "def",
339];
340
341pub(crate) const AUTOMATION_TOOL_SPECS: &[AutomationToolSpec] = &[
342    AutomationToolSpec {
343        id: "macos-agent",
344        command: "macos-agent",
345        probe_args: &["--format", "json", "preflight", "--strict"],
346        supported_platforms: &["macos"],
347        test_mode_env: Some(MACOS_AGENT_TEST_MODE_ENV),
348        install_hint: "Install `macos-agent` and ensure the binary is discoverable on PATH.",
349        capabilities: MACOS_AGENT_CAPABILITIES,
350    },
351    AutomationToolSpec {
352        id: "screen-record",
353        command: "screen-record",
354        probe_args: &["--preflight"],
355        supported_platforms: &["macos", "linux"],
356        test_mode_env: Some(SCREEN_RECORD_TEST_MODE_ENV),
357        install_hint: "Install `screen-record` and ensure the binary is discoverable on PATH.",
358        capabilities: SCREEN_RECORD_CAPABILITIES,
359    },
360    AutomationToolSpec {
361        id: "image-processing",
362        command: "image-processing",
363        probe_args: &["info", "--help"],
364        supported_platforms: &[],
365        test_mode_env: None,
366        install_hint: "Install `image-processing` and ImageMagick (`magick`) support binaries.",
367        capabilities: IMAGE_PROCESSING_CAPABILITIES,
368    },
369    AutomationToolSpec {
370        id: "fzf-cli",
371        command: "fzf-cli",
372        probe_args: &["help"],
373        supported_platforms: &[],
374        test_mode_env: None,
375        install_hint: "Install `fzf-cli` and required runtime helpers (`fzf`, `git`).",
376        capabilities: FZF_CLI_CAPABILITIES,
377    },
378];
379
380pub(crate) fn automation_tools() -> &'static [AutomationToolSpec] {
381    AUTOMATION_TOOL_SPECS
382}