Skip to main content

agent_infra_cli/
lib.rs

1use std::{
2    fs,
3    io::{self, Read},
4    path::{Path, PathBuf},
5    sync::Arc,
6    time::Duration,
7};
8
9use agent_infra_sdk::{
10    CallOptions, InfraClient, StaticCredentials,
11    agent_contract::{
12        AgentId, AgentStatus, CreateAgentRequest, ListAgentsRequest, UpdateAgentRequest,
13    },
14    context_contract::ConversationRecord,
15    runtime_identity_contract::{
16        CreateRuntimeIdentityRequest, RuntimeIdentityId, RuntimeIdentityKind,
17        TransitionIdentityRequest, UpdateRuntimeIdentityRequest,
18    },
19    workspace_contract::{
20        CreateComputerRequest, CreateSpaceRequest, ListEnvironmentResourcesQuery, OwnerKind,
21    },
22};
23use anyhow::{Context, Result, anyhow, bail};
24use clap::{Args, Parser, Subcommand, ValueEnum};
25use serde::{Serialize, de::DeserializeOwned};
26use serde_json::Value;
27
28mod config;
29
30use config::{ConfigStore, resolve_credential};
31
32const DEFAULT_GATEWAY: &str = "http://127.0.0.1:5200";
33
34#[derive(Debug, Parser)]
35#[command(
36    name = "infra",
37    version,
38    about = "Operate Agent Infra through the typed Infra SDK",
39    arg_required_else_help = true
40)]
41pub struct Cli {
42    /// Agent Infra Gateway base URL.
43    #[arg(long, global = true)]
44    pub gateway: Option<String>,
45
46    /// Gateway bearer token. Prefer SKY_GATEWAY_TOKEN over command-line use.
47    #[arg(long, global = true, hide_env_values = true)]
48    pub token: Option<String>,
49
50    /// Override the local Infra CLI config file.
51    #[arg(long, global = true, env = "AGENT_INFRA_CONFIG", value_name = "PATH")]
52    pub config: Option<PathBuf>,
53
54    /// Override the Sky CLI home used for local credential discovery.
55    #[arg(
56        long,
57        global = true,
58        env = "AGENT_INFRA_SKY_HOME",
59        value_name = "PATH",
60        hide = true
61    )]
62    pub sky_home: Option<PathBuf>,
63
64    /// Request timeout in seconds.
65    #[arg(long, global = true, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=600))]
66    pub timeout: u64,
67
68    /// Permit plaintext HTTP to non-loopback hosts protected by a trusted service mesh.
69    #[arg(long, global = true)]
70    pub trusted_mesh_http: bool,
71
72    /// JSON rendering mode.
73    #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Pretty)]
74    pub output: OutputFormat,
75
76    #[command(subcommand)]
77    pub command: Command,
78}
79
80#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
81pub enum OutputFormat {
82    /// Indented JSON.
83    #[default]
84    Pretty,
85    /// Compact JSON.
86    Json,
87}
88
89#[derive(Debug, Subcommand)]
90pub enum Command {
91    /// Persist and inspect local CLI settings.
92    Config {
93        #[command(subcommand)]
94        command: ConfigCommand,
95    },
96    /// Inspect local Gateway credential discovery.
97    Auth {
98        #[command(subcommand)]
99        command: AuthCommand,
100    },
101    /// Inspect Gateway health, discovery, routing, and policy.
102    Gateway {
103        #[command(subcommand)]
104        command: GatewayCommand,
105    },
106    /// Create and inspect Agent Registry resources.
107    Agent {
108        #[command(subcommand)]
109        command: AgentCommand,
110    },
111    /// Create and manage Runtime Identity resources.
112    Runtime {
113        #[command(subcommand)]
114        command: RuntimeCommand,
115    },
116    /// Read and append durable conversation context.
117    Context {
118        #[command(subcommand)]
119        command: ContextCommand,
120    },
121    /// Manage Environment computers.
122    Computer {
123        #[command(subcommand)]
124        command: ComputerCommand,
125    },
126    /// Manage Environment spaces.
127    Space {
128        #[command(subcommand)]
129        command: SpaceCommand,
130    },
131    /// Invoke the OpenAI-compatible Model Infra API.
132    Model {
133        #[command(subcommand)]
134        command: ModelCommand,
135    },
136}
137
138#[derive(Debug, Subcommand)]
139pub enum ConfigCommand {
140    /// Show the config file and effective Gateway selection.
141    Show,
142    /// Manage the persisted Gateway address.
143    Gateway {
144        #[command(subcommand)]
145        command: ConfigGatewayCommand,
146    },
147}
148
149#[derive(Debug, Subcommand)]
150pub enum ConfigGatewayCommand {
151    /// Persist a Gateway URL locally.
152    Set { url: String },
153    /// Show the persisted and effective Gateway URL.
154    Get,
155    /// Remove the persisted Gateway URL.
156    Unset,
157}
158
159#[derive(Debug, Subcommand)]
160pub enum AuthCommand {
161    /// Probe explicit, environment, and local Sky credential sources.
162    Status,
163}
164
165#[derive(Debug, Subcommand)]
166pub enum GatewayCommand {
167    Discovery,
168    Live,
169    Ready,
170    /// Poll until the Gateway and its dependencies are ready.
171    WaitReady {
172        #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=3600))]
173        deadline: u64,
174    },
175    Dependencies,
176    Routes,
177    Metrics,
178    Policy,
179}
180
181#[derive(Debug, Subcommand)]
182pub enum AgentCommand {
183    /// Create an agent from a CreateAgentRequest JSON document.
184    Create(JsonInput),
185    Get {
186        agent_id: String,
187    },
188    List(AgentListArgs),
189    /// Update an agent from an UpdateAgentRequest JSON document.
190    Update {
191        agent_id: String,
192        #[command(flatten)]
193        input: JsonInput,
194    },
195}
196
197#[derive(Debug, Subcommand)]
198pub enum RuntimeCommand {
199    /// Create a named runtime identity.
200    Create(RuntimeCreateArgs),
201    /// Get a runtime identity by ID.
202    Get { runtime_id: String },
203    /// Change a runtime identity name without changing its ID.
204    Rename {
205        runtime_id: String,
206        name: String,
207        #[arg(long)]
208        expected_revision: u64,
209        #[arg(long)]
210        idempotency_key: Option<String>,
211    },
212    /// Activate a pending runtime identity using a TransitionIdentityRequest JSON document.
213    Activate {
214        runtime_id: String,
215        #[command(flatten)]
216        input: JsonInput,
217    },
218    /// Suspend an active runtime identity using a TransitionIdentityRequest JSON document.
219    Suspend {
220        runtime_id: String,
221        #[command(flatten)]
222        input: JsonInput,
223    },
224    /// Resume a suspended runtime identity using a TransitionIdentityRequest JSON document.
225    Resume {
226        runtime_id: String,
227        #[command(flatten)]
228        input: JsonInput,
229    },
230    /// Revoke a runtime identity using a TransitionIdentityRequest JSON document.
231    Revoke {
232        runtime_id: String,
233        #[command(flatten)]
234        input: JsonInput,
235    },
236}
237
238#[derive(Debug, Args)]
239pub struct RuntimeCreateArgs {
240    /// Mutable display name. Required unless --file supplies the full request.
241    #[arg(long, required_unless_present = "file")]
242    name: Option<String>,
243    /// Runtime workload kind. Defaults to code for inline creation.
244    #[arg(long, value_enum)]
245    kind: Option<CliRuntimeKind>,
246    /// Override the deterministic key derived from --name.
247    #[arg(long)]
248    idempotency_key: Option<String>,
249    /// Read a complete CreateRuntimeIdentityRequest JSON document.
250    #[arg(
251        long,
252        short = 'f',
253        value_name = "PATH",
254        conflicts_with_all = ["name", "kind", "idempotency_key"]
255    )]
256    file: Option<PathBuf>,
257}
258
259#[derive(Clone, Copy, Debug, ValueEnum)]
260enum CliRuntimeKind {
261    Code,
262    Evaluate,
263    Tool,
264    Custom,
265}
266
267impl From<CliRuntimeKind> for RuntimeIdentityKind {
268    fn from(value: CliRuntimeKind) -> Self {
269        match value {
270            CliRuntimeKind::Code => Self::Code,
271            CliRuntimeKind::Evaluate => Self::Evaluate,
272            CliRuntimeKind::Tool => Self::Tool,
273            CliRuntimeKind::Custom => Self::Custom,
274        }
275    }
276}
277
278#[derive(Debug, Args)]
279pub struct AgentListArgs {
280    #[arg(long, value_enum)]
281    status: Option<CliAgentStatus>,
282    #[arg(long)]
283    related_agent_id: Option<String>,
284    #[arg(long)]
285    related_runtime_id: Option<String>,
286    #[arg(long)]
287    related_user_id: Option<String>,
288    #[arg(long)]
289    cursor: Option<String>,
290    #[arg(long, value_parser = clap::value_parser!(u32).range(1..=200))]
291    limit: Option<u32>,
292}
293
294#[derive(Clone, Copy, Debug, ValueEnum)]
295enum CliAgentStatus {
296    Active,
297    Archived,
298}
299
300impl From<CliAgentStatus> for AgentStatus {
301    fn from(value: CliAgentStatus) -> Self {
302        match value {
303            CliAgentStatus::Active => Self::Active,
304            CliAgentStatus::Archived => Self::Archived,
305        }
306    }
307}
308
309#[derive(Debug, Subcommand)]
310pub enum ContextCommand {
311    Recent {
312        conversation_id: String,
313        #[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
314        limit: Option<u64>,
315        #[arg(long, value_delimiter = ',')]
316        role: Vec<String>,
317    },
318    Search {
319        conversation_id: String,
320        query: String,
321        #[arg(long)]
322        role: Option<String>,
323        #[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
324        limit: Option<u64>,
325    },
326    /// Append a JSON array of ConversationRecord objects.
327    Append(JsonInput),
328    Maintenance,
329}
330
331#[derive(Debug, Subcommand)]
332pub enum ComputerCommand {
333    /// Create a computer from JSON.
334    Create {
335        #[command(flatten)]
336        input: JsonInput,
337        #[arg(long)]
338        idempotency_key: String,
339    },
340    Get {
341        computer_id: String,
342    },
343    List {
344        #[arg(long)]
345        owner_kind: Option<String>,
346        #[arg(long)]
347        owner_id: Option<String>,
348    },
349    Delete {
350        computer_id: String,
351        #[arg(long)]
352        idempotency_key: String,
353    },
354}
355
356#[derive(Debug, Subcommand)]
357pub enum SpaceCommand {
358    /// Create a space from JSON.
359    Create {
360        #[command(flatten)]
361        input: JsonInput,
362        #[arg(long)]
363        idempotency_key: String,
364    },
365    Get {
366        space_id: String,
367    },
368    List {
369        #[arg(long)]
370        owner_kind: Option<String>,
371        #[arg(long)]
372        owner_id: Option<String>,
373    },
374    Delete {
375        space_id: String,
376        #[arg(long)]
377        idempotency_key: String,
378    },
379}
380
381#[derive(Debug, Subcommand)]
382pub enum ModelCommand {
383    Models,
384    /// Invoke chat/completions with a JSON request document.
385    Chat {
386        #[command(flatten)]
387        input: JsonInput,
388        #[arg(long)]
389        request_id: Option<String>,
390    },
391    /// Invoke responses with a JSON request document.
392    Responses {
393        #[command(flatten)]
394        input: JsonInput,
395        #[arg(long)]
396        request_id: Option<String>,
397    },
398}
399
400#[derive(Debug, Args)]
401pub struct JsonInput {
402    /// JSON file to read; use '-' (the default) for standard input.
403    #[arg(long, short = 'f', default_value = "-")]
404    file: PathBuf,
405}
406
407impl JsonInput {
408    fn read<T: DeserializeOwned>(&self) -> Result<T> {
409        let bytes = read_input(&self.file)?;
410        serde_json::from_slice(&bytes)
411            .with_context(|| format!("invalid JSON input from {}", display_source(&self.file)))
412    }
413}
414
415#[derive(Debug)]
416pub enum CommandOutput {
417    Json(Value),
418    Text(String),
419}
420
421impl CommandOutput {
422    fn json(value: impl Serialize) -> Result<Self> {
423        Ok(Self::Json(serde_json::to_value(value)?))
424    }
425
426    pub fn render(&self, format: OutputFormat) -> Result<String> {
427        match self {
428            Self::Text(value) => Ok(value.clone()),
429            Self::Json(value) => match format {
430                OutputFormat::Pretty => Ok(serde_json::to_string_pretty(value)?),
431                OutputFormat::Json => Ok(serde_json::to_string(value)?),
432            },
433        }
434    }
435}
436
437impl Cli {
438    pub async fn execute(self) -> Result<CommandOutput> {
439        if let Command::Config { command } = &self.command {
440            return execute_config(&self, command);
441        }
442        if let Command::Auth { command } = &self.command {
443            return execute_auth(&self, command);
444        }
445        let client = self.build_client()?;
446        match self.command {
447            Command::Config { .. } | Command::Auth { .. } => {
448                unreachable!("local commands returned before building the client")
449            }
450            Command::Gateway { command } => execute_gateway(&client, command).await,
451            Command::Agent { command } => execute_agent(&client, command).await,
452            Command::Runtime { command } => execute_runtime(&client, command).await,
453            Command::Context { command } => execute_context(&client, command).await,
454            Command::Computer { command } => execute_computer(&client, command).await,
455            Command::Space { command } => execute_space(&client, command).await,
456            Command::Model { command } => execute_model(&client, command).await,
457        }
458    }
459
460    fn build_client(&self) -> Result<InfraClient> {
461        let store = ConfigStore::discover(self.config.as_deref())?;
462        let gateway = store.resolve_gateway(self.gateway.as_deref())?;
463        let (token, _) = resolve_credential(self.token.as_deref(), self.sky_home.as_deref())?;
464        let mut builder = InfraClient::builder(gateway.value)
465            .request_timeout(Duration::from_secs(self.timeout))
466            .trusted_mesh_http(self.trusted_mesh_http)
467            .user_agent(concat!("agent-infra-cli/", env!("CARGO_PKG_VERSION")));
468        if let Some(token) = token {
469            builder = builder.credentials(Arc::new(StaticCredentials::new(token)?));
470        }
471        builder.build().map_err(Into::into)
472    }
473}
474
475fn execute_config(cli: &Cli, command: &ConfigCommand) -> Result<CommandOutput> {
476    let store = ConfigStore::discover(cli.config.as_deref())?;
477    match command {
478        ConfigCommand::Show
479        | ConfigCommand::Gateway {
480            command: ConfigGatewayCommand::Get,
481        } => {
482            let config = store.load()?;
483            let effective = store.resolve_gateway(cli.gateway.as_deref())?;
484            CommandOutput::json(serde_json::json!({
485                "path": store.path(),
486                "gateway": config.gateway,
487                "effectiveGateway": effective.value,
488                "source": effective.source,
489            }))
490        }
491        ConfigCommand::Gateway {
492            command: ConfigGatewayCommand::Set { url },
493        } => {
494            let config = store.set_gateway(url)?;
495            CommandOutput::json(serde_json::json!({
496                "ok": true,
497                "path": store.path(),
498                "gateway": config.gateway,
499            }))
500        }
501        ConfigCommand::Gateway {
502            command: ConfigGatewayCommand::Unset,
503        } => {
504            store.unset_gateway()?;
505            CommandOutput::json(serde_json::json!({
506                "ok": true,
507                "path": store.path(),
508                "gateway": null,
509            }))
510        }
511    }
512}
513
514fn execute_auth(cli: &Cli, command: &AuthCommand) -> Result<CommandOutput> {
515    match command {
516        AuthCommand::Status => {
517            let store = ConfigStore::discover(cli.config.as_deref())?;
518            let gateway = store.resolve_gateway(cli.gateway.as_deref())?;
519            let (_, credential) =
520                resolve_credential(cli.token.as_deref(), cli.sky_home.as_deref())?;
521            CommandOutput::json(serde_json::json!({
522                "gateway": gateway.value,
523                "gatewaySource": gateway.source,
524                "credential": credential,
525            }))
526        }
527    }
528}
529
530async fn execute_gateway(client: &InfraClient, command: GatewayCommand) -> Result<CommandOutput> {
531    match command {
532        GatewayCommand::Discovery => CommandOutput::json(client.gateway().discovery().await?),
533        GatewayCommand::Live => CommandOutput::json(client.gateway().live().await?),
534        GatewayCommand::Ready => CommandOutput::json(client.gateway().ready().await?),
535        GatewayCommand::WaitReady { deadline } => CommandOutput::json(
536            client
537                .gateway()
538                .wait_until_ready(Duration::from_secs(deadline))
539                .await?,
540        ),
541        GatewayCommand::Dependencies => CommandOutput::json(client.gateway().dependencies().await?),
542        GatewayCommand::Routes => CommandOutput::json(client.gateway().routes().await?),
543        GatewayCommand::Metrics => CommandOutput::json(client.gateway().metrics().await?),
544        GatewayCommand::Policy => CommandOutput::json(client.gateway().effective_policy().await?),
545    }
546}
547
548async fn execute_agent(client: &InfraClient, command: AgentCommand) -> Result<CommandOutput> {
549    match command {
550        AgentCommand::Create(input) => {
551            let request: CreateAgentRequest = input.read()?;
552            CommandOutput::json(client.agents().create(&request).await?)
553        }
554        AgentCommand::Get { agent_id } => {
555            let agent_id = parse_agent_id(&agent_id)?;
556            CommandOutput::json(client.agents().get(&agent_id).await?)
557        }
558        AgentCommand::List(args) => {
559            let request = ListAgentsRequest {
560                status: args.status.map(Into::into),
561                related_agent_id: args
562                    .related_agent_id
563                    .as_deref()
564                    .map(parse_agent_id)
565                    .transpose()?,
566                related_runtime_id: args.related_runtime_id,
567                related_user_id: args.related_user_id,
568                cursor: args.cursor,
569                limit: args.limit,
570            };
571            CommandOutput::json(client.agents().list(&request).await?)
572        }
573        AgentCommand::Update { agent_id, input } => {
574            let agent_id = parse_agent_id(&agent_id)?;
575            let request: UpdateAgentRequest = input.read()?;
576            CommandOutput::json(client.agents().update(&agent_id, &request).await?)
577        }
578    }
579}
580
581async fn execute_runtime(client: &InfraClient, command: RuntimeCommand) -> Result<CommandOutput> {
582    match command {
583        RuntimeCommand::Create(args) => {
584            let request = match args.file {
585                Some(file) => JsonInput { file }.read::<CreateRuntimeIdentityRequest>()?,
586                None => CreateRuntimeIdentityRequest {
587                    id: None,
588                    name: args
589                        .name
590                        .ok_or_else(|| anyhow!("--name is required for inline runtime creation"))?,
591                    kind: args.kind.unwrap_or(CliRuntimeKind::Code).into(),
592                    agent_id: None,
593                    relations: Default::default(),
594                    metadata: Default::default(),
595                    idempotency_key: args.idempotency_key.unwrap_or_default(),
596                },
597            };
598            CommandOutput::json(client.runtime_identity().create(&request).await?)
599        }
600        RuntimeCommand::Get { runtime_id } => {
601            let runtime_id = parse_runtime_id(&runtime_id)?;
602            CommandOutput::json(client.runtime_identity().get(&runtime_id).await?)
603        }
604        RuntimeCommand::Rename {
605            runtime_id,
606            name,
607            expected_revision,
608            idempotency_key,
609        } => {
610            let runtime_id = parse_runtime_id(&runtime_id)?;
611            let request = UpdateRuntimeIdentityRequest {
612                expected_revision,
613                name,
614                idempotency_key: idempotency_key.unwrap_or_default(),
615            };
616            CommandOutput::json(
617                client
618                    .runtime_identity()
619                    .update(&runtime_id, &request)
620                    .await?,
621            )
622        }
623        RuntimeCommand::Activate { runtime_id, input } => {
624            execute_runtime_transition(client, runtime_id, "activate", input).await
625        }
626        RuntimeCommand::Suspend { runtime_id, input } => {
627            execute_runtime_transition(client, runtime_id, "suspend", input).await
628        }
629        RuntimeCommand::Resume { runtime_id, input } => {
630            execute_runtime_transition(client, runtime_id, "resume", input).await
631        }
632        RuntimeCommand::Revoke { runtime_id, input } => {
633            execute_runtime_transition(client, runtime_id, "revoke", input).await
634        }
635    }
636}
637
638async fn execute_runtime_transition(
639    client: &InfraClient,
640    runtime_id: String,
641    action: &str,
642    input: JsonInput,
643) -> Result<CommandOutput> {
644    let runtime_id = parse_runtime_id(&runtime_id)?;
645    let request: TransitionIdentityRequest = input.read()?;
646    CommandOutput::json(
647        client
648            .runtime_identity()
649            .transition(&runtime_id, action, &request)
650            .await?,
651    )
652}
653
654async fn execute_context(client: &InfraClient, command: ContextCommand) -> Result<CommandOutput> {
655    match command {
656        ContextCommand::Recent {
657            conversation_id,
658            limit,
659            role,
660        } => {
661            let roles = role.iter().map(String::as_str).collect::<Vec<_>>();
662            CommandOutput::json(
663                client
664                    .context()
665                    .recent_messages(&conversation_id, limit.map(|value| value as usize), &roles)
666                    .await?,
667            )
668        }
669        ContextCommand::Search {
670            conversation_id,
671            query,
672            role,
673            limit,
674        } => CommandOutput::json(
675            client
676                .context()
677                .search_messages(
678                    &conversation_id,
679                    &query,
680                    role.as_deref(),
681                    limit.map(|value| value as usize),
682                )
683                .await?,
684        ),
685        ContextCommand::Append(input) => {
686            let messages: Vec<ConversationRecord> = input.read()?;
687            if messages.is_empty() {
688                bail!("append input must contain at least one message");
689            }
690            CommandOutput::json(client.context().append_messages(messages).await?)
691        }
692        ContextCommand::Maintenance => {
693            CommandOutput::json(client.context().maintenance_status().await?)
694        }
695    }
696}
697
698async fn execute_computer(client: &InfraClient, command: ComputerCommand) -> Result<CommandOutput> {
699    let environment = client.environment();
700    match command {
701        ComputerCommand::Create {
702            input,
703            idempotency_key,
704        } => {
705            let request: CreateComputerRequest = input.read()?;
706            CommandOutput::json(
707                environment
708                    .create_computer(&request, &idempotency_key)
709                    .await?,
710            )
711        }
712        ComputerCommand::Get { computer_id } => {
713            CommandOutput::json(environment.computer(&computer_id).await?)
714        }
715        ComputerCommand::List {
716            owner_kind,
717            owner_id,
718        } => CommandOutput::json(
719            environment
720                .computers(Some(&list_environment_query(owner_kind, owner_id)?))
721                .await?,
722        ),
723        ComputerCommand::Delete {
724            computer_id,
725            idempotency_key,
726        } => CommandOutput::json(
727            environment
728                .delete_computer(&computer_id, &idempotency_key)
729                .await?,
730        ),
731    }
732}
733
734async fn execute_space(client: &InfraClient, command: SpaceCommand) -> Result<CommandOutput> {
735    let environment = client.environment();
736    match command {
737        SpaceCommand::Create {
738            input,
739            idempotency_key,
740        } => {
741            let request: CreateSpaceRequest = input.read()?;
742            CommandOutput::json(
743                environment.create_space(&request, &idempotency_key).await?,
744            )
745        }
746        SpaceCommand::Get { space_id } => {
747            CommandOutput::json(environment.space(&space_id).await?)
748        }
749        SpaceCommand::List {
750            owner_kind,
751            owner_id,
752        } => CommandOutput::json(
753            environment
754                .spaces(Some(&list_environment_query(owner_kind, owner_id)?))
755                .await?,
756        ),
757        SpaceCommand::Delete {
758            space_id,
759            idempotency_key,
760        } => CommandOutput::json(
761            environment
762                .delete_space(&space_id, &idempotency_key)
763                .await?,
764        ),
765    }
766}
767
768fn list_environment_query(
769    owner_kind: Option<String>,
770    owner_id: Option<String>,
771) -> Result<ListEnvironmentResourcesQuery> {
772    let owner_kind = match owner_kind {
773        Some(value) => Some(parse_owner_kind(&value)?),
774        None => None,
775    };
776    if owner_kind.is_some() != owner_id.is_some() {
777        bail!("--owner-kind and --owner-id must be provided together");
778    }
779    Ok(ListEnvironmentResourcesQuery {
780        owner_kind,
781        owner_id,
782        contains_all: vec![],
783    })
784}
785
786fn parse_owner_kind(value: &str) -> Result<OwnerKind> {
787    match value.to_ascii_lowercase().as_str() {
788        "user" => Ok(OwnerKind::User),
789        "agent" => Ok(OwnerKind::Agent),
790        "runtime" => Ok(OwnerKind::Runtime),
791        other => bail!("unsupported owner kind {other:?}; expected user, agent, or runtime"),
792    }
793}
794
795async fn execute_model(client: &InfraClient, command: ModelCommand) -> Result<CommandOutput> {
796    match command {
797        ModelCommand::Models => CommandOutput::json(client.model().models().await?),
798        ModelCommand::Chat { input, request_id } => {
799            let request: Value = input.read()?;
800            ensure_json_object(&request)?;
801            CommandOutput::json(
802                client
803                    .model()
804                    .chat_completions(&request, call_options(request_id))
805                    .await?,
806            )
807        }
808        ModelCommand::Responses { input, request_id } => {
809            let request: Value = input.read()?;
810            ensure_json_object(&request)?;
811            CommandOutput::json(
812                client
813                    .model()
814                    .responses(&request, call_options(request_id))
815                    .await?,
816            )
817        }
818    }
819}
820
821fn call_options(request_id: Option<String>) -> CallOptions {
822    request_id.map_or_else(CallOptions::default, |value| {
823        CallOptions::default().request_id(value)
824    })
825}
826
827fn ensure_json_object(value: &Value) -> Result<()> {
828    if !value.is_object() {
829        bail!("model request must be a JSON object");
830    }
831    Ok(())
832}
833
834fn parse_agent_id(value: &str) -> Result<AgentId> {
835    AgentId::new(value).map_err(|message| anyhow!(message))
836}
837
838fn parse_runtime_id(value: &str) -> Result<RuntimeIdentityId> {
839    RuntimeIdentityId::new(value).map_err(|message| anyhow!(message))
840}
841
842fn read_input(path: &Path) -> Result<Vec<u8>> {
843    if path == Path::new("-") {
844        let mut bytes = Vec::new();
845        io::stdin()
846            .read_to_end(&mut bytes)
847            .context("failed to read standard input")?;
848        Ok(bytes)
849    } else {
850        fs::read(path).with_context(|| format!("failed to read {}", path.display()))
851    }
852}
853
854fn display_source(path: &Path) -> String {
855    if path == Path::new("-") {
856        "standard input".into()
857    } else {
858        path.display().to_string()
859    }
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865    use clap::CommandFactory;
866    use wiremock::{
867        Mock, MockServer, ResponseTemplate,
868        matchers::{body_json, header, method, path},
869    };
870
871    #[test]
872    fn clap_definition_is_valid() {
873        Cli::command().debug_assert();
874    }
875
876    #[test]
877    fn parses_global_options_after_subcommands() {
878        let cli = Cli::try_parse_from([
879            "infra",
880            "gateway",
881            "wait-ready",
882            "--deadline",
883            "42",
884            "--gateway",
885            "https://infra.example.com",
886            "--output",
887            "json",
888        ])
889        .unwrap();
890        assert_eq!(cli.gateway.as_deref(), Some("https://infra.example.com"));
891        assert_eq!(cli.output, OutputFormat::Json);
892        assert!(matches!(
893            cli.command,
894            Command::Gateway {
895                command: GatewayCommand::WaitReady { deadline: 42 }
896            }
897        ));
898    }
899
900    #[test]
901    fn rejects_removed_workspace_subcommand() {
902        let result = Cli::try_parse_from(["infra", "workspace", "write", "README.md"]);
903        assert!(result.is_err());
904    }
905
906    #[test]
907    fn reads_json_request_from_file() {
908        let directory = tempfile::tempdir().unwrap();
909        let path = directory.path().join("request.json");
910        fs::write(&path, br#"{"model":"test"}"#).unwrap();
911        let input = JsonInput { file: path };
912        let value: Value = input.read().unwrap();
913        assert_eq!(value["model"], "test");
914    }
915
916    #[tokio::test]
917    async fn persists_and_resolves_gateway_without_network_access() {
918        let directory = tempfile::tempdir().unwrap();
919        let config = directory.path().join("infra.json");
920        let cli = Cli::try_parse_from([
921            "infra",
922            "config",
923            "gateway",
924            "set",
925            "https://infra.example.com/",
926            "--config",
927            config.to_str().unwrap(),
928        ])
929        .unwrap();
930        let output = cli.execute().await.unwrap();
931        let CommandOutput::Json(value) = output else {
932            panic!("config set must produce JSON");
933        };
934        assert_eq!(value["gateway"], "https://infra.example.com");
935
936        let store = ConfigStore::discover(Some(&config)).unwrap();
937        assert_eq!(
938            store.load().unwrap().gateway.as_deref(),
939            Some("https://infra.example.com")
940        );
941    }
942
943    #[tokio::test]
944    async fn auth_status_detects_encrypted_sky_login_without_exposing_it() {
945        let directory = tempfile::tempdir().unwrap();
946        let sky_home = directory.path().join("sky");
947        fs::create_dir_all(&sky_home).unwrap();
948        fs::write(sky_home.join("credentials"), "ENCv1:secret-ciphertext").unwrap();
949        let config = directory.path().join("infra.json");
950        let cli = Cli::try_parse_from([
951            "infra",
952            "auth",
953            "status",
954            "--config",
955            config.to_str().unwrap(),
956            "--sky-home",
957            sky_home.to_str().unwrap(),
958        ])
959        .unwrap();
960
961        let output = cli.execute().await.unwrap();
962        let rendered = output.render(OutputFormat::Json).unwrap();
963        assert!(rendered.contains("sky_login_encrypted"));
964        assert!(!rendered.contains("secret-ciphertext"));
965    }
966
967    #[test]
968    fn renders_compact_and_pretty_json() {
969        let output = CommandOutput::Json(serde_json::json!({ "ok": true }));
970        assert_eq!(output.render(OutputFormat::Json).unwrap(), r#"{"ok":true}"#);
971        assert!(output.render(OutputFormat::Pretty).unwrap().contains('\n'));
972    }
973
974    #[tokio::test]
975    async fn model_command_uses_sdk_route_and_bearer_credential() {
976        let server = MockServer::start().await;
977        Mock::given(method("GET"))
978            .and(path("/v1/model/models"))
979            .and(header("authorization", "Bearer test-token"))
980            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
981                "object": "list",
982                "data": [{ "id": "test-model" }]
983            })))
984            .expect(1)
985            .mount(&server)
986            .await;
987
988        let cli = Cli::try_parse_from([
989            "infra",
990            "model",
991            "models",
992            "--gateway",
993            &server.uri(),
994            "--token",
995            "test-token",
996        ])
997        .unwrap();
998        let output = cli.execute().await.unwrap();
999        let CommandOutput::Json(value) = output else {
1000            panic!("model list must produce JSON");
1001        };
1002        assert_eq!(value["data"][0]["id"], "test-model");
1003    }
1004
1005    #[tokio::test]
1006    async fn runtime_commands_create_rename_and_revoke_through_the_typed_sdk() {
1007        let server = MockServer::start().await;
1008        let directory = tempfile::tempdir().unwrap();
1009        let transition_request = directory.path().join("transition-runtime.json");
1010        fs::write(
1011            &transition_request,
1012            br#"{"expectedRevision":2,"idempotencyKey":"revoke-runtime-test","reason":"test cleanup"}"#,
1013        )
1014        .unwrap();
1015
1016        Mock::given(method("POST"))
1017            .and(path("/internal/v1/runtime-identities"))
1018            .and(header("authorization", "Bearer test-token"))
1019            .and(body_json(serde_json::json!({
1020                "name": "code-dev",
1021                "kind": "code",
1022                "relations": {},
1023                "metadata": {},
1024                "idempotencyKey": ""
1025            })))
1026            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
1027                "identity": runtime_identity_json("code-dev", "pending", 1),
1028                "bootstrapToken": "rti_boot_secret"
1029            })))
1030            .expect(1)
1031            .mount(&server)
1032            .await;
1033        Mock::given(method("PATCH"))
1034            .and(path("/internal/v1/runtime-identities/rti_test"))
1035            .and(header("authorization", "Bearer test-token"))
1036            .and(body_json(serde_json::json!({
1037                "expectedRevision": 1,
1038                "name": "code-dev-renamed",
1039                "idempotencyKey": ""
1040            })))
1041            .respond_with(
1042                ResponseTemplate::new(200).set_body_json(runtime_identity_json(
1043                    "code-dev-renamed",
1044                    "pending",
1045                    2,
1046                )),
1047            )
1048            .expect(1)
1049            .mount(&server)
1050            .await;
1051        Mock::given(method("POST"))
1052            .and(path("/internal/v1/runtime-identities/rti_test:revoke"))
1053            .and(header("authorization", "Bearer test-token"))
1054            .and(body_json(serde_json::json!({
1055                "expectedRevision": 2,
1056                "idempotencyKey": "revoke-runtime-test",
1057                "reason": "test cleanup"
1058            })))
1059            .respond_with(
1060                ResponseTemplate::new(200).set_body_json(runtime_identity_json(
1061                    "code-dev-renamed",
1062                    "revoked",
1063                    3,
1064                )),
1065            )
1066            .expect(1)
1067            .mount(&server)
1068            .await;
1069
1070        let create = Cli::try_parse_from([
1071            "infra",
1072            "runtime",
1073            "create",
1074            "--name",
1075            "code-dev",
1076            "--gateway",
1077            &server.uri(),
1078            "--token",
1079            "test-token",
1080        ])
1081        .unwrap()
1082        .execute()
1083        .await
1084        .unwrap();
1085        let CommandOutput::Json(created) = create else {
1086            panic!("runtime create must produce JSON");
1087        };
1088        assert_eq!(created["identity"]["id"], "rti_test");
1089        assert_eq!(created["identity"]["name"], "code-dev");
1090        assert_eq!(created["bootstrapToken"], "rti_boot_secret");
1091
1092        let rename = Cli::try_parse_from([
1093            "infra",
1094            "runtime",
1095            "rename",
1096            "rti_test",
1097            "code-dev-renamed",
1098            "--expected-revision",
1099            "1",
1100            "--gateway",
1101            &server.uri(),
1102            "--token",
1103            "test-token",
1104        ])
1105        .unwrap()
1106        .execute()
1107        .await
1108        .unwrap();
1109        let CommandOutput::Json(renamed) = rename else {
1110            panic!("runtime rename must produce JSON");
1111        };
1112        assert_eq!(renamed["id"], "rti_test");
1113        assert_eq!(renamed["name"], "code-dev-renamed");
1114        assert_eq!(renamed["revision"], 2);
1115
1116        let revoke = Cli::try_parse_from([
1117            "infra",
1118            "runtime",
1119            "revoke",
1120            "rti_test",
1121            "--file",
1122            transition_request.to_str().unwrap(),
1123            "--gateway",
1124            &server.uri(),
1125            "--token",
1126            "test-token",
1127        ])
1128        .unwrap()
1129        .execute()
1130        .await
1131        .unwrap();
1132        let CommandOutput::Json(revoked) = revoke else {
1133            panic!("runtime revoke must produce JSON");
1134        };
1135        assert_eq!(revoked["status"], "revoked");
1136        assert_eq!(revoked["revision"], 3);
1137    }
1138
1139    fn runtime_identity_json(name: &str, status: &str, revision: u64) -> Value {
1140        serde_json::json!({
1141            "id": "rti_test",
1142            "name": name,
1143            "scope": { "tenantId": "tenant-test", "projectId": "project-test" },
1144            "kind": "code",
1145            "relations": {
1146                "runtimeIds": ["rti_test"],
1147                "userIds": ["user-test"]
1148            },
1149            "status": status,
1150            "revision": revision,
1151            "grantRevision": 0,
1152            "credentialGeneration": if status == "revoked" { 2 } else { 1 },
1153            "metadata": { "source": "cli-test" },
1154            "createdAt": "2026-08-13T00:00:00Z",
1155            "updatedAt": "2026-08-13T00:00:00Z"
1156        })
1157    }
1158
1159    #[tokio::test]
1160    async fn computer_list_uses_environment_api() {
1161        let server = MockServer::start().await;
1162        Mock::given(method("GET"))
1163            .and(path("/internal/v1/computers"))
1164            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1165            .expect(1)
1166            .mount(&server)
1167            .await;
1168        let cli = Cli::try_parse_from([
1169            "infra",
1170            "computer",
1171            "list",
1172            "--gateway",
1173            &server.uri(),
1174        ])
1175        .unwrap();
1176
1177        let output = cli.execute().await.unwrap();
1178        let CommandOutput::Json(value) = output else {
1179            panic!("computer list must produce JSON");
1180        };
1181        assert_eq!(value, serde_json::json!([]));
1182    }
1183}