Skip to main content

agent_infra_cli/
lib.rs

1use std::{
2    fs,
3    io::{self, Read},
4    path::{Path, PathBuf},
5    sync::Arc,
6    time::Duration,
7};
8
9use agent_infra_sdk::{
10    CallOptions, InfraClient, StaticCredentials,
11    agent_contract::{
12        AgentId, AgentStatus, CreateAgentRequest, ListAgentsRequest, UpdateAgentRequest,
13    },
14    context_contract::ConversationRecord,
15    evaluate_contract::{
16        CreateEvalRunRequest, GetOperationRequest, GetReportRequest, ListCaseRunsRequest,
17        RegisterEnvironmentProfileRequest, RegisterEvaluatorVersionRequest, RegisterSuiteRequest,
18    },
19    runtime_identity_contract::{
20        CreateRuntimeIdentityRequest, IssueRuntimeCredentialRequest, ReplacePermissionsRequest,
21        RuntimeIdentityId, RuntimeIdentityKind, TransitionIdentityRequest,
22        UpdateRuntimeIdentityRequest,
23    },
24    trace_contract::{
25        CreateLangfuseProjectRequest, ListLangfuseProjectsRequest, MAX_METRICS_LOOKBACK_DAYS,
26        MetricsDataSource, MetricsQuery,
27    },
28    workspace_contract::{
29        CreateComputerRequest, CreateSpaceRequest, ListEnvironmentResourcesQuery, OwnerKind,
30    },
31};
32use anyhow::{Context, Result, anyhow, bail};
33use clap::{Args, Parser, Subcommand, ValueEnum};
34use serde::{Serialize, de::DeserializeOwned};
35use serde_json::Value;
36
37mod config;
38
39use config::{ConfigStore, resolve_credential};
40
41const DEFAULT_GATEWAY: &str = "http://127.0.0.1:5200";
42
43#[derive(Debug, Parser)]
44#[command(
45    name = "infra",
46    version,
47    about = "Operate Agent Infra through the typed Infra SDK",
48    arg_required_else_help = true
49)]
50pub struct Cli {
51    /// Agent Infra Gateway base URL.
52    #[arg(long, global = true)]
53    pub gateway: Option<String>,
54
55    /// Gateway bearer token. Prefer SKY_GATEWAY_TOKEN over command-line use.
56    #[arg(long, global = true, hide_env_values = true)]
57    pub token: Option<String>,
58
59    /// Override the local Infra CLI config file.
60    #[arg(long, global = true, env = "AGENT_INFRA_CONFIG", value_name = "PATH")]
61    pub config: Option<PathBuf>,
62
63    /// Override the Sky CLI home used for local credential discovery.
64    #[arg(
65        long,
66        global = true,
67        env = "AGENT_INFRA_SKY_HOME",
68        value_name = "PATH",
69        hide = true
70    )]
71    pub sky_home: Option<PathBuf>,
72
73    /// Request timeout in seconds.
74    #[arg(long, global = true, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=600))]
75    pub timeout: u64,
76
77    /// Permit plaintext HTTP to non-loopback hosts protected by a trusted service mesh.
78    #[arg(long, global = true)]
79    pub trusted_mesh_http: bool,
80
81    /// JSON rendering mode.
82    #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Pretty)]
83    pub output: OutputFormat,
84
85    #[command(subcommand)]
86    pub command: Command,
87}
88
89#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
90pub enum OutputFormat {
91    /// Indented JSON.
92    #[default]
93    Pretty,
94    /// Compact JSON.
95    Json,
96}
97
98#[derive(Debug, Subcommand)]
99pub enum Command {
100    /// Persist and inspect local CLI settings.
101    Config {
102        #[command(subcommand)]
103        command: ConfigCommand,
104    },
105    /// Inspect local Gateway credential discovery.
106    Auth {
107        #[command(subcommand)]
108        command: AuthCommand,
109    },
110    /// Inspect Gateway health, discovery, routing, and policy.
111    Gateway {
112        #[command(subcommand)]
113        command: GatewayCommand,
114    },
115    /// Query trace events and Langfuse-backed usage metrics.
116    Trace {
117        #[command(subcommand)]
118        command: TraceCommand,
119    },
120    /// Create and inspect Agent Registry resources.
121    Agent {
122        #[command(subcommand)]
123        command: AgentCommand,
124    },
125    /// Create and manage Runtime Identity resources.
126    Runtime {
127        #[command(subcommand)]
128        command: RuntimeCommand,
129    },
130    /// Read and append durable conversation context.
131    Context {
132        #[command(subcommand)]
133        command: ContextCommand,
134    },
135    /// Manage Environment computers.
136    Computer {
137        #[command(subcommand)]
138        command: ComputerCommand,
139    },
140    /// Manage Environment spaces.
141    Space {
142        #[command(subcommand)]
143        command: SpaceCommand,
144    },
145    /// Invoke the OpenAI-compatible Model Infra API.
146    Model {
147        #[command(subcommand)]
148        command: ModelCommand,
149    },
150    /// Manage versioned evaluation resources and durable runs.
151    Evaluate {
152        #[command(subcommand)]
153        command: EvaluateCommand,
154    },
155}
156
157#[derive(Debug, Subcommand)]
158pub enum EvaluateCommand {
159    RegisterEnvironment(JsonInput),
160    RegisterEvaluator(JsonInput),
161    RegisterSuite(JsonInput),
162    /// Create a durable task that a matching Runtime starts through POST /evaluate.
163    #[command(alias = "run")]
164    TaskCreate(JsonInput),
165    Operation {
166        operation_id: String,
167    },
168    Cases(JsonInput),
169    Report {
170        report_id: String,
171    },
172}
173
174#[derive(Debug, Subcommand)]
175pub enum ConfigCommand {
176    /// Show the config file and effective Gateway selection.
177    Show,
178    /// Manage the persisted Gateway address.
179    Gateway {
180        #[command(subcommand)]
181        command: ConfigGatewayCommand,
182    },
183}
184
185#[derive(Debug, Subcommand)]
186pub enum ConfigGatewayCommand {
187    /// Persist a Gateway URL locally.
188    Set { url: String },
189    /// Show the persisted and effective Gateway URL.
190    Get,
191    /// Remove the persisted Gateway URL.
192    Unset,
193}
194
195#[derive(Debug, Subcommand)]
196pub enum AuthCommand {
197    /// Probe explicit, environment, and local Sky credential sources.
198    Status,
199}
200
201#[derive(Debug, Subcommand)]
202pub enum GatewayCommand {
203    Discovery,
204    Live,
205    Ready,
206    /// Poll until the Gateway and its dependencies are ready.
207    WaitReady {
208        #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=3600))]
209        deadline: u64,
210    },
211    Dependencies,
212    Routes,
213    Metrics,
214    Policy,
215}
216
217#[derive(Debug, Subcommand)]
218pub enum TraceCommand {
219    /// Query Langfuse metrics through Trace Infra.
220    Metrics {
221        #[command(subcommand)]
222        command: TraceMetricsCommand,
223    },
224    /// Allocate and query Trace-owned Langfuse projects.
225    LangfuseProject {
226        #[command(subcommand)]
227        command: LangfuseProjectCommand,
228    },
229}
230
231#[derive(Debug, Subcommand)]
232pub enum LangfuseProjectCommand {
233    /// Create a project from a CreateLangfuseProjectRequest JSON document.
234    Create {
235        #[command(flatten)]
236        input: JsonInput,
237        #[arg(long)]
238        idempotency_key: String,
239    },
240    Get {
241        project_id: String,
242    },
243    List(LangfuseProjectListArgs),
244}
245
246#[derive(Debug, Args)]
247pub struct LangfuseProjectListArgs {
248    #[arg(long)]
249    related_agent_id: Option<String>,
250    #[arg(long)]
251    related_runtime_id: Option<String>,
252    #[arg(long)]
253    related_user_id: Option<String>,
254    #[arg(long)]
255    cursor: Option<String>,
256    #[arg(long, value_parser = clap::value_parser!(u32).range(1..=200))]
257    limit: Option<u32>,
258}
259
260#[derive(Debug, Subcommand)]
261pub enum TraceMetricsCommand {
262    TokenUsage(TraceMetricsArgs),
263    ModelBreakdown(TraceMetricsArgs),
264    ProjectSummary(TraceMetricsArgs),
265    Latency(TraceMetricsArgs),
266    ErrorRate(TraceMetricsArgs),
267    CacheHitRate(TraceMetricsArgs),
268    CostBreakdown(TraceMetricsArgs),
269    ModelTrend(TraceMetricsArgs),
270    TokenDistribution(TraceMetricsArgs),
271    Activity(TraceMetricsArgs),
272    PricingTier(TraceMetricsArgs),
273}
274
275#[derive(Debug, Args)]
276pub struct TraceMetricsArgs {
277    /// Filter by one or more Langfuse project IDs.
278    #[arg(long = "project-id")]
279    project_ids: Vec<String>,
280    /// Number of days to include in the query window.
281    #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u32).range(1..=3650))]
282    days: u32,
283    /// Select the Langfuse v3/v4 ClickHouse data path.
284    #[arg(long, value_enum, default_value_t = CliMetricsDataSource::Auto)]
285    data_source: CliMetricsDataSource,
286    /// v3/v4 cutover boundary used when --data-source is auto.
287    #[arg(long, default_value = "2026-09-01 00:00:00")]
288    v4_cutover_date: String,
289}
290
291impl TraceMetricsArgs {
292    fn into_query(self) -> MetricsQuery {
293        MetricsQuery {
294            project_ids: (!self.project_ids.is_empty()).then_some(self.project_ids),
295            days: self.days.min(MAX_METRICS_LOOKBACK_DAYS),
296            data_source: self.data_source.into(),
297            v4_cutover_date: self.v4_cutover_date,
298        }
299    }
300}
301
302#[derive(Clone, Copy, Debug, Default, ValueEnum)]
303pub enum CliMetricsDataSource {
304    V3,
305    V4,
306    #[default]
307    Auto,
308}
309
310impl From<CliMetricsDataSource> for MetricsDataSource {
311    fn from(value: CliMetricsDataSource) -> Self {
312        match value {
313            CliMetricsDataSource::V3 => Self::V3,
314            CliMetricsDataSource::V4 => Self::V4,
315            CliMetricsDataSource::Auto => Self::Auto,
316        }
317    }
318}
319
320#[derive(Debug, Subcommand)]
321pub enum AgentCommand {
322    /// Create an agent from a CreateAgentRequest JSON document.
323    Create(JsonInput),
324    Get {
325        agent_id: String,
326    },
327    List(AgentListArgs),
328    /// Update an agent from an UpdateAgentRequest JSON document.
329    Update {
330        agent_id: String,
331        #[command(flatten)]
332        input: JsonInput,
333    },
334}
335
336#[derive(Debug, Subcommand)]
337pub enum RuntimeCommand {
338    /// Create a named runtime identity.
339    Create(RuntimeCreateArgs),
340    /// Get a runtime identity by ID.
341    Get { runtime_id: String },
342    /// Change a runtime identity name without changing its ID.
343    Rename {
344        runtime_id: String,
345        name: String,
346        #[arg(long)]
347        expected_revision: u64,
348        #[arg(long)]
349        idempotency_key: Option<String>,
350    },
351    /// Activate a pending runtime identity using a TransitionIdentityRequest JSON document.
352    Activate {
353        runtime_id: String,
354        #[command(flatten)]
355        input: JsonInput,
356    },
357    /// Suspend an active runtime identity using a TransitionIdentityRequest JSON document.
358    Suspend {
359        runtime_id: String,
360        #[command(flatten)]
361        input: JsonInput,
362    },
363    /// Resume a suspended runtime identity using a TransitionIdentityRequest JSON document.
364    Resume {
365        runtime_id: String,
366        #[command(flatten)]
367        input: JsonInput,
368    },
369    /// Revoke a runtime identity using a TransitionIdentityRequest JSON document.
370    Revoke {
371        runtime_id: String,
372        #[command(flatten)]
373        input: JsonInput,
374    },
375    /// Replace the complete permission grant set from a JSON document.
376    Permissions {
377        runtime_id: String,
378        #[command(flatten)]
379        input: JsonInput,
380    },
381    /// Exchange a one-time bootstrap proof for the first workload credential.
382    BootstrapCredential {
383        #[command(flatten)]
384        input: JsonInput,
385    },
386}
387
388#[derive(Debug, Args)]
389pub struct RuntimeCreateArgs {
390    /// Mutable display name. Required unless --file supplies the full request.
391    #[arg(long, required_unless_present = "file")]
392    name: Option<String>,
393    /// Runtime workload kind. Defaults to code for inline creation.
394    #[arg(long, value_enum)]
395    kind: Option<CliRuntimeKind>,
396    /// Override the deterministic key derived from --name.
397    #[arg(long)]
398    idempotency_key: Option<String>,
399    /// Read a complete CreateRuntimeIdentityRequest JSON document.
400    #[arg(
401        long,
402        short = 'f',
403        value_name = "PATH",
404        conflicts_with_all = ["name", "kind", "idempotency_key"]
405    )]
406    file: Option<PathBuf>,
407}
408
409#[derive(Clone, Copy, Debug, ValueEnum)]
410enum CliRuntimeKind {
411    Code,
412    Evaluate,
413    Tool,
414    Custom,
415}
416
417impl From<CliRuntimeKind> for RuntimeIdentityKind {
418    fn from(value: CliRuntimeKind) -> Self {
419        match value {
420            CliRuntimeKind::Code => Self::Code,
421            CliRuntimeKind::Evaluate => Self::Evaluate,
422            CliRuntimeKind::Tool => Self::Tool,
423            CliRuntimeKind::Custom => Self::Custom,
424        }
425    }
426}
427
428#[derive(Debug, Args)]
429pub struct AgentListArgs {
430    #[arg(long, value_enum)]
431    status: Option<CliAgentStatus>,
432    #[arg(long)]
433    related_agent_id: Option<String>,
434    #[arg(long)]
435    related_runtime_id: Option<String>,
436    #[arg(long)]
437    related_user_id: Option<String>,
438    #[arg(long)]
439    cursor: Option<String>,
440    #[arg(long, value_parser = clap::value_parser!(u32).range(1..=200))]
441    limit: Option<u32>,
442}
443
444#[derive(Clone, Copy, Debug, ValueEnum)]
445enum CliAgentStatus {
446    Active,
447    Archived,
448}
449
450impl From<CliAgentStatus> for AgentStatus {
451    fn from(value: CliAgentStatus) -> Self {
452        match value {
453            CliAgentStatus::Active => Self::Active,
454            CliAgentStatus::Archived => Self::Archived,
455        }
456    }
457}
458
459#[derive(Debug, Subcommand)]
460pub enum ContextCommand {
461    Recent {
462        conversation_id: String,
463        #[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
464        limit: Option<u64>,
465        #[arg(long, value_delimiter = ',')]
466        role: Vec<String>,
467    },
468    Search {
469        conversation_id: String,
470        query: String,
471        #[arg(long)]
472        role: Option<String>,
473        #[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
474        limit: Option<u64>,
475    },
476    /// Append a JSON array of ConversationRecord objects.
477    Append(JsonInput),
478    Maintenance,
479}
480
481#[derive(Debug, Subcommand)]
482pub enum ComputerCommand {
483    /// Create a computer from JSON.
484    Create {
485        #[command(flatten)]
486        input: JsonInput,
487        #[arg(long)]
488        idempotency_key: String,
489    },
490    Get {
491        computer_id: String,
492    },
493    List {
494        #[arg(long)]
495        owner_kind: Option<String>,
496        #[arg(long)]
497        owner_id: Option<String>,
498    },
499    Delete {
500        computer_id: String,
501        #[arg(long)]
502        idempotency_key: String,
503    },
504}
505
506#[derive(Debug, Subcommand)]
507pub enum SpaceCommand {
508    /// Create a space from JSON.
509    Create {
510        #[command(flatten)]
511        input: JsonInput,
512        #[arg(long)]
513        idempotency_key: String,
514    },
515    Get {
516        space_id: String,
517    },
518    List {
519        #[arg(long)]
520        owner_kind: Option<String>,
521        #[arg(long)]
522        owner_id: Option<String>,
523    },
524    Delete {
525        space_id: String,
526        #[arg(long)]
527        idempotency_key: String,
528    },
529}
530
531#[derive(Debug, Subcommand)]
532pub enum ModelCommand {
533    Models,
534    /// Invoke chat/completions with a JSON request document.
535    Chat {
536        #[command(flatten)]
537        input: JsonInput,
538        #[arg(long)]
539        request_id: Option<String>,
540    },
541    /// Invoke responses with a JSON request document.
542    Responses {
543        #[command(flatten)]
544        input: JsonInput,
545        #[arg(long)]
546        request_id: Option<String>,
547    },
548}
549
550#[derive(Debug, Args)]
551pub struct JsonInput {
552    /// JSON file to read; use '-' (the default) for standard input.
553    #[arg(long, short = 'f', default_value = "-")]
554    file: PathBuf,
555}
556
557impl JsonInput {
558    fn read<T: DeserializeOwned>(&self) -> Result<T> {
559        let bytes = read_input(&self.file)?;
560        serde_json::from_slice(&bytes)
561            .with_context(|| format!("invalid JSON input from {}", display_source(&self.file)))
562    }
563}
564
565#[derive(Debug)]
566pub enum CommandOutput {
567    Json(Value),
568    Text(String),
569}
570
571impl CommandOutput {
572    fn json(value: impl Serialize) -> Result<Self> {
573        Ok(Self::Json(serde_json::to_value(value)?))
574    }
575
576    pub fn render(&self, format: OutputFormat) -> Result<String> {
577        match self {
578            Self::Text(value) => Ok(value.clone()),
579            Self::Json(value) => match format {
580                OutputFormat::Pretty => Ok(serde_json::to_string_pretty(value)?),
581                OutputFormat::Json => Ok(serde_json::to_string(value)?),
582            },
583        }
584    }
585}
586
587impl Cli {
588    pub async fn execute(self) -> Result<CommandOutput> {
589        if let Command::Config { command } = &self.command {
590            return execute_config(&self, command);
591        }
592        if let Command::Auth { command } = &self.command {
593            return execute_auth(&self, command);
594        }
595        let client = self.build_client()?;
596        match self.command {
597            Command::Config { .. } | Command::Auth { .. } => {
598                unreachable!("local commands returned before building the client")
599            }
600            Command::Gateway { command } => execute_gateway(&client, command).await,
601            Command::Trace { command } => execute_trace(&client, command).await,
602            Command::Agent { command } => execute_agent(&client, command).await,
603            Command::Runtime { command } => execute_runtime(&client, command).await,
604            Command::Context { command } => execute_context(&client, command).await,
605            Command::Computer { command } => execute_computer(&client, command).await,
606            Command::Space { command } => execute_space(&client, command).await,
607            Command::Model { command } => execute_model(&client, command).await,
608            Command::Evaluate { command } => execute_evaluate(&client, command).await,
609        }
610    }
611
612    fn build_client(&self) -> Result<InfraClient> {
613        let store = ConfigStore::discover(self.config.as_deref())?;
614        let gateway = store.resolve_gateway(self.gateway.as_deref())?;
615        let (token, _) = resolve_credential(self.token.as_deref(), self.sky_home.as_deref())?;
616        let mut builder = InfraClient::builder(gateway.value)
617            .request_timeout(Duration::from_secs(self.timeout))
618            .trusted_mesh_http(self.trusted_mesh_http)
619            .user_agent(concat!("agent-infra-cli/", env!("CARGO_PKG_VERSION")));
620        if let Some(token) = token {
621            builder = builder.credentials(Arc::new(StaticCredentials::new(token)?));
622        }
623        builder.build().map_err(Into::into)
624    }
625}
626
627fn execute_config(cli: &Cli, command: &ConfigCommand) -> Result<CommandOutput> {
628    let store = ConfigStore::discover(cli.config.as_deref())?;
629    match command {
630        ConfigCommand::Show
631        | ConfigCommand::Gateway {
632            command: ConfigGatewayCommand::Get,
633        } => {
634            let config = store.load()?;
635            let effective = store.resolve_gateway(cli.gateway.as_deref())?;
636            CommandOutput::json(serde_json::json!({
637                "path": store.path(),
638                "gateway": config.gateway,
639                "effectiveGateway": effective.value,
640                "source": effective.source,
641            }))
642        }
643        ConfigCommand::Gateway {
644            command: ConfigGatewayCommand::Set { url },
645        } => {
646            let config = store.set_gateway(url)?;
647            CommandOutput::json(serde_json::json!({
648                "ok": true,
649                "path": store.path(),
650                "gateway": config.gateway,
651            }))
652        }
653        ConfigCommand::Gateway {
654            command: ConfigGatewayCommand::Unset,
655        } => {
656            store.unset_gateway()?;
657            CommandOutput::json(serde_json::json!({
658                "ok": true,
659                "path": store.path(),
660                "gateway": null,
661            }))
662        }
663    }
664}
665
666fn execute_auth(cli: &Cli, command: &AuthCommand) -> Result<CommandOutput> {
667    match command {
668        AuthCommand::Status => {
669            let store = ConfigStore::discover(cli.config.as_deref())?;
670            let gateway = store.resolve_gateway(cli.gateway.as_deref())?;
671            let (_, credential) =
672                resolve_credential(cli.token.as_deref(), cli.sky_home.as_deref())?;
673            CommandOutput::json(serde_json::json!({
674                "gateway": gateway.value,
675                "gatewaySource": gateway.source,
676                "credential": credential,
677            }))
678        }
679    }
680}
681
682async fn execute_gateway(client: &InfraClient, command: GatewayCommand) -> Result<CommandOutput> {
683    match command {
684        GatewayCommand::Discovery => CommandOutput::json(client.gateway().discovery().await?),
685        GatewayCommand::Live => CommandOutput::json(client.gateway().live().await?),
686        GatewayCommand::Ready => CommandOutput::json(client.gateway().ready().await?),
687        GatewayCommand::WaitReady { deadline } => CommandOutput::json(
688            client
689                .gateway()
690                .wait_until_ready(Duration::from_secs(deadline))
691                .await?,
692        ),
693        GatewayCommand::Dependencies => CommandOutput::json(client.gateway().dependencies().await?),
694        GatewayCommand::Routes => CommandOutput::json(client.gateway().routes().await?),
695        GatewayCommand::Metrics => CommandOutput::json(client.gateway().metrics().await?),
696        GatewayCommand::Policy => CommandOutput::json(client.gateway().effective_policy().await?),
697    }
698}
699
700async fn execute_trace(client: &InfraClient, command: TraceCommand) -> Result<CommandOutput> {
701    match command {
702        TraceCommand::Metrics { command } => execute_trace_metrics(client, command).await,
703        TraceCommand::LangfuseProject { command } => match command {
704            LangfuseProjectCommand::Create {
705                input,
706                idempotency_key,
707            } => {
708                let request: CreateLangfuseProjectRequest = input.read()?;
709                CommandOutput::json(
710                    client
711                        .admin()
712                        .create_langfuse_project(&request, &idempotency_key)
713                        .await?,
714                )
715            }
716            LangfuseProjectCommand::Get { project_id } => {
717                CommandOutput::json(client.admin().langfuse_project(&project_id).await?)
718            }
719            LangfuseProjectCommand::List(args) => CommandOutput::json(
720                client
721                    .admin()
722                    .list_langfuse_projects(&ListLangfuseProjectsRequest {
723                        related_agent_id: args
724                            .related_agent_id
725                            .as_deref()
726                            .map(parse_agent_id)
727                            .transpose()?,
728                        related_runtime_id: args.related_runtime_id,
729                        related_user_id: args.related_user_id,
730                        cursor: args.cursor,
731                        limit: args.limit,
732                    })
733                    .await?,
734            ),
735        },
736    }
737}
738
739async fn execute_trace_metrics(
740    client: &InfraClient,
741    command: TraceMetricsCommand,
742) -> Result<CommandOutput> {
743    match command {
744        TraceMetricsCommand::TokenUsage(args) => {
745            CommandOutput::json(client.trace().token_usage(&args.into_query()).await?)
746        }
747        TraceMetricsCommand::ModelBreakdown(args) => {
748            CommandOutput::json(client.trace().model_breakdown(&args.into_query()).await?)
749        }
750        TraceMetricsCommand::ProjectSummary(args) => {
751            CommandOutput::json(client.trace().project_summary(&args.into_query()).await?)
752        }
753        TraceMetricsCommand::Latency(args) => {
754            CommandOutput::json(client.trace().latency(&args.into_query()).await?)
755        }
756        TraceMetricsCommand::ErrorRate(args) => {
757            CommandOutput::json(client.trace().error_rate(&args.into_query()).await?)
758        }
759        TraceMetricsCommand::CacheHitRate(args) => {
760            CommandOutput::json(client.trace().cache_hit_rate(&args.into_query()).await?)
761        }
762        TraceMetricsCommand::CostBreakdown(args) => {
763            CommandOutput::json(client.trace().cost_breakdown(&args.into_query()).await?)
764        }
765        TraceMetricsCommand::ModelTrend(args) => {
766            CommandOutput::json(client.trace().model_trend(&args.into_query()).await?)
767        }
768        TraceMetricsCommand::TokenDistribution(args) => CommandOutput::json(
769            client
770                .trace()
771                .token_distribution(&args.into_query())
772                .await?,
773        ),
774        TraceMetricsCommand::Activity(args) => {
775            CommandOutput::json(client.trace().activity(&args.into_query()).await?)
776        }
777        TraceMetricsCommand::PricingTier(args) => {
778            CommandOutput::json(client.trace().pricing_tier(&args.into_query()).await?)
779        }
780    }
781}
782
783async fn execute_agent(client: &InfraClient, command: AgentCommand) -> Result<CommandOutput> {
784    match command {
785        AgentCommand::Create(input) => {
786            let request: CreateAgentRequest = input.read()?;
787            CommandOutput::json(client.agents().create(&request).await?)
788        }
789        AgentCommand::Get { agent_id } => {
790            let agent_id = parse_agent_id(&agent_id)?;
791            CommandOutput::json(client.agents().get(&agent_id).await?)
792        }
793        AgentCommand::List(args) => {
794            let request = ListAgentsRequest {
795                status: args.status.map(Into::into),
796                related_agent_id: args
797                    .related_agent_id
798                    .as_deref()
799                    .map(parse_agent_id)
800                    .transpose()?,
801                related_runtime_id: args.related_runtime_id,
802                related_user_id: args.related_user_id,
803                cursor: args.cursor,
804                limit: args.limit,
805            };
806            CommandOutput::json(client.agents().list(&request).await?)
807        }
808        AgentCommand::Update { agent_id, input } => {
809            let agent_id = parse_agent_id(&agent_id)?;
810            let request: UpdateAgentRequest = input.read()?;
811            CommandOutput::json(client.agents().update(&agent_id, &request).await?)
812        }
813    }
814}
815
816async fn execute_runtime(client: &InfraClient, command: RuntimeCommand) -> Result<CommandOutput> {
817    match command {
818        RuntimeCommand::Create(args) => {
819            let request = match args.file {
820                Some(file) => JsonInput { file }.read::<CreateRuntimeIdentityRequest>()?,
821                None => CreateRuntimeIdentityRequest {
822                    id: None,
823                    name: args
824                        .name
825                        .ok_or_else(|| anyhow!("--name is required for inline runtime creation"))?,
826                    kind: args.kind.unwrap_or(CliRuntimeKind::Code).into(),
827                    agent_id: None,
828                    relations: Default::default(),
829                    metadata: Default::default(),
830                    idempotency_key: args.idempotency_key.unwrap_or_default(),
831                },
832            };
833            CommandOutput::json(client.runtime_identity().create(&request).await?)
834        }
835        RuntimeCommand::Get { runtime_id } => {
836            let runtime_id = parse_runtime_id(&runtime_id)?;
837            CommandOutput::json(client.runtime_identity().get(&runtime_id).await?)
838        }
839        RuntimeCommand::Rename {
840            runtime_id,
841            name,
842            expected_revision,
843            idempotency_key,
844        } => {
845            let runtime_id = parse_runtime_id(&runtime_id)?;
846            let request = UpdateRuntimeIdentityRequest {
847                expected_revision,
848                name,
849                idempotency_key: idempotency_key.unwrap_or_default(),
850            };
851            CommandOutput::json(
852                client
853                    .runtime_identity()
854                    .update(&runtime_id, &request)
855                    .await?,
856            )
857        }
858        RuntimeCommand::Activate { runtime_id, input } => {
859            execute_runtime_transition(client, runtime_id, "activate", input).await
860        }
861        RuntimeCommand::Suspend { runtime_id, input } => {
862            execute_runtime_transition(client, runtime_id, "suspend", input).await
863        }
864        RuntimeCommand::Resume { runtime_id, input } => {
865            execute_runtime_transition(client, runtime_id, "resume", input).await
866        }
867        RuntimeCommand::Revoke { runtime_id, input } => {
868            execute_runtime_transition(client, runtime_id, "revoke", input).await
869        }
870        RuntimeCommand::Permissions { runtime_id, input } => {
871            let runtime_id = parse_runtime_id(&runtime_id)?;
872            let request: ReplacePermissionsRequest = input.read()?;
873            CommandOutput::json(
874                client
875                    .runtime_identity()
876                    .replace_permissions(&runtime_id, &request)
877                    .await?,
878            )
879        }
880        RuntimeCommand::BootstrapCredential { input } => {
881            let request: IssueRuntimeCredentialRequest = input.read()?;
882            CommandOutput::json(
883                client
884                    .gateway()
885                    .bootstrap_runtime_credential(&request)
886                    .await?,
887            )
888        }
889    }
890}
891
892async fn execute_runtime_transition(
893    client: &InfraClient,
894    runtime_id: String,
895    action: &str,
896    input: JsonInput,
897) -> Result<CommandOutput> {
898    let runtime_id = parse_runtime_id(&runtime_id)?;
899    let request: TransitionIdentityRequest = input.read()?;
900    CommandOutput::json(
901        client
902            .runtime_identity()
903            .transition(&runtime_id, action, &request)
904            .await?,
905    )
906}
907
908async fn execute_context(client: &InfraClient, command: ContextCommand) -> Result<CommandOutput> {
909    match command {
910        ContextCommand::Recent {
911            conversation_id,
912            limit,
913            role,
914        } => {
915            let roles = role.iter().map(String::as_str).collect::<Vec<_>>();
916            CommandOutput::json(
917                client
918                    .context()
919                    .recent_messages(&conversation_id, limit.map(|value| value as usize), &roles)
920                    .await?,
921            )
922        }
923        ContextCommand::Search {
924            conversation_id,
925            query,
926            role,
927            limit,
928        } => CommandOutput::json(
929            client
930                .context()
931                .search_messages(
932                    &conversation_id,
933                    &query,
934                    role.as_deref(),
935                    limit.map(|value| value as usize),
936                )
937                .await?,
938        ),
939        ContextCommand::Append(input) => {
940            let messages: Vec<ConversationRecord> = input.read()?;
941            if messages.is_empty() {
942                bail!("append input must contain at least one message");
943            }
944            CommandOutput::json(client.context().append_messages(messages).await?)
945        }
946        ContextCommand::Maintenance => {
947            CommandOutput::json(client.context().maintenance_status().await?)
948        }
949    }
950}
951
952async fn execute_computer(client: &InfraClient, command: ComputerCommand) -> Result<CommandOutput> {
953    let environment = client.environment();
954    match command {
955        ComputerCommand::Create {
956            input,
957            idempotency_key,
958        } => {
959            let request: CreateComputerRequest = input.read()?;
960            CommandOutput::json(
961                environment
962                    .create_computer(&request, &idempotency_key)
963                    .await?,
964            )
965        }
966        ComputerCommand::Get { computer_id } => {
967            CommandOutput::json(environment.computer(&computer_id).await?)
968        }
969        ComputerCommand::List {
970            owner_kind,
971            owner_id,
972        } => CommandOutput::json(
973            environment
974                .computers(Some(&list_environment_query(owner_kind, owner_id)?))
975                .await?,
976        ),
977        ComputerCommand::Delete {
978            computer_id,
979            idempotency_key,
980        } => CommandOutput::json(
981            environment
982                .delete_computer(&computer_id, &idempotency_key)
983                .await?,
984        ),
985    }
986}
987
988async fn execute_space(client: &InfraClient, command: SpaceCommand) -> Result<CommandOutput> {
989    let environment = client.environment();
990    match command {
991        SpaceCommand::Create {
992            input,
993            idempotency_key,
994        } => {
995            let request: CreateSpaceRequest = input.read()?;
996            CommandOutput::json(environment.create_space(&request, &idempotency_key).await?)
997        }
998        SpaceCommand::Get { space_id } => CommandOutput::json(environment.space(&space_id).await?),
999        SpaceCommand::List {
1000            owner_kind,
1001            owner_id,
1002        } => CommandOutput::json(
1003            environment
1004                .spaces(Some(&list_environment_query(owner_kind, owner_id)?))
1005                .await?,
1006        ),
1007        SpaceCommand::Delete {
1008            space_id,
1009            idempotency_key,
1010        } => CommandOutput::json(
1011            environment
1012                .delete_space(&space_id, &idempotency_key)
1013                .await?,
1014        ),
1015    }
1016}
1017
1018fn list_environment_query(
1019    owner_kind: Option<String>,
1020    owner_id: Option<String>,
1021) -> Result<ListEnvironmentResourcesQuery> {
1022    let owner_kind = match owner_kind {
1023        Some(value) => Some(parse_owner_kind(&value)?),
1024        None => None,
1025    };
1026    if owner_kind.is_some() != owner_id.is_some() {
1027        bail!("--owner-kind and --owner-id must be provided together");
1028    }
1029    Ok(ListEnvironmentResourcesQuery {
1030        owner_kind,
1031        owner_id,
1032        contains_all: vec![],
1033    })
1034}
1035
1036fn parse_owner_kind(value: &str) -> Result<OwnerKind> {
1037    match value.to_ascii_lowercase().as_str() {
1038        "user" => Ok(OwnerKind::User),
1039        "agent" => Ok(OwnerKind::Agent),
1040        "runtime" => Ok(OwnerKind::Runtime),
1041        other => bail!("unsupported owner kind {other:?}; expected user, agent, or runtime"),
1042    }
1043}
1044
1045async fn execute_model(client: &InfraClient, command: ModelCommand) -> Result<CommandOutput> {
1046    match command {
1047        ModelCommand::Models => CommandOutput::json(client.model().models().await?),
1048        ModelCommand::Chat { input, request_id } => {
1049            let request: Value = input.read()?;
1050            ensure_json_object(&request)?;
1051            CommandOutput::json(
1052                client
1053                    .model()
1054                    .chat_completions(&request, call_options(request_id))
1055                    .await?,
1056            )
1057        }
1058        ModelCommand::Responses { input, request_id } => {
1059            let request: Value = input.read()?;
1060            ensure_json_object(&request)?;
1061            CommandOutput::json(
1062                client
1063                    .model()
1064                    .responses(&request, call_options(request_id))
1065                    .await?,
1066            )
1067        }
1068    }
1069}
1070
1071async fn execute_evaluate(client: &InfraClient, command: EvaluateCommand) -> Result<CommandOutput> {
1072    let evaluate = client.evaluate();
1073    match command {
1074        EvaluateCommand::RegisterEnvironment(input) => {
1075            let request: RegisterEnvironmentProfileRequest = input.read()?;
1076            CommandOutput::json(evaluate.register_environment_profile(&request).await?)
1077        }
1078        EvaluateCommand::RegisterEvaluator(input) => {
1079            let request: RegisterEvaluatorVersionRequest = input.read()?;
1080            CommandOutput::json(evaluate.register_evaluator_version(&request).await?)
1081        }
1082        EvaluateCommand::RegisterSuite(input) => {
1083            let request: RegisterSuiteRequest = input.read()?;
1084            CommandOutput::json(evaluate.register_suite(&request).await?)
1085        }
1086        EvaluateCommand::TaskCreate(input) => {
1087            let request: CreateEvalRunRequest = input.read()?;
1088            let handle = evaluate.create_task(&request).await?;
1089            let operation = handle.refresh().await?;
1090            CommandOutput::json(serde_json::json!({
1091                "evaluateTaskId": operation.id,
1092                "operation": operation,
1093            }))
1094        }
1095        EvaluateCommand::Operation { operation_id } => {
1096            let request = GetOperationRequest { operation_id };
1097            let handle = evaluate.operation(request.operation_id).await?;
1098            CommandOutput::json(handle.refresh().await?)
1099        }
1100        EvaluateCommand::Cases(input) => {
1101            let request: ListCaseRunsRequest = input.read()?;
1102            CommandOutput::json(evaluate.list_case_runs(&request).await?)
1103        }
1104        EvaluateCommand::Report { report_id } => {
1105            let request = GetReportRequest { report_id };
1106            CommandOutput::json(evaluate.report(&request.report_id).await?)
1107        }
1108    }
1109}
1110
1111fn call_options(request_id: Option<String>) -> CallOptions {
1112    request_id.map_or_else(CallOptions::default, |value| {
1113        CallOptions::default().request_id(value)
1114    })
1115}
1116
1117fn ensure_json_object(value: &Value) -> Result<()> {
1118    if !value.is_object() {
1119        bail!("model request must be a JSON object");
1120    }
1121    Ok(())
1122}
1123
1124fn parse_agent_id(value: &str) -> Result<AgentId> {
1125    AgentId::new(value).map_err(|message| anyhow!(message))
1126}
1127
1128fn parse_runtime_id(value: &str) -> Result<RuntimeIdentityId> {
1129    RuntimeIdentityId::new(value).map_err(|message| anyhow!(message))
1130}
1131
1132fn read_input(path: &Path) -> Result<Vec<u8>> {
1133    if path == Path::new("-") {
1134        let mut bytes = Vec::new();
1135        io::stdin()
1136            .read_to_end(&mut bytes)
1137            .context("failed to read standard input")?;
1138        Ok(bytes)
1139    } else {
1140        fs::read(path).with_context(|| format!("failed to read {}", path.display()))
1141    }
1142}
1143
1144fn display_source(path: &Path) -> String {
1145    if path == Path::new("-") {
1146        "standard input".into()
1147    } else {
1148        path.display().to_string()
1149    }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155    use clap::CommandFactory;
1156    use wiremock::{
1157        Mock, MockServer, ResponseTemplate,
1158        matchers::{body_json, header, method, path},
1159    };
1160
1161    #[test]
1162    fn clap_definition_is_valid() {
1163        Cli::command().debug_assert();
1164    }
1165
1166    #[test]
1167    fn parses_global_options_after_subcommands() {
1168        let cli = Cli::try_parse_from([
1169            "infra",
1170            "gateway",
1171            "wait-ready",
1172            "--deadline",
1173            "42",
1174            "--gateway",
1175            "https://infra.example.com",
1176            "--output",
1177            "json",
1178        ])
1179        .unwrap();
1180        assert_eq!(cli.gateway.as_deref(), Some("https://infra.example.com"));
1181        assert_eq!(cli.output, OutputFormat::Json);
1182        assert!(matches!(
1183            cli.command,
1184            Command::Gateway {
1185                command: GatewayCommand::WaitReady { deadline: 42 }
1186            }
1187        ));
1188    }
1189
1190    #[test]
1191    fn rejects_removed_workspace_subcommand() {
1192        let result = Cli::try_parse_from(["infra", "workspace", "write", "README.md"]);
1193        assert!(result.is_err());
1194    }
1195
1196    #[test]
1197    fn parses_trace_metrics_query_options() {
1198        let cli = Cli::try_parse_from([
1199            "infra",
1200            "trace",
1201            "metrics",
1202            "token-usage",
1203            "--project-id",
1204            "project-a",
1205            "--project-id",
1206            "project-b",
1207            "--days",
1208            "7",
1209            "--data-source",
1210            "v4",
1211        ])
1212        .unwrap();
1213        let Command::Trace {
1214            command:
1215                TraceCommand::Metrics {
1216                    command: TraceMetricsCommand::TokenUsage(args),
1217                },
1218        } = cli.command
1219        else {
1220            panic!("expected trace token-usage command");
1221        };
1222        let query = args.into_query();
1223        assert_eq!(query.project_ids.unwrap(), ["project-a", "project-b"]);
1224        assert_eq!(query.days, 7);
1225        assert_eq!(query.data_source, MetricsDataSource::V4);
1226    }
1227
1228    #[test]
1229    fn parses_langfuse_project_relationship_query() {
1230        let cli = Cli::try_parse_from([
1231            "infra",
1232            "trace",
1233            "langfuse-project",
1234            "list",
1235            "--related-agent-id",
1236            "agt_weather",
1237            "--limit",
1238            "20",
1239        ])
1240        .unwrap();
1241        assert!(matches!(
1242            cli.command,
1243            Command::Trace {
1244                command: TraceCommand::LangfuseProject {
1245                    command: LangfuseProjectCommand::List(LangfuseProjectListArgs {
1246                        related_agent_id: Some(ref agent_id),
1247                        limit: Some(20),
1248                        ..
1249                    })
1250                }
1251            } if agent_id == "agt_weather"
1252        ));
1253    }
1254
1255    #[test]
1256    fn parses_evaluate_resource_and_task_commands() {
1257        let register = Cli::try_parse_from([
1258            "infra",
1259            "evaluate",
1260            "register-evaluator",
1261            "--file",
1262            "evaluator.json",
1263        ])
1264        .unwrap();
1265        assert!(matches!(
1266            register.command,
1267            Command::Evaluate {
1268                command: EvaluateCommand::RegisterEvaluator(_)
1269            }
1270        ));
1271        let task = Cli::try_parse_from(["infra", "evaluate", "task-create", "--file", "run.json"])
1272            .unwrap();
1273        assert!(matches!(
1274            task.command,
1275            Command::Evaluate {
1276                command: EvaluateCommand::TaskCreate(_)
1277            }
1278        ));
1279        let alias =
1280            Cli::try_parse_from(["infra", "evaluate", "run", "--file", "run.json"]).unwrap();
1281        assert!(matches!(
1282            alias.command,
1283            Command::Evaluate {
1284                command: EvaluateCommand::TaskCreate(_)
1285            }
1286        ));
1287    }
1288
1289    #[test]
1290    fn reads_json_request_from_file() {
1291        let directory = tempfile::tempdir().unwrap();
1292        let path = directory.path().join("request.json");
1293        fs::write(&path, br#"{"model":"test"}"#).unwrap();
1294        let input = JsonInput { file: path };
1295        let value: Value = input.read().unwrap();
1296        assert_eq!(value["model"], "test");
1297    }
1298
1299    #[tokio::test]
1300    async fn persists_and_resolves_gateway_without_network_access() {
1301        let directory = tempfile::tempdir().unwrap();
1302        let config = directory.path().join("infra.json");
1303        let cli = Cli::try_parse_from([
1304            "infra",
1305            "config",
1306            "gateway",
1307            "set",
1308            "https://infra.example.com/",
1309            "--config",
1310            config.to_str().unwrap(),
1311        ])
1312        .unwrap();
1313        let output = cli.execute().await.unwrap();
1314        let CommandOutput::Json(value) = output else {
1315            panic!("config set must produce JSON");
1316        };
1317        assert_eq!(value["gateway"], "https://infra.example.com");
1318
1319        let store = ConfigStore::discover(Some(&config)).unwrap();
1320        assert_eq!(
1321            store.load().unwrap().gateway.as_deref(),
1322            Some("https://infra.example.com")
1323        );
1324    }
1325
1326    #[tokio::test]
1327    async fn trace_metrics_command_calls_typed_sdk() {
1328        let server = MockServer::start().await;
1329        Mock::given(method("POST"))
1330            .and(path(
1331                agent_infra_sdk::trace_contract::METRICS_PROJECT_SUMMARY_PATH,
1332            ))
1333            .and(body_json(serde_json::json!({
1334                "projectIds": ["project-a"],
1335                "days": 14,
1336                "dataSource": "auto",
1337                "v4CutoverDate": "2026-09-01 00:00:00"
1338            })))
1339            .respond_with(
1340                ResponseTemplate::new(200).set_body_json(serde_json::json!([{
1341                    "projectId": "project-a",
1342                    "totalTokens": 15,
1343                    "totalInputTokens": 10,
1344                    "totalOutputTokens": 5,
1345                    "observationCount": 2,
1346                    "generationCount": 1,
1347                    "totalCost": 0.01,
1348                    "uniqueModels": 1,
1349                    "uniqueTraces": 1
1350                }])),
1351            )
1352            .expect(1)
1353            .mount(&server)
1354            .await;
1355
1356        let server_uri = server.uri();
1357        let cli = Cli::try_parse_from([
1358            "infra",
1359            "--gateway",
1360            server_uri.as_str(),
1361            "trace",
1362            "metrics",
1363            "project-summary",
1364            "--project-id",
1365            "project-a",
1366            "--days",
1367            "14",
1368        ])
1369        .unwrap();
1370        let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
1371            panic!("trace metrics must produce JSON");
1372        };
1373        assert_eq!(output[0]["totalTokens"], 15);
1374    }
1375
1376    #[tokio::test]
1377    async fn auth_status_detects_encrypted_sky_login_without_exposing_it() {
1378        let directory = tempfile::tempdir().unwrap();
1379        let sky_home = directory.path().join("sky");
1380        fs::create_dir_all(&sky_home).unwrap();
1381        fs::write(sky_home.join("credentials"), "ENCv1:secret-ciphertext").unwrap();
1382        let config = directory.path().join("infra.json");
1383        let cli = Cli::try_parse_from([
1384            "infra",
1385            "auth",
1386            "status",
1387            "--config",
1388            config.to_str().unwrap(),
1389            "--sky-home",
1390            sky_home.to_str().unwrap(),
1391        ])
1392        .unwrap();
1393
1394        let output = cli.execute().await.unwrap();
1395        let rendered = output.render(OutputFormat::Json).unwrap();
1396        assert!(rendered.contains("sky_login_encrypted"));
1397        assert!(!rendered.contains("secret-ciphertext"));
1398    }
1399
1400    #[test]
1401    fn renders_compact_and_pretty_json() {
1402        let output = CommandOutput::Json(serde_json::json!({ "ok": true }));
1403        assert_eq!(output.render(OutputFormat::Json).unwrap(), r#"{"ok":true}"#);
1404        assert!(output.render(OutputFormat::Pretty).unwrap().contains('\n'));
1405    }
1406
1407    #[tokio::test]
1408    async fn model_command_uses_sdk_route_and_bearer_credential() {
1409        let server = MockServer::start().await;
1410        Mock::given(method("GET"))
1411            .and(path("/v1/model/models"))
1412            .and(header("authorization", "Bearer test-token"))
1413            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1414                "object": "list",
1415                "data": [{ "id": "test-model" }]
1416            })))
1417            .expect(1)
1418            .mount(&server)
1419            .await;
1420
1421        let cli = Cli::try_parse_from([
1422            "infra",
1423            "model",
1424            "models",
1425            "--gateway",
1426            &server.uri(),
1427            "--token",
1428            "test-token",
1429        ])
1430        .unwrap();
1431        let output = cli.execute().await.unwrap();
1432        let CommandOutput::Json(value) = output else {
1433            panic!("model list must produce JSON");
1434        };
1435        assert_eq!(value["data"][0]["id"], "test-model");
1436    }
1437
1438    #[tokio::test]
1439    async fn runtime_commands_create_rename_and_revoke_through_the_typed_sdk() {
1440        let server = MockServer::start().await;
1441        let directory = tempfile::tempdir().unwrap();
1442        let transition_request = directory.path().join("transition-runtime.json");
1443        fs::write(
1444            &transition_request,
1445            br#"{"expectedRevision":2,"idempotencyKey":"revoke-runtime-test","reason":"test cleanup"}"#,
1446        )
1447        .unwrap();
1448
1449        Mock::given(method("POST"))
1450            .and(path("/internal/v1/runtime-identities"))
1451            .and(header("authorization", "Bearer test-token"))
1452            .and(body_json(serde_json::json!({
1453                "name": "code-dev",
1454                "kind": "code",
1455                "relations": {},
1456                "metadata": {},
1457                "idempotencyKey": ""
1458            })))
1459            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
1460                "identity": runtime_identity_json("code-dev", "pending", 1),
1461                "bootstrapToken": "rti_boot_secret"
1462            })))
1463            .expect(1)
1464            .mount(&server)
1465            .await;
1466        Mock::given(method("PATCH"))
1467            .and(path("/internal/v1/runtime-identities/rti_test"))
1468            .and(header("authorization", "Bearer test-token"))
1469            .and(body_json(serde_json::json!({
1470                "expectedRevision": 1,
1471                "name": "code-dev-renamed",
1472                "idempotencyKey": ""
1473            })))
1474            .respond_with(
1475                ResponseTemplate::new(200).set_body_json(runtime_identity_json(
1476                    "code-dev-renamed",
1477                    "pending",
1478                    2,
1479                )),
1480            )
1481            .expect(1)
1482            .mount(&server)
1483            .await;
1484        Mock::given(method("POST"))
1485            .and(path("/internal/v1/runtime-identities/rti_test:revoke"))
1486            .and(header("authorization", "Bearer test-token"))
1487            .and(body_json(serde_json::json!({
1488                "expectedRevision": 2,
1489                "idempotencyKey": "revoke-runtime-test",
1490                "reason": "test cleanup"
1491            })))
1492            .respond_with(
1493                ResponseTemplate::new(200).set_body_json(runtime_identity_json(
1494                    "code-dev-renamed",
1495                    "revoked",
1496                    3,
1497                )),
1498            )
1499            .expect(1)
1500            .mount(&server)
1501            .await;
1502
1503        let create = Cli::try_parse_from([
1504            "infra",
1505            "runtime",
1506            "create",
1507            "--name",
1508            "code-dev",
1509            "--gateway",
1510            &server.uri(),
1511            "--token",
1512            "test-token",
1513        ])
1514        .unwrap()
1515        .execute()
1516        .await
1517        .unwrap();
1518        let CommandOutput::Json(created) = create else {
1519            panic!("runtime create must produce JSON");
1520        };
1521        assert_eq!(created["identity"]["id"], "rti_test");
1522        assert_eq!(created["identity"]["name"], "code-dev");
1523        assert_eq!(created["bootstrapToken"], "rti_boot_secret");
1524
1525        let rename = Cli::try_parse_from([
1526            "infra",
1527            "runtime",
1528            "rename",
1529            "rti_test",
1530            "code-dev-renamed",
1531            "--expected-revision",
1532            "1",
1533            "--gateway",
1534            &server.uri(),
1535            "--token",
1536            "test-token",
1537        ])
1538        .unwrap()
1539        .execute()
1540        .await
1541        .unwrap();
1542        let CommandOutput::Json(renamed) = rename else {
1543            panic!("runtime rename must produce JSON");
1544        };
1545        assert_eq!(renamed["id"], "rti_test");
1546        assert_eq!(renamed["name"], "code-dev-renamed");
1547        assert_eq!(renamed["revision"], 2);
1548
1549        let revoke = Cli::try_parse_from([
1550            "infra",
1551            "runtime",
1552            "revoke",
1553            "rti_test",
1554            "--file",
1555            transition_request.to_str().unwrap(),
1556            "--gateway",
1557            &server.uri(),
1558            "--token",
1559            "test-token",
1560        ])
1561        .unwrap()
1562        .execute()
1563        .await
1564        .unwrap();
1565        let CommandOutput::Json(revoked) = revoke else {
1566            panic!("runtime revoke must produce JSON");
1567        };
1568        assert_eq!(revoked["status"], "revoked");
1569        assert_eq!(revoked["revision"], 3);
1570    }
1571
1572    #[tokio::test]
1573    async fn runtime_commands_replace_permissions_and_bootstrap_credential() {
1574        let server = MockServer::start().await;
1575        let directory = tempfile::tempdir().unwrap();
1576        let permissions_request = directory.path().join("permissions.json");
1577        fs::write(
1578            &permissions_request,
1579            br#"{"expectedRevision":0,"idempotencyKey":"grant-runtime-test","grants":[{"audience":"agent-infra","capabilities":["gateway:delegation:manage"]}]}"#,
1580        )
1581        .unwrap();
1582        let credential_request = directory.path().join("credential.json");
1583        fs::write(
1584            &credential_request,
1585            br#"{"workloadProof":"bootstrap-proof","audience":"agent-infra","capabilities":["gateway:delegation:manage"]}"#,
1586        )
1587        .unwrap();
1588
1589        Mock::given(method("PUT"))
1590            .and(path("/internal/v1/runtime-identities/rti_test/permissions"))
1591            .and(header("authorization", "Bearer test-token"))
1592            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1593                "identityId": "rti_test",
1594                "revision": 1,
1595                "grants": [{
1596                    "id": "grt_test",
1597                    "identityId": "rti_test",
1598                    "audience": "agent-infra",
1599                    "capabilities": ["gateway:delegation:manage"],
1600                    "createdAt": "2026-08-13T00:00:00Z"
1601                }]
1602            })))
1603            .expect(1)
1604            .mount(&server)
1605            .await;
1606        Mock::given(method("POST"))
1607            .and(path(
1608                agent_infra_sdk::gateway_contract::RUNTIME_CREDENTIAL_BOOTSTRAP_PATH,
1609            ))
1610            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1611                "accessToken": "runtime-token",
1612                "tokenType": "Bearer",
1613                "expiresInSeconds": 120,
1614                "keyId": "runtime-key"
1615            })))
1616            .expect(1)
1617            .mount(&server)
1618            .await;
1619
1620        let permissions = Cli::try_parse_from([
1621            "infra",
1622            "runtime",
1623            "permissions",
1624            "rti_test",
1625            "--file",
1626            permissions_request.to_str().unwrap(),
1627            "--gateway",
1628            &server.uri(),
1629            "--token",
1630            "test-token",
1631        ])
1632        .unwrap()
1633        .execute()
1634        .await
1635        .unwrap();
1636        let CommandOutput::Json(permissions) = permissions else {
1637            panic!("runtime permissions must produce JSON");
1638        };
1639        assert_eq!(permissions["revision"], 1);
1640
1641        let credential = Cli::try_parse_from([
1642            "infra",
1643            "runtime",
1644            "bootstrap-credential",
1645            "--file",
1646            credential_request.to_str().unwrap(),
1647            "--gateway",
1648            &server.uri(),
1649            "--token",
1650            "test-token",
1651        ])
1652        .unwrap()
1653        .execute()
1654        .await
1655        .unwrap();
1656        let CommandOutput::Json(credential) = credential else {
1657            panic!("runtime bootstrap credential must produce JSON");
1658        };
1659        assert_eq!(credential["accessToken"], "runtime-token");
1660        let requests = server.received_requests().await.unwrap();
1661        let bootstrap = requests
1662            .iter()
1663            .find(|request| {
1664                request.url.path()
1665                    == agent_infra_sdk::gateway_contract::RUNTIME_CREDENTIAL_BOOTSTRAP_PATH
1666            })
1667            .unwrap();
1668        assert!(!bootstrap.headers.contains_key("authorization"));
1669    }
1670
1671    fn runtime_identity_json(name: &str, status: &str, revision: u64) -> Value {
1672        serde_json::json!({
1673            "id": "rti_test",
1674            "name": name,
1675            "scope": { "tenantId": "tenant-test", "projectId": "project-test" },
1676            "kind": "code",
1677            "relations": {
1678                "runtimeIds": ["rti_test"],
1679                "userIds": ["user-test"]
1680            },
1681            "status": status,
1682            "revision": revision,
1683            "grantRevision": 0,
1684            "credentialGeneration": if status == "revoked" { 2 } else { 1 },
1685            "metadata": { "source": "cli-test" },
1686            "createdAt": "2026-08-13T00:00:00Z",
1687            "updatedAt": "2026-08-13T00:00:00Z"
1688        })
1689    }
1690
1691    #[tokio::test]
1692    async fn computer_list_uses_environment_api() {
1693        let server = MockServer::start().await;
1694        Mock::given(method("GET"))
1695            .and(path("/internal/v1/computers"))
1696            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1697            .expect(1)
1698            .mount(&server)
1699            .await;
1700        let cli =
1701            Cli::try_parse_from(["infra", "computer", "list", "--gateway", &server.uri()]).unwrap();
1702
1703        let output = cli.execute().await.unwrap();
1704        let CommandOutput::Json(value) = output else {
1705            panic!("computer list must produce JSON");
1706        };
1707        assert_eq!(value, serde_json::json!([]));
1708    }
1709}