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