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 #[arg(long, global = true)]
44 pub gateway: Option<String>,
45
46 #[arg(long, global = true, hide_env_values = true)]
48 pub token: Option<String>,
49
50 #[arg(long, global = true, env = "AGENT_INFRA_CONFIG", value_name = "PATH")]
52 pub config: Option<PathBuf>,
53
54 #[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 #[arg(long, global = true, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=600))]
66 pub timeout: u64,
67
68 #[arg(long, global = true)]
70 pub trusted_mesh_http: bool,
71
72 #[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 #[default]
84 Pretty,
85 Json,
87}
88
89#[derive(Debug, Subcommand)]
90pub enum Command {
91 Config {
93 #[command(subcommand)]
94 command: ConfigCommand,
95 },
96 Auth {
98 #[command(subcommand)]
99 command: AuthCommand,
100 },
101 Gateway {
103 #[command(subcommand)]
104 command: GatewayCommand,
105 },
106 Agent {
108 #[command(subcommand)]
109 command: AgentCommand,
110 },
111 Runtime {
113 #[command(subcommand)]
114 command: RuntimeCommand,
115 },
116 Context {
118 #[command(subcommand)]
119 command: ContextCommand,
120 },
121 Computer {
123 #[command(subcommand)]
124 command: ComputerCommand,
125 },
126 Space {
128 #[command(subcommand)]
129 command: SpaceCommand,
130 },
131 Model {
133 #[command(subcommand)]
134 command: ModelCommand,
135 },
136}
137
138#[derive(Debug, Subcommand)]
139pub enum ConfigCommand {
140 Show,
142 Gateway {
144 #[command(subcommand)]
145 command: ConfigGatewayCommand,
146 },
147}
148
149#[derive(Debug, Subcommand)]
150pub enum ConfigGatewayCommand {
151 Set { url: String },
153 Get,
155 Unset,
157}
158
159#[derive(Debug, Subcommand)]
160pub enum AuthCommand {
161 Status,
163}
164
165#[derive(Debug, Subcommand)]
166pub enum GatewayCommand {
167 Discovery,
168 Live,
169 Ready,
170 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(JsonInput),
185 Get {
186 agent_id: String,
187 },
188 List(AgentListArgs),
189 Update {
191 agent_id: String,
192 #[command(flatten)]
193 input: JsonInput,
194 },
195}
196
197#[derive(Debug, Subcommand)]
198pub enum RuntimeCommand {
199 Create(RuntimeCreateArgs),
201 Get { runtime_id: String },
203 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 {
214 runtime_id: String,
215 #[command(flatten)]
216 input: JsonInput,
217 },
218 Suspend {
220 runtime_id: String,
221 #[command(flatten)]
222 input: JsonInput,
223 },
224 Resume {
226 runtime_id: String,
227 #[command(flatten)]
228 input: JsonInput,
229 },
230 Revoke {
232 runtime_id: String,
233 #[command(flatten)]
234 input: JsonInput,
235 },
236}
237
238#[derive(Debug, Args)]
239pub struct RuntimeCreateArgs {
240 #[arg(long, required_unless_present = "file")]
242 name: Option<String>,
243 #[arg(long, value_enum)]
245 kind: Option<CliRuntimeKind>,
246 #[arg(long)]
248 idempotency_key: Option<String>,
249 #[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(JsonInput),
328 Maintenance,
329}
330
331#[derive(Debug, Subcommand)]
332pub enum ComputerCommand {
333 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 {
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 Chat {
386 #[command(flatten)]
387 input: JsonInput,
388 #[arg(long)]
389 request_id: Option<String>,
390 },
391 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 #[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}