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 Doctor(doctor::DoctorArgs),
26 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}