use std::{
fs,
io::{self, Read},
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use agent_infra_sdk::{
CallOptions, InfraClient, StaticCredentials, WaitOptions,
agent_contract::{
AgentId, AgentStatus, CreateAgentRequest, ListAgentsRequest, UpdateAgentRequest,
},
context_contract::ConversationRecord,
workspace_contract::CreateWorkspaceRequest,
};
use agent_workspace_adapter_local::LocalWorkspace;
use anyhow::{Context, Result, anyhow, bail};
use clap::{Args, Parser, Subcommand, ValueEnum};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;
mod config;
use config::{ConfigStore, resolve_credential};
const DEFAULT_GATEWAY: &str = "http://127.0.0.1:5200";
#[derive(Debug, Parser)]
#[command(
name = "infra",
version,
about = "Operate Agent Infra through the typed Infra SDK",
arg_required_else_help = true
)]
pub struct Cli {
#[arg(long, global = true)]
pub gateway: Option<String>,
#[arg(long, global = true, hide_env_values = true)]
pub token: Option<String>,
#[arg(long, global = true, env = "AGENT_INFRA_CONFIG", value_name = "PATH")]
pub config: Option<PathBuf>,
#[arg(
long,
global = true,
env = "AGENT_INFRA_SKY_HOME",
value_name = "PATH",
hide = true
)]
pub sky_home: Option<PathBuf>,
#[arg(long, global = true, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=600))]
pub timeout: u64,
#[arg(long, global = true)]
pub trusted_mesh_http: bool,
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Pretty)]
pub output: OutputFormat,
#[arg(
long,
global = true,
env = "AGENT_INFRA_LOCAL_WORKSPACE",
value_name = "PATH"
)]
pub local_workspace: Option<PathBuf>,
#[arg(long, global = true, requires = "local_workspace")]
pub allow_local_workspace_exec: bool,
#[command(subcommand)]
pub command: Command,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum OutputFormat {
#[default]
Pretty,
Json,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Config {
#[command(subcommand)]
command: ConfigCommand,
},
Auth {
#[command(subcommand)]
command: AuthCommand,
},
Gateway {
#[command(subcommand)]
command: GatewayCommand,
},
Agent {
#[command(subcommand)]
command: AgentCommand,
},
Context {
#[command(subcommand)]
command: ContextCommand,
},
Workspace {
#[command(subcommand)]
command: WorkspaceCommand,
},
Model {
#[command(subcommand)]
command: ModelCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
Show,
Gateway {
#[command(subcommand)]
command: ConfigGatewayCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum ConfigGatewayCommand {
Set { url: String },
Get,
Unset,
}
#[derive(Debug, Subcommand)]
pub enum AuthCommand {
Status,
}
#[derive(Debug, Subcommand)]
pub enum GatewayCommand {
Discovery,
Live,
Ready,
WaitReady {
#[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=3600))]
deadline: u64,
},
Dependencies,
Routes,
Metrics,
Policy,
}
#[derive(Debug, Subcommand)]
pub enum AgentCommand {
Create(JsonInput),
Get {
agent_id: String,
},
List(AgentListArgs),
Update {
agent_id: String,
#[command(flatten)]
input: JsonInput,
},
}
#[derive(Debug, Args)]
pub struct AgentListArgs {
#[arg(long, value_enum)]
status: Option<CliAgentStatus>,
#[arg(long)]
related_agent_id: Option<String>,
#[arg(long)]
related_runtime_id: Option<String>,
#[arg(long)]
related_user_id: Option<String>,
#[arg(long)]
cursor: Option<String>,
#[arg(long, value_parser = clap::value_parser!(u32).range(1..=200))]
limit: Option<u32>,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CliAgentStatus {
Active,
Archived,
}
impl From<CliAgentStatus> for AgentStatus {
fn from(value: CliAgentStatus) -> Self {
match value {
CliAgentStatus::Active => Self::Active,
CliAgentStatus::Archived => Self::Archived,
}
}
}
#[derive(Debug, Subcommand)]
pub enum ContextCommand {
Recent {
conversation_id: String,
#[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
limit: Option<u64>,
#[arg(long, value_delimiter = ',')]
role: Vec<String>,
},
Search {
conversation_id: String,
query: String,
#[arg(long)]
role: Option<String>,
#[arg(long, value_parser = clap::value_parser!(u64).range(1..=1000))]
limit: Option<u64>,
},
Append(JsonInput),
Maintenance,
}
#[derive(Debug, Subcommand)]
pub enum WorkspaceCommand {
Capabilities,
Read {
path: String,
},
Write(WorkspaceWriteArgs),
Exists {
path: String,
},
Mkdir {
path: String,
},
List {
path: String,
},
Exec {
command: String,
#[arg(long)]
cwd: Option<String>,
},
Remove {
path: String,
},
ListWorkspaces {
#[arg(long)]
after: Option<String>,
#[arg(long, value_parser = clap::value_parser!(u64).range(1..=200))]
limit: Option<u64>,
},
GetWorkspace {
workspace_id: String,
},
WorkspaceUsage {
workspace_id: String,
},
CreateWorkspace {
#[command(flatten)]
input: JsonInput,
#[arg(long)]
idempotency_key: String,
#[arg(long)]
wait: bool,
#[arg(long, default_value_t = 900, value_parser = clap::value_parser!(u64).range(1..=604800))]
wait_timeout: u64,
},
}
#[derive(Debug, Args)]
pub struct WorkspaceWriteArgs {
pub path: String,
#[arg(long, conflicts_with = "file", required_unless_present = "file")]
content: Option<String>,
#[arg(long, conflicts_with = "content", required_unless_present = "content")]
file: Option<PathBuf>,
#[arg(long)]
if_match: Option<String>,
}
#[derive(Debug, Subcommand)]
pub enum ModelCommand {
Models,
Chat {
#[command(flatten)]
input: JsonInput,
#[arg(long)]
request_id: Option<String>,
},
Responses {
#[command(flatten)]
input: JsonInput,
#[arg(long)]
request_id: Option<String>,
},
}
#[derive(Debug, Args)]
pub struct JsonInput {
#[arg(long, short = 'f', default_value = "-")]
file: PathBuf,
}
impl JsonInput {
fn read<T: DeserializeOwned>(&self) -> Result<T> {
let bytes = read_input(&self.file)?;
serde_json::from_slice(&bytes)
.with_context(|| format!("invalid JSON input from {}", display_source(&self.file)))
}
}
#[derive(Debug)]
pub enum CommandOutput {
Json(Value),
Text(String),
}
impl CommandOutput {
fn json(value: impl Serialize) -> Result<Self> {
Ok(Self::Json(serde_json::to_value(value)?))
}
pub fn render(&self, format: OutputFormat) -> Result<String> {
match self {
Self::Text(value) => Ok(value.clone()),
Self::Json(value) => match format {
OutputFormat::Pretty => Ok(serde_json::to_string_pretty(value)?),
OutputFormat::Json => Ok(serde_json::to_string(value)?),
},
}
}
}
impl Cli {
pub async fn execute(self) -> Result<CommandOutput> {
if let Command::Config { command } = &self.command {
return execute_config(&self, command);
}
if let Command::Auth { command } = &self.command {
return execute_auth(&self, command);
}
let client = self.build_client()?;
match self.command {
Command::Config { .. } | Command::Auth { .. } => {
unreachable!("local commands returned before building the client")
}
Command::Gateway { command } => execute_gateway(&client, command).await,
Command::Agent { command } => execute_agent(&client, command).await,
Command::Context { command } => execute_context(&client, command).await,
Command::Workspace { command } => execute_workspace(&client, command).await,
Command::Model { command } => execute_model(&client, command).await,
}
}
fn build_client(&self) -> Result<InfraClient> {
let store = ConfigStore::discover(self.config.as_deref())?;
let gateway = store.resolve_gateway(self.gateway.as_deref())?;
let (token, _) = resolve_credential(self.token.as_deref(), self.sky_home.as_deref())?;
let mut builder = InfraClient::builder(gateway.value)
.request_timeout(Duration::from_secs(self.timeout))
.trusted_mesh_http(self.trusted_mesh_http)
.user_agent(concat!("agent-infra-cli/", env!("CARGO_PKG_VERSION")));
if let Some(token) = token {
builder = builder.credentials(Arc::new(StaticCredentials::new(token)?));
}
if let Some(root) = self.local_workspace.as_ref() {
let workspace =
LocalWorkspace::new_with_host_exec(root.clone(), self.allow_local_workspace_exec);
builder = builder.local_workspace(Arc::new(workspace));
}
builder.build().map_err(Into::into)
}
}
fn execute_config(cli: &Cli, command: &ConfigCommand) -> Result<CommandOutput> {
let store = ConfigStore::discover(cli.config.as_deref())?;
match command {
ConfigCommand::Show
| ConfigCommand::Gateway {
command: ConfigGatewayCommand::Get,
} => {
let config = store.load()?;
let effective = store.resolve_gateway(cli.gateway.as_deref())?;
CommandOutput::json(serde_json::json!({
"path": store.path(),
"gateway": config.gateway,
"effectiveGateway": effective.value,
"source": effective.source,
}))
}
ConfigCommand::Gateway {
command: ConfigGatewayCommand::Set { url },
} => {
let config = store.set_gateway(url)?;
CommandOutput::json(serde_json::json!({
"ok": true,
"path": store.path(),
"gateway": config.gateway,
}))
}
ConfigCommand::Gateway {
command: ConfigGatewayCommand::Unset,
} => {
store.unset_gateway()?;
CommandOutput::json(serde_json::json!({
"ok": true,
"path": store.path(),
"gateway": null,
}))
}
}
}
fn execute_auth(cli: &Cli, command: &AuthCommand) -> Result<CommandOutput> {
match command {
AuthCommand::Status => {
let store = ConfigStore::discover(cli.config.as_deref())?;
let gateway = store.resolve_gateway(cli.gateway.as_deref())?;
let (_, credential) =
resolve_credential(cli.token.as_deref(), cli.sky_home.as_deref())?;
CommandOutput::json(serde_json::json!({
"gateway": gateway.value,
"gatewaySource": gateway.source,
"credential": credential,
}))
}
}
}
async fn execute_gateway(client: &InfraClient, command: GatewayCommand) -> Result<CommandOutput> {
match command {
GatewayCommand::Discovery => CommandOutput::json(client.gateway().discovery().await?),
GatewayCommand::Live => CommandOutput::json(client.gateway().live().await?),
GatewayCommand::Ready => CommandOutput::json(client.gateway().ready().await?),
GatewayCommand::WaitReady { deadline } => CommandOutput::json(
client
.gateway()
.wait_until_ready(Duration::from_secs(deadline))
.await?,
),
GatewayCommand::Dependencies => CommandOutput::json(client.gateway().dependencies().await?),
GatewayCommand::Routes => CommandOutput::json(client.gateway().routes().await?),
GatewayCommand::Metrics => CommandOutput::json(client.gateway().metrics().await?),
GatewayCommand::Policy => CommandOutput::json(client.gateway().effective_policy().await?),
}
}
async fn execute_agent(client: &InfraClient, command: AgentCommand) -> Result<CommandOutput> {
match command {
AgentCommand::Create(input) => {
let request: CreateAgentRequest = input.read()?;
CommandOutput::json(client.agents().create(&request).await?)
}
AgentCommand::Get { agent_id } => {
let agent_id = parse_agent_id(&agent_id)?;
CommandOutput::json(client.agents().get(&agent_id).await?)
}
AgentCommand::List(args) => {
let request = ListAgentsRequest {
status: args.status.map(Into::into),
related_agent_id: args
.related_agent_id
.as_deref()
.map(parse_agent_id)
.transpose()?,
related_runtime_id: args.related_runtime_id,
related_user_id: args.related_user_id,
cursor: args.cursor,
limit: args.limit,
};
CommandOutput::json(client.agents().list(&request).await?)
}
AgentCommand::Update { agent_id, input } => {
let agent_id = parse_agent_id(&agent_id)?;
let request: UpdateAgentRequest = input.read()?;
CommandOutput::json(client.agents().update(&agent_id, &request).await?)
}
}
}
async fn execute_context(client: &InfraClient, command: ContextCommand) -> Result<CommandOutput> {
match command {
ContextCommand::Recent {
conversation_id,
limit,
role,
} => {
let roles = role.iter().map(String::as_str).collect::<Vec<_>>();
CommandOutput::json(
client
.context()
.recent_messages(&conversation_id, limit.map(|value| value as usize), &roles)
.await?,
)
}
ContextCommand::Search {
conversation_id,
query,
role,
limit,
} => CommandOutput::json(
client
.context()
.search_messages(
&conversation_id,
&query,
role.as_deref(),
limit.map(|value| value as usize),
)
.await?,
),
ContextCommand::Append(input) => {
let messages: Vec<ConversationRecord> = input.read()?;
if messages.is_empty() {
bail!("append input must contain at least one message");
}
CommandOutput::json(client.context().append_messages(messages).await?)
}
ContextCommand::Maintenance => {
CommandOutput::json(client.context().maintenance_status().await?)
}
}
}
async fn execute_workspace(
client: &InfraClient,
command: WorkspaceCommand,
) -> Result<CommandOutput> {
let workspace = client.workspace();
match command {
WorkspaceCommand::Capabilities => CommandOutput::json(workspace.capabilities().await?),
WorkspaceCommand::Read { path } => {
Ok(CommandOutput::Text(workspace.read_file(&path).await?))
}
WorkspaceCommand::Write(args) => {
let content = match (args.content, args.file) {
(Some(content), None) => content,
(None, Some(path)) => String::from_utf8(read_input(&path)?)
.with_context(|| format!("{} is not valid UTF-8", display_source(&path)))?,
_ => return Err(anyhow!("exactly one of --content or --file is required")),
};
let revision = workspace
.write_file_with_options(
&args.path,
&content,
args.if_match.as_deref(),
CallOptions::default(),
)
.await?;
CommandOutput::json(serde_json::json!({ "ok": true, "revision": revision }))
}
WorkspaceCommand::Exists { path } => {
CommandOutput::json(serde_json::json!({ "exists": workspace.exists(&path).await? }))
}
WorkspaceCommand::Mkdir { path } => {
workspace.create_dir_all(&path).await?;
CommandOutput::json(serde_json::json!({ "ok": true }))
}
WorkspaceCommand::List { path } => CommandOutput::json(workspace.list_dir(&path).await?),
WorkspaceCommand::Exec { command, cwd } => {
CommandOutput::json(workspace.exec(&command, cwd.as_deref()).await?)
}
WorkspaceCommand::Remove { path } => {
workspace.remove_file(&path).await?;
CommandOutput::json(serde_json::json!({ "ok": true }))
}
WorkspaceCommand::ListWorkspaces { after, limit } => CommandOutput::json(
workspace
.list_workspaces(after.as_deref(), limit.map(|value| value as usize))
.await?,
),
WorkspaceCommand::GetWorkspace { workspace_id } => {
CommandOutput::json(workspace.workspace(&workspace_id).await?)
}
WorkspaceCommand::WorkspaceUsage { workspace_id } => {
CommandOutput::json(workspace.workspace_usage(&workspace_id).await?)
}
WorkspaceCommand::CreateWorkspace {
input,
idempotency_key,
wait,
wait_timeout,
} => {
let request: CreateWorkspaceRequest = input.read()?;
let handle = workspace
.create_workspace(&request, &idempotency_key)
.await?;
if wait {
CommandOutput::json(
handle
.wait(WaitOptions::deadline(Duration::from_secs(wait_timeout)))
.await?,
)
} else {
CommandOutput::json(serde_json::json!({ "operationId": handle.id() }))
}
}
}
}
async fn execute_model(client: &InfraClient, command: ModelCommand) -> Result<CommandOutput> {
match command {
ModelCommand::Models => CommandOutput::json(client.model().models().await?),
ModelCommand::Chat { input, request_id } => {
let request: Value = input.read()?;
ensure_json_object(&request)?;
CommandOutput::json(
client
.model()
.chat_completions(&request, call_options(request_id))
.await?,
)
}
ModelCommand::Responses { input, request_id } => {
let request: Value = input.read()?;
ensure_json_object(&request)?;
CommandOutput::json(
client
.model()
.responses(&request, call_options(request_id))
.await?,
)
}
}
}
fn call_options(request_id: Option<String>) -> CallOptions {
request_id.map_or_else(CallOptions::default, |value| {
CallOptions::default().request_id(value)
})
}
fn ensure_json_object(value: &Value) -> Result<()> {
if !value.is_object() {
bail!("model request must be a JSON object");
}
Ok(())
}
fn parse_agent_id(value: &str) -> Result<AgentId> {
AgentId::new(value).map_err(|message| anyhow!(message))
}
fn read_input(path: &Path) -> Result<Vec<u8>> {
if path == Path::new("-") {
let mut bytes = Vec::new();
io::stdin()
.read_to_end(&mut bytes)
.context("failed to read standard input")?;
Ok(bytes)
} else {
fs::read(path).with_context(|| format!("failed to read {}", path.display()))
}
}
fn display_source(path: &Path) -> String {
if path == Path::new("-") {
"standard input".into()
} else {
path.display().to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{header, method, path},
};
#[test]
fn clap_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn parses_global_options_after_subcommands() {
let cli = Cli::try_parse_from([
"infra",
"gateway",
"wait-ready",
"--deadline",
"42",
"--gateway",
"https://infra.example.com",
"--output",
"json",
])
.unwrap();
assert_eq!(cli.gateway.as_deref(), Some("https://infra.example.com"));
assert_eq!(cli.output, OutputFormat::Json);
assert!(matches!(
cli.command,
Command::Gateway {
command: GatewayCommand::WaitReady { deadline: 42 }
}
));
}
#[test]
fn rejects_ambiguous_workspace_write_content() {
let result = Cli::try_parse_from([
"infra",
"workspace",
"write",
"README.md",
"--content",
"one",
"--file",
"two.txt",
]);
assert!(result.is_err());
}
#[test]
fn reads_json_request_from_file() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("request.json");
fs::write(&path, br#"{"model":"test"}"#).unwrap();
let input = JsonInput { file: path };
let value: Value = input.read().unwrap();
assert_eq!(value["model"], "test");
}
#[tokio::test]
async fn persists_and_resolves_gateway_without_network_access() {
let directory = tempfile::tempdir().unwrap();
let config = directory.path().join("infra.json");
let cli = Cli::try_parse_from([
"infra",
"config",
"gateway",
"set",
"https://infra.example.com/",
"--config",
config.to_str().unwrap(),
])
.unwrap();
let output = cli.execute().await.unwrap();
let CommandOutput::Json(value) = output else {
panic!("config set must produce JSON");
};
assert_eq!(value["gateway"], "https://infra.example.com");
let store = ConfigStore::discover(Some(&config)).unwrap();
assert_eq!(
store.load().unwrap().gateway.as_deref(),
Some("https://infra.example.com")
);
}
#[tokio::test]
async fn auth_status_detects_encrypted_sky_login_without_exposing_it() {
let directory = tempfile::tempdir().unwrap();
let sky_home = directory.path().join("sky");
fs::create_dir_all(&sky_home).unwrap();
fs::write(sky_home.join("credentials"), "ENCv1:secret-ciphertext").unwrap();
let config = directory.path().join("infra.json");
let cli = Cli::try_parse_from([
"infra",
"auth",
"status",
"--config",
config.to_str().unwrap(),
"--sky-home",
sky_home.to_str().unwrap(),
])
.unwrap();
let output = cli.execute().await.unwrap();
let rendered = output.render(OutputFormat::Json).unwrap();
assert!(rendered.contains("sky_login_encrypted"));
assert!(!rendered.contains("secret-ciphertext"));
}
#[test]
fn renders_compact_and_pretty_json() {
let output = CommandOutput::Json(serde_json::json!({ "ok": true }));
assert_eq!(output.render(OutputFormat::Json).unwrap(), r#"{"ok":true}"#);
assert!(output.render(OutputFormat::Pretty).unwrap().contains('\n'));
}
#[tokio::test]
async fn model_command_uses_sdk_route_and_bearer_credential() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/model/models"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"object": "list",
"data": [{ "id": "test-model" }]
})))
.expect(1)
.mount(&server)
.await;
let cli = Cli::try_parse_from([
"infra",
"model",
"models",
"--gateway",
&server.uri(),
"--token",
"test-token",
])
.unwrap();
let output = cli.execute().await.unwrap();
let CommandOutput::Json(value) = output else {
panic!("model list must produce JSON");
};
assert_eq!(value["data"][0]["id"], "test-model");
}
#[tokio::test]
async fn local_workspace_data_plane_does_not_call_gateway() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("input.txt"), "local-only").unwrap();
let cli = Cli::try_parse_from([
"infra",
"workspace",
"read",
"input.txt",
"--local-workspace",
directory.path().to_str().unwrap(),
"--gateway",
"https://gateway.invalid",
])
.unwrap();
let client = cli.build_client().unwrap();
assert_eq!(
client.workspace().read_file("input.txt").await.unwrap(),
"local-only"
);
client.workspace().create_dir_all("nested").await.unwrap();
let revision = client
.workspace()
.write_file_with_options(
"nested/output.txt",
"written-directly",
None,
CallOptions::default(),
)
.await
.unwrap();
assert!(revision.is_some());
assert!(
client
.workspace()
.exists("nested/output.txt")
.await
.unwrap()
);
assert_eq!(
fs::read_to_string(directory.path().join("nested/output.txt")).unwrap(),
"written-directly"
);
assert!(client.workspace().is_dir("nested").await.unwrap());
assert_eq!(
client
.workspace()
.read_file_bytes("nested/output.txt")
.await
.unwrap(),
b"written-directly"
);
assert!(
client
.workspace()
.walk_tree(".", 4)
.await
.unwrap()
.iter()
.any(|path| path.ends_with("nested/output.txt"))
);
client
.workspace()
.remove_file_if_match("nested/output.txt", revision.as_deref().unwrap())
.await
.unwrap();
assert!(
!client
.workspace()
.exists("nested/output.txt")
.await
.unwrap()
);
}
#[tokio::test]
async fn local_workspace_binding_keeps_managed_lifecycle_on_gateway() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/internal/v1/workspaces"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"items": [],
"nextCursor": null
})))
.expect(1)
.mount(&server)
.await;
let directory = tempfile::tempdir().unwrap();
let cli = Cli::try_parse_from([
"infra",
"workspace",
"list-workspaces",
"--gateway",
&server.uri(),
"--local-workspace",
directory.path().to_str().unwrap(),
])
.unwrap();
let output = cli.execute().await.unwrap();
let CommandOutput::Json(value) = output else {
panic!("managed workspace list must produce JSON");
};
assert_eq!(value["items"], serde_json::json!([]));
}
}