use agent_contracts::backend::{
capability::{
exec::{ExecRequest, ExecResult},
export::ExportFileRequest,
filesystem::{
ReadBytesRequest, TempPathKind, TempPathRequest, WriteBytesRequest, WriteMode,
},
path::{ResolveBase, ResolvePathRequest},
search::{GlobRequest, GrepMode, GrepRequest},
},
BackendPath, ExportedFileHandle, OperationBackendBuildInput, OperationBackendBuilder,
OperationBackendConfig, OperationError, PathKind, PathStat,
};
use operation_backend::OperationBackendBuilderImpl;
use serde::Deserialize;
use serde_json::{json, Value};
pub(crate) fn run_from_env() -> Result<(), String> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
let parsed = parse_cli_args(args.as_slice())?;
let config = load_backend_config(parsed.config_path.as_str())?;
let request = serde_json::from_str::<BackendCliRequest>(parsed.request_json.as_str())
.map_err(|error| format!("invalid request json: {error}"))?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("failed to build tokio runtime: {error}"))?;
runtime.block_on(async move {
let builder = OperationBackendBuilderImpl::new();
let backend = builder
.build(&OperationBackendBuildInput {
config: Some(config),
..OperationBackendBuildInput::default()
})
.await
.map_err(|error| format!("backend build failed: {error}"))?;
let request_result = handle_request(backend.as_ref(), request).await;
let shutdown_result = backend.shutdown().await;
let output = request_result.map_err(|error| format!("request failed: {error}"))?;
shutdown_result.map_err(|error| format!("backend shutdown failed: {error}"))?;
println!(
"{}",
serde_json::to_string(&output).map_err(|error| error.to_string())?
);
Ok(())
})
}
struct ParsedCli {
config_path: String,
request_json: String,
}
fn parse_cli_args(args: &[String]) -> Result<ParsedCli, String> {
if args.len() != 5 {
return Err("usage: xiaoo-backend-cli --config <path> request --json <json>".to_string());
}
if args[0] != "--config" {
return Err("missing --config".to_string());
}
if args[2] != "request" {
return Err("only the 'request' subcommand is currently supported".to_string());
}
if args[3] != "--json" {
return Err("missing --json".to_string());
}
if args[4].is_empty() {
return Err("missing request json".to_string());
}
Ok(ParsedCli {
config_path: args[1].clone(),
request_json: args[4].clone(),
})
}
fn load_backend_config(path: &str) -> Result<OperationBackendConfig, String> {
let raw = std::fs::read_to_string(path)
.map_err(|error| format!("failed to read config file: {error}"))?;
let stripped = strip_jsonc_comments(raw.as_str());
let file_config = serde_json::from_str::<BackendConfigFile>(stripped.as_str())
.map_err(|error| format!("invalid config json: {error}"))?;
Ok(OperationBackendConfig::new(
file_config.backend.kind,
file_config.backend.options,
))
}
async fn handle_request(
backend: &dyn agent_contracts::backend::OperationBackend,
request: BackendCliRequest,
) -> Result<Value, OperationError> {
match request {
BackendCliRequest::ResolvePath { raw_path, base } => {
let resolved = backend
.paths()
.resolve_path(ResolvePathRequest {
raw_path,
base: base.into_contract(),
})
.await?;
Ok(json!({ "path": resolved.0 }))
}
BackendCliRequest::Stat { path } => {
let backend_path = BackendPath(path.clone());
let stat = backend.files().stat(&backend_path).await?;
Ok(path_stat_json(path.as_str(), &stat))
}
BackendCliRequest::ReadBytes { path } => {
let bytes = backend
.files()
.read_bytes(ReadBytesRequest {
path: BackendPath(path.clone()),
})
.await?;
Ok(json!({
"path": path,
"text": String::from_utf8_lossy(bytes.as_slice()).to_string()
}))
}
BackendCliRequest::WriteBytes {
path,
content,
mode,
} => {
let outcome = backend
.files()
.write_bytes(WriteBytesRequest {
path: BackendPath(path),
content: content.into_bytes(),
mode: mode.into_contract(),
})
.await?;
Ok(json!({
"path": outcome.path.0,
"created": outcome.created
}))
}
BackendCliRequest::CreateDirAll { path } => {
let backend_path = BackendPath(path.clone());
backend.files().create_dir_all(&backend_path).await?;
Ok(json!({ "path": path, "created": true }))
}
BackendCliRequest::Glob {
pattern,
base_dir,
limit,
} => {
let entries = backend
.search()
.glob(GlobRequest {
pattern,
base_dir: Some(BackendPath(base_dir)),
limit,
})
.await?;
Ok(json!({
"entries": entries.into_iter().map(|entry| entry.0).collect::<Vec<_>>()
}))
}
BackendCliRequest::Grep {
query,
base_dir,
include,
mode,
head_limit,
} => {
let result = backend
.search()
.grep(GrepRequest {
query,
base_dir: BackendPath(base_dir),
include,
mode: mode.into_contract(),
head_limit,
})
.await?;
Ok(json!({
"entries": result.entries,
}))
}
BackendCliRequest::TempPath {
kind,
preferred_parent,
prefix,
suffix,
} => {
let path = backend
.files()
.temp_path(TempPathRequest {
kind: kind.into_contract(),
preferred_parent: preferred_parent.map(BackendPath),
prefix,
suffix,
})
.await?;
Ok(json!({ "path": path.0 }))
}
BackendCliRequest::ExportFile {
path,
preferred_name,
} => {
let exported = backend
.export()
.export_file(ExportFileRequest {
path: BackendPath(path),
preferred_name,
})
.await?;
Ok(exported_file_json(exported.as_ref()))
}
BackendCliRequest::Exec {
command,
args,
shell,
cwd,
timeout_ms,
} => {
let result = backend
.exec()
.exec(ExecRequest {
command,
args,
shell,
cwd: cwd.map(BackendPath),
timeout_ms,
env: None,
})
.await?;
Ok(exec_result_json(&result))
}
}
}
fn path_stat_json(path: &str, stat: &PathStat) -> Value {
json!({
"path": path,
"exists": stat.exists,
"kind": stat.kind.map(path_kind_name),
"size_bytes": stat.size_bytes,
"modified_at_ms": stat.modified_at.map(|value| {
value.duration_since(std::time::SystemTime::UNIX_EPOCH).map(|duration| duration.as_millis()).unwrap_or(0)
})
})
}
fn exported_file_json(file: &dyn ExportedFileHandle) -> Value {
let metadata = file.metadata();
json!({
"file_name": metadata.file_name,
"size_bytes": metadata.size_bytes,
"media_type": metadata.media_type,
"reader": { "kind": "opaque" }
})
}
fn exec_result_json(result: &ExecResult) -> Value {
json!({
"stdout": String::from_utf8_lossy(result.stdout.as_slice()).to_string(),
"stderr": String::from_utf8_lossy(result.stderr.as_slice()).to_string(),
"exit_code": result.exit_code,
"timed_out": result.timed_out
})
}
fn path_kind_name(kind: PathKind) -> &'static str {
match kind {
PathKind::File => "file",
PathKind::Directory => "directory",
PathKind::Symlink => "symlink",
PathKind::Other => "other",
}
}
fn strip_jsonc_comments(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut escaped = false;
let mut line_comment = false;
let mut block_comment = false;
while let Some(ch) = chars.next() {
if line_comment {
if ch == '\n' {
line_comment = false;
output.push(ch);
}
continue;
}
if block_comment {
if ch == '*' && chars.peek() == Some(&'/') {
chars.next();
block_comment = false;
}
continue;
}
if in_string {
output.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
output.push(ch);
continue;
}
if ch == '/' {
match chars.peek() {
Some('/') => {
chars.next();
line_comment = true;
continue;
}
Some('*') => {
chars.next();
block_comment = true;
continue;
}
_ => {}
}
}
output.push(ch);
}
output
}
#[allow(dead_code)]
fn default_exclude_vcs() -> bool {
true
}
#[derive(Debug, Deserialize)]
struct BackendConfigFile {
backend: BackendConfigEntry,
}
#[derive(Debug, Deserialize)]
struct BackendConfigEntry {
kind: String,
options: Value,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", content = "args")]
enum BackendCliRequest {
#[serde(rename = "resolve_path")]
ResolvePath {
raw_path: String,
base: ResolveBaseInput,
},
#[serde(rename = "stat")]
Stat { path: String },
#[serde(rename = "read_bytes")]
ReadBytes { path: String },
#[serde(rename = "write_bytes")]
WriteBytes {
path: String,
content: String,
mode: WriteModeInput,
},
#[serde(rename = "create_dir_all")]
CreateDirAll { path: String },
#[serde(rename = "glob")]
Glob {
pattern: String,
base_dir: String,
limit: Option<usize>,
},
#[serde(rename = "grep")]
Grep {
#[serde(alias = "pattern")]
query: String,
#[serde(alias = "target")]
base_dir: String,
include: Option<String>,
mode: GrepModeInput,
head_limit: Option<usize>,
},
#[serde(rename = "temp_path")]
TempPath {
kind: TempPathKindInput,
preferred_parent: Option<String>,
prefix: Option<String>,
suffix: Option<String>,
},
#[serde(rename = "export_file")]
ExportFile {
path: String,
preferred_name: Option<String>,
},
#[serde(rename = "exec")]
Exec {
command: String,
args: Vec<String>,
shell: Option<String>,
cwd: Option<String>,
timeout_ms: Option<u64>,
},
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ResolveBaseInput {
WorkspaceRoot,
HomeDir,
Explicit(String),
}
impl ResolveBaseInput {
fn into_contract(self) -> ResolveBase {
match self {
ResolveBaseInput::WorkspaceRoot => ResolveBase::WorkspaceRoot,
ResolveBaseInput::HomeDir => ResolveBase::HomeDir,
ResolveBaseInput::Explicit(path) => ResolveBase::Explicit(BackendPath(path)),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum WriteModeInput {
Create,
Overwrite,
AtomicOverwrite,
}
impl WriteModeInput {
fn into_contract(self) -> WriteMode {
match self {
WriteModeInput::Create => WriteMode::Create,
WriteModeInput::Overwrite => WriteMode::Overwrite,
WriteModeInput::AtomicOverwrite => WriteMode::AtomicOverwrite,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum TempPathKindInput {
File,
Directory,
}
impl TempPathKindInput {
fn into_contract(self) -> TempPathKind {
match self {
TempPathKindInput::File => TempPathKind::File,
TempPathKindInput::Directory => TempPathKind::Directory,
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum GrepModeInput {
FilesWithMatches,
Content,
Count,
}
impl GrepModeInput {
fn into_contract(self) -> GrepMode {
match self {
GrepModeInput::FilesWithMatches => GrepMode::FilesWithMatches,
GrepModeInput::Content => GrepMode::Content,
GrepModeInput::Count => GrepMode::Count,
}
}
}