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, Instant},
7};
8
9use agent_infra_sdk::{
10    CallOptions, InfraClient, StaticCredentials,
11    agent_contract::{
12        AgentId, AgentStatus, CreateAgentRequest, ListAgentsRequest, ResourceRelations,
13        UpdateAgentRequest,
14    },
15    context_contract::ConversationRecord,
16    runtime_identity_contract::{
17        CreateRuntimeIdentityRequest, IssueRuntimeCredentialRequest, ReplacePermissionsRequest,
18        RuntimeIdentityId, RuntimeIdentityKind, TransitionIdentityRequest,
19        UpdateRuntimeIdentityRequest,
20    },
21    workspace_contract::{
22        CreateComputerRequest, CreateSnapshotResourceRequest, CreateSpaceRequest,
23        ListEnvironmentResourcesQuery, OwnerKind, SnapshotVisibility,
24    },
25};
26use anyhow::{Context, Result, anyhow, bail};
27use clap::{Args, Parser, Subcommand, ValueEnum};
28use serde::{Serialize, de::DeserializeOwned};
29use serde_json::Value;
30
31mod config;
32mod credential;
33mod evaluate_bundle;
34mod evaluate_cli;
35mod init;
36mod runtime_repo_checkout;
37mod trace;
38
39use config::{ConfigStore, resolve_credential};
40use evaluate_cli::execute_evaluate;
41pub use evaluate_cli::{EvaluateCommand, EvaluateMetricsCommand, EvaluateReviewCommand};
42use trace::TraceCommand;
43
44const DEFAULT_GATEWAY: &str = "http://127.0.0.1:5200";
45
46#[derive(Debug, Parser)]
47#[command(
48    name = "infra",
49    version,
50    about = "Operate Agent Infra through the typed Infra SDK",
51    arg_required_else_help = true
52)]
53pub struct Cli {
54    /// Agent Infra Gateway base URL.
55    #[arg(long, global = true)]
56    pub gateway: Option<String>,
57
58    /// Gateway bearer token. Prefer SKY_GATEWAY_TOKEN over command-line use.
59    #[arg(long, global = true, hide_env_values = true)]
60    pub token: Option<String>,
61
62    /// Override the local Infra CLI config file.
63    #[arg(long, global = true, env = "AGENT_INFRA_CONFIG", value_name = "PATH")]
64    pub config: Option<PathBuf>,
65
66    /// Override the Sky CLI home used for local credential discovery.
67    #[arg(
68        long,
69        global = true,
70        env = "AGENT_INFRA_SKY_HOME",
71        value_name = "PATH",
72        hide = true
73    )]
74    pub sky_home: Option<PathBuf>,
75
76    /// Request timeout in seconds.
77    #[arg(long, global = true, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=600))]
78    pub timeout: u64,
79
80    /// Permit plaintext HTTP to non-loopback hosts protected by a trusted service mesh.
81    #[arg(long, global = true)]
82    pub trusted_mesh_http: bool,
83
84    /// JSON rendering mode.
85    #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Pretty)]
86    pub output: OutputFormat,
87
88    #[command(subcommand)]
89    pub command: Command,
90}
91
92#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
93pub enum OutputFormat {
94    /// Indented JSON.
95    #[default]
96    Pretty,
97    /// Compact JSON.
98    Json,
99}
100
101#[derive(Debug, Subcommand)]
102pub enum Command {
103    /// Persist and inspect local CLI settings.
104    Config {
105        #[command(subcommand)]
106        command: ConfigCommand,
107    },
108    /// Inspect local Gateway credential discovery.
109    Auth {
110        #[command(subcommand)]
111        command: AuthCommand,
112    },
113    /// Create a runtime, agent, repo, trace binding, context, and default model.
114    Init {
115        /// Shared name used for runtime, agent, repo, conversation, and message ids.
116        #[arg(long)]
117        name: String,
118        /// Enabled Runtime template. Required when more than one template is enabled.
119        #[arg(long)]
120        template_id: Option<String>,
121        /// Local checkout directory. Defaults to the repository name under the current directory.
122        #[arg(long)]
123        dir: Option<PathBuf>,
124        /// Skip git clone and only write .env.local.
125        #[arg(long)]
126        no_clone: bool,
127        /// Email used when allocating the Langfuse project.
128        #[arg(long)]
129        owner_email: Option<String>,
130    },
131    /// Inspect Gateway health, discovery, routing, and policy.
132    Gateway {
133        #[command(subcommand)]
134        command: GatewayCommand,
135    },
136    /// Query trace events and Langfuse-backed usage metrics.
137    Trace {
138        #[command(subcommand)]
139        command: TraceCommand,
140    },
141    /// Create and inspect Agent Registry resources.
142    Agent {
143        #[command(subcommand)]
144        command: AgentCommand,
145    },
146    /// Create and manage Runtime Identity resources.
147    Runtime {
148        #[command(subcommand)]
149        command: RuntimeCommand,
150    },
151    /// Read and append durable conversation context.
152    Context {
153        #[command(subcommand)]
154        command: ContextCommand,
155    },
156    /// Manage Environment computers.
157    Computer {
158        #[command(subcommand)]
159        command: ComputerCommand,
160    },
161    /// Manage Environment spaces.
162    Space {
163        #[command(subcommand)]
164        command: SpaceCommand,
165    },
166    /// Manage Environment snapshots.
167    Snapshot {
168        #[command(subcommand)]
169        command: SnapshotCommand,
170    },
171    /// Invoke the OpenAI-compatible Model Infra API.
172    Model {
173        #[command(subcommand)]
174        command: ModelCommand,
175    },
176    /// Manage versioned evaluation resources and durable runs.
177    Evaluate {
178        #[command(subcommand)]
179        command: EvaluateCommand,
180    },
181    /// List enabled Runtime templates and bound Runtime repositories.
182    Deploy {
183        #[command(subcommand)]
184        command: DeployCommand,
185    },
186}
187
188#[derive(Debug, Subcommand)]
189pub enum DeployCommand {
190    /// List enabled Runtime templates from the shared catalog.
191    Template {
192        #[command(subcommand)]
193        command: DeployTemplateCommand,
194    },
195    /// Initialize and inspect Runtime repositories bound to the current identity.
196    Repo {
197        #[command(subcommand)]
198        command: DeployRepoCommand,
199    },
200}
201
202#[derive(Debug, Subcommand)]
203pub enum DeployTemplateCommand {
204    List,
205}
206
207#[derive(Debug, Subcommand)]
208pub enum DeployRepoCommand {
209    /// Create a Runtime repository from a CreateRuntimeRepositoryRequest JSON document.
210    Init {
211        #[command(flatten)]
212        input: JsonInput,
213        #[arg(long)]
214        idempotency_key: String,
215        /// Local checkout directory. Defaults to the repository name under the current directory.
216        #[arg(long)]
217        dir: Option<PathBuf>,
218        /// Skip git clone and only write .env.local.
219        #[arg(long)]
220        no_clone: bool,
221        /// Agent id to bind and write into .env.local.
222        #[arg(long)]
223        agent_id: Option<String>,
224        /// Runtime id to bind and write into .env.local.
225        #[arg(long)]
226        runtime_id: Option<String>,
227    },
228    List {
229        #[arg(long)]
230        owner_kind: Option<String>,
231        #[arg(long)]
232        owner_id: Option<String>,
233    },
234    Get {
235        repository_id: String,
236    },
237}
238
239#[derive(Debug, Subcommand)]
240pub enum ConfigCommand {
241    /// Show the config file and effective Gateway selection.
242    Show,
243    /// Manage the persisted Gateway address.
244    Gateway {
245        #[command(subcommand)]
246        command: ConfigGatewayCommand,
247    },
248}
249
250#[derive(Debug, Subcommand)]
251pub enum ConfigGatewayCommand {
252    /// Persist a Gateway URL locally.
253    Set { url: String },
254    /// Show the persisted and effective Gateway URL.
255    Get,
256    /// Remove the persisted Gateway URL.
257    Unset,
258}
259
260#[derive(Debug, Subcommand)]
261pub enum AuthCommand {
262    /// Probe explicit, environment, and local Sky credential sources.
263    Status,
264}
265
266#[derive(Debug, Subcommand)]
267pub enum GatewayCommand {
268    Discovery,
269    Live,
270    Ready,
271    /// Poll until the Gateway and its dependencies are ready.
272    WaitReady {
273        #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=3600))]
274        deadline: u64,
275    },
276    Dependencies,
277    Routes,
278    Metrics,
279    Policy,
280}
281
282#[derive(Debug, Subcommand)]
283pub enum AgentCommand {
284    /// Create a named agent bound to a Runtime ID.
285    Create(AgentCreateArgs),
286    Get {
287        agent_id: String,
288    },
289    List(AgentListArgs),
290    /// Update an agent from an UpdateAgentRequest JSON document.
291    Update {
292        agent_id: String,
293        #[command(flatten)]
294        input: JsonInput,
295    },
296}
297
298#[derive(Debug, Args)]
299pub struct AgentCreateArgs {
300    /// Mutable display name. Required unless --file supplies the full request.
301    #[arg(long, required_unless_present = "file")]
302    name: Option<String>,
303    /// Runtime identity the agent must reference. Required unless --file supplies the full request.
304    #[arg(long, required_unless_present = "file")]
305    runtime_id: Option<String>,
306    /// Optional description for inline creation.
307    #[arg(long)]
308    description: Option<String>,
309    /// Override the deterministic key derived from --name.
310    #[arg(long)]
311    idempotency_key: Option<String>,
312    /// Read a complete CreateAgentRequest JSON document.
313    #[arg(
314        long,
315        short = 'f',
316        value_name = "PATH",
317        conflicts_with_all = ["name", "runtime_id", "description", "idempotency_key"]
318    )]
319    file: Option<PathBuf>,
320}
321
322#[derive(Debug, Subcommand)]
323pub enum RuntimeCommand {
324    /// Create a named runtime identity.
325    Create(RuntimeCreateArgs),
326    /// Get a runtime identity by ID.
327    Get { runtime_id: String },
328    /// Change a runtime identity name without changing its ID.
329    Rename {
330        runtime_id: String,
331        name: String,
332        #[arg(long)]
333        expected_revision: u64,
334        #[arg(long)]
335        idempotency_key: Option<String>,
336    },
337    /// Activate a pending runtime identity using a TransitionIdentityRequest JSON document.
338    Activate {
339        runtime_id: String,
340        #[command(flatten)]
341        input: JsonInput,
342    },
343    /// Suspend an active runtime identity using a TransitionIdentityRequest JSON document.
344    Suspend {
345        runtime_id: String,
346        #[command(flatten)]
347        input: JsonInput,
348    },
349    /// Resume a suspended runtime identity using a TransitionIdentityRequest JSON document.
350    Resume {
351        runtime_id: String,
352        #[command(flatten)]
353        input: JsonInput,
354    },
355    /// Revoke a runtime identity using a TransitionIdentityRequest JSON document.
356    Revoke {
357        runtime_id: String,
358        #[command(flatten)]
359        input: JsonInput,
360    },
361    /// Replace the complete permission grant set from a JSON document.
362    Permissions {
363        runtime_id: String,
364        #[command(flatten)]
365        input: JsonInput,
366    },
367    /// Exchange a one-time bootstrap proof for the first workload credential.
368    BootstrapCredential {
369        #[command(flatten)]
370        input: JsonInput,
371    },
372    /// Manage durable Runtime recovery credentials.
373    Credential {
374        #[command(subcommand)]
375        command: credential::RuntimeCredentialCommand,
376    },
377}
378
379#[derive(Debug, Args)]
380pub struct RuntimeCreateArgs {
381    /// Mutable display name. Required unless --file supplies the full request.
382    #[arg(long, required_unless_present = "file")]
383    name: Option<String>,
384    /// Runtime workload kind. Defaults to code for inline creation.
385    #[arg(long, value_enum)]
386    kind: Option<CliRuntimeKind>,
387    /// Override the deterministic key derived from --name.
388    #[arg(long)]
389    idempotency_key: Option<String>,
390    /// Read a complete CreateRuntimeIdentityRequest JSON document.
391    #[arg(
392        long,
393        short = 'f',
394        value_name = "PATH",
395        conflicts_with_all = ["name", "kind", "idempotency_key"]
396    )]
397    file: Option<PathBuf>,
398}
399
400#[derive(Clone, Copy, Debug, ValueEnum)]
401enum CliRuntimeKind {
402    Code,
403    Evaluate,
404    Tool,
405    Custom,
406}
407
408impl From<CliRuntimeKind> for RuntimeIdentityKind {
409    fn from(value: CliRuntimeKind) -> Self {
410        match value {
411            CliRuntimeKind::Code => Self::Code,
412            CliRuntimeKind::Evaluate => Self::Evaluate,
413            CliRuntimeKind::Tool => Self::Tool,
414            CliRuntimeKind::Custom => Self::Custom,
415        }
416    }
417}
418
419#[derive(Debug, Args)]
420pub struct AgentListArgs {
421    #[arg(long, value_enum)]
422    status: Option<CliAgentStatus>,
423    #[arg(long)]
424    related_agent_id: Option<String>,
425    #[arg(long)]
426    related_runtime_id: Option<String>,
427    #[arg(long)]
428    related_user_id: Option<String>,
429    #[arg(long)]
430    cursor: Option<String>,
431    #[arg(long, value_parser = clap::value_parser!(u32).range(1..=200))]
432    limit: Option<u32>,
433}
434
435#[derive(Clone, Copy, Debug, ValueEnum)]
436enum CliAgentStatus {
437    Active,
438    Archived,
439}
440
441impl From<CliAgentStatus> for AgentStatus {
442    fn from(value: CliAgentStatus) -> Self {
443        match value {
444            CliAgentStatus::Active => Self::Active,
445            CliAgentStatus::Archived => Self::Archived,
446        }
447    }
448}
449
450#[derive(Debug, Subcommand)]
451pub enum ContextCommand {
452    Recent {
453        conversation_id: String,
454        #[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
455        limit: Option<u64>,
456        #[arg(long, value_delimiter = ',')]
457        role: Vec<String>,
458    },
459    Search {
460        conversation_id: String,
461        query: String,
462        #[arg(long)]
463        role: Option<String>,
464        #[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
465        limit: Option<u64>,
466    },
467    /// Append a JSON array of ConversationRecord objects.
468    Append(JsonInput),
469    Maintenance,
470}
471
472#[derive(Debug, Subcommand)]
473pub enum ComputerCommand {
474    /// Create a computer from JSON.
475    Create {
476        #[command(flatten)]
477        input: JsonInput,
478        #[arg(long)]
479        idempotency_key: String,
480    },
481    Get {
482        computer_id: String,
483    },
484    List {
485        #[arg(long)]
486        owner_kind: Option<String>,
487        #[arg(long)]
488        owner_id: Option<String>,
489    },
490    Delete {
491        computer_id: String,
492        #[arg(long)]
493        idempotency_key: String,
494    },
495}
496
497#[derive(Debug, Subcommand)]
498pub enum SpaceCommand {
499    /// Create a space from JSON.
500    Create {
501        #[command(flatten)]
502        input: JsonInput,
503        #[arg(long)]
504        idempotency_key: String,
505    },
506    Get {
507        space_id: String,
508    },
509    List {
510        #[arg(long)]
511        owner_kind: Option<String>,
512        #[arg(long)]
513        owner_id: Option<String>,
514    },
515    Delete {
516        space_id: String,
517        #[arg(long)]
518        idempotency_key: String,
519    },
520}
521
522#[derive(Debug, Subcommand)]
523pub enum SnapshotCommand {
524    /// Create a catalog snapshot from Admin defaultBuild.
525    Create {
526        #[arg(long)]
527        provider: Option<String>,
528        #[arg(long)]
529        public: bool,
530        #[arg(long)]
531        idempotency_key: String,
532    },
533    Get {
534        snapshot_id: String,
535    },
536    List,
537    Delete {
538        snapshot_id: String,
539        #[arg(long)]
540        idempotency_key: String,
541    },
542}
543
544#[derive(Debug, Subcommand)]
545pub enum ModelCommand {
546    Models,
547    /// Invoke chat/completions with a JSON request document.
548    Chat {
549        #[command(flatten)]
550        input: JsonInput,
551        #[arg(long)]
552        request_id: Option<String>,
553    },
554    /// Invoke responses with a JSON request document.
555    Responses {
556        #[command(flatten)]
557        input: JsonInput,
558        #[arg(long)]
559        request_id: Option<String>,
560    },
561}
562
563#[derive(Debug, Args)]
564pub struct JsonInput {
565    /// JSON file to read; use '-' (the default) for standard input.
566    #[arg(long, short = 'f', default_value = "-")]
567    file: PathBuf,
568}
569
570impl JsonInput {
571    fn read<T: DeserializeOwned>(&self) -> Result<T> {
572        let bytes = read_input(&self.file)?;
573        serde_json::from_slice(&bytes)
574            .with_context(|| format!("invalid JSON input from {}", display_source(&self.file)))
575    }
576}
577
578#[derive(Debug)]
579pub enum CommandOutput {
580    Json(Value),
581    Text(String),
582}
583
584impl CommandOutput {
585    fn json(value: impl Serialize) -> Result<Self> {
586        Ok(Self::Json(serde_json::to_value(value)?))
587    }
588
589    pub fn render(&self, format: OutputFormat) -> Result<String> {
590        match self {
591            Self::Text(value) => Ok(value.clone()),
592            Self::Json(value) => match format {
593                OutputFormat::Pretty => Ok(serde_json::to_string_pretty(value)?),
594                OutputFormat::Json => Ok(serde_json::to_string(value)?),
595            },
596        }
597    }
598}
599
600impl Cli {
601    pub async fn execute(self) -> Result<CommandOutput> {
602        if let Command::Config { command } = &self.command {
603            return execute_config(&self, command);
604        }
605        if let Command::Auth { command } = &self.command {
606            return execute_auth(&self, command);
607        }
608        let gateway_url = ConfigStore::discover(self.config.as_deref())?
609            .resolve_gateway(self.gateway.as_deref())?
610            .value;
611        let client = self.build_client()?;
612        match self.command {
613            Command::Config { .. } | Command::Auth { .. } => {
614                unreachable!("local commands returned before building the client")
615            }
616            Command::Gateway { command } => execute_gateway(&client, command).await,
617            Command::Init {
618                name,
619                template_id,
620                dir,
621                no_clone,
622                owner_email,
623            } => {
624                init::execute(
625                    &client,
626                    init::InitArgs {
627                        name,
628                        template_id,
629                        dir,
630                        no_clone,
631                        owner_email,
632                    },
633                )
634                .await
635            }
636            Command::Trace { command } => trace::execute(&client, command).await,
637            Command::Agent { command } => execute_agent(&client, command).await,
638            Command::Runtime { command } => execute_runtime(&client, &gateway_url, command).await,
639            Command::Context { command } => execute_context(&client, command).await,
640            Command::Computer { command } => execute_computer(&client, command).await,
641            Command::Space { command } => execute_space(&client, command).await,
642            Command::Snapshot { command } => execute_snapshot(&client, command).await,
643            Command::Model { command } => execute_model(&client, command).await,
644            Command::Evaluate { command } => execute_evaluate(&client, command).await,
645            Command::Deploy { command } => execute_deploy(&client, command).await,
646        }
647    }
648
649    fn build_client(&self) -> Result<InfraClient> {
650        let store = ConfigStore::discover(self.config.as_deref())?;
651        let gateway = store.resolve_gateway(self.gateway.as_deref())?;
652        let (token, _) = resolve_credential(self.token.as_deref(), self.sky_home.as_deref())?;
653        let mut builder = InfraClient::builder(gateway.value)
654            .request_timeout(Duration::from_secs(self.timeout))
655            .trusted_mesh_http(self.trusted_mesh_http)
656            .user_agent(concat!("agent-infra-cli/", env!("CARGO_PKG_VERSION")));
657        if let Some(token) = token {
658            builder = builder.credentials(Arc::new(StaticCredentials::new(token)?));
659        }
660        builder.build().map_err(Into::into)
661    }
662}
663
664fn execute_config(cli: &Cli, command: &ConfigCommand) -> Result<CommandOutput> {
665    let store = ConfigStore::discover(cli.config.as_deref())?;
666    match command {
667        ConfigCommand::Show
668        | ConfigCommand::Gateway {
669            command: ConfigGatewayCommand::Get,
670        } => {
671            let config = store.load()?;
672            let effective = store.resolve_gateway(cli.gateway.as_deref())?;
673            CommandOutput::json(serde_json::json!({
674                "path": store.path(),
675                "gateway": config.gateway,
676                "effectiveGateway": effective.value,
677                "source": effective.source,
678            }))
679        }
680        ConfigCommand::Gateway {
681            command: ConfigGatewayCommand::Set { url },
682        } => {
683            let config = store.set_gateway(url)?;
684            CommandOutput::json(serde_json::json!({
685                "ok": true,
686                "path": store.path(),
687                "gateway": config.gateway,
688            }))
689        }
690        ConfigCommand::Gateway {
691            command: ConfigGatewayCommand::Unset,
692        } => {
693            store.unset_gateway()?;
694            CommandOutput::json(serde_json::json!({
695                "ok": true,
696                "path": store.path(),
697                "gateway": null,
698            }))
699        }
700    }
701}
702
703fn execute_auth(cli: &Cli, command: &AuthCommand) -> Result<CommandOutput> {
704    match command {
705        AuthCommand::Status => {
706            let store = ConfigStore::discover(cli.config.as_deref())?;
707            let gateway = store.resolve_gateway(cli.gateway.as_deref())?;
708            let (_, credential) =
709                resolve_credential(cli.token.as_deref(), cli.sky_home.as_deref())?;
710            CommandOutput::json(serde_json::json!({
711                "gateway": gateway.value,
712                "gatewaySource": gateway.source,
713                "credential": credential,
714            }))
715        }
716    }
717}
718
719async fn execute_gateway(client: &InfraClient, command: GatewayCommand) -> Result<CommandOutput> {
720    match command {
721        GatewayCommand::Discovery => CommandOutput::json(client.gateway().discovery().await?),
722        GatewayCommand::Live => CommandOutput::json(client.gateway().live().await?),
723        GatewayCommand::Ready => CommandOutput::json(client.gateway().ready().await?),
724        GatewayCommand::WaitReady { deadline } => CommandOutput::json(
725            client
726                .gateway()
727                .wait_until_ready(Duration::from_secs(deadline))
728                .await?,
729        ),
730        GatewayCommand::Dependencies => CommandOutput::json(client.gateway().dependencies().await?),
731        GatewayCommand::Routes => CommandOutput::json(client.gateway().routes().await?),
732        GatewayCommand::Metrics => CommandOutput::json(client.gateway().metrics().await?),
733        GatewayCommand::Policy => CommandOutput::json(client.gateway().effective_policy().await?),
734    }
735}
736
737async fn execute_agent(client: &InfraClient, command: AgentCommand) -> Result<CommandOutput> {
738    match command {
739        AgentCommand::Create(args) => {
740            let request = match args.file {
741                Some(file) => JsonInput { file }.read::<CreateAgentRequest>()?,
742                None => CreateAgentRequest {
743                    name: args
744                        .name
745                        .ok_or_else(|| anyhow!("--name is required for inline agent creation"))?,
746                    description: args.description.unwrap_or_default(),
747                    relations: ResourceRelations {
748                        runtime_ids: vec![args.runtime_id.ok_or_else(|| {
749                            anyhow!("--runtime-id is required for inline agent creation")
750                        })?],
751                        ..Default::default()
752                    },
753                    idempotency_key: args.idempotency_key.unwrap_or_default(),
754                },
755            };
756            CommandOutput::json(client.agents().create(&request).await?)
757        }
758        AgentCommand::Get { agent_id } => {
759            let agent_id = parse_agent_id(&agent_id)?;
760            CommandOutput::json(client.agents().get(&agent_id).await?)
761        }
762        AgentCommand::List(args) => {
763            let request = ListAgentsRequest {
764                status: args.status.map(Into::into),
765                related_agent_id: args
766                    .related_agent_id
767                    .as_deref()
768                    .map(parse_agent_id)
769                    .transpose()?,
770                related_runtime_id: args.related_runtime_id,
771                related_user_id: args.related_user_id,
772                cursor: args.cursor,
773                limit: args.limit,
774            };
775            CommandOutput::json(client.agents().list(&request).await?)
776        }
777        AgentCommand::Update { agent_id, input } => {
778            let agent_id = parse_agent_id(&agent_id)?;
779            let request: UpdateAgentRequest = input.read()?;
780            CommandOutput::json(client.agents().update(&agent_id, &request).await?)
781        }
782    }
783}
784
785async fn execute_runtime(
786    client: &InfraClient,
787    gateway: &str,
788    command: RuntimeCommand,
789) -> Result<CommandOutput> {
790    match command {
791        RuntimeCommand::Create(args) => {
792            let request = match args.file {
793                Some(file) => JsonInput { file }.read::<CreateRuntimeIdentityRequest>()?,
794                None => CreateRuntimeIdentityRequest {
795                    id: None,
796                    name: args
797                        .name
798                        .ok_or_else(|| anyhow!("--name is required for inline runtime creation"))?,
799                    kind: args.kind.unwrap_or(CliRuntimeKind::Code).into(),
800                    agent_id: None,
801                    relations: Default::default(),
802                    metadata: Default::default(),
803                    idempotency_key: args.idempotency_key.unwrap_or_default(),
804                },
805            };
806            CommandOutput::json(client.runtime_identity().create(&request).await?)
807        }
808        RuntimeCommand::Get { runtime_id } => {
809            let runtime_id = parse_runtime_id(&runtime_id)?;
810            CommandOutput::json(client.runtime_identity().get(&runtime_id).await?)
811        }
812        RuntimeCommand::Rename {
813            runtime_id,
814            name,
815            expected_revision,
816            idempotency_key,
817        } => {
818            let runtime_id = parse_runtime_id(&runtime_id)?;
819            let request = UpdateRuntimeIdentityRequest {
820                expected_revision,
821                name,
822                idempotency_key: idempotency_key.unwrap_or_default(),
823            };
824            CommandOutput::json(
825                client
826                    .runtime_identity()
827                    .update(&runtime_id, &request)
828                    .await?,
829            )
830        }
831        RuntimeCommand::Activate { runtime_id, input } => {
832            execute_runtime_transition(client, runtime_id, "activate", input).await
833        }
834        RuntimeCommand::Suspend { runtime_id, input } => {
835            execute_runtime_transition(client, runtime_id, "suspend", input).await
836        }
837        RuntimeCommand::Resume { runtime_id, input } => {
838            execute_runtime_transition(client, runtime_id, "resume", input).await
839        }
840        RuntimeCommand::Revoke { runtime_id, input } => {
841            execute_runtime_transition(client, runtime_id, "revoke", input).await
842        }
843        RuntimeCommand::Permissions { runtime_id, input } => {
844            let runtime_id = parse_runtime_id(&runtime_id)?;
845            let request: ReplacePermissionsRequest = input.read()?;
846            CommandOutput::json(
847                client
848                    .runtime_identity()
849                    .replace_permissions(&runtime_id, &request)
850                    .await?,
851            )
852        }
853        RuntimeCommand::BootstrapCredential { input } => {
854            let request: IssueRuntimeCredentialRequest = input.read()?;
855            CommandOutput::json(
856                client
857                    .gateway()
858                    .bootstrap_runtime_credential(&request)
859                    .await?,
860            )
861        }
862        RuntimeCommand::Credential { command } => {
863            credential::execute(client, gateway, command).await
864        }
865    }
866}
867
868async fn execute_runtime_transition(
869    client: &InfraClient,
870    runtime_id: String,
871    action: &str,
872    input: JsonInput,
873) -> Result<CommandOutput> {
874    let runtime_id = parse_runtime_id(&runtime_id)?;
875    let request: TransitionIdentityRequest = input.read()?;
876    CommandOutput::json(
877        client
878            .runtime_identity()
879            .transition(&runtime_id, action, &request)
880            .await?,
881    )
882}
883
884async fn execute_context(client: &InfraClient, command: ContextCommand) -> Result<CommandOutput> {
885    match command {
886        ContextCommand::Recent {
887            conversation_id,
888            limit,
889            role,
890        } => {
891            let roles = role.iter().map(String::as_str).collect::<Vec<_>>();
892            CommandOutput::json(
893                client
894                    .context()
895                    .recent_messages(&conversation_id, limit.map(|value| value as usize), &roles)
896                    .await?,
897            )
898        }
899        ContextCommand::Search {
900            conversation_id,
901            query,
902            role,
903            limit,
904        } => CommandOutput::json(
905            client
906                .context()
907                .search_messages(
908                    &conversation_id,
909                    &query,
910                    role.as_deref(),
911                    limit.map(|value| value as usize),
912                )
913                .await?,
914        ),
915        ContextCommand::Append(input) => {
916            let messages: Vec<ConversationRecord> = input.read()?;
917            if messages.is_empty() {
918                bail!("append input must contain at least one message");
919            }
920            CommandOutput::json(client.context().append_messages(messages).await?)
921        }
922        ContextCommand::Maintenance => {
923            CommandOutput::json(client.context().maintenance_status().await?)
924        }
925    }
926}
927
928async fn execute_computer(client: &InfraClient, command: ComputerCommand) -> Result<CommandOutput> {
929    let environment = client.environment();
930    match command {
931        ComputerCommand::Create {
932            input,
933            idempotency_key,
934        } => {
935            let request: CreateComputerRequest = input.read()?;
936            CommandOutput::json(
937                environment
938                    .create_computer(&request, &idempotency_key)
939                    .await?,
940            )
941        }
942        ComputerCommand::Get { computer_id } => {
943            CommandOutput::json(environment.computer(&computer_id).await?)
944        }
945        ComputerCommand::List {
946            owner_kind,
947            owner_id,
948        } => CommandOutput::json(
949            environment
950                .computers(Some(&list_environment_query(owner_kind, owner_id)?))
951                .await?,
952        ),
953        ComputerCommand::Delete {
954            computer_id,
955            idempotency_key,
956        } => CommandOutput::json(
957            environment
958                .delete_computer(&computer_id, &idempotency_key)
959                .await?,
960        ),
961    }
962}
963
964async fn execute_space(client: &InfraClient, command: SpaceCommand) -> Result<CommandOutput> {
965    let environment = client.environment();
966    match command {
967        SpaceCommand::Create {
968            input,
969            idempotency_key,
970        } => {
971            let request: CreateSpaceRequest = input.read()?;
972            CommandOutput::json(environment.create_space(&request, &idempotency_key).await?)
973        }
974        SpaceCommand::Get { space_id } => CommandOutput::json(environment.space(&space_id).await?),
975        SpaceCommand::List {
976            owner_kind,
977            owner_id,
978        } => CommandOutput::json(
979            environment
980                .spaces(Some(&list_environment_query(owner_kind, owner_id)?))
981                .await?,
982        ),
983        SpaceCommand::Delete {
984            space_id,
985            idempotency_key,
986        } => CommandOutput::json(
987            environment
988                .delete_space(&space_id, &idempotency_key)
989                .await?,
990        ),
991    }
992}
993
994async fn execute_snapshot(client: &InfraClient, command: SnapshotCommand) -> Result<CommandOutput> {
995    let environment = client.environment();
996    match command {
997        SnapshotCommand::Create {
998            provider,
999            public,
1000            idempotency_key,
1001        } => {
1002            let request = CreateSnapshotResourceRequest {
1003                provider,
1004                visibility: if public {
1005                    SnapshotVisibility::Public
1006                } else {
1007                    SnapshotVisibility::Private
1008                },
1009                owners: vec![],
1010                builtin: false,
1011                build: None,
1012            };
1013            CommandOutput::json(
1014                environment
1015                    .create_snapshot(&request, &idempotency_key)
1016                    .await?,
1017            )
1018        }
1019        SnapshotCommand::Get { snapshot_id } => {
1020            CommandOutput::json(environment.snapshot(&snapshot_id).await?)
1021        }
1022        SnapshotCommand::List => CommandOutput::json(environment.snapshots().await?),
1023        SnapshotCommand::Delete {
1024            snapshot_id,
1025            idempotency_key,
1026        } => CommandOutput::json(
1027            environment
1028                .delete_snapshot(&snapshot_id, &idempotency_key)
1029                .await?,
1030        ),
1031    }
1032}
1033
1034fn list_environment_query(
1035    owner_kind: Option<String>,
1036    owner_id: Option<String>,
1037) -> Result<ListEnvironmentResourcesQuery> {
1038    let owner_kind = match owner_kind {
1039        Some(value) => Some(parse_owner_kind(&value)?),
1040        None => None,
1041    };
1042    if owner_kind.is_some() != owner_id.is_some() {
1043        bail!("--owner-kind and --owner-id must be provided together");
1044    }
1045    Ok(ListEnvironmentResourcesQuery {
1046        owner_kind,
1047        owner_id,
1048        contains_all: vec![],
1049    })
1050}
1051
1052fn parse_owner_kind(value: &str) -> Result<OwnerKind> {
1053    match value.to_ascii_lowercase().as_str() {
1054        "user" => Ok(OwnerKind::User),
1055        "agent" => Ok(OwnerKind::Agent),
1056        "runtime" => Ok(OwnerKind::Runtime),
1057        other => bail!("unsupported owner kind {other:?}; expected user, agent, or runtime"),
1058    }
1059}
1060
1061fn parse_deploy_owner_kind(value: &str) -> Result<agent_infra_sdk::deploy_contract::OwnerKind> {
1062    agent_infra_sdk::deploy_contract::OwnerKind::parse(&value.to_ascii_lowercase()).ok_or_else(
1063        || anyhow!("unsupported owner kind {value:?}; expected user, agent, or runtime"),
1064    )
1065}
1066
1067async fn execute_model(client: &InfraClient, command: ModelCommand) -> Result<CommandOutput> {
1068    match command {
1069        ModelCommand::Models => CommandOutput::json(client.model().models().await?),
1070        ModelCommand::Chat { input, request_id } => {
1071            let request: Value = input.read()?;
1072            ensure_json_object(&request)?;
1073            CommandOutput::json(
1074                client
1075                    .model()
1076                    .chat_completions(&request, call_options(request_id))
1077                    .await?,
1078            )
1079        }
1080        ModelCommand::Responses { input, request_id } => {
1081            let request: Value = input.read()?;
1082            ensure_json_object(&request)?;
1083            CommandOutput::json(
1084                client
1085                    .model()
1086                    .responses(&request, call_options(request_id))
1087                    .await?,
1088            )
1089        }
1090    }
1091}
1092
1093async fn execute_deploy(client: &InfraClient, command: DeployCommand) -> Result<CommandOutput> {
1094    let deploy = client.deploy();
1095    match command {
1096        DeployCommand::Template {
1097            command: DeployTemplateCommand::List,
1098        } => CommandOutput::json(deploy.list_runtime_templates(None, None).await?),
1099        DeployCommand::Repo { command } => match command {
1100            DeployRepoCommand::Init {
1101                input,
1102                idempotency_key,
1103                dir,
1104                no_clone,
1105                agent_id,
1106                runtime_id,
1107            } => {
1108                let mut request: agent_infra_sdk::deploy_contract::CreateRuntimeRepositoryRequest =
1109                    input.read()?;
1110                request.owners = runtime_repo_checkout::merge_identity_owners(
1111                    request.owners,
1112                    agent_id.as_deref(),
1113                    runtime_id.as_deref(),
1114                );
1115                let mut record = deploy
1116                    .create_runtime_repository(&request, &idempotency_key)
1117                    .await?;
1118                let deadline = Instant::now() + Duration::from_secs(120);
1119                while record.status
1120                    == agent_infra_sdk::deploy_contract::RuntimeRepositoryStatus::Importing
1121                {
1122                    if Instant::now() >= deadline {
1123                        bail!(
1124                            "runtime repository import timed out for {}",
1125                            record.path_with_namespace
1126                        );
1127                    }
1128                    tokio::time::sleep(Duration::from_secs(2)).await;
1129                    record = deploy.runtime_repository(&record.id).await?;
1130                }
1131                if record.status
1132                    == agent_infra_sdk::deploy_contract::RuntimeRepositoryStatus::Failed
1133                {
1134                    bail!(
1135                        "{}",
1136                        record
1137                            .error_message
1138                            .unwrap_or_else(|| { "runtime repository import failed".to_string() })
1139                    );
1140                }
1141                let checkout = runtime_repo_checkout::checkout_runtime_repository(
1142                    &record,
1143                    &runtime_repo_checkout::CheckoutOptions {
1144                        dir,
1145                        clone: !no_clone,
1146                        agent_id,
1147                        runtime_id,
1148                    },
1149                )?;
1150                eprintln!(
1151                    "checked out {} ({})",
1152                    checkout.display(),
1153                    checkout.join(".env.local").display()
1154                );
1155                CommandOutput::json(record)
1156            }
1157            DeployRepoCommand::List {
1158                owner_kind,
1159                owner_id,
1160            } => {
1161                let owner_kind = match owner_kind {
1162                    Some(value) => Some(parse_deploy_owner_kind(&value)?),
1163                    None => None,
1164                };
1165                if owner_kind.is_some() != owner_id.is_some() {
1166                    bail!("--owner-kind and --owner-id must be provided together");
1167                }
1168                CommandOutput::json(
1169                    deploy
1170                        .runtime_repositories(owner_kind, owner_id.as_deref(), None, None)
1171                        .await?,
1172                )
1173            }
1174            DeployRepoCommand::Get { repository_id } => {
1175                CommandOutput::json(deploy.runtime_repository(&repository_id).await?)
1176            }
1177        },
1178    }
1179}
1180
1181fn call_options(request_id: Option<String>) -> CallOptions {
1182    request_id.map_or_else(CallOptions::default, |value| {
1183        CallOptions::default().request_id(value)
1184    })
1185}
1186
1187fn ensure_json_object(value: &Value) -> Result<()> {
1188    if !value.is_object() {
1189        bail!("model request must be a JSON object");
1190    }
1191    Ok(())
1192}
1193
1194fn parse_agent_id(value: &str) -> Result<AgentId> {
1195    AgentId::new(value).map_err(|message| anyhow!(message))
1196}
1197
1198fn parse_runtime_id(value: &str) -> Result<RuntimeIdentityId> {
1199    RuntimeIdentityId::new(value).map_err(|message| anyhow!(message))
1200}
1201
1202pub(crate) fn read_input(path: &Path) -> Result<Vec<u8>> {
1203    if path == Path::new("-") {
1204        let mut bytes = Vec::new();
1205        io::stdin()
1206            .read_to_end(&mut bytes)
1207            .context("failed to read standard input")?;
1208        Ok(bytes)
1209    } else {
1210        fs::read(path).with_context(|| format!("failed to read {}", path.display()))
1211    }
1212}
1213
1214pub(crate) fn display_source(path: &Path) -> String {
1215    if path == Path::new("-") {
1216        "standard input".into()
1217    } else {
1218        path.display().to_string()
1219    }
1220}
1221
1222#[cfg(test)]
1223mod tests {
1224    use super::*;
1225    use crate::trace::{
1226        LangfuseProjectCommand, LangfuseProjectListArgs, TraceBindingCommand, TraceMetricsCommand,
1227    };
1228    use agent_infra_sdk::trace_contract::MetricsDataSource;
1229    use clap::CommandFactory;
1230    use wiremock::{
1231        Mock, MockServer, ResponseTemplate,
1232        matchers::{body_json, header, method, path},
1233    };
1234
1235    #[test]
1236    fn clap_definition_is_valid() {
1237        Cli::command().debug_assert();
1238    }
1239
1240    #[test]
1241    fn parses_runtime_credential_ensure() {
1242        let command = Cli::try_parse_from([
1243            "infra",
1244            "runtime",
1245            "credential",
1246            "ensure",
1247            "rti_test",
1248            "--store",
1249            "file",
1250            "--yes",
1251        ])
1252        .unwrap()
1253        .command;
1254        match command {
1255            Command::Runtime {
1256                command:
1257                    RuntimeCommand::Credential {
1258                        command: crate::credential::RuntimeCredentialCommand::Ensure(args),
1259                    },
1260            } => {
1261                assert_eq!(args.runtime_id, "rti_test");
1262                assert_eq!(
1263                    args.store.store,
1264                    crate::credential::CredentialStoreKind::File
1265                );
1266                assert!(args.store.yes);
1267            }
1268            other => panic!("unexpected command: {other:?}"),
1269        }
1270    }
1271
1272    #[test]
1273    fn rejects_admin_subcommand() {
1274        assert!(Cli::try_parse_from(["infra", "admin"]).is_err());
1275        assert!(Cli::try_parse_from(["infra", "admin", "langfuse-config", "get"]).is_err());
1276        assert!(Cli::try_parse_from(["infra", "admin", "clickhouse-config", "get"]).is_err());
1277    }
1278
1279    #[test]
1280    fn parses_snapshot_create_provider_and_public_flags() {
1281        let command = Cli::try_parse_from([
1282            "infra",
1283            "snapshot",
1284            "create",
1285            "--provider",
1286            "moulin",
1287            "--public",
1288            "--idempotency-key",
1289            "snap-1",
1290        ])
1291        .unwrap()
1292        .command;
1293        match command {
1294            Command::Snapshot {
1295                command:
1296                    SnapshotCommand::Create {
1297                        provider,
1298                        public,
1299                        idempotency_key,
1300                    },
1301            } => {
1302                assert_eq!(provider.as_deref(), Some("moulin"));
1303                assert!(public);
1304                assert_eq!(idempotency_key, "snap-1");
1305            }
1306            other => panic!("unexpected command: {other:?}"),
1307        }
1308    }
1309
1310    #[test]
1311    fn parses_init_name_template_and_no_clone() {
1312        let command = Cli::try_parse_from([
1313            "infra",
1314            "init",
1315            "--name",
1316            "Weather Bot",
1317            "--template-id",
1318            "rtpl-1",
1319            "--dir",
1320            "./weather",
1321            "--no-clone",
1322        ])
1323        .unwrap()
1324        .command;
1325        match command {
1326            Command::Init {
1327                name,
1328                template_id,
1329                dir,
1330                no_clone,
1331                owner_email,
1332            } => {
1333                assert_eq!(name, "Weather Bot");
1334                assert_eq!(template_id.as_deref(), Some("rtpl-1"));
1335                assert_eq!(dir, Some(PathBuf::from("./weather")));
1336                assert!(no_clone);
1337                assert!(owner_email.is_none());
1338            }
1339            other => panic!("unexpected command: {other:?}"),
1340        }
1341    }
1342
1343    #[test]
1344    fn parses_agent_create_name_and_runtime_id() {
1345        let command = Cli::try_parse_from([
1346            "infra",
1347            "agent",
1348            "create",
1349            "--name",
1350            "weather",
1351            "--runtime-id",
1352            "rti_1",
1353            "--description",
1354            "demo",
1355        ])
1356        .unwrap()
1357        .command;
1358        match command {
1359            Command::Agent {
1360                command:
1361                    AgentCommand::Create(AgentCreateArgs {
1362                        name,
1363                        runtime_id,
1364                        description,
1365                        idempotency_key,
1366                        file,
1367                    }),
1368            } => {
1369                assert_eq!(name.as_deref(), Some("weather"));
1370                assert_eq!(runtime_id.as_deref(), Some("rti_1"));
1371                assert_eq!(description.as_deref(), Some("demo"));
1372                assert!(idempotency_key.is_none());
1373                assert!(file.is_none());
1374            }
1375            other => panic!("unexpected command: {other:?}"),
1376        }
1377    }
1378
1379    #[test]
1380    fn parses_deploy_template_and_repository_commands() {
1381        assert!(matches!(
1382            Cli::try_parse_from(["infra", "deploy", "template", "list"])
1383                .unwrap()
1384                .command,
1385            Command::Deploy {
1386                command: DeployCommand::Template {
1387                    command: DeployTemplateCommand::List,
1388                },
1389            }
1390        ));
1391        let init = Cli::try_parse_from([
1392            "infra",
1393            "deploy",
1394            "repo",
1395            "init",
1396            "--idempotency-key",
1397            "init-1",
1398            "--file",
1399            "repo.json",
1400            "--dir",
1401            "./demo",
1402            "--no-clone",
1403            "--agent-id",
1404            "agt_1",
1405            "--runtime-id",
1406            "rt-1",
1407        ])
1408        .unwrap()
1409        .command;
1410        match init {
1411            Command::Deploy {
1412                command:
1413                    DeployCommand::Repo {
1414                        command:
1415                            DeployRepoCommand::Init {
1416                                dir,
1417                                no_clone,
1418                                agent_id,
1419                                runtime_id,
1420                                ..
1421                            },
1422                    },
1423            } => {
1424                assert_eq!(dir, Some(PathBuf::from("./demo")));
1425                assert!(no_clone);
1426                assert_eq!(agent_id.as_deref(), Some("agt_1"));
1427                assert_eq!(runtime_id.as_deref(), Some("rt-1"));
1428            }
1429            other => panic!("unexpected command: {other:?}"),
1430        }
1431        assert!(matches!(
1432            Cli::try_parse_from([
1433                "infra",
1434                "deploy",
1435                "repo",
1436                "list",
1437                "--owner-kind",
1438                "user",
1439                "--owner-id",
1440                "user-1"
1441            ])
1442            .unwrap()
1443            .command,
1444            Command::Deploy {
1445                command: DeployCommand::Repo {
1446                    command: DeployRepoCommand::List { .. },
1447                },
1448            }
1449        ));
1450        assert!(matches!(
1451            Cli::try_parse_from(["infra", "deploy", "repo", "get", "rrepo-1"])
1452                .unwrap()
1453                .command,
1454            Command::Deploy {
1455                command: DeployCommand::Repo {
1456                    command: DeployRepoCommand::Get { repository_id },
1457                },
1458            } if repository_id == "rrepo-1"
1459        ));
1460    }
1461
1462    #[test]
1463    fn parses_global_options_after_subcommands() {
1464        let cli = Cli::try_parse_from([
1465            "infra",
1466            "gateway",
1467            "wait-ready",
1468            "--deadline",
1469            "42",
1470            "--gateway",
1471            "https://infra.example.com",
1472            "--output",
1473            "json",
1474        ])
1475        .unwrap();
1476        assert_eq!(cli.gateway.as_deref(), Some("https://infra.example.com"));
1477        assert_eq!(cli.output, OutputFormat::Json);
1478        assert!(matches!(
1479            cli.command,
1480            Command::Gateway {
1481                command: GatewayCommand::WaitReady { deadline: 42 }
1482            }
1483        ));
1484    }
1485
1486    #[test]
1487    fn rejects_removed_workspace_subcommand() {
1488        let result = Cli::try_parse_from(["infra", "workspace", "write", "README.md"]);
1489        assert!(result.is_err());
1490    }
1491
1492    #[test]
1493    fn parses_trace_metrics_query_options() {
1494        let cli = Cli::try_parse_from([
1495            "infra",
1496            "trace",
1497            "metrics",
1498            "token-usage",
1499            "--project-id",
1500            "project-a",
1501            "--project-id",
1502            "project-b",
1503            "--days",
1504            "7",
1505            "--data-source",
1506            "v4",
1507        ])
1508        .unwrap();
1509        let Command::Trace {
1510            command:
1511                TraceCommand::Metrics {
1512                    command: TraceMetricsCommand::TokenUsage(args),
1513                },
1514        } = cli.command
1515        else {
1516            panic!("expected trace token-usage command");
1517        };
1518        let query = args.into_query();
1519        assert_eq!(query.project_ids.unwrap(), ["project-a", "project-b"]);
1520        assert_eq!(query.days, 7);
1521        assert_eq!(query.data_source, MetricsDataSource::V4);
1522    }
1523
1524    #[test]
1525    fn parses_langfuse_project_relationship_query() {
1526        let cli = Cli::try_parse_from([
1527            "infra",
1528            "trace",
1529            "langfuse-project",
1530            "list",
1531            "--related-agent-id",
1532            "agt_weather",
1533            "--limit",
1534            "20",
1535        ])
1536        .unwrap();
1537        assert!(matches!(
1538            cli.command,
1539            Command::Trace {
1540                command: TraceCommand::LangfuseProject {
1541                    command: LangfuseProjectCommand::List(LangfuseProjectListArgs {
1542                        related_agent_id: Some(ref agent_id),
1543                        limit: Some(20),
1544                        ..
1545                    })
1546                }
1547            } if agent_id == "agt_weather"
1548        ));
1549    }
1550
1551    #[test]
1552    fn parses_trace_binding_get() {
1553        let cli = Cli::try_parse_from(["infra", "trace", "binding", "get", "rti_1"]).unwrap();
1554        assert!(matches!(
1555            cli.command,
1556            Command::Trace {
1557                command: TraceCommand::Binding {
1558                    command: TraceBindingCommand::Get { ref runtime_id }
1559                }
1560            } if runtime_id == "rti_1"
1561        ));
1562    }
1563
1564    #[test]
1565    fn parses_trace_binding_initialize() {
1566        let cli = Cli::try_parse_from([
1567            "infra",
1568            "trace",
1569            "binding",
1570            "initialize",
1571            "rti_1",
1572            "--project-name",
1573            "demo",
1574            "--idempotency-key",
1575            "init-trace-rti_1",
1576        ])
1577        .unwrap();
1578        assert!(matches!(
1579            cli.command,
1580            Command::Trace {
1581                command: TraceCommand::Binding {
1582                    command: TraceBindingCommand::Initialize {
1583                        ref runtime_id,
1584                        project_name: Some(ref project_name),
1585                        ..
1586                    }
1587                }
1588            } if runtime_id == "rti_1" && project_name == "demo"
1589        ));
1590    }
1591
1592    #[test]
1593    fn parses_evaluate_resource_and_task_commands() {
1594        let register = Cli::try_parse_from([
1595            "infra",
1596            "evaluate",
1597            "register-evaluator",
1598            "--file",
1599            "evaluator.json",
1600        ])
1601        .unwrap();
1602        assert!(matches!(
1603            register.command,
1604            Command::Evaluate {
1605                command: EvaluateCommand::RegisterEvaluator(_)
1606            }
1607        ));
1608        let task = Cli::try_parse_from(["infra", "evaluate", "task-create", "--file", "run.json"])
1609            .unwrap();
1610        assert!(matches!(
1611            task.command,
1612            Command::Evaluate {
1613                command: EvaluateCommand::TaskCreate(_)
1614            }
1615        ));
1616        let alias =
1617            Cli::try_parse_from(["infra", "evaluate", "run", "--file", "run.json"]).unwrap();
1618        assert!(matches!(
1619            alias.command,
1620            Command::Evaluate {
1621                command: EvaluateCommand::TaskCreate(_)
1622            }
1623        ));
1624        let health = Cli::try_parse_from([
1625            "infra",
1626            "evaluate",
1627            "metrics",
1628            "health",
1629            "--window-runs",
1630            "30",
1631        ])
1632        .unwrap();
1633        assert!(matches!(
1634            health.command,
1635            Command::Evaluate {
1636                command: EvaluateCommand::Metrics {
1637                    command: EvaluateMetricsCommand::Health {
1638                        window_runs: 30,
1639                        evaluator_id: None
1640                    }
1641                }
1642            }
1643        ));
1644        let sample = Cli::try_parse_from([
1645            "infra",
1646            "evaluate",
1647            "review",
1648            "sample",
1649            "--file",
1650            "sample.json",
1651        ])
1652        .unwrap();
1653        assert!(matches!(
1654            sample.command,
1655            Command::Evaluate {
1656                command: EvaluateCommand::Review {
1657                    command: EvaluateReviewCommand::Sample(_)
1658                }
1659            }
1660        ));
1661    }
1662
1663    #[tokio::test]
1664    async fn evaluate_push_derives_versions_from_content_and_registers_dependencies_first() {
1665        let directory = tempfile::tempdir().unwrap();
1666        let bundle = directory.path().join("eval.json");
1667        fs::write(
1668            &bundle,
1669            serde_json::to_vec(&serde_json::json!({
1670                "suiteId": "core",
1671                "evaluators": [{
1672                    "evaluatorId": "grounded",
1673                    "implementation": {
1674                        "kind": "builtin_assertions",
1675                        "assertions": [{
1676                            "kind": "output_contains",
1677                            "turn": 0,
1678                            "values": ["ok"]
1679                        }]
1680                    },
1681                    "metric": { "class": "goal", "decision": "block_release", "owner": "team" }
1682                }],
1683                "cases": [{
1684                    "id": "answer",
1685                    "category": "correctness",
1686                    "turns": [{ "user": "say ok" }],
1687                    "evaluators": ["grounded"]
1688                }]
1689            }))
1690            .unwrap(),
1691        )
1692        .unwrap();
1693
1694        let dry_run = Cli::try_parse_from([
1695            "infra",
1696            "evaluate",
1697            "push",
1698            "--file",
1699            bundle.to_str().unwrap(),
1700            "--dry-run",
1701        ])
1702        .unwrap();
1703        let CommandOutput::Json(planned) = dry_run.execute().await.unwrap() else {
1704            panic!("push must produce JSON");
1705        };
1706        let suite_version = planned["suiteVersion"].as_str().unwrap().to_string();
1707        let evaluator_version = planned["evaluators"][0]["version"]
1708            .as_str()
1709            .unwrap()
1710            .to_string();
1711        let case_version = planned["cases"][0]["version"].as_str().unwrap().to_string();
1712        assert!(suite_version.starts_with("sha256-"));
1713        assert!(case_version.starts_with("sha256-"));
1714        assert_eq!(planned["applied"], serde_json::json!(false));
1715
1716        let server = MockServer::start().await;
1717        Mock::given(method("POST"))
1718            .and(path(
1719                agent_infra_sdk::evaluate_contract::EVALUATOR_VERSIONS_PATH,
1720            ))
1721            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
1722                "tenantId": "tenant",
1723                "projectId": "project",
1724                "evaluatorId": "grounded",
1725                "version": evaluator_version,
1726                "digest": "sha256:test",
1727                "implementation": { "kind": "builtin_assertions", "assertions": [] },
1728                "createdAtMs": 1
1729            })))
1730            .expect(1)
1731            .mount(&server)
1732            .await;
1733        Mock::given(method("POST"))
1734            .and(path(agent_infra_sdk::evaluate_contract::CASE_VERSIONS_PATH))
1735            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
1736                "tenantId": "tenant",
1737                "projectId": "project",
1738                "caseId": "answer",
1739                "version": case_version,
1740                "digest": "sha256:test",
1741                "category": "correctness",
1742                "turns": [{ "user": "say ok" }],
1743                "createdAtMs": 1
1744            })))
1745            .expect(1)
1746            .mount(&server)
1747            .await;
1748        Mock::given(method("POST"))
1749            .and(path(agent_infra_sdk::evaluate_contract::SUITES_PATH))
1750            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
1751                "tenantId": "tenant",
1752                "projectId": "project",
1753                "suiteId": "core",
1754                "version": suite_version,
1755                "manifestDigest": "sha256:test",
1756                "schemaVersion": 1,
1757                "cases": [{ "id": "answer", "version": case_version }],
1758                "createdAtMs": 1
1759            })))
1760            .expect(1)
1761            .mount(&server)
1762            .await;
1763
1764        let server_uri = server.uri();
1765        let applied = Cli::try_parse_from([
1766            "infra",
1767            "--gateway",
1768            server_uri.as_str(),
1769            "--token",
1770            "test-token",
1771            "evaluate",
1772            "push",
1773            "--file",
1774            bundle.to_str().unwrap(),
1775        ])
1776        .unwrap()
1777        .execute()
1778        .await
1779        .unwrap();
1780        let CommandOutput::Json(applied) = applied else {
1781            panic!("push must produce JSON");
1782        };
1783        assert_eq!(applied["applied"], serde_json::json!(true));
1784        assert_eq!(applied["suiteVersion"], serde_json::json!(suite_version));
1785    }
1786
1787    #[tokio::test]
1788    async fn evaluate_diff_fails_the_command_and_names_the_blocking_metric() {
1789        let server = MockServer::start().await;
1790        Mock::given(method("POST"))
1791            .and(path(
1792                agent_infra_sdk::evaluate_contract::COMPARE_REPORTS_PATH,
1793            ))
1794            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1795                "reportId": "report-b",
1796                "baselineReportId": "report-a",
1797                "passRateDelta": -0.5,
1798                "infraFailureRateDelta": 0.0,
1799                "passRateConfidenceLow": 0.1,
1800                "passRateConfidenceHigh": 0.4,
1801                "effectConfidenceLow": -0.6,
1802                "effectConfidenceHigh": -0.4,
1803                "pValue": 0.001,
1804                "statisticallySignificant": true,
1805                "changedCases": [],
1806                "gatePassed": false,
1807                "gate": {
1808                    "passed": false,
1809                    "blockingMetrics": [{
1810                        "evaluatorId": "no-secret-leak",
1811                        "class": "guardrail",
1812                        "reason": "guardrail_failed",
1813                        "decision": "block_release",
1814                        "observedRate": 0.99,
1815                        "delta": -0.01,
1816                        "detail": "1 guardrail failures out of 100 observations"
1817                    }],
1818                    "warnings": []
1819                }
1820            })))
1821            .expect(1)
1822            .mount(&server)
1823            .await;
1824
1825        let server_uri = server.uri();
1826        let error = Cli::try_parse_from([
1827            "infra",
1828            "--gateway",
1829            server_uri.as_str(),
1830            "--token",
1831            "test-token",
1832            "evaluate",
1833            "diff",
1834            "--report-id",
1835            "report-b",
1836            "--baseline-report-id",
1837            "report-a",
1838        ])
1839        .unwrap()
1840        .execute()
1841        .await
1842        .unwrap_err()
1843        .to_string();
1844        assert!(
1845            error.contains("no-secret-leak"),
1846            "unexpected error: {error}"
1847        );
1848        assert!(error.contains("guardrail"), "unexpected error: {error}");
1849    }
1850
1851    #[test]
1852    fn reads_json_request_from_file() {
1853        let directory = tempfile::tempdir().unwrap();
1854        let path = directory.path().join("request.json");
1855        fs::write(&path, br#"{"model":"test"}"#).unwrap();
1856        let input = JsonInput { file: path };
1857        let value: Value = input.read().unwrap();
1858        assert_eq!(value["model"], "test");
1859    }
1860
1861    #[tokio::test]
1862    async fn agent_create_sends_name_and_runtime_id_without_idempotency_key() {
1863        let server = MockServer::start().await;
1864        Mock::given(method("POST"))
1865            .and(path("/v1/agents"))
1866            .and(header("authorization", "Bearer test-token"))
1867            .and(body_json(serde_json::json!({
1868                "name": "weather",
1869                "description": "demo",
1870                "relations": { "runtimeIds": ["rti_1"] },
1871                "idempotencyKey": ""
1872            })))
1873            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
1874                "id": "agt_weather",
1875                "ownerUserId": "user-1",
1876                "name": "weather",
1877                "description": "demo",
1878                "status": "active",
1879                "revision": 1,
1880                "relations": { "runtimeIds": ["rti_1"], "userIds": ["user-1"] },
1881                "createdAt": "2026-08-17T00:00:00Z",
1882                "updatedAt": "2026-08-17T00:00:00Z"
1883            })))
1884            .expect(1)
1885            .mount(&server)
1886            .await;
1887
1888        let cli = Cli::try_parse_from([
1889            "infra",
1890            "--gateway",
1891            server.uri().as_str(),
1892            "--token",
1893            "test-token",
1894            "agent",
1895            "create",
1896            "--name",
1897            "weather",
1898            "--runtime-id",
1899            "rti_1",
1900            "--description",
1901            "demo",
1902        ])
1903        .unwrap();
1904        let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
1905            panic!("agent create must produce JSON");
1906        };
1907        assert_eq!(output["id"], "agt_weather");
1908        assert_eq!(output["name"], "weather");
1909    }
1910
1911    #[tokio::test]
1912    async fn deploy_repo_init_writes_env_local_without_clone() {
1913        let root = tempfile::tempdir().unwrap();
1914        let request_path = root.path().join("repo.json");
1915        fs::write(
1916            &request_path,
1917            r#"{"templateId":"rtpl-1","projectName":"demo"}"#,
1918        )
1919        .unwrap();
1920        let checkout = root.path().join("demo-runtime");
1921        let server = MockServer::start().await;
1922        Mock::given(method("POST"))
1923            .and(path("/internal/v1/deploy/runtime-repositories"))
1924            .and(header("Idempotency-Key", "init-1"))
1925            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
1926                "id": "rrepo-1",
1927                "tenantId": "local",
1928                "projectId": "default",
1929                "templateId": "rtpl-1",
1930                "owners": [
1931                    {"kind":"agent","id":"agt_1"},
1932                    {"kind":"runtime","id":"rt-1"}
1933                ],
1934                "namespace": "runtimes",
1935                "gitlabProjectId": 1,
1936                "pathWithNamespace": "runtimes/demo-runtime",
1937                "repositoryUrl": "https://git.test/runtimes/demo-runtime.git",
1938                "defaultBranch": "master",
1939                "status": "ready",
1940                "version": 1,
1941                "createdAt": "t",
1942                "updatedAt": "t"
1943            })))
1944            .expect(1)
1945            .mount(&server)
1946            .await;
1947
1948        let cli = Cli::try_parse_from([
1949            "infra",
1950            "--gateway",
1951            server.uri().as_str(),
1952            "--token",
1953            "test-token",
1954            "deploy",
1955            "repo",
1956            "init",
1957            "--file",
1958            request_path.to_str().unwrap(),
1959            "--idempotency-key",
1960            "init-1",
1961            "--dir",
1962            checkout.to_str().unwrap(),
1963            "--no-clone",
1964            "--agent-id",
1965            "agt_1",
1966            "--runtime-id",
1967            "rt-1",
1968        ])
1969        .unwrap();
1970        let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
1971            panic!("init must produce JSON");
1972        };
1973        assert_eq!(output["id"], "rrepo-1");
1974        let env = fs::read_to_string(checkout.join(".env.local")).unwrap();
1975        assert!(env.contains("RREPO_ID=rrepo-1"));
1976        assert!(env.contains("AGENT_ID=agt_1"));
1977        assert!(env.contains("RUNTIME_ID=rt-1"));
1978        assert!(
1979            fs::read_to_string(checkout.join(".gitignore"))
1980                .unwrap()
1981                .contains(".env.local")
1982        );
1983    }
1984
1985    #[tokio::test]
1986    async fn init_orchestrates_runtime_agent_repo_trace_context_and_model() {
1987        let root = tempfile::tempdir().unwrap();
1988        let checkout = root.path().join("e2e-init");
1989        let server = MockServer::start().await;
1990        Mock::given(method("POST"))
1991            .and(path("/internal/v1/runtime-identities"))
1992            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
1993                "identity": runtime_identity_json("e2e_init", "pending", 1),
1994                "bootstrapToken": "rti_boot_secret"
1995            })))
1996            .expect(1)
1997            .mount(&server)
1998            .await;
1999        Mock::given(method("POST"))
2000            .and(path("/v1/agents"))
2001            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
2002                "id": "agt_e2e",
2003                "ownerUserId": "user-1",
2004                "name": "e2e_init",
2005                "description": "initialized by infra init (e2e_init)",
2006                "status": "active",
2007                "revision": 1,
2008                "relations": { "runtimeIds": ["rti_test"] },
2009                "createdAt": "2026-08-17T00:00:00Z",
2010                "updatedAt": "2026-08-17T00:00:00Z"
2011            })))
2012            .expect(1)
2013            .mount(&server)
2014            .await;
2015        Mock::given(method("POST"))
2016            .and(path("/internal/v1/deploy/runtime-repositories"))
2017            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
2018                "id": "rrepo-e2e",
2019                "tenantId": "local",
2020                "projectId": "default",
2021                "templateId": "rtpl-1",
2022                "owners": [],
2023                "namespace": "runtimes",
2024                "gitlabProjectId": 1,
2025                "pathWithNamespace": "runtimes/e2e-init",
2026                "repositoryUrl": "https://git.test/runtimes/e2e-init.git",
2027                "defaultBranch": "master",
2028                "status": "ready",
2029                "version": 1,
2030                "createdAt": "t",
2031                "updatedAt": "t"
2032            })))
2033            .expect(1)
2034            .mount(&server)
2035            .await;
2036        Mock::given(method("POST"))
2037            .and(path("/v1/trace/runtime-bindings/rti_test/initialize"))
2038            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2039                "runtimeId": "rti_test",
2040                "projectName": "e2e_init",
2041                "langfuseProjectId": "lf-e2e",
2042                "publicKey": "pk-lf-e2e",
2043                "status": "ready"
2044            })))
2045            .expect(1)
2046            .mount(&server)
2047            .await;
2048        Mock::given(method("POST"))
2049            .and(path("/internal/v1/context/messages/batch"))
2050            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2051                "appended": 1,
2052                "skipped": 0
2053            })))
2054            .expect(1)
2055            .mount(&server)
2056            .await;
2057        Mock::given(method("POST"))
2058            .and(path("/internal/v1/context/messages/recent"))
2059            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2060                "items": [{
2061                    "id": "msg_init_e2e_init",
2062                    "scope": {"conversationId": "conv_init_e2e_init"},
2063                    "role": "system",
2064                    "content": {"text": "initialized by infra init"},
2065                    "sequence": 1,
2066                    "createdAtMs": 1,
2067                    "metadata": {"source": "infra-init"}
2068                }]
2069            })))
2070            .expect(1)
2071            .mount(&server)
2072            .await;
2073        Mock::given(method("POST"))
2074            .and(path("/v1/agents/agt_e2e/model-configs:ensure-default"))
2075            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2076                "id": "default",
2077                "displayName": "Platform Default",
2078                "enabled": true
2079            })))
2080            .expect(1)
2081            .mount(&server)
2082            .await;
2083
2084        let cli = Cli::try_parse_from([
2085            "infra",
2086            "--gateway",
2087            server.uri().as_str(),
2088            "--token",
2089            "test-token",
2090            "init",
2091            "--name",
2092            "e2e-init",
2093            "--template-id",
2094            "rtpl-1",
2095            "--dir",
2096            checkout.to_str().unwrap(),
2097            "--no-clone",
2098        ])
2099        .unwrap();
2100        let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
2101            panic!("infra init must produce JSON");
2102        };
2103        assert_eq!(output["runtimeId"], "rti_test");
2104        assert_eq!(output["agentId"], "agt_e2e");
2105        assert_eq!(output["rrepoId"], "rrepo-e2e");
2106        assert_eq!(output["langfuseProjectId"], "lf-e2e");
2107        assert_eq!(output["conversationId"], "conv_init_e2e_init");
2108        assert_eq!(output["messageId"], "msg_init_e2e_init");
2109        assert_eq!(output["modelConfigId"], "default");
2110    }
2111
2112    #[tokio::test]
2113    async fn persists_and_resolves_gateway_without_network_access() {
2114        let directory = tempfile::tempdir().unwrap();
2115        let config = directory.path().join("infra.json");
2116        let cli = Cli::try_parse_from([
2117            "infra",
2118            "config",
2119            "gateway",
2120            "set",
2121            "https://infra.example.com/",
2122            "--config",
2123            config.to_str().unwrap(),
2124        ])
2125        .unwrap();
2126        let output = cli.execute().await.unwrap();
2127        let CommandOutput::Json(value) = output else {
2128            panic!("config set must produce JSON");
2129        };
2130        assert_eq!(value["gateway"], "https://infra.example.com");
2131
2132        let store = ConfigStore::discover(Some(&config)).unwrap();
2133        assert_eq!(
2134            store.load().unwrap().gateway.as_deref(),
2135            Some("https://infra.example.com")
2136        );
2137    }
2138
2139    #[tokio::test]
2140    async fn trace_metrics_command_calls_typed_sdk() {
2141        let server = MockServer::start().await;
2142        Mock::given(method("POST"))
2143            .and(path(
2144                agent_infra_sdk::trace_contract::METRICS_PROJECT_SUMMARY_PATH,
2145            ))
2146            .and(body_json(serde_json::json!({
2147                "projectIds": ["project-a"],
2148                "days": 14,
2149                "dataSource": "auto",
2150                "v4CutoverDate": "2026-09-01 00:00:00"
2151            })))
2152            .respond_with(
2153                ResponseTemplate::new(200).set_body_json(serde_json::json!([{
2154                    "projectId": "project-a",
2155                    "totalTokens": 15,
2156                    "totalInputTokens": 10,
2157                    "totalOutputTokens": 5,
2158                    "observationCount": 2,
2159                    "generationCount": 1,
2160                    "totalCost": 0.01,
2161                    "uniqueModels": 1,
2162                    "uniqueTraces": 1
2163                }])),
2164            )
2165            .expect(1)
2166            .mount(&server)
2167            .await;
2168
2169        let server_uri = server.uri();
2170        let cli = Cli::try_parse_from([
2171            "infra",
2172            "--gateway",
2173            server_uri.as_str(),
2174            "trace",
2175            "metrics",
2176            "project-summary",
2177            "--project-id",
2178            "project-a",
2179            "--days",
2180            "14",
2181        ])
2182        .unwrap();
2183        let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
2184            panic!("trace metrics must produce JSON");
2185        };
2186        assert_eq!(output[0]["totalTokens"], 15);
2187    }
2188
2189    #[tokio::test]
2190    async fn auth_status_detects_encrypted_sky_login_without_exposing_it() {
2191        let directory = tempfile::tempdir().unwrap();
2192        let sky_home = directory.path().join("sky");
2193        fs::create_dir_all(&sky_home).unwrap();
2194        fs::write(sky_home.join("credentials"), "ENCv1:secret-ciphertext").unwrap();
2195        let config = directory.path().join("infra.json");
2196        let cli = Cli::try_parse_from([
2197            "infra",
2198            "auth",
2199            "status",
2200            "--config",
2201            config.to_str().unwrap(),
2202            "--sky-home",
2203            sky_home.to_str().unwrap(),
2204        ])
2205        .unwrap();
2206
2207        let output = cli.execute().await.unwrap();
2208        let rendered = output.render(OutputFormat::Json).unwrap();
2209        assert!(rendered.contains("sky_login_encrypted"));
2210        assert!(!rendered.contains("secret-ciphertext"));
2211    }
2212
2213    #[test]
2214    fn renders_compact_and_pretty_json() {
2215        let output = CommandOutput::Json(serde_json::json!({ "ok": true }));
2216        assert_eq!(output.render(OutputFormat::Json).unwrap(), r#"{"ok":true}"#);
2217        assert!(output.render(OutputFormat::Pretty).unwrap().contains('\n'));
2218    }
2219
2220    #[tokio::test]
2221    async fn model_command_uses_sdk_route_and_bearer_credential() {
2222        let server = MockServer::start().await;
2223        Mock::given(method("GET"))
2224            .and(path("/v1/model/models"))
2225            .and(header("authorization", "Bearer test-token"))
2226            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2227                "object": "list",
2228                "data": [{ "id": "test-model" }]
2229            })))
2230            .expect(1)
2231            .mount(&server)
2232            .await;
2233
2234        let cli = Cli::try_parse_from([
2235            "infra",
2236            "model",
2237            "models",
2238            "--gateway",
2239            &server.uri(),
2240            "--token",
2241            "test-token",
2242        ])
2243        .unwrap();
2244        let output = cli.execute().await.unwrap();
2245        let CommandOutput::Json(value) = output else {
2246            panic!("model list must produce JSON");
2247        };
2248        assert_eq!(value["data"][0]["id"], "test-model");
2249    }
2250
2251    #[tokio::test]
2252    async fn runtime_commands_create_rename_and_revoke_through_the_typed_sdk() {
2253        let server = MockServer::start().await;
2254        let directory = tempfile::tempdir().unwrap();
2255        let transition_request = directory.path().join("transition-runtime.json");
2256        fs::write(
2257            &transition_request,
2258            br#"{"expectedRevision":2,"idempotencyKey":"revoke-runtime-test","reason":"test cleanup"}"#,
2259        )
2260        .unwrap();
2261
2262        Mock::given(method("POST"))
2263            .and(path("/internal/v1/runtime-identities"))
2264            .and(header("authorization", "Bearer test-token"))
2265            .and(body_json(serde_json::json!({
2266                "name": "code-dev",
2267                "kind": "code",
2268                "relations": {},
2269                "metadata": {},
2270                "idempotencyKey": ""
2271            })))
2272            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
2273                "identity": runtime_identity_json("code-dev", "pending", 1),
2274                "bootstrapToken": "rti_boot_secret"
2275            })))
2276            .expect(1)
2277            .mount(&server)
2278            .await;
2279        Mock::given(method("PATCH"))
2280            .and(path("/internal/v1/runtime-identities/rti_test"))
2281            .and(header("authorization", "Bearer test-token"))
2282            .and(body_json(serde_json::json!({
2283                "expectedRevision": 1,
2284                "name": "code-dev-renamed",
2285                "idempotencyKey": ""
2286            })))
2287            .respond_with(
2288                ResponseTemplate::new(200).set_body_json(runtime_identity_json(
2289                    "code-dev-renamed",
2290                    "pending",
2291                    2,
2292                )),
2293            )
2294            .expect(1)
2295            .mount(&server)
2296            .await;
2297        Mock::given(method("POST"))
2298            .and(path("/internal/v1/runtime-identities/rti_test:revoke"))
2299            .and(header("authorization", "Bearer test-token"))
2300            .and(body_json(serde_json::json!({
2301                "expectedRevision": 2,
2302                "idempotencyKey": "revoke-runtime-test",
2303                "reason": "test cleanup"
2304            })))
2305            .respond_with(
2306                ResponseTemplate::new(200).set_body_json(runtime_identity_json(
2307                    "code-dev-renamed",
2308                    "revoked",
2309                    3,
2310                )),
2311            )
2312            .expect(1)
2313            .mount(&server)
2314            .await;
2315
2316        let create = Cli::try_parse_from([
2317            "infra",
2318            "runtime",
2319            "create",
2320            "--name",
2321            "code-dev",
2322            "--gateway",
2323            &server.uri(),
2324            "--token",
2325            "test-token",
2326        ])
2327        .unwrap()
2328        .execute()
2329        .await
2330        .unwrap();
2331        let CommandOutput::Json(created) = create else {
2332            panic!("runtime create must produce JSON");
2333        };
2334        assert_eq!(created["identity"]["id"], "rti_test");
2335        assert_eq!(created["identity"]["name"], "code-dev");
2336        assert_eq!(created["bootstrapToken"], "rti_boot_secret");
2337
2338        let rename = Cli::try_parse_from([
2339            "infra",
2340            "runtime",
2341            "rename",
2342            "rti_test",
2343            "code-dev-renamed",
2344            "--expected-revision",
2345            "1",
2346            "--gateway",
2347            &server.uri(),
2348            "--token",
2349            "test-token",
2350        ])
2351        .unwrap()
2352        .execute()
2353        .await
2354        .unwrap();
2355        let CommandOutput::Json(renamed) = rename else {
2356            panic!("runtime rename must produce JSON");
2357        };
2358        assert_eq!(renamed["id"], "rti_test");
2359        assert_eq!(renamed["name"], "code-dev-renamed");
2360        assert_eq!(renamed["revision"], 2);
2361
2362        let revoke = Cli::try_parse_from([
2363            "infra",
2364            "runtime",
2365            "revoke",
2366            "rti_test",
2367            "--file",
2368            transition_request.to_str().unwrap(),
2369            "--gateway",
2370            &server.uri(),
2371            "--token",
2372            "test-token",
2373        ])
2374        .unwrap()
2375        .execute()
2376        .await
2377        .unwrap();
2378        let CommandOutput::Json(revoked) = revoke else {
2379            panic!("runtime revoke must produce JSON");
2380        };
2381        assert_eq!(revoked["status"], "revoked");
2382        assert_eq!(revoked["revision"], 3);
2383    }
2384
2385    #[tokio::test]
2386    async fn runtime_commands_replace_permissions_and_bootstrap_credential() {
2387        let server = MockServer::start().await;
2388        let directory = tempfile::tempdir().unwrap();
2389        let permissions_request = directory.path().join("permissions.json");
2390        fs::write(
2391            &permissions_request,
2392            br#"{"expectedRevision":0,"idempotencyKey":"grant-runtime-test","grants":[{"audience":"agent-infra","capabilities":["gateway:delegation:manage"]}]}"#,
2393        )
2394        .unwrap();
2395        let credential_request = directory.path().join("credential.json");
2396        fs::write(
2397            &credential_request,
2398            br#"{"workloadProof":"bootstrap-proof","audience":"agent-infra","capabilities":["gateway:delegation:manage"]}"#,
2399        )
2400        .unwrap();
2401
2402        Mock::given(method("PUT"))
2403            .and(path("/internal/v1/runtime-identities/rti_test/permissions"))
2404            .and(header("authorization", "Bearer test-token"))
2405            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2406                "identityId": "rti_test",
2407                "revision": 1,
2408                "grants": [{
2409                    "id": "grt_test",
2410                    "identityId": "rti_test",
2411                    "audience": "agent-infra",
2412                    "capabilities": ["gateway:delegation:manage"],
2413                    "createdAt": "2026-08-13T00:00:00Z"
2414                }]
2415            })))
2416            .expect(1)
2417            .mount(&server)
2418            .await;
2419        Mock::given(method("POST"))
2420            .and(path(
2421                agent_infra_sdk::gateway_contract::RUNTIME_CREDENTIAL_BOOTSTRAP_PATH,
2422            ))
2423            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2424                "accessToken": "runtime-token",
2425                "tokenType": "Bearer",
2426                "expiresInSeconds": 120,
2427                "keyId": "runtime-key"
2428            })))
2429            .expect(1)
2430            .mount(&server)
2431            .await;
2432
2433        let permissions = Cli::try_parse_from([
2434            "infra",
2435            "runtime",
2436            "permissions",
2437            "rti_test",
2438            "--file",
2439            permissions_request.to_str().unwrap(),
2440            "--gateway",
2441            &server.uri(),
2442            "--token",
2443            "test-token",
2444        ])
2445        .unwrap()
2446        .execute()
2447        .await
2448        .unwrap();
2449        let CommandOutput::Json(permissions) = permissions else {
2450            panic!("runtime permissions must produce JSON");
2451        };
2452        assert_eq!(permissions["revision"], 1);
2453
2454        let credential = Cli::try_parse_from([
2455            "infra",
2456            "runtime",
2457            "bootstrap-credential",
2458            "--file",
2459            credential_request.to_str().unwrap(),
2460            "--gateway",
2461            &server.uri(),
2462            "--token",
2463            "test-token",
2464        ])
2465        .unwrap()
2466        .execute()
2467        .await
2468        .unwrap();
2469        let CommandOutput::Json(credential) = credential else {
2470            panic!("runtime bootstrap credential must produce JSON");
2471        };
2472        assert_eq!(credential["accessToken"], "runtime-token");
2473        let requests = server.received_requests().await.unwrap();
2474        let bootstrap = requests
2475            .iter()
2476            .find(|request| {
2477                request.url.path()
2478                    == agent_infra_sdk::gateway_contract::RUNTIME_CREDENTIAL_BOOTSTRAP_PATH
2479            })
2480            .unwrap();
2481        assert!(!bootstrap.headers.contains_key("authorization"));
2482    }
2483
2484    fn runtime_identity_json(name: &str, status: &str, revision: u64) -> Value {
2485        serde_json::json!({
2486            "id": "rti_test",
2487            "name": name,
2488            "scope": { "tenantId": "tenant-test", "projectId": "project-test" },
2489            "kind": "code",
2490            "relations": {
2491                "runtimeIds": ["rti_test"],
2492                "userIds": ["user-test"]
2493            },
2494            "status": status,
2495            "revision": revision,
2496            "grantRevision": 0,
2497            "credentialGeneration": if status == "revoked" { 2 } else { 1 },
2498            "metadata": { "source": "cli-test" },
2499            "createdAt": "2026-08-13T00:00:00Z",
2500            "updatedAt": "2026-08-13T00:00:00Z"
2501        })
2502    }
2503
2504    #[tokio::test]
2505    async fn computer_list_uses_environment_api() {
2506        let server = MockServer::start().await;
2507        Mock::given(method("GET"))
2508            .and(path("/internal/v1/workspace/computers"))
2509            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
2510            .expect(1)
2511            .mount(&server)
2512            .await;
2513        let cli =
2514            Cli::try_parse_from(["infra", "computer", "list", "--gateway", &server.uri()]).unwrap();
2515
2516        let output = cli.execute().await.unwrap();
2517        let CommandOutput::Json(value) = output else {
2518            panic!("computer list must produce JSON");
2519        };
2520        assert_eq!(value, serde_json::json!([]));
2521    }
2522}