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