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 = "AGENTS_MACOS_AGENT_TEST_MODE";
14pub const SCREEN_RECORD_TEST_MODE_ENV: &str = "AGENTS_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    "svg-validate",
315    "convert.from-svg",
316    "auto-orient",
317    "convert",
318    "resize",
319    "rotate",
320    "crop",
321    "pad",
322    "flip",
323    "flop",
324    "optimize",
325];
326const FZF_CLI_CAPABILITIES: &[&str] = &[
327    "file",
328    "directory",
329    "git-status",
330    "git-commit",
331    "git-checkout",
332    "git-branch",
333    "git-tag",
334    "process",
335    "port",
336    "history",
337    "env",
338    "alias",
339    "function",
340    "def",
341];
342
343pub(crate) const AUTOMATION_TOOL_SPECS: &[AutomationToolSpec] = &[
344    AutomationToolSpec {
345        id: "macos-agent",
346        command: "macos-agent",
347        probe_args: &["--format", "json", "preflight", "--strict"],
348        supported_platforms: &["macos"],
349        test_mode_env: Some(MACOS_AGENT_TEST_MODE_ENV),
350        install_hint: "Install `macos-agent` and ensure the binary is discoverable on PATH.",
351        capabilities: MACOS_AGENT_CAPABILITIES,
352    },
353    AutomationToolSpec {
354        id: "screen-record",
355        command: "screen-record",
356        probe_args: &["--preflight"],
357        supported_platforms: &["macos", "linux"],
358        test_mode_env: Some(SCREEN_RECORD_TEST_MODE_ENV),
359        install_hint: "Install `screen-record` and ensure the binary is discoverable on PATH.",
360        capabilities: SCREEN_RECORD_CAPABILITIES,
361    },
362    AutomationToolSpec {
363        id: "image-processing",
364        command: "image-processing",
365        probe_args: &["info", "--help"],
366        supported_platforms: &[],
367        test_mode_env: None,
368        install_hint: "Install `image-processing` and ImageMagick (`magick`) support binaries.",
369        capabilities: IMAGE_PROCESSING_CAPABILITIES,
370    },
371    AutomationToolSpec {
372        id: "fzf-cli",
373        command: "fzf-cli",
374        probe_args: &["help"],
375        supported_platforms: &[],
376        test_mode_env: None,
377        install_hint: "Install `fzf-cli` and required runtime helpers (`fzf`, `git`).",
378        capabilities: FZF_CLI_CAPABILITIES,
379    },
380];
381
382pub(crate) fn automation_tools() -> &'static [AutomationToolSpec] {
383    AUTOMATION_TOOL_SPECS
384}