Skip to main content

agentctl/provider/
commands.rs

1use crate::provider::registry::{ProviderRegistry, ResolveProviderError};
2use agent_runtime_core::schema::{HealthStatus, HealthcheckRequest};
3use clap::{Args, Subcommand, ValueEnum};
4use serde::Serialize;
5
6const EXIT_OK: i32 = 0;
7const EXIT_RUNTIME_ERROR: i32 = 1;
8const EXIT_USAGE: i32 = 64;
9
10#[derive(Debug, Args)]
11pub struct ProviderArgs {
12    #[command(subcommand)]
13    pub command: Option<ProviderSubcommand>,
14}
15
16#[derive(Debug, Subcommand)]
17pub enum ProviderSubcommand {
18    /// List registered providers and current health status
19    List(ProviderListArgs),
20    /// Execute healthcheck for one provider
21    Healthcheck(ProviderHealthcheckArgs),
22}
23
24#[derive(Debug, Args)]
25pub struct ProviderListArgs {
26    /// Optional provider override (otherwise uses AGENTCTL_PROVIDER/default)
27    #[arg(long)]
28    pub provider: Option<String>,
29
30    /// Healthcheck timeout passed to adapters
31    #[arg(long)]
32    pub timeout_ms: Option<u64>,
33
34    /// Render format
35    #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
36    pub format: OutputFormat,
37}
38
39#[derive(Debug, Args)]
40pub struct ProviderHealthcheckArgs {
41    /// Optional provider override (otherwise uses AGENTCTL_PROVIDER/default)
42    #[arg(long)]
43    pub provider: Option<String>,
44
45    /// Healthcheck timeout passed to adapters
46    #[arg(long)]
47    pub timeout_ms: Option<u64>,
48
49    /// Render format
50    #[arg(long, value_enum, default_value_t = OutputFormat::Text)]
51    pub format: OutputFormat,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
55pub enum OutputFormat {
56    #[default]
57    Text,
58    Json,
59}
60
61pub fn run(command: ProviderSubcommand) -> i32 {
62    match command {
63        ProviderSubcommand::List(args) => run_list(args),
64        ProviderSubcommand::Healthcheck(args) => run_healthcheck(args),
65    }
66}
67
68fn run_list(args: ProviderListArgs) -> i32 {
69    let registry = ProviderRegistry::with_builtins();
70    let selection = match registry.resolve_selection(args.provider.as_deref()) {
71        Ok(selection) => selection,
72        Err(error) => return report_selection_error(&registry, error),
73    };
74    let default_provider = registry.default_provider_id().map(ToOwned::to_owned);
75    let selected_provider = selection.provider_id;
76    let selected_source = selection.source.as_str().to_string();
77
78    let providers = registry
79        .iter()
80        .map(|(provider_id, adapter)| {
81            let metadata = adapter.metadata();
82            let health = adapter.healthcheck(HealthcheckRequest {
83                timeout_ms: args.timeout_ms,
84            });
85            let (status, summary) = match health {
86                Ok(response) => (response.status, response.summary),
87                Err(error) => (
88                    HealthStatus::Unknown,
89                    Some(format!("healthcheck failed: {}", error.message)),
90                ),
91            };
92
93            ProviderListItem {
94                id: provider_id.to_string(),
95                contract_version: metadata.contract_version.as_str().to_string(),
96                maturity: metadata.maturity.as_str().to_string(),
97                status: health_status_text(status).to_string(),
98                summary,
99                is_default: default_provider.as_deref() == Some(provider_id),
100                is_selected: selected_provider.as_str() == provider_id,
101            }
102        })
103        .collect::<Vec<_>>();
104
105    let output = ProviderListOutput {
106        default_provider,
107        selected_provider,
108        selected_source,
109        providers,
110    };
111
112    emit_list_output(&output, args.format)
113}
114
115fn run_healthcheck(args: ProviderHealthcheckArgs) -> i32 {
116    let registry = ProviderRegistry::with_builtins();
117    let selection = match registry.resolve_selection(args.provider.as_deref()) {
118        Ok(selection) => selection,
119        Err(error) => return report_selection_error(&registry, error),
120    };
121
122    let Some(adapter) = registry.get(selection.provider_id.as_str()) else {
123        eprintln!(
124            "agentctl provider: selected provider '{}' is not registered",
125            selection.provider_id
126        );
127        return EXIT_RUNTIME_ERROR;
128    };
129
130    let health = adapter.healthcheck(HealthcheckRequest {
131        timeout_ms: args.timeout_ms,
132    });
133    match health {
134        Ok(response) => {
135            let output = ProviderHealthcheckOutput {
136                provider: selection.provider_id,
137                selected_source: selection.source.as_str().to_string(),
138                status: health_status_text(response.status).to_string(),
139                summary: response.summary,
140                details: response.details,
141            };
142            emit_healthcheck_output(&output, args.format)
143        }
144        Err(error) => {
145            if args.format == OutputFormat::Json {
146                let output = ProviderHealthcheckFailureOutput {
147                    provider: selection.provider_id,
148                    selected_source: selection.source.as_str().to_string(),
149                    error: ProviderCommandErrorOutput {
150                        category: serde_json::to_value(error.category)
151                            .ok()
152                            .and_then(|value| value.as_str().map(ToOwned::to_owned))
153                            .unwrap_or_else(|| "unknown".to_string()),
154                        code: error.code,
155                        message: error.message,
156                    },
157                };
158                return emit_json(&output);
159            }
160
161            eprintln!(
162                "agentctl provider: healthcheck failed for '{}': {}",
163                selection.provider_id, error.message
164            );
165            EXIT_RUNTIME_ERROR
166        }
167    }
168}
169
170fn emit_list_output(output: &ProviderListOutput, format: OutputFormat) -> i32 {
171    match format {
172        OutputFormat::Json => emit_json(output),
173        OutputFormat::Text => {
174            println!(
175                "default_provider: {}",
176                output.default_provider.as_deref().unwrap_or("<none>")
177            );
178            println!(
179                "selected_provider: {} ({})",
180                output.selected_provider, output.selected_source
181            );
182            println!("providers:");
183            for provider in &output.providers {
184                let mut tags = Vec::new();
185                if provider.is_default {
186                    tags.push("default");
187                }
188                if provider.is_selected {
189                    tags.push("selected");
190                }
191
192                if tags.is_empty() {
193                    println!(
194                        "- {} [{}] (maturity: {})",
195                        provider.id, provider.status, provider.maturity
196                    );
197                } else {
198                    println!(
199                        "- {} [{}] (maturity: {}, {})",
200                        provider.id,
201                        provider.status,
202                        provider.maturity,
203                        tags.join(", ")
204                    );
205                }
206                if let Some(summary) = provider.summary.as_deref() {
207                    println!("  summary: {summary}");
208                }
209            }
210            EXIT_OK
211        }
212    }
213}
214
215fn emit_healthcheck_output(output: &ProviderHealthcheckOutput, format: OutputFormat) -> i32 {
216    match format {
217        OutputFormat::Json => emit_json(output),
218        OutputFormat::Text => {
219            println!("provider: {}", output.provider);
220            println!("selected_source: {}", output.selected_source);
221            println!("status: {}", output.status);
222            if let Some(summary) = output.summary.as_deref() {
223                println!("summary: {summary}");
224            }
225            EXIT_OK
226        }
227    }
228}
229
230fn 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 provider: failed to render json output: {error}");
238            EXIT_RUNTIME_ERROR
239        }
240    }
241}
242
243fn report_selection_error(registry: &ProviderRegistry, error: ResolveProviderError) -> i32 {
244    eprintln!("agentctl provider: {error}");
245
246    let registered = registry
247        .iter()
248        .map(|(provider_id, _)| provider_id.to_string())
249        .collect::<Vec<_>>();
250    if !registered.is_empty() {
251        eprintln!(
252            "agentctl provider: available providers: {}",
253            registered.join(", ")
254        );
255    }
256
257    EXIT_USAGE
258}
259
260fn health_status_text(status: HealthStatus) -> &'static str {
261    match status {
262        HealthStatus::Healthy => "healthy",
263        HealthStatus::Degraded => "degraded",
264        HealthStatus::Unhealthy => "unhealthy",
265        HealthStatus::Unknown => "unknown",
266    }
267}
268
269#[derive(Debug, Serialize)]
270struct ProviderListOutput {
271    #[serde(skip_serializing_if = "Option::is_none")]
272    default_provider: Option<String>,
273    selected_provider: String,
274    selected_source: String,
275    providers: Vec<ProviderListItem>,
276}
277
278#[derive(Debug, Serialize)]
279struct ProviderListItem {
280    id: String,
281    contract_version: String,
282    maturity: String,
283    status: String,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    summary: Option<String>,
286    is_default: bool,
287    is_selected: bool,
288}
289
290#[derive(Debug, Serialize)]
291struct ProviderHealthcheckOutput {
292    provider: String,
293    selected_source: String,
294    status: String,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    summary: Option<String>,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    details: Option<serde_json::Value>,
299}
300
301#[derive(Debug, Serialize)]
302struct ProviderHealthcheckFailureOutput {
303    provider: String,
304    selected_source: String,
305    error: ProviderCommandErrorOutput,
306}
307
308#[derive(Debug, Serialize)]
309struct ProviderCommandErrorOutput {
310    category: String,
311    code: String,
312    message: String,
313}