use std::{
fs,
io::{self, Read},
path::{Path, PathBuf},
sync::Arc,
time::{Duration, Instant},
};
use agent_infra_sdk::{
CallOptions, InfraClient, StaticCredentials,
agent_contract::{
AgentId, AgentSkillRef, AgentStatus, CreateAgentRequest, ListAgentsRequest,
ResourceRelations, UpdateAgentRequest,
},
context_contract::ConversationRecord,
runtime_identity_contract::{
CreateRuntimeIdentityRequest, IssueRuntimeCredentialRequest, ReplacePermissionsRequest,
RuntimeIdentityId, RuntimeIdentityKind, TransitionIdentityRequest,
UpdateRuntimeIdentityRequest,
},
workspace_contract::{
CreateComputerRequest, CreateSnapshotResourceRequest, CreateSpaceRequest,
ListEnvironmentResourcesQuery, OwnerKind, SnapshotVisibility,
},
};
use anyhow::{Context, Result, anyhow, bail};
use clap::{Args, Parser, Subcommand, ValueEnum};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;
mod config;
mod credential;
mod eval_cli;
mod evaluate_bundle;
mod evaluate_cli;
mod execution_cli;
mod init;
mod runtime_repo_checkout;
mod trace;
use config::{ConfigStore, resolve_credential};
use eval_cli::execute_eval;
pub use eval_cli::{EvalAdminCommand, EvalCommand};
use evaluate_cli::execute_evaluate;
pub use evaluate_cli::{EvaluateCommand, EvaluateMetricsCommand, EvaluateReviewCommand};
pub use execution_cli::ExecutionCommand;
use execution_cli::execute as execute_execution;
use trace::TraceCommand;
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,
#[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,
},
Init {
#[arg(long)]
name: String,
#[arg(long)]
template_id: Option<String>,
#[arg(long)]
dir: Option<PathBuf>,
#[arg(long)]
no_clone: bool,
#[arg(long)]
owner_email: Option<String>,
#[arg(long)]
system_prompt: Option<String>,
#[arg(long = "skill")]
skills: Vec<String>,
},
Gateway {
#[command(subcommand)]
command: GatewayCommand,
},
Trace {
#[command(subcommand)]
command: TraceCommand,
},
Agent {
#[command(subcommand)]
command: AgentCommand,
},
Runtime {
#[command(subcommand)]
command: RuntimeCommand,
},
Context {
#[command(subcommand)]
command: ContextCommand,
},
Computer {
#[command(subcommand)]
command: ComputerCommand,
},
Space {
#[command(subcommand)]
command: SpaceCommand,
},
Snapshot {
#[command(subcommand)]
command: SnapshotCommand,
},
Model {
#[command(subcommand)]
command: ModelCommand,
},
Evaluate {
#[command(subcommand)]
command: EvaluateCommand,
},
Eval {
#[command(subcommand)]
command: EvalCommand,
},
Execution {
#[command(subcommand)]
command: ExecutionCommand,
},
Deploy {
#[command(subcommand)]
command: DeployCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum DeployCommand {
Template {
#[command(subcommand)]
command: DeployTemplateCommand,
},
MoulinConfig {
#[command(subcommand)]
command: DeployMoulinConfigCommand,
},
Repo {
#[command(subcommand)]
command: DeployRepoCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum DeployMoulinConfigCommand {
Get,
Update {
#[command(flatten)]
input: JsonInput,
},
}
#[derive(Debug, Subcommand)]
pub enum DeployTemplateCommand {
List,
}
#[derive(Debug, Subcommand)]
pub enum DeployRepoCommand {
Init {
#[command(flatten)]
input: JsonInput,
#[arg(long)]
idempotency_key: String,
#[arg(long)]
dir: Option<PathBuf>,
#[arg(long)]
no_clone: bool,
#[arg(long)]
agent_id: Option<String>,
#[arg(long)]
runtime_id: Option<String>,
},
List {
#[arg(long)]
owner_kind: Option<String>,
#[arg(long)]
owner_id: Option<String>,
},
Get {
repository_id: String,
},
}
#[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(AgentCreateArgs),
Get {
agent_id: String,
},
List(AgentListArgs),
Update {
agent_id: String,
#[command(flatten)]
input: JsonInput,
},
}
#[derive(Debug, Args)]
pub struct AgentCreateArgs {
#[arg(long, required_unless_present = "file")]
name: Option<String>,
#[arg(long, required_unless_present = "file")]
runtime_id: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long)]
system_prompt: Option<String>,
#[arg(long = "skill")]
skills: Vec<String>,
#[arg(long)]
idempotency_key: Option<String>,
#[arg(
long,
short = 'f',
value_name = "PATH",
conflicts_with_all = ["name", "runtime_id", "description", "system_prompt", "skills", "idempotency_key"]
)]
file: Option<PathBuf>,
}
#[derive(Debug, Subcommand)]
pub enum RuntimeCommand {
Create(RuntimeCreateArgs),
Get { runtime_id: String },
Rename {
runtime_id: String,
name: String,
#[arg(long)]
expected_revision: u64,
#[arg(long)]
idempotency_key: Option<String>,
},
Activate {
runtime_id: String,
#[command(flatten)]
input: JsonInput,
},
Suspend {
runtime_id: String,
#[command(flatten)]
input: JsonInput,
},
Resume {
runtime_id: String,
#[command(flatten)]
input: JsonInput,
},
Revoke {
runtime_id: String,
#[command(flatten)]
input: JsonInput,
},
Permissions {
runtime_id: String,
#[command(flatten)]
input: JsonInput,
},
BootstrapCredential {
#[command(flatten)]
input: JsonInput,
},
Credential {
#[command(subcommand)]
command: credential::RuntimeCredentialCommand,
},
}
#[derive(Debug, Args)]
pub struct RuntimeCreateArgs {
#[arg(long, required_unless_present = "file")]
name: Option<String>,
#[arg(long, value_enum)]
kind: Option<CliRuntimeKind>,
#[arg(long)]
idempotency_key: Option<String>,
#[arg(
long,
short = 'f',
value_name = "PATH",
conflicts_with_all = ["name", "kind", "idempotency_key"]
)]
file: Option<PathBuf>,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CliRuntimeKind {
Code,
Evaluate,
Tool,
Custom,
}
impl From<CliRuntimeKind> for RuntimeIdentityKind {
fn from(value: CliRuntimeKind) -> Self {
match value {
CliRuntimeKind::Code => Self::Code,
CliRuntimeKind::Evaluate => Self::Evaluate,
CliRuntimeKind::Tool => Self::Tool,
CliRuntimeKind::Custom => Self::Custom,
}
}
}
#[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 ComputerCommand {
Create {
#[command(flatten)]
input: JsonInput,
#[arg(long)]
idempotency_key: String,
},
Get {
computer_id: String,
},
List {
#[arg(long)]
owner_kind: Option<String>,
#[arg(long)]
owner_id: Option<String>,
},
Delete {
computer_id: String,
#[arg(long)]
idempotency_key: String,
},
}
#[derive(Debug, Subcommand)]
pub enum SpaceCommand {
Create {
#[command(flatten)]
input: JsonInput,
#[arg(long)]
idempotency_key: String,
},
Get {
space_id: String,
},
List {
#[arg(long)]
owner_kind: Option<String>,
#[arg(long)]
owner_id: Option<String>,
},
Delete {
space_id: String,
#[arg(long)]
idempotency_key: String,
},
}
#[derive(Debug, Subcommand)]
pub enum SnapshotCommand {
Create {
#[arg(long)]
provider: Option<String>,
#[arg(long)]
public: bool,
#[arg(long)]
idempotency_key: String,
},
Build {
#[command(flatten)]
input: JsonInput,
#[arg(long)]
idempotency_key: String,
},
Poll { snapshot_id: String },
Get { snapshot_id: String },
List,
Delete {
snapshot_id: String,
#[arg(long)]
idempotency_key: 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 gateway_url = ConfigStore::discover(self.config.as_deref())?
.resolve_gateway(self.gateway.as_deref())?
.value;
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::Init {
name,
template_id,
dir,
no_clone,
owner_email,
system_prompt,
skills,
} => {
init::execute(
&client,
init::InitArgs {
name,
template_id,
dir,
no_clone,
owner_email,
system_prompt,
skills: parse_skill_refs(&skills)?,
},
)
.await
}
Command::Trace { command } => trace::execute(&client, command).await,
Command::Agent { command } => execute_agent(&client, command).await,
Command::Runtime { command } => execute_runtime(&client, &gateway_url, command).await,
Command::Context { command } => execute_context(&client, command).await,
Command::Computer { command } => execute_computer(&client, command).await,
Command::Space { command } => execute_space(&client, command).await,
Command::Snapshot { command } => execute_snapshot(&client, command).await,
Command::Model { command } => execute_model(&client, command).await,
Command::Evaluate { command } => execute_evaluate(&client, command).await,
Command::Eval { command } => execute_eval(&client, command).await,
Command::Execution { command } => execute_execution(&client, command).await,
Command::Deploy { command } => execute_deploy(&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)?));
}
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(args) => {
let request = match args.file {
Some(file) => JsonInput { file }.read::<CreateAgentRequest>()?,
None => CreateAgentRequest {
name: args
.name
.ok_or_else(|| anyhow!("--name is required for inline agent creation"))?,
description: args.description.unwrap_or_default(),
system_prompt: args.system_prompt.unwrap_or_default(),
skills: parse_skill_refs(&args.skills)?,
relations: ResourceRelations {
runtime_ids: vec![args.runtime_id.ok_or_else(|| {
anyhow!("--runtime-id is required for inline agent creation")
})?],
..Default::default()
},
idempotency_key: args.idempotency_key.unwrap_or_default(),
},
};
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_runtime(
client: &InfraClient,
gateway: &str,
command: RuntimeCommand,
) -> Result<CommandOutput> {
match command {
RuntimeCommand::Create(args) => {
let request = match args.file {
Some(file) => JsonInput { file }.read::<CreateRuntimeIdentityRequest>()?,
None => CreateRuntimeIdentityRequest {
id: None,
name: args
.name
.ok_or_else(|| anyhow!("--name is required for inline runtime creation"))?,
kind: args.kind.unwrap_or(CliRuntimeKind::Code).into(),
agent_id: None,
relations: Default::default(),
metadata: Default::default(),
idempotency_key: args.idempotency_key.unwrap_or_default(),
},
};
CommandOutput::json(client.runtime_identity().create(&request).await?)
}
RuntimeCommand::Get { runtime_id } => {
let runtime_id = parse_runtime_id(&runtime_id)?;
CommandOutput::json(client.runtime_identity().get(&runtime_id).await?)
}
RuntimeCommand::Rename {
runtime_id,
name,
expected_revision,
idempotency_key,
} => {
let runtime_id = parse_runtime_id(&runtime_id)?;
let request = UpdateRuntimeIdentityRequest {
expected_revision,
name,
idempotency_key: idempotency_key.unwrap_or_default(),
};
CommandOutput::json(
client
.runtime_identity()
.update(&runtime_id, &request)
.await?,
)
}
RuntimeCommand::Activate { runtime_id, input } => {
execute_runtime_transition(client, runtime_id, "activate", input).await
}
RuntimeCommand::Suspend { runtime_id, input } => {
execute_runtime_transition(client, runtime_id, "suspend", input).await
}
RuntimeCommand::Resume { runtime_id, input } => {
execute_runtime_transition(client, runtime_id, "resume", input).await
}
RuntimeCommand::Revoke { runtime_id, input } => {
execute_runtime_transition(client, runtime_id, "revoke", input).await
}
RuntimeCommand::Permissions { runtime_id, input } => {
let runtime_id = parse_runtime_id(&runtime_id)?;
let request: ReplacePermissionsRequest = input.read()?;
CommandOutput::json(
client
.runtime_identity()
.replace_permissions(&runtime_id, &request)
.await?,
)
}
RuntimeCommand::BootstrapCredential { input } => {
let request: IssueRuntimeCredentialRequest = input.read()?;
CommandOutput::json(
client
.gateway()
.bootstrap_runtime_credential(&request)
.await?,
)
}
RuntimeCommand::Credential { command } => {
credential::execute(client, gateway, command).await
}
}
}
async fn execute_runtime_transition(
client: &InfraClient,
runtime_id: String,
action: &str,
input: JsonInput,
) -> Result<CommandOutput> {
let runtime_id = parse_runtime_id(&runtime_id)?;
let request: TransitionIdentityRequest = input.read()?;
CommandOutput::json(
client
.runtime_identity()
.transition(&runtime_id, action, &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_computer(client: &InfraClient, command: ComputerCommand) -> Result<CommandOutput> {
let environment = client.environment();
match command {
ComputerCommand::Create {
input,
idempotency_key,
} => {
let request: CreateComputerRequest = input.read()?;
CommandOutput::json(
environment
.create_computer(&request, &idempotency_key)
.await?,
)
}
ComputerCommand::Get { computer_id } => {
CommandOutput::json(environment.computer(&computer_id).await?)
}
ComputerCommand::List {
owner_kind,
owner_id,
} => CommandOutput::json(
environment
.computers(Some(&list_environment_query(owner_kind, owner_id)?))
.await?,
),
ComputerCommand::Delete {
computer_id,
idempotency_key,
} => CommandOutput::json(
environment
.delete_computer(&computer_id, &idempotency_key)
.await?,
),
}
}
async fn execute_space(client: &InfraClient, command: SpaceCommand) -> Result<CommandOutput> {
let environment = client.environment();
match command {
SpaceCommand::Create {
input,
idempotency_key,
} => {
let request: CreateSpaceRequest = input.read()?;
CommandOutput::json(environment.create_space(&request, &idempotency_key).await?)
}
SpaceCommand::Get { space_id } => CommandOutput::json(environment.space(&space_id).await?),
SpaceCommand::List {
owner_kind,
owner_id,
} => CommandOutput::json(
environment
.spaces(Some(&list_environment_query(owner_kind, owner_id)?))
.await?,
),
SpaceCommand::Delete {
space_id,
idempotency_key,
} => CommandOutput::json(
environment
.delete_space(&space_id, &idempotency_key)
.await?,
),
}
}
async fn execute_snapshot(client: &InfraClient, command: SnapshotCommand) -> Result<CommandOutput> {
let environment = client.environment();
match command {
SnapshotCommand::Create {
provider,
public,
idempotency_key,
} => {
let request = CreateSnapshotResourceRequest {
provider,
visibility: if public {
SnapshotVisibility::Public
} else {
SnapshotVisibility::Private
},
owners: vec![],
builtin: false,
build: None,
};
CommandOutput::json(
environment
.create_snapshot(&request, &idempotency_key)
.await?,
)
}
SnapshotCommand::Build {
input,
idempotency_key,
} => {
let request: CreateSnapshotResourceRequest = input.read()?;
CommandOutput::json(
environment
.create_snapshot(&request, &idempotency_key)
.await?,
)
}
SnapshotCommand::Poll { snapshot_id } => {
CommandOutput::json(environment.poll_snapshot(&snapshot_id).await?)
}
SnapshotCommand::Get { snapshot_id } => {
CommandOutput::json(environment.poll_snapshot(&snapshot_id).await?)
}
SnapshotCommand::List => CommandOutput::json(environment.snapshots().await?),
SnapshotCommand::Delete {
snapshot_id,
idempotency_key,
} => CommandOutput::json(
environment
.delete_snapshot(&snapshot_id, &idempotency_key)
.await?,
),
}
}
fn list_environment_query(
owner_kind: Option<String>,
owner_id: Option<String>,
) -> Result<ListEnvironmentResourcesQuery> {
let owner_kind = match owner_kind {
Some(value) => Some(parse_owner_kind(&value)?),
None => None,
};
if owner_kind.is_some() != owner_id.is_some() {
bail!("--owner-kind and --owner-id must be provided together");
}
Ok(ListEnvironmentResourcesQuery {
owner_kind,
owner_id,
contains_all: vec![],
})
}
fn parse_owner_kind(value: &str) -> Result<OwnerKind> {
match value.to_ascii_lowercase().as_str() {
"user" => Ok(OwnerKind::User),
"agent" => Ok(OwnerKind::Agent),
"runtime" => Ok(OwnerKind::Runtime),
other => bail!("unsupported owner kind {other:?}; expected user, agent, or runtime"),
}
}
fn parse_deploy_owner_kind(value: &str) -> Result<agent_infra_sdk::deploy_contract::OwnerKind> {
agent_infra_sdk::deploy_contract::OwnerKind::parse(&value.to_ascii_lowercase()).ok_or_else(
|| anyhow!("unsupported owner kind {value:?}; expected user, agent, or runtime"),
)
}
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?,
)
}
}
}
async fn execute_deploy(client: &InfraClient, command: DeployCommand) -> Result<CommandOutput> {
let deploy = client.deploy();
match command {
DeployCommand::Template {
command: DeployTemplateCommand::List,
} => CommandOutput::json(deploy.list_runtime_templates(None, None).await?),
DeployCommand::MoulinConfig { command } => match command {
DeployMoulinConfigCommand::Get => {
CommandOutput::json(client.admin().moulin_config().await?)
}
DeployMoulinConfigCommand::Update { input } => {
let request = input
.read::<agent_infra_sdk::deploy_contract::UpdateMoulinAdminConfigRequest>(
)?;
CommandOutput::json(client.admin().update_moulin_config(&request).await?)
}
},
DeployCommand::Repo { command } => match command {
DeployRepoCommand::Init {
input,
idempotency_key,
dir,
no_clone,
agent_id,
runtime_id,
} => {
let mut request: agent_infra_sdk::deploy_contract::CreateRuntimeRepositoryRequest =
input.read()?;
request.owners = runtime_repo_checkout::merge_identity_owners(
request.owners,
agent_id.as_deref(),
runtime_id.as_deref(),
);
let mut record = deploy
.create_runtime_repository(&request, &idempotency_key)
.await?;
let deadline = Instant::now() + Duration::from_secs(120);
while record.status
== agent_infra_sdk::deploy_contract::RuntimeRepositoryStatus::Importing
{
if Instant::now() >= deadline {
bail!(
"runtime repository import timed out for {}",
record.path_with_namespace
);
}
tokio::time::sleep(Duration::from_secs(2)).await;
record = deploy.runtime_repository(&record.id).await?;
}
if record.status
== agent_infra_sdk::deploy_contract::RuntimeRepositoryStatus::Failed
{
bail!(
"{}",
record
.error_message
.unwrap_or_else(|| { "runtime repository import failed".to_string() })
);
}
let checkout = runtime_repo_checkout::checkout_runtime_repository(
&record,
&runtime_repo_checkout::CheckoutOptions {
dir,
clone: !no_clone,
agent_id,
runtime_id,
},
)?;
eprintln!(
"checked out {} ({})",
checkout.display(),
checkout.join(".env.local").display()
);
CommandOutput::json(record)
}
DeployRepoCommand::List {
owner_kind,
owner_id,
} => {
let owner_kind = match owner_kind {
Some(value) => Some(parse_deploy_owner_kind(&value)?),
None => None,
};
if owner_kind.is_some() != owner_id.is_some() {
bail!("--owner-kind and --owner-id must be provided together");
}
CommandOutput::json(
deploy
.runtime_repositories(owner_kind, owner_id.as_deref(), None, None)
.await?,
)
}
DeployRepoCommand::Get { repository_id } => {
CommandOutput::json(deploy.runtime_repository(&repository_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 parse_skill_refs(values: &[String]) -> Result<Vec<AgentSkillRef>> {
values
.iter()
.map(|value| AgentSkillRef::parse(value).map_err(|message| anyhow!(message)))
.collect()
}
fn parse_runtime_id(value: &str) -> Result<RuntimeIdentityId> {
RuntimeIdentityId::new(value).map_err(|message| anyhow!(message))
}
pub(crate) 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()))
}
}
pub(crate) 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 crate::trace::{
LangfuseProjectCommand, LangfuseProjectListArgs, TraceBindingCommand, TraceMetricsCommand,
};
use agent_infra_sdk::trace_contract::MetricsDataSource;
use clap::CommandFactory;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{body_json, header, method, path},
};
#[test]
fn clap_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn parses_runtime_credential_ensure() {
let command = Cli::try_parse_from([
"infra",
"runtime",
"credential",
"ensure",
"rti_test",
"--store",
"file",
"--yes",
])
.unwrap()
.command;
match command {
Command::Runtime {
command:
RuntimeCommand::Credential {
command: crate::credential::RuntimeCredentialCommand::Ensure(args),
},
} => {
assert_eq!(args.runtime_id, "rti_test");
assert_eq!(
args.store.store,
crate::credential::CredentialStoreKind::File
);
assert!(args.store.yes);
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn rejects_admin_subcommand() {
assert!(Cli::try_parse_from(["infra", "admin"]).is_err());
assert!(Cli::try_parse_from(["infra", "admin", "langfuse-config", "get"]).is_err());
assert!(Cli::try_parse_from(["infra", "admin", "clickhouse-config", "get"]).is_err());
}
#[test]
fn parses_snapshot_create_provider_and_public_flags() {
let command = Cli::try_parse_from([
"infra",
"snapshot",
"create",
"--provider",
"moulin",
"--public",
"--idempotency-key",
"snap-1",
])
.unwrap()
.command;
match command {
Command::Snapshot {
command:
SnapshotCommand::Create {
provider,
public,
idempotency_key,
},
} => {
assert_eq!(provider.as_deref(), Some("moulin"));
assert!(public);
assert_eq!(idempotency_key, "snap-1");
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn parses_snapshot_build_and_single_poll() {
let build = Cli::try_parse_from([
"infra",
"snapshot",
"build",
"--file",
"e2b-build.json",
"--idempotency-key",
"build-1",
])
.unwrap()
.command;
match build {
Command::Snapshot {
command:
SnapshotCommand::Build {
input,
idempotency_key,
},
} => {
assert_eq!(input.file, PathBuf::from("e2b-build.json"));
assert_eq!(idempotency_key, "build-1");
}
other => panic!("unexpected command: {other:?}"),
}
let poll = Cli::try_parse_from(["infra", "snapshot", "poll", "snap_1"])
.unwrap()
.command;
assert!(matches!(
poll,
Command::Snapshot {
command: SnapshotCommand::Poll { snapshot_id }
} if snapshot_id == "snap_1"
));
}
#[test]
fn parses_init_name_template_and_no_clone() {
let command = Cli::try_parse_from([
"infra",
"init",
"--name",
"Weather Bot",
"--template-id",
"rtpl-1",
"--dir",
"./weather",
"--no-clone",
])
.unwrap()
.command;
match command {
Command::Init {
name,
template_id,
dir,
no_clone,
owner_email,
system_prompt,
skills,
} => {
assert_eq!(name, "Weather Bot");
assert_eq!(template_id.as_deref(), Some("rtpl-1"));
assert_eq!(dir, Some(PathBuf::from("./weather")));
assert!(no_clone);
assert!(owner_email.is_none());
assert!(system_prompt.is_none());
assert!(skills.is_empty());
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn parses_agent_create_name_and_runtime_id() {
let command = Cli::try_parse_from([
"infra",
"agent",
"create",
"--name",
"weather",
"--runtime-id",
"rti_1",
"--description",
"demo",
])
.unwrap()
.command;
match command {
Command::Agent {
command:
AgentCommand::Create(AgentCreateArgs {
name,
runtime_id,
description,
system_prompt,
skills,
idempotency_key,
file,
}),
} => {
assert_eq!(name.as_deref(), Some("weather"));
assert_eq!(runtime_id.as_deref(), Some("rti_1"));
assert_eq!(description.as_deref(), Some("demo"));
assert!(system_prompt.is_none());
assert!(skills.is_empty());
assert!(idempotency_key.is_none());
assert!(file.is_none());
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn parses_deploy_template_and_repository_commands() {
assert!(matches!(
Cli::try_parse_from(["infra", "deploy", "moulin-config", "get"])
.unwrap()
.command,
Command::Deploy {
command: DeployCommand::MoulinConfig {
command: DeployMoulinConfigCommand::Get,
},
}
));
assert!(matches!(
Cli::try_parse_from([
"infra",
"deploy",
"moulin-config",
"update",
"--file",
"moulin.json"
])
.unwrap()
.command,
Command::Deploy {
command: DeployCommand::MoulinConfig {
command: DeployMoulinConfigCommand::Update { .. },
},
}
));
assert!(matches!(
Cli::try_parse_from(["infra", "deploy", "template", "list"])
.unwrap()
.command,
Command::Deploy {
command: DeployCommand::Template {
command: DeployTemplateCommand::List,
},
}
));
let init = Cli::try_parse_from([
"infra",
"deploy",
"repo",
"init",
"--idempotency-key",
"init-1",
"--file",
"repo.json",
"--dir",
"./demo",
"--no-clone",
"--agent-id",
"agt_1",
"--runtime-id",
"rt-1",
])
.unwrap()
.command;
match init {
Command::Deploy {
command:
DeployCommand::Repo {
command:
DeployRepoCommand::Init {
dir,
no_clone,
agent_id,
runtime_id,
..
},
},
} => {
assert_eq!(dir, Some(PathBuf::from("./demo")));
assert!(no_clone);
assert_eq!(agent_id.as_deref(), Some("agt_1"));
assert_eq!(runtime_id.as_deref(), Some("rt-1"));
}
other => panic!("unexpected command: {other:?}"),
}
assert!(matches!(
Cli::try_parse_from([
"infra",
"deploy",
"repo",
"list",
"--owner-kind",
"user",
"--owner-id",
"user-1"
])
.unwrap()
.command,
Command::Deploy {
command: DeployCommand::Repo {
command: DeployRepoCommand::List { .. },
},
}
));
assert!(matches!(
Cli::try_parse_from(["infra", "deploy", "repo", "get", "rrepo-1"])
.unwrap()
.command,
Command::Deploy {
command: DeployCommand::Repo {
command: DeployRepoCommand::Get { repository_id },
},
} if repository_id == "rrepo-1"
));
}
#[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_removed_workspace_subcommand() {
let result = Cli::try_parse_from(["infra", "workspace", "write", "README.md"]);
assert!(result.is_err());
}
#[test]
fn parses_trace_metrics_query_options() {
let cli = Cli::try_parse_from([
"infra",
"trace",
"metrics",
"token-usage",
"--project-id",
"project-a",
"--project-id",
"project-b",
"--days",
"7",
"--data-source",
"v4",
])
.unwrap();
let Command::Trace {
command:
TraceCommand::Metrics {
command: TraceMetricsCommand::TokenUsage(args),
},
} = cli.command
else {
panic!("expected trace token-usage command");
};
let query = args.into_query();
assert_eq!(query.project_ids.unwrap(), ["project-a", "project-b"]);
assert_eq!(query.days, 7);
assert_eq!(query.data_source, MetricsDataSource::V4);
}
#[test]
fn parses_langfuse_project_relationship_query() {
let cli = Cli::try_parse_from([
"infra",
"trace",
"langfuse-project",
"list",
"--related-agent-id",
"agt_weather",
"--limit",
"20",
])
.unwrap();
assert!(matches!(
cli.command,
Command::Trace {
command: TraceCommand::LangfuseProject {
command: LangfuseProjectCommand::List(LangfuseProjectListArgs {
related_agent_id: Some(ref agent_id),
limit: Some(20),
..
})
}
} if agent_id == "agt_weather"
));
}
#[test]
fn parses_trace_binding_get() {
let cli = Cli::try_parse_from(["infra", "trace", "binding", "get", "rti_1"]).unwrap();
assert!(matches!(
cli.command,
Command::Trace {
command: TraceCommand::Binding {
command: TraceBindingCommand::Get { ref runtime_id }
}
} if runtime_id == "rti_1"
));
}
#[test]
fn parses_trace_binding_initialize() {
let cli = Cli::try_parse_from([
"infra",
"trace",
"binding",
"initialize",
"rti_1",
"--project-name",
"demo",
"--idempotency-key",
"init-trace-rti_1",
])
.unwrap();
assert!(matches!(
cli.command,
Command::Trace {
command: TraceCommand::Binding {
command: TraceBindingCommand::Initialize {
ref runtime_id,
project_name: Some(ref project_name),
..
}
}
} if runtime_id == "rti_1" && project_name == "demo"
));
}
#[test]
fn parses_evaluate_resource_and_task_commands() {
let register = Cli::try_parse_from([
"infra",
"evaluate",
"register-evaluator",
"--file",
"evaluator.json",
])
.unwrap();
assert!(matches!(
register.command,
Command::Evaluate {
command: EvaluateCommand::RegisterEvaluator(_)
}
));
let task = Cli::try_parse_from(["infra", "evaluate", "task-create", "--file", "run.json"])
.unwrap();
assert!(matches!(
task.command,
Command::Evaluate {
command: EvaluateCommand::TaskCreate(_)
}
));
let alias =
Cli::try_parse_from(["infra", "evaluate", "run", "--file", "run.json"]).unwrap();
assert!(matches!(
alias.command,
Command::Evaluate {
command: EvaluateCommand::TaskCreate(_)
}
));
let health = Cli::try_parse_from([
"infra",
"evaluate",
"metrics",
"health",
"--window-runs",
"30",
])
.unwrap();
assert!(matches!(
health.command,
Command::Evaluate {
command: EvaluateCommand::Metrics {
command: EvaluateMetricsCommand::Health {
window_runs: 30,
evaluator_id: None
}
}
}
));
let sample = Cli::try_parse_from([
"infra",
"evaluate",
"review",
"sample",
"--file",
"sample.json",
])
.unwrap();
assert!(matches!(
sample.command,
Command::Evaluate {
command: EvaluateCommand::Review {
command: EvaluateReviewCommand::Sample(_)
}
}
));
}
#[tokio::test]
async fn evaluate_push_derives_versions_from_content_and_registers_dependencies_first() {
let directory = tempfile::tempdir().unwrap();
let bundle = directory.path().join("eval.json");
fs::write(
&bundle,
serde_json::to_vec(&serde_json::json!({
"suiteId": "core",
"evaluators": [{
"evaluatorId": "grounded",
"implementation": {
"kind": "builtin_assertions",
"assertions": [{
"kind": "output_contains",
"turn": 0,
"values": ["ok"]
}]
},
"metric": { "class": "goal", "decision": "block_release", "owner": "team" }
}],
"cases": [{
"id": "answer",
"category": "correctness",
"turns": [{ "user": "say ok" }],
"evaluators": ["grounded"]
}]
}))
.unwrap(),
)
.unwrap();
let dry_run = Cli::try_parse_from([
"infra",
"evaluate",
"push",
"--file",
bundle.to_str().unwrap(),
"--dry-run",
])
.unwrap();
let CommandOutput::Json(planned) = dry_run.execute().await.unwrap() else {
panic!("push must produce JSON");
};
let suite_version = planned["suiteVersion"].as_str().unwrap().to_string();
let evaluator_version = planned["evaluators"][0]["version"]
.as_str()
.unwrap()
.to_string();
let case_version = planned["cases"][0]["version"].as_str().unwrap().to_string();
assert!(suite_version.starts_with("sha256-"));
assert!(case_version.starts_with("sha256-"));
assert_eq!(planned["applied"], serde_json::json!(false));
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
agent_infra_sdk::evaluate_contract::EVALUATOR_VERSIONS_PATH,
))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"tenantId": "tenant",
"projectId": "project",
"evaluatorId": "grounded",
"version": evaluator_version,
"digest": "sha256:test",
"implementation": { "kind": "builtin_assertions", "assertions": [] },
"createdAtMs": 1
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path(agent_infra_sdk::evaluate_contract::CASE_VERSIONS_PATH))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"tenantId": "tenant",
"projectId": "project",
"caseId": "answer",
"version": case_version,
"digest": "sha256:test",
"category": "correctness",
"turns": [{ "user": "say ok" }],
"createdAtMs": 1
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path(agent_infra_sdk::evaluate_contract::SUITES_PATH))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"tenantId": "tenant",
"projectId": "project",
"suiteId": "core",
"version": suite_version,
"manifestDigest": "sha256:test",
"schemaVersion": 1,
"cases": [{ "id": "answer", "version": case_version }],
"createdAtMs": 1
})))
.expect(1)
.mount(&server)
.await;
let server_uri = server.uri();
let applied = Cli::try_parse_from([
"infra",
"--gateway",
server_uri.as_str(),
"--token",
"test-token",
"evaluate",
"push",
"--file",
bundle.to_str().unwrap(),
])
.unwrap()
.execute()
.await
.unwrap();
let CommandOutput::Json(applied) = applied else {
panic!("push must produce JSON");
};
assert_eq!(applied["applied"], serde_json::json!(true));
assert_eq!(applied["suiteVersion"], serde_json::json!(suite_version));
}
#[tokio::test]
async fn evaluate_diff_fails_the_command_and_names_the_blocking_metric() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
agent_infra_sdk::evaluate_contract::COMPARE_REPORTS_PATH,
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"reportId": "report-b",
"baselineReportId": "report-a",
"passRateDelta": -0.5,
"infraFailureRateDelta": 0.0,
"passRateConfidenceLow": 0.1,
"passRateConfidenceHigh": 0.4,
"effectConfidenceLow": -0.6,
"effectConfidenceHigh": -0.4,
"pValue": 0.001,
"statisticallySignificant": true,
"changedCases": [],
"gatePassed": false,
"gate": {
"passed": false,
"blockingMetrics": [{
"evaluatorId": "no-secret-leak",
"class": "guardrail",
"reason": "guardrail_failed",
"decision": "block_release",
"observedRate": 0.99,
"delta": -0.01,
"detail": "1 guardrail failures out of 100 observations"
}],
"warnings": []
}
})))
.expect(1)
.mount(&server)
.await;
let server_uri = server.uri();
let error = Cli::try_parse_from([
"infra",
"--gateway",
server_uri.as_str(),
"--token",
"test-token",
"evaluate",
"diff",
"--report-id",
"report-b",
"--baseline-report-id",
"report-a",
])
.unwrap()
.execute()
.await
.unwrap_err()
.to_string();
assert!(
error.contains("no-secret-leak"),
"unexpected error: {error}"
);
assert!(error.contains("guardrail"), "unexpected error: {error}");
}
#[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 agent_create_sends_name_and_runtime_id_without_idempotency_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/agents"))
.and(header("authorization", "Bearer test-token"))
.and(body_json(serde_json::json!({
"name": "weather",
"description": "demo",
"relations": { "runtimeIds": ["rti_1"] },
"idempotencyKey": ""
})))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "agt_weather",
"ownerUserId": "user-1",
"name": "weather",
"description": "demo",
"status": "active",
"revision": 1,
"relations": { "runtimeIds": ["rti_1"], "userIds": ["user-1"] },
"createdAt": "2026-08-17T00:00:00Z",
"updatedAt": "2026-08-17T00:00:00Z"
})))
.expect(1)
.mount(&server)
.await;
let cli = Cli::try_parse_from([
"infra",
"--gateway",
server.uri().as_str(),
"--token",
"test-token",
"agent",
"create",
"--name",
"weather",
"--runtime-id",
"rti_1",
"--description",
"demo",
])
.unwrap();
let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
panic!("agent create must produce JSON");
};
assert_eq!(output["id"], "agt_weather");
assert_eq!(output["name"], "weather");
}
#[tokio::test]
async fn deploy_repo_init_writes_env_local_without_clone() {
let root = tempfile::tempdir().unwrap();
let request_path = root.path().join("repo.json");
fs::write(
&request_path,
r#"{"templateId":"rtpl-1","projectName":"demo"}"#,
)
.unwrap();
let checkout = root.path().join("demo-runtime");
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/internal/v1/deploy/runtime-repositories"))
.and(header("Idempotency-Key", "init-1"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "rrepo-1",
"tenantId": "local",
"projectId": "default",
"templateId": "rtpl-1",
"owners": [
{"kind":"agent","id":"agt_1"},
{"kind":"runtime","id":"rt-1"}
],
"namespace": "runtimes",
"gitlabProjectId": 1,
"pathWithNamespace": "runtimes/demo-runtime",
"repositoryUrl": "https://git.test/runtimes/demo-runtime.git",
"defaultBranch": "master",
"status": "ready",
"version": 1,
"createdAt": "t",
"updatedAt": "t"
})))
.expect(1)
.mount(&server)
.await;
let cli = Cli::try_parse_from([
"infra",
"--gateway",
server.uri().as_str(),
"--token",
"test-token",
"deploy",
"repo",
"init",
"--file",
request_path.to_str().unwrap(),
"--idempotency-key",
"init-1",
"--dir",
checkout.to_str().unwrap(),
"--no-clone",
"--agent-id",
"agt_1",
"--runtime-id",
"rt-1",
])
.unwrap();
let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
panic!("init must produce JSON");
};
assert_eq!(output["id"], "rrepo-1");
let env = fs::read_to_string(checkout.join(".env.local")).unwrap();
assert!(env.contains("RREPO_ID=rrepo-1"));
assert!(env.contains("AGENT_ID=agt_1"));
assert!(env.contains("RUNTIME_ID=rt-1"));
assert!(
fs::read_to_string(checkout.join(".gitignore"))
.unwrap()
.contains(".env.local")
);
}
#[tokio::test]
async fn init_orchestrates_runtime_agent_repo_trace_context_and_model() {
let root = tempfile::tempdir().unwrap();
let checkout = root.path().join("e2e-init");
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/internal/v1/runtime-identities"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"identity": runtime_identity_json("e2e_init", "pending", 1),
"bootstrapToken": "rti_boot_secret"
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/agents"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "agt_e2e",
"ownerUserId": "user-1",
"name": "e2e_init",
"description": "initialized by infra init (e2e_init)",
"status": "active",
"revision": 1,
"relations": { "runtimeIds": ["rti_test"] },
"createdAt": "2026-08-17T00:00:00Z",
"updatedAt": "2026-08-17T00:00:00Z"
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/internal/v1/deploy/runtime-repositories"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "rrepo-e2e",
"tenantId": "local",
"projectId": "default",
"templateId": "rtpl-1",
"owners": [],
"namespace": "runtimes",
"gitlabProjectId": 1,
"pathWithNamespace": "runtimes/e2e-init",
"repositoryUrl": "https://git.test/runtimes/e2e-init.git",
"defaultBranch": "master",
"status": "ready",
"version": 1,
"createdAt": "t",
"updatedAt": "t"
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/trace/runtime-bindings/rti_test/initialize"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"runtimeId": "rti_test",
"projectName": "e2e_init",
"langfuseProjectId": "lf-e2e",
"publicKey": "pk-lf-e2e",
"status": "ready"
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/internal/v1/context/messages/batch"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"appended": 1,
"skipped": 0
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/internal/v1/context/messages/recent"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"items": [{
"id": "msg_init_e2e_init",
"scope": {"conversationId": "conv_init_e2e_init"},
"role": "system",
"content": {"text": "initialized by infra init"},
"sequence": 1,
"createdAtMs": 1,
"metadata": {"source": "infra-init"}
}]
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/agents/agt_e2e/model-configs:ensure-default"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "default",
"displayName": "Platform Default",
"enabled": true
})))
.expect(1)
.mount(&server)
.await;
let cli = Cli::try_parse_from([
"infra",
"--gateway",
server.uri().as_str(),
"--token",
"test-token",
"init",
"--name",
"e2e-init",
"--template-id",
"rtpl-1",
"--dir",
checkout.to_str().unwrap(),
"--no-clone",
])
.unwrap();
let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
panic!("infra init must produce JSON");
};
assert_eq!(output["runtimeId"], "rti_test");
assert_eq!(output["agentId"], "agt_e2e");
assert_eq!(output["rrepoId"], "rrepo-e2e");
assert_eq!(output["langfuseProjectId"], "lf-e2e");
assert_eq!(output["conversationId"], "conv_init_e2e_init");
assert_eq!(output["messageId"], "msg_init_e2e_init");
assert_eq!(output["modelConfigId"], "default");
}
#[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 trace_metrics_command_calls_typed_sdk() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
agent_infra_sdk::trace_contract::METRICS_PROJECT_SUMMARY_PATH,
))
.and(body_json(serde_json::json!({
"projectIds": ["project-a"],
"days": 14,
"dataSource": "auto",
"v4CutoverDate": "2026-09-01 00:00:00"
})))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!([{
"projectId": "project-a",
"totalTokens": 15,
"totalInputTokens": 10,
"totalOutputTokens": 5,
"observationCount": 2,
"generationCount": 1,
"totalCost": 0.01,
"uniqueModels": 1,
"uniqueTraces": 1
}])),
)
.expect(1)
.mount(&server)
.await;
let server_uri = server.uri();
let cli = Cli::try_parse_from([
"infra",
"--gateway",
server_uri.as_str(),
"trace",
"metrics",
"project-summary",
"--project-id",
"project-a",
"--days",
"14",
])
.unwrap();
let CommandOutput::Json(output) = cli.execute().await.unwrap() else {
panic!("trace metrics must produce JSON");
};
assert_eq!(output[0]["totalTokens"], 15);
}
#[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 runtime_commands_create_rename_and_revoke_through_the_typed_sdk() {
let server = MockServer::start().await;
let directory = tempfile::tempdir().unwrap();
let transition_request = directory.path().join("transition-runtime.json");
fs::write(
&transition_request,
br#"{"expectedRevision":2,"idempotencyKey":"revoke-runtime-test","reason":"test cleanup"}"#,
)
.unwrap();
Mock::given(method("POST"))
.and(path("/internal/v1/runtime-identities"))
.and(header("authorization", "Bearer test-token"))
.and(body_json(serde_json::json!({
"name": "code-dev",
"kind": "code",
"relations": {},
"metadata": {},
"idempotencyKey": ""
})))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"identity": runtime_identity_json("code-dev", "pending", 1),
"bootstrapToken": "rti_boot_secret"
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PATCH"))
.and(path("/internal/v1/runtime-identities/rti_test"))
.and(header("authorization", "Bearer test-token"))
.and(body_json(serde_json::json!({
"expectedRevision": 1,
"name": "code-dev-renamed",
"idempotencyKey": ""
})))
.respond_with(
ResponseTemplate::new(200).set_body_json(runtime_identity_json(
"code-dev-renamed",
"pending",
2,
)),
)
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/internal/v1/runtime-identities/rti_test:revoke"))
.and(header("authorization", "Bearer test-token"))
.and(body_json(serde_json::json!({
"expectedRevision": 2,
"idempotencyKey": "revoke-runtime-test",
"reason": "test cleanup"
})))
.respond_with(
ResponseTemplate::new(200).set_body_json(runtime_identity_json(
"code-dev-renamed",
"revoked",
3,
)),
)
.expect(1)
.mount(&server)
.await;
let create = Cli::try_parse_from([
"infra",
"runtime",
"create",
"--name",
"code-dev",
"--gateway",
&server.uri(),
"--token",
"test-token",
])
.unwrap()
.execute()
.await
.unwrap();
let CommandOutput::Json(created) = create else {
panic!("runtime create must produce JSON");
};
assert_eq!(created["identity"]["id"], "rti_test");
assert_eq!(created["identity"]["name"], "code-dev");
assert_eq!(created["bootstrapToken"], "rti_boot_secret");
let rename = Cli::try_parse_from([
"infra",
"runtime",
"rename",
"rti_test",
"code-dev-renamed",
"--expected-revision",
"1",
"--gateway",
&server.uri(),
"--token",
"test-token",
])
.unwrap()
.execute()
.await
.unwrap();
let CommandOutput::Json(renamed) = rename else {
panic!("runtime rename must produce JSON");
};
assert_eq!(renamed["id"], "rti_test");
assert_eq!(renamed["name"], "code-dev-renamed");
assert_eq!(renamed["revision"], 2);
let revoke = Cli::try_parse_from([
"infra",
"runtime",
"revoke",
"rti_test",
"--file",
transition_request.to_str().unwrap(),
"--gateway",
&server.uri(),
"--token",
"test-token",
])
.unwrap()
.execute()
.await
.unwrap();
let CommandOutput::Json(revoked) = revoke else {
panic!("runtime revoke must produce JSON");
};
assert_eq!(revoked["status"], "revoked");
assert_eq!(revoked["revision"], 3);
}
#[tokio::test]
async fn runtime_commands_replace_permissions_and_bootstrap_credential() {
let server = MockServer::start().await;
let directory = tempfile::tempdir().unwrap();
let permissions_request = directory.path().join("permissions.json");
fs::write(
&permissions_request,
br#"{"expectedRevision":0,"idempotencyKey":"grant-runtime-test","grants":[{"audience":"agent-infra","capabilities":["gateway:delegation:manage"]}]}"#,
)
.unwrap();
let credential_request = directory.path().join("credential.json");
fs::write(
&credential_request,
br#"{"workloadProof":"bootstrap-proof","audience":"agent-infra","capabilities":["gateway:delegation:manage"]}"#,
)
.unwrap();
Mock::given(method("PUT"))
.and(path("/internal/v1/runtime-identities/rti_test/permissions"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"identityId": "rti_test",
"revision": 1,
"grants": [{
"id": "grt_test",
"identityId": "rti_test",
"audience": "agent-infra",
"capabilities": ["gateway:delegation:manage"],
"createdAt": "2026-08-13T00:00:00Z"
}]
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path(
agent_infra_sdk::gateway_contract::RUNTIME_CREDENTIAL_BOOTSTRAP_PATH,
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"accessToken": "runtime-token",
"tokenType": "Bearer",
"expiresInSeconds": 120,
"keyId": "runtime-key"
})))
.expect(1)
.mount(&server)
.await;
let permissions = Cli::try_parse_from([
"infra",
"runtime",
"permissions",
"rti_test",
"--file",
permissions_request.to_str().unwrap(),
"--gateway",
&server.uri(),
"--token",
"test-token",
])
.unwrap()
.execute()
.await
.unwrap();
let CommandOutput::Json(permissions) = permissions else {
panic!("runtime permissions must produce JSON");
};
assert_eq!(permissions["revision"], 1);
let credential = Cli::try_parse_from([
"infra",
"runtime",
"bootstrap-credential",
"--file",
credential_request.to_str().unwrap(),
"--gateway",
&server.uri(),
"--token",
"test-token",
])
.unwrap()
.execute()
.await
.unwrap();
let CommandOutput::Json(credential) = credential else {
panic!("runtime bootstrap credential must produce JSON");
};
assert_eq!(credential["accessToken"], "runtime-token");
let requests = server.received_requests().await.unwrap();
let bootstrap = requests
.iter()
.find(|request| {
request.url.path()
== agent_infra_sdk::gateway_contract::RUNTIME_CREDENTIAL_BOOTSTRAP_PATH
})
.unwrap();
assert!(!bootstrap.headers.contains_key("authorization"));
}
fn runtime_identity_json(name: &str, status: &str, revision: u64) -> Value {
serde_json::json!({
"id": "rti_test",
"name": name,
"scope": { "tenantId": "tenant-test", "projectId": "project-test" },
"kind": "code",
"relations": {
"runtimeIds": ["rti_test"],
"userIds": ["user-test"]
},
"status": status,
"revision": revision,
"grantRevision": 0,
"credentialGeneration": if status == "revoked" { 2 } else { 1 },
"metadata": { "source": "cli-test" },
"createdAt": "2026-08-13T00:00:00Z",
"updatedAt": "2026-08-13T00:00:00Z"
})
}
#[tokio::test]
async fn computer_list_uses_environment_api() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/internal/v1/workspace/computers"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.expect(1)
.mount(&server)
.await;
let cli =
Cli::try_parse_from(["infra", "computer", "list", "--gateway", &server.uri()]).unwrap();
let output = cli.execute().await.unwrap();
let CommandOutput::Json(value) = output else {
panic!("computer list must produce JSON");
};
assert_eq!(value, serde_json::json!([]));
}
}