1use std::{
2 fs,
3 io::{self, Read},
4 path::{Path, PathBuf},
5 sync::Arc,
6 time::Duration,
7};
8
9use agent_infra_sdk::{
10 CallOptions, InfraClient, StaticCredentials, WaitOptions,
11 agent_contract::{
12 AgentId, AgentStatus, CreateAgentRequest, ListAgentsRequest, UpdateAgentRequest,
13 },
14 context_contract::ConversationRecord,
15 workspace_contract::CreateWorkspaceRequest,
16};
17use agent_workspace_adapter_local::LocalWorkspace;
18use anyhow::{Context, Result, anyhow, bail};
19use clap::{Args, Parser, Subcommand, ValueEnum};
20use serde::{Serialize, de::DeserializeOwned};
21use serde_json::Value;
22
23mod config;
24
25use config::{ConfigStore, resolve_credential};
26
27const DEFAULT_GATEWAY: &str = "http://127.0.0.1:5200";
28
29#[derive(Debug, Parser)]
30#[command(
31 name = "infra",
32 version,
33 about = "Operate Agent Infra through the typed Infra SDK",
34 arg_required_else_help = true
35)]
36pub struct Cli {
37 #[arg(long, global = true)]
39 pub gateway: Option<String>,
40
41 #[arg(long, global = true, hide_env_values = true)]
43 pub token: Option<String>,
44
45 #[arg(long, global = true, env = "AGENT_INFRA_CONFIG", value_name = "PATH")]
47 pub config: Option<PathBuf>,
48
49 #[arg(
51 long,
52 global = true,
53 env = "AGENT_INFRA_SKY_HOME",
54 value_name = "PATH",
55 hide = true
56 )]
57 pub sky_home: Option<PathBuf>,
58
59 #[arg(long, global = true, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=600))]
61 pub timeout: u64,
62
63 #[arg(long, global = true)]
65 pub trusted_mesh_http: bool,
66
67 #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Pretty)]
69 pub output: OutputFormat,
70
71 #[arg(
74 long,
75 global = true,
76 env = "AGENT_INFRA_LOCAL_WORKSPACE",
77 value_name = "PATH"
78 )]
79 pub local_workspace: Option<PathBuf>,
80
81 #[arg(long, global = true, requires = "local_workspace")]
83 pub allow_local_workspace_exec: bool,
84
85 #[command(subcommand)]
86 pub command: Command,
87}
88
89#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
90pub enum OutputFormat {
91 #[default]
93 Pretty,
94 Json,
96}
97
98#[derive(Debug, Subcommand)]
99pub enum Command {
100 Config {
102 #[command(subcommand)]
103 command: ConfigCommand,
104 },
105 Auth {
107 #[command(subcommand)]
108 command: AuthCommand,
109 },
110 Gateway {
112 #[command(subcommand)]
113 command: GatewayCommand,
114 },
115 Agent {
117 #[command(subcommand)]
118 command: AgentCommand,
119 },
120 Context {
122 #[command(subcommand)]
123 command: ContextCommand,
124 },
125 Workspace {
127 #[command(subcommand)]
128 command: WorkspaceCommand,
129 },
130 Model {
132 #[command(subcommand)]
133 command: ModelCommand,
134 },
135}
136
137#[derive(Debug, Subcommand)]
138pub enum ConfigCommand {
139 Show,
141 Gateway {
143 #[command(subcommand)]
144 command: ConfigGatewayCommand,
145 },
146}
147
148#[derive(Debug, Subcommand)]
149pub enum ConfigGatewayCommand {
150 Set { url: String },
152 Get,
154 Unset,
156}
157
158#[derive(Debug, Subcommand)]
159pub enum AuthCommand {
160 Status,
162}
163
164#[derive(Debug, Subcommand)]
165pub enum GatewayCommand {
166 Discovery,
167 Live,
168 Ready,
169 WaitReady {
171 #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=3600))]
172 deadline: u64,
173 },
174 Dependencies,
175 Routes,
176 Metrics,
177 Policy,
178}
179
180#[derive(Debug, Subcommand)]
181pub enum AgentCommand {
182 Create(JsonInput),
184 Get {
185 agent_id: String,
186 },
187 List(AgentListArgs),
188 Update {
190 agent_id: String,
191 #[command(flatten)]
192 input: JsonInput,
193 },
194}
195
196#[derive(Debug, Args)]
197pub struct AgentListArgs {
198 #[arg(long, value_enum)]
199 status: Option<CliAgentStatus>,
200 #[arg(long)]
201 related_agent_id: Option<String>,
202 #[arg(long)]
203 related_runtime_id: Option<String>,
204 #[arg(long)]
205 related_user_id: Option<String>,
206 #[arg(long)]
207 cursor: Option<String>,
208 #[arg(long, value_parser = clap::value_parser!(u32).range(1..=200))]
209 limit: Option<u32>,
210}
211
212#[derive(Clone, Copy, Debug, ValueEnum)]
213enum CliAgentStatus {
214 Active,
215 Archived,
216}
217
218impl From<CliAgentStatus> for AgentStatus {
219 fn from(value: CliAgentStatus) -> Self {
220 match value {
221 CliAgentStatus::Active => Self::Active,
222 CliAgentStatus::Archived => Self::Archived,
223 }
224 }
225}
226
227#[derive(Debug, Subcommand)]
228pub enum ContextCommand {
229 Recent {
230 conversation_id: String,
231 #[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
232 limit: Option<u64>,
233 #[arg(long, value_delimiter = ',')]
234 role: Vec<String>,
235 },
236 Search {
237 conversation_id: String,
238 query: String,
239 #[arg(long)]
240 role: Option<String>,
241 #[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
242 limit: Option<u64>,
243 },
244 Append(JsonInput),
246 Maintenance,
247}
248
249#[derive(Debug, Subcommand)]
250pub enum WorkspaceCommand {
251 Capabilities,
252 Read {
253 path: String,
254 },
255 Write(WorkspaceWriteArgs),
256 Exists {
257 path: String,
258 },
259 Mkdir {
260 path: String,
261 },
262 List {
263 path: String,
264 },
265 Exec {
266 command: String,
267 #[arg(long)]
268 cwd: Option<String>,
269 },
270 Remove {
271 path: String,
272 },
273 ListWorkspaces {
274 #[arg(long)]
275 after: Option<String>,
276 #[arg(long, value_parser = clap::value_parser!(u64).range(1..=200))]
277 limit: Option<u64>,
278 },
279 GetWorkspace {
280 workspace_id: String,
281 },
282 WorkspaceUsage {
283 workspace_id: String,
284 },
285 CreateWorkspace {
287 #[command(flatten)]
288 input: JsonInput,
289 #[arg(long)]
290 idempotency_key: String,
291 #[arg(long)]
293 wait: bool,
294 #[arg(long, default_value_t = 900, value_parser = clap::value_parser!(u64).range(1..=604800))]
295 wait_timeout: u64,
296 },
297}
298
299#[derive(Debug, Args)]
300pub struct WorkspaceWriteArgs {
301 pub path: String,
302 #[arg(long, conflicts_with = "file", required_unless_present = "file")]
304 content: Option<String>,
305 #[arg(long, conflicts_with = "content", required_unless_present = "content")]
307 file: Option<PathBuf>,
308 #[arg(long)]
310 if_match: Option<String>,
311}
312
313#[derive(Debug, Subcommand)]
314pub enum ModelCommand {
315 Models,
316 Chat {
318 #[command(flatten)]
319 input: JsonInput,
320 #[arg(long)]
321 request_id: Option<String>,
322 },
323 Responses {
325 #[command(flatten)]
326 input: JsonInput,
327 #[arg(long)]
328 request_id: Option<String>,
329 },
330}
331
332#[derive(Debug, Args)]
333pub struct JsonInput {
334 #[arg(long, short = 'f', default_value = "-")]
336 file: PathBuf,
337}
338
339impl JsonInput {
340 fn read<T: DeserializeOwned>(&self) -> Result<T> {
341 let bytes = read_input(&self.file)?;
342 serde_json::from_slice(&bytes)
343 .with_context(|| format!("invalid JSON input from {}", display_source(&self.file)))
344 }
345}
346
347#[derive(Debug)]
348pub enum CommandOutput {
349 Json(Value),
350 Text(String),
351}
352
353impl CommandOutput {
354 fn json(value: impl Serialize) -> Result<Self> {
355 Ok(Self::Json(serde_json::to_value(value)?))
356 }
357
358 pub fn render(&self, format: OutputFormat) -> Result<String> {
359 match self {
360 Self::Text(value) => Ok(value.clone()),
361 Self::Json(value) => match format {
362 OutputFormat::Pretty => Ok(serde_json::to_string_pretty(value)?),
363 OutputFormat::Json => Ok(serde_json::to_string(value)?),
364 },
365 }
366 }
367}
368
369impl Cli {
370 pub async fn execute(self) -> Result<CommandOutput> {
371 if let Command::Config { command } = &self.command {
372 return execute_config(&self, command);
373 }
374 if let Command::Auth { command } = &self.command {
375 return execute_auth(&self, command);
376 }
377 let client = self.build_client()?;
378 match self.command {
379 Command::Config { .. } | Command::Auth { .. } => {
380 unreachable!("local commands returned before building the client")
381 }
382 Command::Gateway { command } => execute_gateway(&client, command).await,
383 Command::Agent { command } => execute_agent(&client, command).await,
384 Command::Context { command } => execute_context(&client, command).await,
385 Command::Workspace { command } => execute_workspace(&client, command).await,
386 Command::Model { command } => execute_model(&client, command).await,
387 }
388 }
389
390 fn build_client(&self) -> Result<InfraClient> {
391 let store = ConfigStore::discover(self.config.as_deref())?;
392 let gateway = store.resolve_gateway(self.gateway.as_deref())?;
393 let (token, _) = resolve_credential(self.token.as_deref(), self.sky_home.as_deref())?;
394 let mut builder = InfraClient::builder(gateway.value)
395 .request_timeout(Duration::from_secs(self.timeout))
396 .trusted_mesh_http(self.trusted_mesh_http)
397 .user_agent(concat!("agent-infra-cli/", env!("CARGO_PKG_VERSION")));
398 if let Some(token) = token {
399 builder = builder.credentials(Arc::new(StaticCredentials::new(token)?));
400 }
401 if let Some(root) = self.local_workspace.as_ref() {
402 let workspace =
403 LocalWorkspace::new_with_host_exec(root.clone(), self.allow_local_workspace_exec);
404 builder = builder.local_workspace(Arc::new(workspace));
405 }
406 builder.build().map_err(Into::into)
407 }
408}
409
410fn execute_config(cli: &Cli, command: &ConfigCommand) -> Result<CommandOutput> {
411 let store = ConfigStore::discover(cli.config.as_deref())?;
412 match command {
413 ConfigCommand::Show
414 | ConfigCommand::Gateway {
415 command: ConfigGatewayCommand::Get,
416 } => {
417 let config = store.load()?;
418 let effective = store.resolve_gateway(cli.gateway.as_deref())?;
419 CommandOutput::json(serde_json::json!({
420 "path": store.path(),
421 "gateway": config.gateway,
422 "effectiveGateway": effective.value,
423 "source": effective.source,
424 }))
425 }
426 ConfigCommand::Gateway {
427 command: ConfigGatewayCommand::Set { url },
428 } => {
429 let config = store.set_gateway(url)?;
430 CommandOutput::json(serde_json::json!({
431 "ok": true,
432 "path": store.path(),
433 "gateway": config.gateway,
434 }))
435 }
436 ConfigCommand::Gateway {
437 command: ConfigGatewayCommand::Unset,
438 } => {
439 store.unset_gateway()?;
440 CommandOutput::json(serde_json::json!({
441 "ok": true,
442 "path": store.path(),
443 "gateway": null,
444 }))
445 }
446 }
447}
448
449fn execute_auth(cli: &Cli, command: &AuthCommand) -> Result<CommandOutput> {
450 match command {
451 AuthCommand::Status => {
452 let store = ConfigStore::discover(cli.config.as_deref())?;
453 let gateway = store.resolve_gateway(cli.gateway.as_deref())?;
454 let (_, credential) =
455 resolve_credential(cli.token.as_deref(), cli.sky_home.as_deref())?;
456 CommandOutput::json(serde_json::json!({
457 "gateway": gateway.value,
458 "gatewaySource": gateway.source,
459 "credential": credential,
460 }))
461 }
462 }
463}
464
465async fn execute_gateway(client: &InfraClient, command: GatewayCommand) -> Result<CommandOutput> {
466 match command {
467 GatewayCommand::Discovery => CommandOutput::json(client.gateway().discovery().await?),
468 GatewayCommand::Live => CommandOutput::json(client.gateway().live().await?),
469 GatewayCommand::Ready => CommandOutput::json(client.gateway().ready().await?),
470 GatewayCommand::WaitReady { deadline } => CommandOutput::json(
471 client
472 .gateway()
473 .wait_until_ready(Duration::from_secs(deadline))
474 .await?,
475 ),
476 GatewayCommand::Dependencies => CommandOutput::json(client.gateway().dependencies().await?),
477 GatewayCommand::Routes => CommandOutput::json(client.gateway().routes().await?),
478 GatewayCommand::Metrics => CommandOutput::json(client.gateway().metrics().await?),
479 GatewayCommand::Policy => CommandOutput::json(client.gateway().effective_policy().await?),
480 }
481}
482
483async fn execute_agent(client: &InfraClient, command: AgentCommand) -> Result<CommandOutput> {
484 match command {
485 AgentCommand::Create(input) => {
486 let request: CreateAgentRequest = input.read()?;
487 CommandOutput::json(client.agents().create(&request).await?)
488 }
489 AgentCommand::Get { agent_id } => {
490 let agent_id = parse_agent_id(&agent_id)?;
491 CommandOutput::json(client.agents().get(&agent_id).await?)
492 }
493 AgentCommand::List(args) => {
494 let request = ListAgentsRequest {
495 status: args.status.map(Into::into),
496 related_agent_id: args
497 .related_agent_id
498 .as_deref()
499 .map(parse_agent_id)
500 .transpose()?,
501 related_runtime_id: args.related_runtime_id,
502 related_user_id: args.related_user_id,
503 cursor: args.cursor,
504 limit: args.limit,
505 };
506 CommandOutput::json(client.agents().list(&request).await?)
507 }
508 AgentCommand::Update { agent_id, input } => {
509 let agent_id = parse_agent_id(&agent_id)?;
510 let request: UpdateAgentRequest = input.read()?;
511 CommandOutput::json(client.agents().update(&agent_id, &request).await?)
512 }
513 }
514}
515
516async fn execute_context(client: &InfraClient, command: ContextCommand) -> Result<CommandOutput> {
517 match command {
518 ContextCommand::Recent {
519 conversation_id,
520 limit,
521 role,
522 } => {
523 let roles = role.iter().map(String::as_str).collect::<Vec<_>>();
524 CommandOutput::json(
525 client
526 .context()
527 .recent_messages(&conversation_id, limit.map(|value| value as usize), &roles)
528 .await?,
529 )
530 }
531 ContextCommand::Search {
532 conversation_id,
533 query,
534 role,
535 limit,
536 } => CommandOutput::json(
537 client
538 .context()
539 .search_messages(
540 &conversation_id,
541 &query,
542 role.as_deref(),
543 limit.map(|value| value as usize),
544 )
545 .await?,
546 ),
547 ContextCommand::Append(input) => {
548 let messages: Vec<ConversationRecord> = input.read()?;
549 if messages.is_empty() {
550 bail!("append input must contain at least one message");
551 }
552 CommandOutput::json(client.context().append_messages(messages).await?)
553 }
554 ContextCommand::Maintenance => {
555 CommandOutput::json(client.context().maintenance_status().await?)
556 }
557 }
558}
559
560async fn execute_workspace(
561 client: &InfraClient,
562 command: WorkspaceCommand,
563) -> Result<CommandOutput> {
564 let workspace = client.workspace();
565 match command {
566 WorkspaceCommand::Capabilities => CommandOutput::json(workspace.capabilities().await?),
567 WorkspaceCommand::Read { path } => {
568 Ok(CommandOutput::Text(workspace.read_file(&path).await?))
569 }
570 WorkspaceCommand::Write(args) => {
571 let content = match (args.content, args.file) {
572 (Some(content), None) => content,
573 (None, Some(path)) => String::from_utf8(read_input(&path)?)
574 .with_context(|| format!("{} is not valid UTF-8", display_source(&path)))?,
575 _ => return Err(anyhow!("exactly one of --content or --file is required")),
576 };
577 let revision = workspace
578 .write_file_with_options(
579 &args.path,
580 &content,
581 args.if_match.as_deref(),
582 CallOptions::default(),
583 )
584 .await?;
585 CommandOutput::json(serde_json::json!({ "ok": true, "revision": revision }))
586 }
587 WorkspaceCommand::Exists { path } => {
588 CommandOutput::json(serde_json::json!({ "exists": workspace.exists(&path).await? }))
589 }
590 WorkspaceCommand::Mkdir { path } => {
591 workspace.create_dir_all(&path).await?;
592 CommandOutput::json(serde_json::json!({ "ok": true }))
593 }
594 WorkspaceCommand::List { path } => CommandOutput::json(workspace.list_dir(&path).await?),
595 WorkspaceCommand::Exec { command, cwd } => {
596 CommandOutput::json(workspace.exec(&command, cwd.as_deref()).await?)
597 }
598 WorkspaceCommand::Remove { path } => {
599 workspace.remove_file(&path).await?;
600 CommandOutput::json(serde_json::json!({ "ok": true }))
601 }
602 WorkspaceCommand::ListWorkspaces { after, limit } => CommandOutput::json(
603 workspace
604 .list_workspaces(after.as_deref(), limit.map(|value| value as usize))
605 .await?,
606 ),
607 WorkspaceCommand::GetWorkspace { workspace_id } => {
608 CommandOutput::json(workspace.workspace(&workspace_id).await?)
609 }
610 WorkspaceCommand::WorkspaceUsage { workspace_id } => {
611 CommandOutput::json(workspace.workspace_usage(&workspace_id).await?)
612 }
613 WorkspaceCommand::CreateWorkspace {
614 input,
615 idempotency_key,
616 wait,
617 wait_timeout,
618 } => {
619 let request: CreateWorkspaceRequest = input.read()?;
620 let handle = workspace
621 .create_workspace(&request, &idempotency_key)
622 .await?;
623 if wait {
624 CommandOutput::json(
625 handle
626 .wait(WaitOptions::deadline(Duration::from_secs(wait_timeout)))
627 .await?,
628 )
629 } else {
630 CommandOutput::json(serde_json::json!({ "operationId": handle.id() }))
631 }
632 }
633 }
634}
635
636async fn execute_model(client: &InfraClient, command: ModelCommand) -> Result<CommandOutput> {
637 match command {
638 ModelCommand::Models => CommandOutput::json(client.model().models().await?),
639 ModelCommand::Chat { input, request_id } => {
640 let request: Value = input.read()?;
641 ensure_json_object(&request)?;
642 CommandOutput::json(
643 client
644 .model()
645 .chat_completions(&request, call_options(request_id))
646 .await?,
647 )
648 }
649 ModelCommand::Responses { input, request_id } => {
650 let request: Value = input.read()?;
651 ensure_json_object(&request)?;
652 CommandOutput::json(
653 client
654 .model()
655 .responses(&request, call_options(request_id))
656 .await?,
657 )
658 }
659 }
660}
661
662fn call_options(request_id: Option<String>) -> CallOptions {
663 request_id.map_or_else(CallOptions::default, |value| {
664 CallOptions::default().request_id(value)
665 })
666}
667
668fn ensure_json_object(value: &Value) -> Result<()> {
669 if !value.is_object() {
670 bail!("model request must be a JSON object");
671 }
672 Ok(())
673}
674
675fn parse_agent_id(value: &str) -> Result<AgentId> {
676 AgentId::new(value).map_err(|message| anyhow!(message))
677}
678
679fn read_input(path: &Path) -> Result<Vec<u8>> {
680 if path == Path::new("-") {
681 let mut bytes = Vec::new();
682 io::stdin()
683 .read_to_end(&mut bytes)
684 .context("failed to read standard input")?;
685 Ok(bytes)
686 } else {
687 fs::read(path).with_context(|| format!("failed to read {}", path.display()))
688 }
689}
690
691fn display_source(path: &Path) -> String {
692 if path == Path::new("-") {
693 "standard input".into()
694 } else {
695 path.display().to_string()
696 }
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702 use clap::CommandFactory;
703 use wiremock::{
704 Mock, MockServer, ResponseTemplate,
705 matchers::{header, method, path},
706 };
707
708 #[test]
709 fn clap_definition_is_valid() {
710 Cli::command().debug_assert();
711 }
712
713 #[test]
714 fn parses_global_options_after_subcommands() {
715 let cli = Cli::try_parse_from([
716 "infra",
717 "gateway",
718 "wait-ready",
719 "--deadline",
720 "42",
721 "--gateway",
722 "https://infra.example.com",
723 "--output",
724 "json",
725 ])
726 .unwrap();
727 assert_eq!(cli.gateway.as_deref(), Some("https://infra.example.com"));
728 assert_eq!(cli.output, OutputFormat::Json);
729 assert!(matches!(
730 cli.command,
731 Command::Gateway {
732 command: GatewayCommand::WaitReady { deadline: 42 }
733 }
734 ));
735 }
736
737 #[test]
738 fn rejects_ambiguous_workspace_write_content() {
739 let result = Cli::try_parse_from([
740 "infra",
741 "workspace",
742 "write",
743 "README.md",
744 "--content",
745 "one",
746 "--file",
747 "two.txt",
748 ]);
749 assert!(result.is_err());
750 }
751
752 #[test]
753 fn reads_json_request_from_file() {
754 let directory = tempfile::tempdir().unwrap();
755 let path = directory.path().join("request.json");
756 fs::write(&path, br#"{"model":"test"}"#).unwrap();
757 let input = JsonInput { file: path };
758 let value: Value = input.read().unwrap();
759 assert_eq!(value["model"], "test");
760 }
761
762 #[tokio::test]
763 async fn persists_and_resolves_gateway_without_network_access() {
764 let directory = tempfile::tempdir().unwrap();
765 let config = directory.path().join("infra.json");
766 let cli = Cli::try_parse_from([
767 "infra",
768 "config",
769 "gateway",
770 "set",
771 "https://infra.example.com/",
772 "--config",
773 config.to_str().unwrap(),
774 ])
775 .unwrap();
776 let output = cli.execute().await.unwrap();
777 let CommandOutput::Json(value) = output else {
778 panic!("config set must produce JSON");
779 };
780 assert_eq!(value["gateway"], "https://infra.example.com");
781
782 let store = ConfigStore::discover(Some(&config)).unwrap();
783 assert_eq!(
784 store.load().unwrap().gateway.as_deref(),
785 Some("https://infra.example.com")
786 );
787 }
788
789 #[tokio::test]
790 async fn auth_status_detects_encrypted_sky_login_without_exposing_it() {
791 let directory = tempfile::tempdir().unwrap();
792 let sky_home = directory.path().join("sky");
793 fs::create_dir_all(&sky_home).unwrap();
794 fs::write(sky_home.join("credentials"), "ENCv1:secret-ciphertext").unwrap();
795 let config = directory.path().join("infra.json");
796 let cli = Cli::try_parse_from([
797 "infra",
798 "auth",
799 "status",
800 "--config",
801 config.to_str().unwrap(),
802 "--sky-home",
803 sky_home.to_str().unwrap(),
804 ])
805 .unwrap();
806
807 let output = cli.execute().await.unwrap();
808 let rendered = output.render(OutputFormat::Json).unwrap();
809 assert!(rendered.contains("sky_login_encrypted"));
810 assert!(!rendered.contains("secret-ciphertext"));
811 }
812
813 #[test]
814 fn renders_compact_and_pretty_json() {
815 let output = CommandOutput::Json(serde_json::json!({ "ok": true }));
816 assert_eq!(output.render(OutputFormat::Json).unwrap(), r#"{"ok":true}"#);
817 assert!(output.render(OutputFormat::Pretty).unwrap().contains('\n'));
818 }
819
820 #[tokio::test]
821 async fn model_command_uses_sdk_route_and_bearer_credential() {
822 let server = MockServer::start().await;
823 Mock::given(method("GET"))
824 .and(path("/v1/model/models"))
825 .and(header("authorization", "Bearer test-token"))
826 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
827 "object": "list",
828 "data": [{ "id": "test-model" }]
829 })))
830 .expect(1)
831 .mount(&server)
832 .await;
833
834 let cli = Cli::try_parse_from([
835 "infra",
836 "model",
837 "models",
838 "--gateway",
839 &server.uri(),
840 "--token",
841 "test-token",
842 ])
843 .unwrap();
844 let output = cli.execute().await.unwrap();
845 let CommandOutput::Json(value) = output else {
846 panic!("model list must produce JSON");
847 };
848 assert_eq!(value["data"][0]["id"], "test-model");
849 }
850
851 #[tokio::test]
852 async fn local_workspace_data_plane_does_not_call_gateway() {
853 let directory = tempfile::tempdir().unwrap();
854 fs::write(directory.path().join("input.txt"), "local-only").unwrap();
855
856 let cli = Cli::try_parse_from([
857 "infra",
858 "workspace",
859 "read",
860 "input.txt",
861 "--local-workspace",
862 directory.path().to_str().unwrap(),
863 "--gateway",
864 "https://gateway.invalid",
865 ])
866 .unwrap();
867 let client = cli.build_client().unwrap();
868
869 assert_eq!(
870 client.workspace().read_file("input.txt").await.unwrap(),
871 "local-only"
872 );
873 client.workspace().create_dir_all("nested").await.unwrap();
874 let revision = client
875 .workspace()
876 .write_file_with_options(
877 "nested/output.txt",
878 "written-directly",
879 None,
880 CallOptions::default(),
881 )
882 .await
883 .unwrap();
884 assert!(revision.is_some());
885 assert!(
886 client
887 .workspace()
888 .exists("nested/output.txt")
889 .await
890 .unwrap()
891 );
892 assert_eq!(
893 fs::read_to_string(directory.path().join("nested/output.txt")).unwrap(),
894 "written-directly"
895 );
896 assert!(client.workspace().is_dir("nested").await.unwrap());
897 assert_eq!(
898 client
899 .workspace()
900 .read_file_bytes("nested/output.txt")
901 .await
902 .unwrap(),
903 b"written-directly"
904 );
905 assert!(
906 client
907 .workspace()
908 .walk_tree(".", 4)
909 .await
910 .unwrap()
911 .iter()
912 .any(|path| path.ends_with("nested/output.txt"))
913 );
914 client
915 .workspace()
916 .remove_file_if_match("nested/output.txt", revision.as_deref().unwrap())
917 .await
918 .unwrap();
919 assert!(
920 !client
921 .workspace()
922 .exists("nested/output.txt")
923 .await
924 .unwrap()
925 );
926 }
927
928 #[tokio::test]
929 async fn local_workspace_binding_keeps_managed_lifecycle_on_gateway() {
930 let server = MockServer::start().await;
931 Mock::given(method("GET"))
932 .and(path("/internal/v1/workspaces"))
933 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
934 "items": [],
935 "nextCursor": null
936 })))
937 .expect(1)
938 .mount(&server)
939 .await;
940 let directory = tempfile::tempdir().unwrap();
941 let cli = Cli::try_parse_from([
942 "infra",
943 "workspace",
944 "list-workspaces",
945 "--gateway",
946 &server.uri(),
947 "--local-workspace",
948 directory.path().to_str().unwrap(),
949 ])
950 .unwrap();
951
952 let output = cli.execute().await.unwrap();
953 let CommandOutput::Json(value) = output else {
954 panic!("managed workspace list must produce JSON");
955 };
956 assert_eq!(value["items"], serde_json::json!([]));
957 }
958}