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 evaluate_contract::{
16 CreateEvalRunRequest, GetOperationRequest, GetReportRequest, ListCaseRunsRequest,
17 RegisterEnvironmentProfileRequest, RegisterEvaluatorVersionRequest, RegisterSuiteRequest,
18 },
19 runtime_identity_contract::{
20 CreateRuntimeIdentityRequest, IssueRuntimeCredentialRequest, ReplacePermissionsRequest,
21 RuntimeIdentityId, RuntimeIdentityKind, TransitionIdentityRequest,
22 UpdateRuntimeIdentityRequest,
23 },
24 trace_contract::{
25 CreateLangfuseProjectRequest, ListLangfuseProjectsRequest, MAX_METRICS_LOOKBACK_DAYS,
26 MetricsDataSource, MetricsQuery,
27 },
28 workspace_contract::{
29 CreateComputerRequest, CreateSpaceRequest, ListEnvironmentResourcesQuery, OwnerKind,
30 },
31};
32use anyhow::{Context, Result, anyhow, bail};
33use clap::{Args, Parser, Subcommand, ValueEnum};
34use serde::{Serialize, de::DeserializeOwned};
35use serde_json::Value;
36
37mod config;
38
39use config::{ConfigStore, resolve_credential};
40
41const DEFAULT_GATEWAY: &str = "http://127.0.0.1:5200";
42
43#[derive(Debug, Parser)]
44#[command(
45 name = "infra",
46 version,
47 about = "Operate Agent Infra through the typed Infra SDK",
48 arg_required_else_help = true
49)]
50pub struct Cli {
51 #[arg(long, global = true)]
53 pub gateway: Option<String>,
54
55 #[arg(long, global = true, hide_env_values = true)]
57 pub token: Option<String>,
58
59 #[arg(long, global = true, env = "AGENT_INFRA_CONFIG", value_name = "PATH")]
61 pub config: Option<PathBuf>,
62
63 #[arg(
65 long,
66 global = true,
67 env = "AGENT_INFRA_SKY_HOME",
68 value_name = "PATH",
69 hide = true
70 )]
71 pub sky_home: Option<PathBuf>,
72
73 #[arg(long, global = true, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=600))]
75 pub timeout: u64,
76
77 #[arg(long, global = true)]
79 pub trusted_mesh_http: bool,
80
81 #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Pretty)]
83 pub output: OutputFormat,
84
85 #[command(subcommand)]
86 pub command: Command,
87}
88
89#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
90pub enum OutputFormat {
91 #[default]
93 Pretty,
94 Json,
96}
97
98#[derive(Debug, Subcommand)]
99pub enum Command {
100 Config {
102 #[command(subcommand)]
103 command: ConfigCommand,
104 },
105 Auth {
107 #[command(subcommand)]
108 command: AuthCommand,
109 },
110 Gateway {
112 #[command(subcommand)]
113 command: GatewayCommand,
114 },
115 Trace {
117 #[command(subcommand)]
118 command: TraceCommand,
119 },
120 Agent {
122 #[command(subcommand)]
123 command: AgentCommand,
124 },
125 Runtime {
127 #[command(subcommand)]
128 command: RuntimeCommand,
129 },
130 Context {
132 #[command(subcommand)]
133 command: ContextCommand,
134 },
135 Computer {
137 #[command(subcommand)]
138 command: ComputerCommand,
139 },
140 Space {
142 #[command(subcommand)]
143 command: SpaceCommand,
144 },
145 Model {
147 #[command(subcommand)]
148 command: ModelCommand,
149 },
150 Evaluate {
152 #[command(subcommand)]
153 command: EvaluateCommand,
154 },
155}
156
157#[derive(Debug, Subcommand)]
158pub enum EvaluateCommand {
159 RegisterEnvironment(JsonInput),
160 RegisterEvaluator(JsonInput),
161 RegisterSuite(JsonInput),
162 #[command(alias = "run")]
164 TaskCreate(JsonInput),
165 Operation {
166 operation_id: String,
167 },
168 Cases(JsonInput),
169 Report {
170 report_id: String,
171 },
172}
173
174#[derive(Debug, Subcommand)]
175pub enum ConfigCommand {
176 Show,
178 Gateway {
180 #[command(subcommand)]
181 command: ConfigGatewayCommand,
182 },
183}
184
185#[derive(Debug, Subcommand)]
186pub enum ConfigGatewayCommand {
187 Set { url: String },
189 Get,
191 Unset,
193}
194
195#[derive(Debug, Subcommand)]
196pub enum AuthCommand {
197 Status,
199}
200
201#[derive(Debug, Subcommand)]
202pub enum GatewayCommand {
203 Discovery,
204 Live,
205 Ready,
206 WaitReady {
208 #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=3600))]
209 deadline: u64,
210 },
211 Dependencies,
212 Routes,
213 Metrics,
214 Policy,
215}
216
217#[derive(Debug, Subcommand)]
218pub enum TraceCommand {
219 Metrics {
221 #[command(subcommand)]
222 command: TraceMetricsCommand,
223 },
224 LangfuseProject {
226 #[command(subcommand)]
227 command: LangfuseProjectCommand,
228 },
229}
230
231#[derive(Debug, Subcommand)]
232pub enum LangfuseProjectCommand {
233 Create {
235 #[command(flatten)]
236 input: JsonInput,
237 #[arg(long)]
238 idempotency_key: String,
239 },
240 Get {
241 project_id: String,
242 },
243 List(LangfuseProjectListArgs),
244}
245
246#[derive(Debug, Args)]
247pub struct LangfuseProjectListArgs {
248 #[arg(long)]
249 related_agent_id: Option<String>,
250 #[arg(long)]
251 related_runtime_id: Option<String>,
252 #[arg(long)]
253 related_user_id: Option<String>,
254 #[arg(long)]
255 cursor: Option<String>,
256 #[arg(long, value_parser = clap::value_parser!(u32).range(1..=200))]
257 limit: Option<u32>,
258}
259
260#[derive(Debug, Subcommand)]
261pub enum TraceMetricsCommand {
262 TokenUsage(TraceMetricsArgs),
263 ModelBreakdown(TraceMetricsArgs),
264 ProjectSummary(TraceMetricsArgs),
265 Latency(TraceMetricsArgs),
266 ErrorRate(TraceMetricsArgs),
267 CacheHitRate(TraceMetricsArgs),
268 CostBreakdown(TraceMetricsArgs),
269 ModelTrend(TraceMetricsArgs),
270 TokenDistribution(TraceMetricsArgs),
271 Activity(TraceMetricsArgs),
272 PricingTier(TraceMetricsArgs),
273}
274
275#[derive(Debug, Args)]
276pub struct TraceMetricsArgs {
277 #[arg(long = "project-id")]
279 project_ids: Vec<String>,
280 #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u32).range(1..=3650))]
282 days: u32,
283 #[arg(long, value_enum, default_value_t = CliMetricsDataSource::Auto)]
285 data_source: CliMetricsDataSource,
286 #[arg(long, default_value = "2026-09-01 00:00:00")]
288 v4_cutover_date: String,
289}
290
291impl TraceMetricsArgs {
292 fn into_query(self) -> MetricsQuery {
293 MetricsQuery {
294 project_ids: (!self.project_ids.is_empty()).then_some(self.project_ids),
295 days: self.days.min(MAX_METRICS_LOOKBACK_DAYS),
296 data_source: self.data_source.into(),
297 v4_cutover_date: self.v4_cutover_date,
298 }
299 }
300}
301
302#[derive(Clone, Copy, Debug, Default, ValueEnum)]
303pub enum CliMetricsDataSource {
304 V3,
305 V4,
306 #[default]
307 Auto,
308}
309
310impl From<CliMetricsDataSource> for MetricsDataSource {
311 fn from(value: CliMetricsDataSource) -> Self {
312 match value {
313 CliMetricsDataSource::V3 => Self::V3,
314 CliMetricsDataSource::V4 => Self::V4,
315 CliMetricsDataSource::Auto => Self::Auto,
316 }
317 }
318}
319
320#[derive(Debug, Subcommand)]
321pub enum AgentCommand {
322 Create(JsonInput),
324 Get {
325 agent_id: String,
326 },
327 List(AgentListArgs),
328 Update {
330 agent_id: String,
331 #[command(flatten)]
332 input: JsonInput,
333 },
334}
335
336#[derive(Debug, Subcommand)]
337pub enum RuntimeCommand {
338 Create(RuntimeCreateArgs),
340 Get { runtime_id: String },
342 Rename {
344 runtime_id: String,
345 name: String,
346 #[arg(long)]
347 expected_revision: u64,
348 #[arg(long)]
349 idempotency_key: Option<String>,
350 },
351 Activate {
353 runtime_id: String,
354 #[command(flatten)]
355 input: JsonInput,
356 },
357 Suspend {
359 runtime_id: String,
360 #[command(flatten)]
361 input: JsonInput,
362 },
363 Resume {
365 runtime_id: String,
366 #[command(flatten)]
367 input: JsonInput,
368 },
369 Revoke {
371 runtime_id: String,
372 #[command(flatten)]
373 input: JsonInput,
374 },
375 Permissions {
377 runtime_id: String,
378 #[command(flatten)]
379 input: JsonInput,
380 },
381 BootstrapCredential {
383 #[command(flatten)]
384 input: JsonInput,
385 },
386}
387
388#[derive(Debug, Args)]
389pub struct RuntimeCreateArgs {
390 #[arg(long, required_unless_present = "file")]
392 name: Option<String>,
393 #[arg(long, value_enum)]
395 kind: Option<CliRuntimeKind>,
396 #[arg(long)]
398 idempotency_key: Option<String>,
399 #[arg(
401 long,
402 short = 'f',
403 value_name = "PATH",
404 conflicts_with_all = ["name", "kind", "idempotency_key"]
405 )]
406 file: Option<PathBuf>,
407}
408
409#[derive(Clone, Copy, Debug, ValueEnum)]
410enum CliRuntimeKind {
411 Code,
412 Evaluate,
413 Tool,
414 Custom,
415}
416
417impl From<CliRuntimeKind> for RuntimeIdentityKind {
418 fn from(value: CliRuntimeKind) -> Self {
419 match value {
420 CliRuntimeKind::Code => Self::Code,
421 CliRuntimeKind::Evaluate => Self::Evaluate,
422 CliRuntimeKind::Tool => Self::Tool,
423 CliRuntimeKind::Custom => Self::Custom,
424 }
425 }
426}
427
428#[derive(Debug, Args)]
429pub struct AgentListArgs {
430 #[arg(long, value_enum)]
431 status: Option<CliAgentStatus>,
432 #[arg(long)]
433 related_agent_id: Option<String>,
434 #[arg(long)]
435 related_runtime_id: Option<String>,
436 #[arg(long)]
437 related_user_id: Option<String>,
438 #[arg(long)]
439 cursor: Option<String>,
440 #[arg(long, value_parser = clap::value_parser!(u32).range(1..=200))]
441 limit: Option<u32>,
442}
443
444#[derive(Clone, Copy, Debug, ValueEnum)]
445enum CliAgentStatus {
446 Active,
447 Archived,
448}
449
450impl From<CliAgentStatus> for AgentStatus {
451 fn from(value: CliAgentStatus) -> Self {
452 match value {
453 CliAgentStatus::Active => Self::Active,
454 CliAgentStatus::Archived => Self::Archived,
455 }
456 }
457}
458
459#[derive(Debug, Subcommand)]
460pub enum ContextCommand {
461 Recent {
462 conversation_id: String,
463 #[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
464 limit: Option<u64>,
465 #[arg(long, value_delimiter = ',')]
466 role: Vec<String>,
467 },
468 Search {
469 conversation_id: String,
470 query: String,
471 #[arg(long)]
472 role: Option<String>,
473 #[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
474 limit: Option<u64>,
475 },
476 Append(JsonInput),
478 Maintenance,
479}
480
481#[derive(Debug, Subcommand)]
482pub enum ComputerCommand {
483 Create {
485 #[command(flatten)]
486 input: JsonInput,
487 #[arg(long)]
488 idempotency_key: String,
489 },
490 Get {
491 computer_id: String,
492 },
493 List {
494 #[arg(long)]
495 owner_kind: Option<String>,
496 #[arg(long)]
497 owner_id: Option<String>,
498 },
499 Delete {
500 computer_id: String,
501 #[arg(long)]
502 idempotency_key: String,
503 },
504}
505
506#[derive(Debug, Subcommand)]
507pub enum SpaceCommand {
508 Create {
510 #[command(flatten)]
511 input: JsonInput,
512 #[arg(long)]
513 idempotency_key: String,
514 },
515 Get {
516 space_id: String,
517 },
518 List {
519 #[arg(long)]
520 owner_kind: Option<String>,
521 #[arg(long)]
522 owner_id: Option<String>,
523 },
524 Delete {
525 space_id: String,
526 #[arg(long)]
527 idempotency_key: String,
528 },
529}
530
531#[derive(Debug, Subcommand)]
532pub enum ModelCommand {
533 Models,
534 Chat {
536 #[command(flatten)]
537 input: JsonInput,
538 #[arg(long)]
539 request_id: Option<String>,
540 },
541 Responses {
543 #[command(flatten)]
544 input: JsonInput,
545 #[arg(long)]
546 request_id: Option<String>,
547 },
548}
549
550#[derive(Debug, Args)]
551pub struct JsonInput {
552 #[arg(long, short = 'f', default_value = "-")]
554 file: PathBuf,
555}
556
557impl JsonInput {
558 fn read<T: DeserializeOwned>(&self) -> Result<T> {
559 let bytes = read_input(&self.file)?;
560 serde_json::from_slice(&bytes)
561 .with_context(|| format!("invalid JSON input from {}", display_source(&self.file)))
562 }
563}
564
565#[derive(Debug)]
566pub enum CommandOutput {
567 Json(Value),
568 Text(String),
569}
570
571impl CommandOutput {
572 fn json(value: impl Serialize) -> Result<Self> {
573 Ok(Self::Json(serde_json::to_value(value)?))
574 }
575
576 pub fn render(&self, format: OutputFormat) -> Result<String> {
577 match self {
578 Self::Text(value) => Ok(value.clone()),
579 Self::Json(value) => match format {
580 OutputFormat::Pretty => Ok(serde_json::to_string_pretty(value)?),
581 OutputFormat::Json => Ok(serde_json::to_string(value)?),
582 },
583 }
584 }
585}
586
587impl Cli {
588 pub async fn execute(self) -> Result<CommandOutput> {
589 if let Command::Config { command } = &self.command {
590 return execute_config(&self, command);
591 }
592 if let Command::Auth { command } = &self.command {
593 return execute_auth(&self, command);
594 }
595 let client = self.build_client()?;
596 match self.command {
597 Command::Config { .. } | Command::Auth { .. } => {
598 unreachable!("local commands returned before building the client")
599 }
600 Command::Gateway { command } => execute_gateway(&client, command).await,
601 Command::Trace { command } => execute_trace(&client, command).await,
602 Command::Agent { command } => execute_agent(&client, command).await,
603 Command::Runtime { command } => execute_runtime(&client, command).await,
604 Command::Context { command } => execute_context(&client, command).await,
605 Command::Computer { command } => execute_computer(&client, command).await,
606 Command::Space { command } => execute_space(&client, command).await,
607 Command::Model { command } => execute_model(&client, command).await,
608 Command::Evaluate { command } => execute_evaluate(&client, command).await,
609 }
610 }
611
612 fn build_client(&self) -> Result<InfraClient> {
613 let store = ConfigStore::discover(self.config.as_deref())?;
614 let gateway = store.resolve_gateway(self.gateway.as_deref())?;
615 let (token, _) = resolve_credential(self.token.as_deref(), self.sky_home.as_deref())?;
616 let mut builder = InfraClient::builder(gateway.value)
617 .request_timeout(Duration::from_secs(self.timeout))
618 .trusted_mesh_http(self.trusted_mesh_http)
619 .user_agent(concat!("agent-infra-cli/", env!("CARGO_PKG_VERSION")));
620 if let Some(token) = token {
621 builder = builder.credentials(Arc::new(StaticCredentials::new(token)?));
622 }
623 builder.build().map_err(Into::into)
624 }
625}
626
627fn execute_config(cli: &Cli, command: &ConfigCommand) -> Result<CommandOutput> {
628 let store = ConfigStore::discover(cli.config.as_deref())?;
629 match command {
630 ConfigCommand::Show
631 | ConfigCommand::Gateway {
632 command: ConfigGatewayCommand::Get,
633 } => {
634 let config = store.load()?;
635 let effective = store.resolve_gateway(cli.gateway.as_deref())?;
636 CommandOutput::json(serde_json::json!({
637 "path": store.path(),
638 "gateway": config.gateway,
639 "effectiveGateway": effective.value,
640 "source": effective.source,
641 }))
642 }
643 ConfigCommand::Gateway {
644 command: ConfigGatewayCommand::Set { url },
645 } => {
646 let config = store.set_gateway(url)?;
647 CommandOutput::json(serde_json::json!({
648 "ok": true,
649 "path": store.path(),
650 "gateway": config.gateway,
651 }))
652 }
653 ConfigCommand::Gateway {
654 command: ConfigGatewayCommand::Unset,
655 } => {
656 store.unset_gateway()?;
657 CommandOutput::json(serde_json::json!({
658 "ok": true,
659 "path": store.path(),
660 "gateway": null,
661 }))
662 }
663 }
664}
665
666fn execute_auth(cli: &Cli, command: &AuthCommand) -> Result<CommandOutput> {
667 match command {
668 AuthCommand::Status => {
669 let store = ConfigStore::discover(cli.config.as_deref())?;
670 let gateway = store.resolve_gateway(cli.gateway.as_deref())?;
671 let (_, credential) =
672 resolve_credential(cli.token.as_deref(), cli.sky_home.as_deref())?;
673 CommandOutput::json(serde_json::json!({
674 "gateway": gateway.value,
675 "gatewaySource": gateway.source,
676 "credential": credential,
677 }))
678 }
679 }
680}
681
682async fn execute_gateway(client: &InfraClient, command: GatewayCommand) -> Result<CommandOutput> {
683 match command {
684 GatewayCommand::Discovery => CommandOutput::json(client.gateway().discovery().await?),
685 GatewayCommand::Live => CommandOutput::json(client.gateway().live().await?),
686 GatewayCommand::Ready => CommandOutput::json(client.gateway().ready().await?),
687 GatewayCommand::WaitReady { deadline } => CommandOutput::json(
688 client
689 .gateway()
690 .wait_until_ready(Duration::from_secs(deadline))
691 .await?,
692 ),
693 GatewayCommand::Dependencies => CommandOutput::json(client.gateway().dependencies().await?),
694 GatewayCommand::Routes => CommandOutput::json(client.gateway().routes().await?),
695 GatewayCommand::Metrics => CommandOutput::json(client.gateway().metrics().await?),
696 GatewayCommand::Policy => CommandOutput::json(client.gateway().effective_policy().await?),
697 }
698}
699
700async fn execute_trace(client: &InfraClient, command: TraceCommand) -> Result<CommandOutput> {
701 match command {
702 TraceCommand::Metrics { command } => execute_trace_metrics(client, command).await,
703 TraceCommand::LangfuseProject { command } => match command {
704 LangfuseProjectCommand::Create {
705 input,
706 idempotency_key,
707 } => {
708 let request: CreateLangfuseProjectRequest = input.read()?;
709 CommandOutput::json(
710 client
711 .admin()
712 .create_langfuse_project(&request, &idempotency_key)
713 .await?,
714 )
715 }
716 LangfuseProjectCommand::Get { project_id } => {
717 CommandOutput::json(client.admin().langfuse_project(&project_id).await?)
718 }
719 LangfuseProjectCommand::List(args) => CommandOutput::json(
720 client
721 .admin()
722 .list_langfuse_projects(&ListLangfuseProjectsRequest {
723 related_agent_id: args
724 .related_agent_id
725 .as_deref()
726 .map(parse_agent_id)
727 .transpose()?,
728 related_runtime_id: args.related_runtime_id,
729 related_user_id: args.related_user_id,
730 cursor: args.cursor,
731 limit: args.limit,
732 })
733 .await?,
734 ),
735 },
736 }
737}
738
739async fn execute_trace_metrics(
740 client: &InfraClient,
741 command: TraceMetricsCommand,
742) -> Result<CommandOutput> {
743 match command {
744 TraceMetricsCommand::TokenUsage(args) => {
745 CommandOutput::json(client.trace().token_usage(&args.into_query()).await?)
746 }
747 TraceMetricsCommand::ModelBreakdown(args) => {
748 CommandOutput::json(client.trace().model_breakdown(&args.into_query()).await?)
749 }
750 TraceMetricsCommand::ProjectSummary(args) => {
751 CommandOutput::json(client.trace().project_summary(&args.into_query()).await?)
752 }
753 TraceMetricsCommand::Latency(args) => {
754 CommandOutput::json(client.trace().latency(&args.into_query()).await?)
755 }
756 TraceMetricsCommand::ErrorRate(args) => {
757 CommandOutput::json(client.trace().error_rate(&args.into_query()).await?)
758 }
759 TraceMetricsCommand::CacheHitRate(args) => {
760 CommandOutput::json(client.trace().cache_hit_rate(&args.into_query()).await?)
761 }
762 TraceMetricsCommand::CostBreakdown(args) => {
763 CommandOutput::json(client.trace().cost_breakdown(&args.into_query()).await?)
764 }
765 TraceMetricsCommand::ModelTrend(args) => {
766 CommandOutput::json(client.trace().model_trend(&args.into_query()).await?)
767 }
768 TraceMetricsCommand::TokenDistribution(args) => CommandOutput::json(
769 client
770 .trace()
771 .token_distribution(&args.into_query())
772 .await?,
773 ),
774 TraceMetricsCommand::Activity(args) => {
775 CommandOutput::json(client.trace().activity(&args.into_query()).await?)
776 }
777 TraceMetricsCommand::PricingTier(args) => {
778 CommandOutput::json(client.trace().pricing_tier(&args.into_query()).await?)
779 }
780 }
781}
782
783async fn execute_agent(client: &InfraClient, command: AgentCommand) -> Result<CommandOutput> {
784 match command {
785 AgentCommand::Create(input) => {
786 let request: CreateAgentRequest = input.read()?;
787 CommandOutput::json(client.agents().create(&request).await?)
788 }
789 AgentCommand::Get { agent_id } => {
790 let agent_id = parse_agent_id(&agent_id)?;
791 CommandOutput::json(client.agents().get(&agent_id).await?)
792 }
793 AgentCommand::List(args) => {
794 let request = ListAgentsRequest {
795 status: args.status.map(Into::into),
796 related_agent_id: args
797 .related_agent_id
798 .as_deref()
799 .map(parse_agent_id)
800 .transpose()?,
801 related_runtime_id: args.related_runtime_id,
802 related_user_id: args.related_user_id,
803 cursor: args.cursor,
804 limit: args.limit,
805 };
806 CommandOutput::json(client.agents().list(&request).await?)
807 }
808 AgentCommand::Update { agent_id, input } => {
809 let agent_id = parse_agent_id(&agent_id)?;
810 let request: UpdateAgentRequest = input.read()?;
811 CommandOutput::json(client.agents().update(&agent_id, &request).await?)
812 }
813 }
814}
815
816async fn execute_runtime(client: &InfraClient, command: RuntimeCommand) -> Result<CommandOutput> {
817 match command {
818 RuntimeCommand::Create(args) => {
819 let request = match args.file {
820 Some(file) => JsonInput { file }.read::<CreateRuntimeIdentityRequest>()?,
821 None => CreateRuntimeIdentityRequest {
822 id: None,
823 name: args
824 .name
825 .ok_or_else(|| anyhow!("--name is required for inline runtime creation"))?,
826 kind: args.kind.unwrap_or(CliRuntimeKind::Code).into(),
827 agent_id: None,
828 relations: Default::default(),
829 metadata: Default::default(),
830 idempotency_key: args.idempotency_key.unwrap_or_default(),
831 },
832 };
833 CommandOutput::json(client.runtime_identity().create(&request).await?)
834 }
835 RuntimeCommand::Get { runtime_id } => {
836 let runtime_id = parse_runtime_id(&runtime_id)?;
837 CommandOutput::json(client.runtime_identity().get(&runtime_id).await?)
838 }
839 RuntimeCommand::Rename {
840 runtime_id,
841 name,
842 expected_revision,
843 idempotency_key,
844 } => {
845 let runtime_id = parse_runtime_id(&runtime_id)?;
846 let request = UpdateRuntimeIdentityRequest {
847 expected_revision,
848 name,
849 idempotency_key: idempotency_key.unwrap_or_default(),
850 };
851 CommandOutput::json(
852 client
853 .runtime_identity()
854 .update(&runtime_id, &request)
855 .await?,
856 )
857 }
858 RuntimeCommand::Activate { runtime_id, input } => {
859 execute_runtime_transition(client, runtime_id, "activate", input).await
860 }
861 RuntimeCommand::Suspend { runtime_id, input } => {
862 execute_runtime_transition(client, runtime_id, "suspend", input).await
863 }
864 RuntimeCommand::Resume { runtime_id, input } => {
865 execute_runtime_transition(client, runtime_id, "resume", input).await
866 }
867 RuntimeCommand::Revoke { runtime_id, input } => {
868 execute_runtime_transition(client, runtime_id, "revoke", input).await
869 }
870 RuntimeCommand::Permissions { runtime_id, input } => {
871 let runtime_id = parse_runtime_id(&runtime_id)?;
872 let request: ReplacePermissionsRequest = input.read()?;
873 CommandOutput::json(
874 client
875 .runtime_identity()
876 .replace_permissions(&runtime_id, &request)
877 .await?,
878 )
879 }
880 RuntimeCommand::BootstrapCredential { input } => {
881 let request: IssueRuntimeCredentialRequest = input.read()?;
882 CommandOutput::json(
883 client
884 .gateway()
885 .bootstrap_runtime_credential(&request)
886 .await?,
887 )
888 }
889 }
890}
891
892async fn execute_runtime_transition(
893 client: &InfraClient,
894 runtime_id: String,
895 action: &str,
896 input: JsonInput,
897) -> Result<CommandOutput> {
898 let runtime_id = parse_runtime_id(&runtime_id)?;
899 let request: TransitionIdentityRequest = input.read()?;
900 CommandOutput::json(
901 client
902 .runtime_identity()
903 .transition(&runtime_id, action, &request)
904 .await?,
905 )
906}
907
908async fn execute_context(client: &InfraClient, command: ContextCommand) -> Result<CommandOutput> {
909 match command {
910 ContextCommand::Recent {
911 conversation_id,
912 limit,
913 role,
914 } => {
915 let roles = role.iter().map(String::as_str).collect::<Vec<_>>();
916 CommandOutput::json(
917 client
918 .context()
919 .recent_messages(&conversation_id, limit.map(|value| value as usize), &roles)
920 .await?,
921 )
922 }
923 ContextCommand::Search {
924 conversation_id,
925 query,
926 role,
927 limit,
928 } => CommandOutput::json(
929 client
930 .context()
931 .search_messages(
932 &conversation_id,
933 &query,
934 role.as_deref(),
935 limit.map(|value| value as usize),
936 )
937 .await?,
938 ),
939 ContextCommand::Append(input) => {
940 let messages: Vec<ConversationRecord> = input.read()?;
941 if messages.is_empty() {
942 bail!("append input must contain at least one message");
943 }
944 CommandOutput::json(client.context().append_messages(messages).await?)
945 }
946 ContextCommand::Maintenance => {
947 CommandOutput::json(client.context().maintenance_status().await?)
948 }
949 }
950}
951
952async fn execute_computer(client: &InfraClient, command: ComputerCommand) -> Result<CommandOutput> {
953 let environment = client.environment();
954 match command {
955 ComputerCommand::Create {
956 input,
957 idempotency_key,
958 } => {
959 let request: CreateComputerRequest = input.read()?;
960 CommandOutput::json(
961 environment
962 .create_computer(&request, &idempotency_key)
963 .await?,
964 )
965 }
966 ComputerCommand::Get { computer_id } => {
967 CommandOutput::json(environment.computer(&computer_id).await?)
968 }
969 ComputerCommand::List {
970 owner_kind,
971 owner_id,
972 } => CommandOutput::json(
973 environment
974 .computers(Some(&list_environment_query(owner_kind, owner_id)?))
975 .await?,
976 ),
977 ComputerCommand::Delete {
978 computer_id,
979 idempotency_key,
980 } => CommandOutput::json(
981 environment
982 .delete_computer(&computer_id, &idempotency_key)
983 .await?,
984 ),
985 }
986}
987
988async fn execute_space(client: &InfraClient, command: SpaceCommand) -> Result<CommandOutput> {
989 let environment = client.environment();
990 match command {
991 SpaceCommand::Create {
992 input,
993 idempotency_key,
994 } => {
995 let request: CreateSpaceRequest = input.read()?;
996 CommandOutput::json(environment.create_space(&request, &idempotency_key).await?)
997 }
998 SpaceCommand::Get { space_id } => CommandOutput::json(environment.space(&space_id).await?),
999 SpaceCommand::List {
1000 owner_kind,
1001 owner_id,
1002 } => CommandOutput::json(
1003 environment
1004 .spaces(Some(&list_environment_query(owner_kind, owner_id)?))
1005 .await?,
1006 ),
1007 SpaceCommand::Delete {
1008 space_id,
1009 idempotency_key,
1010 } => CommandOutput::json(
1011 environment
1012 .delete_space(&space_id, &idempotency_key)
1013 .await?,
1014 ),
1015 }
1016}
1017
1018fn list_environment_query(
1019 owner_kind: Option<String>,
1020 owner_id: Option<String>,
1021) -> Result<ListEnvironmentResourcesQuery> {
1022 let owner_kind = match owner_kind {
1023 Some(value) => Some(parse_owner_kind(&value)?),
1024 None => None,
1025 };
1026 if owner_kind.is_some() != owner_id.is_some() {
1027 bail!("--owner-kind and --owner-id must be provided together");
1028 }
1029 Ok(ListEnvironmentResourcesQuery {
1030 owner_kind,
1031 owner_id,
1032 contains_all: vec![],
1033 })
1034}
1035
1036fn parse_owner_kind(value: &str) -> Result<OwnerKind> {
1037 match value.to_ascii_lowercase().as_str() {
1038 "user" => Ok(OwnerKind::User),
1039 "agent" => Ok(OwnerKind::Agent),
1040 "runtime" => Ok(OwnerKind::Runtime),
1041 other => bail!("unsupported owner kind {other:?}; expected user, agent, or runtime"),
1042 }
1043}
1044
1045async fn execute_model(client: &InfraClient, command: ModelCommand) -> Result<CommandOutput> {
1046 match command {
1047 ModelCommand::Models => CommandOutput::json(client.model().models().await?),
1048 ModelCommand::Chat { input, request_id } => {
1049 let request: Value = input.read()?;
1050 ensure_json_object(&request)?;
1051 CommandOutput::json(
1052 client
1053 .model()
1054 .chat_completions(&request, call_options(request_id))
1055 .await?,
1056 )
1057 }
1058 ModelCommand::Responses { input, request_id } => {
1059 let request: Value = input.read()?;
1060 ensure_json_object(&request)?;
1061 CommandOutput::json(
1062 client
1063 .model()
1064 .responses(&request, call_options(request_id))
1065 .await?,
1066 )
1067 }
1068 }
1069}
1070
1071async fn execute_evaluate(client: &InfraClient, command: EvaluateCommand) -> Result<CommandOutput> {
1072 let evaluate = client.evaluate();
1073 match command {
1074 EvaluateCommand::RegisterEnvironment(input) => {
1075 let request: RegisterEnvironmentProfileRequest = input.read()?;
1076 CommandOutput::json(evaluate.register_environment_profile(&request).await?)
1077 }
1078 EvaluateCommand::RegisterEvaluator(input) => {
1079 let request: RegisterEvaluatorVersionRequest = input.read()?;
1080 CommandOutput::json(evaluate.register_evaluator_version(&request).await?)
1081 }
1082 EvaluateCommand::RegisterSuite(input) => {
1083 let request: RegisterSuiteRequest = input.read()?;
1084 CommandOutput::json(evaluate.register_suite(&request).await?)
1085 }
1086 EvaluateCommand::TaskCreate(input) => {
1087 let request: CreateEvalRunRequest = input.read()?;
1088 let handle = evaluate.create_task(&request).await?;
1089 let operation = handle.refresh().await?;
1090 CommandOutput::json(serde_json::json!({
1091 "evaluateTaskId": operation.id,
1092 "operation": operation,
1093 }))
1094 }
1095 EvaluateCommand::Operation { operation_id } => {
1096 let request = GetOperationRequest { operation_id };
1097 let handle = evaluate.operation(request.operation_id).await?;
1098 CommandOutput::json(handle.refresh().await?)
1099 }
1100 EvaluateCommand::Cases(input) => {
1101 let request: ListCaseRunsRequest = input.read()?;
1102 CommandOutput::json(evaluate.list_case_runs(&request).await?)
1103 }
1104 EvaluateCommand::Report { report_id } => {
1105 let request = GetReportRequest { report_id };
1106 CommandOutput::json(evaluate.report(&request.report_id).await?)
1107 }
1108 }
1109}
1110
1111fn call_options(request_id: Option<String>) -> CallOptions {
1112 request_id.map_or_else(CallOptions::default, |value| {
1113 CallOptions::default().request_id(value)
1114 })
1115}
1116
1117fn ensure_json_object(value: &Value) -> Result<()> {
1118 if !value.is_object() {
1119 bail!("model request must be a JSON object");
1120 }
1121 Ok(())
1122}
1123
1124fn parse_agent_id(value: &str) -> Result<AgentId> {
1125 AgentId::new(value).map_err(|message| anyhow!(message))
1126}
1127
1128fn parse_runtime_id(value: &str) -> Result<RuntimeIdentityId> {
1129 RuntimeIdentityId::new(value).map_err(|message| anyhow!(message))
1130}
1131
1132fn read_input(path: &Path) -> Result<Vec<u8>> {
1133 if path == Path::new("-") {
1134 let mut bytes = Vec::new();
1135 io::stdin()
1136 .read_to_end(&mut bytes)
1137 .context("failed to read standard input")?;
1138 Ok(bytes)
1139 } else {
1140 fs::read(path).with_context(|| format!("failed to read {}", path.display()))
1141 }
1142}
1143
1144fn display_source(path: &Path) -> String {
1145 if path == Path::new("-") {
1146 "standard input".into()
1147 } else {
1148 path.display().to_string()
1149 }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154 use super::*;
1155 use clap::CommandFactory;
1156 use wiremock::{
1157 Mock, MockServer, ResponseTemplate,
1158 matchers::{body_json, header, method, path},
1159 };
1160
1161 #[test]
1162 fn clap_definition_is_valid() {
1163 Cli::command().debug_assert();
1164 }
1165
1166 #[test]
1167 fn parses_global_options_after_subcommands() {
1168 let cli = Cli::try_parse_from([
1169 "infra",
1170 "gateway",
1171 "wait-ready",
1172 "--deadline",
1173 "42",
1174 "--gateway",
1175 "https://infra.example.com",
1176 "--output",
1177 "json",
1178 ])
1179 .unwrap();
1180 assert_eq!(cli.gateway.as_deref(), Some("https://infra.example.com"));
1181 assert_eq!(cli.output, OutputFormat::Json);
1182 assert!(matches!(
1183 cli.command,
1184 Command::Gateway {
1185 command: GatewayCommand::WaitReady { deadline: 42 }
1186 }
1187 ));
1188 }
1189
1190 #[test]
1191 fn rejects_removed_workspace_subcommand() {
1192 let result = Cli::try_parse_from(["infra", "workspace", "write", "README.md"]);
1193 assert!(result.is_err());
1194 }
1195
1196 #[test]
1197 fn parses_trace_metrics_query_options() {
1198 let cli = Cli::try_parse_from([
1199 "infra",
1200 "trace",
1201 "metrics",
1202 "token-usage",
1203 "--project-id",
1204 "project-a",
1205 "--project-id",
1206 "project-b",
1207 "--days",
1208 "7",
1209 "--data-source",
1210 "v4",
1211 ])
1212 .unwrap();
1213 let Command::Trace {
1214 command:
1215 TraceCommand::Metrics {
1216 command: TraceMetricsCommand::TokenUsage(args),
1217 },
1218 } = cli.command
1219 else {
1220 panic!("expected trace token-usage command");
1221 };
1222 let query = args.into_query();
1223 assert_eq!(query.project_ids.unwrap(), ["project-a", "project-b"]);
1224 assert_eq!(query.days, 7);
1225 assert_eq!(query.data_source, MetricsDataSource::V4);
1226 }
1227
1228 #[test]
1229 fn parses_langfuse_project_relationship_query() {
1230 let cli = Cli::try_parse_from([
1231 "infra",
1232 "trace",
1233 "langfuse-project",
1234 "list",
1235 "--related-agent-id",
1236 "agt_weather",
1237 "--limit",
1238 "20",
1239 ])
1240 .unwrap();
1241 assert!(matches!(
1242 cli.command,
1243 Command::Trace {
1244 command: TraceCommand::LangfuseProject {
1245 command: LangfuseProjectCommand::List(LangfuseProjectListArgs {
1246 related_agent_id: Some(ref agent_id),
1247 limit: Some(20),
1248 ..
1249 })
1250 }
1251 } if agent_id == "agt_weather"
1252 ));
1253 }
1254
1255 #[test]
1256 fn parses_evaluate_resource_and_task_commands() {
1257 let register = Cli::try_parse_from([
1258 "infra",
1259 "evaluate",
1260 "register-evaluator",
1261 "--file",
1262 "evaluator.json",
1263 ])
1264 .unwrap();
1265 assert!(matches!(
1266 register.command,
1267 Command::Evaluate {
1268 command: EvaluateCommand::RegisterEvaluator(_)
1269 }
1270 ));
1271 let task = Cli::try_parse_from(["infra", "evaluate", "task-create", "--file", "run.json"])
1272 .unwrap();
1273 assert!(matches!(
1274 task.command,
1275 Command::Evaluate {
1276 command: EvaluateCommand::TaskCreate(_)
1277 }
1278 ));
1279 let alias =
1280 Cli::try_parse_from(["infra", "evaluate", "run", "--file", "run.json"]).unwrap();
1281 assert!(matches!(
1282 alias.command,
1283 Command::Evaluate {
1284 command: EvaluateCommand::TaskCreate(_)
1285 }
1286 ));
1287 }
1288
1289 #[test]
1290 fn reads_json_request_from_file() {
1291 let directory = tempfile::tempdir().unwrap();
1292 let path = directory.path().join("request.json");
1293 fs::write(&path, br#"{"model":"test"}"#).unwrap();
1294 let input = JsonInput { file: path };
1295 let value: Value = input.read().unwrap();
1296 assert_eq!(value["model"], "test");
1297 }
1298
1299 #[tokio::test]
1300 async fn persists_and_resolves_gateway_without_network_access() {
1301 let directory = tempfile::tempdir().unwrap();
1302 let config = directory.path().join("infra.json");
1303 let cli = Cli::try_parse_from([
1304 "infra",
1305 "config",
1306 "gateway",
1307 "set",
1308 "https://infra.example.com/",
1309 "--config",
1310 config.to_str().unwrap(),
1311 ])
1312 .unwrap();
1313 let output = cli.execute().await.unwrap();
1314 let CommandOutput::Json(value) = output else {
1315 panic!("config set must produce JSON");
1316 };
1317 assert_eq!(value["gateway"], "https://infra.example.com");
1318
1319 let store = ConfigStore::discover(Some(&config)).unwrap();
1320 assert_eq!(
1321 store.load().unwrap().gateway.as_deref(),
1322 Some("https://infra.example.com")
1323 );
1324 }
1325
1326 #[tokio::test]
1327 async fn trace_metrics_command_calls_typed_sdk() {
1328 let server = MockServer::start().await;
1329 Mock::given(method("POST"))
1330 .and(path(
1331 agent_infra_sdk::trace_contract::METRICS_PROJECT_SUMMARY_PATH,
1332 ))
1333 .and(body_json(serde_json::json!({
1334 "projectIds": ["project-a"],
1335 "days": 14,
1336 "dataSource": "auto",
1337 "v4CutoverDate": "2026-09-01 00:00:00"
1338 })))
1339 .respond_with(
1340 ResponseTemplate::new(200).set_body_json(serde_json::json!([{
1341 "projectId": "project-a",
1342 "totalTokens": 15,
1343 "totalInputTokens": 10,
1344 "totalOutputTokens": 5,
1345 "observationCount": 2,
1346 "generationCount": 1,
1347 "totalCost": 0.01,
1348 "uniqueModels": 1,
1349 "uniqueTraces": 1
1350 }])),
1351 )
1352 .expect(1)
1353 .mount(&server)
1354 .await;
1355
1356 let server_uri = server.uri();
1357 let cli = Cli::try_parse_from([
1358 "infra",
1359 "--gateway",
1360 server_uri.as_str(),
1361 "trace",
1362 "metrics",
1363 "project-summary",
1364 "--project-id",
1365 "project-a",
1366 "--days",
1367 "14",
1368 ])
1369 .unwrap();
1370 let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
1371 panic!("trace metrics must produce JSON");
1372 };
1373 assert_eq!(output[0]["totalTokens"], 15);
1374 }
1375
1376 #[tokio::test]
1377 async fn auth_status_detects_encrypted_sky_login_without_exposing_it() {
1378 let directory = tempfile::tempdir().unwrap();
1379 let sky_home = directory.path().join("sky");
1380 fs::create_dir_all(&sky_home).unwrap();
1381 fs::write(sky_home.join("credentials"), "ENCv1:secret-ciphertext").unwrap();
1382 let config = directory.path().join("infra.json");
1383 let cli = Cli::try_parse_from([
1384 "infra",
1385 "auth",
1386 "status",
1387 "--config",
1388 config.to_str().unwrap(),
1389 "--sky-home",
1390 sky_home.to_str().unwrap(),
1391 ])
1392 .unwrap();
1393
1394 let output = cli.execute().await.unwrap();
1395 let rendered = output.render(OutputFormat::Json).unwrap();
1396 assert!(rendered.contains("sky_login_encrypted"));
1397 assert!(!rendered.contains("secret-ciphertext"));
1398 }
1399
1400 #[test]
1401 fn renders_compact_and_pretty_json() {
1402 let output = CommandOutput::Json(serde_json::json!({ "ok": true }));
1403 assert_eq!(output.render(OutputFormat::Json).unwrap(), r#"{"ok":true}"#);
1404 assert!(output.render(OutputFormat::Pretty).unwrap().contains('\n'));
1405 }
1406
1407 #[tokio::test]
1408 async fn model_command_uses_sdk_route_and_bearer_credential() {
1409 let server = MockServer::start().await;
1410 Mock::given(method("GET"))
1411 .and(path("/v1/model/models"))
1412 .and(header("authorization", "Bearer test-token"))
1413 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1414 "object": "list",
1415 "data": [{ "id": "test-model" }]
1416 })))
1417 .expect(1)
1418 .mount(&server)
1419 .await;
1420
1421 let cli = Cli::try_parse_from([
1422 "infra",
1423 "model",
1424 "models",
1425 "--gateway",
1426 &server.uri(),
1427 "--token",
1428 "test-token",
1429 ])
1430 .unwrap();
1431 let output = cli.execute().await.unwrap();
1432 let CommandOutput::Json(value) = output else {
1433 panic!("model list must produce JSON");
1434 };
1435 assert_eq!(value["data"][0]["id"], "test-model");
1436 }
1437
1438 #[tokio::test]
1439 async fn runtime_commands_create_rename_and_revoke_through_the_typed_sdk() {
1440 let server = MockServer::start().await;
1441 let directory = tempfile::tempdir().unwrap();
1442 let transition_request = directory.path().join("transition-runtime.json");
1443 fs::write(
1444 &transition_request,
1445 br#"{"expectedRevision":2,"idempotencyKey":"revoke-runtime-test","reason":"test cleanup"}"#,
1446 )
1447 .unwrap();
1448
1449 Mock::given(method("POST"))
1450 .and(path("/internal/v1/runtime-identities"))
1451 .and(header("authorization", "Bearer test-token"))
1452 .and(body_json(serde_json::json!({
1453 "name": "code-dev",
1454 "kind": "code",
1455 "relations": {},
1456 "metadata": {},
1457 "idempotencyKey": ""
1458 })))
1459 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
1460 "identity": runtime_identity_json("code-dev", "pending", 1),
1461 "bootstrapToken": "rti_boot_secret"
1462 })))
1463 .expect(1)
1464 .mount(&server)
1465 .await;
1466 Mock::given(method("PATCH"))
1467 .and(path("/internal/v1/runtime-identities/rti_test"))
1468 .and(header("authorization", "Bearer test-token"))
1469 .and(body_json(serde_json::json!({
1470 "expectedRevision": 1,
1471 "name": "code-dev-renamed",
1472 "idempotencyKey": ""
1473 })))
1474 .respond_with(
1475 ResponseTemplate::new(200).set_body_json(runtime_identity_json(
1476 "code-dev-renamed",
1477 "pending",
1478 2,
1479 )),
1480 )
1481 .expect(1)
1482 .mount(&server)
1483 .await;
1484 Mock::given(method("POST"))
1485 .and(path("/internal/v1/runtime-identities/rti_test:revoke"))
1486 .and(header("authorization", "Bearer test-token"))
1487 .and(body_json(serde_json::json!({
1488 "expectedRevision": 2,
1489 "idempotencyKey": "revoke-runtime-test",
1490 "reason": "test cleanup"
1491 })))
1492 .respond_with(
1493 ResponseTemplate::new(200).set_body_json(runtime_identity_json(
1494 "code-dev-renamed",
1495 "revoked",
1496 3,
1497 )),
1498 )
1499 .expect(1)
1500 .mount(&server)
1501 .await;
1502
1503 let create = Cli::try_parse_from([
1504 "infra",
1505 "runtime",
1506 "create",
1507 "--name",
1508 "code-dev",
1509 "--gateway",
1510 &server.uri(),
1511 "--token",
1512 "test-token",
1513 ])
1514 .unwrap()
1515 .execute()
1516 .await
1517 .unwrap();
1518 let CommandOutput::Json(created) = create else {
1519 panic!("runtime create must produce JSON");
1520 };
1521 assert_eq!(created["identity"]["id"], "rti_test");
1522 assert_eq!(created["identity"]["name"], "code-dev");
1523 assert_eq!(created["bootstrapToken"], "rti_boot_secret");
1524
1525 let rename = Cli::try_parse_from([
1526 "infra",
1527 "runtime",
1528 "rename",
1529 "rti_test",
1530 "code-dev-renamed",
1531 "--expected-revision",
1532 "1",
1533 "--gateway",
1534 &server.uri(),
1535 "--token",
1536 "test-token",
1537 ])
1538 .unwrap()
1539 .execute()
1540 .await
1541 .unwrap();
1542 let CommandOutput::Json(renamed) = rename else {
1543 panic!("runtime rename must produce JSON");
1544 };
1545 assert_eq!(renamed["id"], "rti_test");
1546 assert_eq!(renamed["name"], "code-dev-renamed");
1547 assert_eq!(renamed["revision"], 2);
1548
1549 let revoke = Cli::try_parse_from([
1550 "infra",
1551 "runtime",
1552 "revoke",
1553 "rti_test",
1554 "--file",
1555 transition_request.to_str().unwrap(),
1556 "--gateway",
1557 &server.uri(),
1558 "--token",
1559 "test-token",
1560 ])
1561 .unwrap()
1562 .execute()
1563 .await
1564 .unwrap();
1565 let CommandOutput::Json(revoked) = revoke else {
1566 panic!("runtime revoke must produce JSON");
1567 };
1568 assert_eq!(revoked["status"], "revoked");
1569 assert_eq!(revoked["revision"], 3);
1570 }
1571
1572 #[tokio::test]
1573 async fn runtime_commands_replace_permissions_and_bootstrap_credential() {
1574 let server = MockServer::start().await;
1575 let directory = tempfile::tempdir().unwrap();
1576 let permissions_request = directory.path().join("permissions.json");
1577 fs::write(
1578 &permissions_request,
1579 br#"{"expectedRevision":0,"idempotencyKey":"grant-runtime-test","grants":[{"audience":"agent-infra","capabilities":["gateway:delegation:manage"]}]}"#,
1580 )
1581 .unwrap();
1582 let credential_request = directory.path().join("credential.json");
1583 fs::write(
1584 &credential_request,
1585 br#"{"workloadProof":"bootstrap-proof","audience":"agent-infra","capabilities":["gateway:delegation:manage"]}"#,
1586 )
1587 .unwrap();
1588
1589 Mock::given(method("PUT"))
1590 .and(path("/internal/v1/runtime-identities/rti_test/permissions"))
1591 .and(header("authorization", "Bearer test-token"))
1592 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1593 "identityId": "rti_test",
1594 "revision": 1,
1595 "grants": [{
1596 "id": "grt_test",
1597 "identityId": "rti_test",
1598 "audience": "agent-infra",
1599 "capabilities": ["gateway:delegation:manage"],
1600 "createdAt": "2026-08-13T00:00:00Z"
1601 }]
1602 })))
1603 .expect(1)
1604 .mount(&server)
1605 .await;
1606 Mock::given(method("POST"))
1607 .and(path(
1608 agent_infra_sdk::gateway_contract::RUNTIME_CREDENTIAL_BOOTSTRAP_PATH,
1609 ))
1610 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1611 "accessToken": "runtime-token",
1612 "tokenType": "Bearer",
1613 "expiresInSeconds": 120,
1614 "keyId": "runtime-key"
1615 })))
1616 .expect(1)
1617 .mount(&server)
1618 .await;
1619
1620 let permissions = Cli::try_parse_from([
1621 "infra",
1622 "runtime",
1623 "permissions",
1624 "rti_test",
1625 "--file",
1626 permissions_request.to_str().unwrap(),
1627 "--gateway",
1628 &server.uri(),
1629 "--token",
1630 "test-token",
1631 ])
1632 .unwrap()
1633 .execute()
1634 .await
1635 .unwrap();
1636 let CommandOutput::Json(permissions) = permissions else {
1637 panic!("runtime permissions must produce JSON");
1638 };
1639 assert_eq!(permissions["revision"], 1);
1640
1641 let credential = Cli::try_parse_from([
1642 "infra",
1643 "runtime",
1644 "bootstrap-credential",
1645 "--file",
1646 credential_request.to_str().unwrap(),
1647 "--gateway",
1648 &server.uri(),
1649 "--token",
1650 "test-token",
1651 ])
1652 .unwrap()
1653 .execute()
1654 .await
1655 .unwrap();
1656 let CommandOutput::Json(credential) = credential else {
1657 panic!("runtime bootstrap credential must produce JSON");
1658 };
1659 assert_eq!(credential["accessToken"], "runtime-token");
1660 let requests = server.received_requests().await.unwrap();
1661 let bootstrap = requests
1662 .iter()
1663 .find(|request| {
1664 request.url.path()
1665 == agent_infra_sdk::gateway_contract::RUNTIME_CREDENTIAL_BOOTSTRAP_PATH
1666 })
1667 .unwrap();
1668 assert!(!bootstrap.headers.contains_key("authorization"));
1669 }
1670
1671 fn runtime_identity_json(name: &str, status: &str, revision: u64) -> Value {
1672 serde_json::json!({
1673 "id": "rti_test",
1674 "name": name,
1675 "scope": { "tenantId": "tenant-test", "projectId": "project-test" },
1676 "kind": "code",
1677 "relations": {
1678 "runtimeIds": ["rti_test"],
1679 "userIds": ["user-test"]
1680 },
1681 "status": status,
1682 "revision": revision,
1683 "grantRevision": 0,
1684 "credentialGeneration": if status == "revoked" { 2 } else { 1 },
1685 "metadata": { "source": "cli-test" },
1686 "createdAt": "2026-08-13T00:00:00Z",
1687 "updatedAt": "2026-08-13T00:00:00Z"
1688 })
1689 }
1690
1691 #[tokio::test]
1692 async fn computer_list_uses_environment_api() {
1693 let server = MockServer::start().await;
1694 Mock::given(method("GET"))
1695 .and(path("/internal/v1/computers"))
1696 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1697 .expect(1)
1698 .mount(&server)
1699 .await;
1700 let cli =
1701 Cli::try_parse_from(["infra", "computer", "list", "--gateway", &server.uri()]).unwrap();
1702
1703 let output = cli.execute().await.unwrap();
1704 let CommandOutput::Json(value) = output else {
1705 panic!("computer list must produce JSON");
1706 };
1707 assert_eq!(value, serde_json::json!([]));
1708 }
1709}