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