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