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