mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Add CLI subcommands for WASM tool management
Introduces a new CLI module with subcommands for managing WASM tools: - `tool install`: Build and install tools from source or .wasm files - `tool list`: List installed tools with optional verbose output - `tool remove`: Remove installed tools - `tool info`: Show detailed tool information including capabilities The install command supports building from Cargo source directories using cargo-component, or installing pre-compiled .wasm files directly. It auto-detects capabilities JSON sidecar files and validates them before installation. Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
9232e623e8
commit
d1cb748914
Generated
+40
-1
@@ -936,6 +936,27 @@ dependencies = [
|
||||
"dirs-sys-next",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys-next"
|
||||
version = "0.1.2"
|
||||
@@ -943,7 +964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"redox_users",
|
||||
"redox_users 0.4.6",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
@@ -1932,6 +1953,7 @@ dependencies = [
|
||||
"clap",
|
||||
"crossterm",
|
||||
"deadpool-postgres",
|
||||
"dirs",
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"hkdf",
|
||||
@@ -2043,6 +2065,12 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2514,6 +2542,17 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ref-cast"
|
||||
version = "1.0.25"
|
||||
|
||||
@@ -61,6 +61,9 @@ tower-http = { version = "0.6", features = ["trace", "cors"] }
|
||||
regex = "1"
|
||||
aho-corasick = "1"
|
||||
|
||||
# Filesystem paths
|
||||
dirs = "6"
|
||||
|
||||
# Secrecy for sensitive values
|
||||
secrecy = { version = "0.10", features = ["serde"] }
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! CLI command handling.
|
||||
//!
|
||||
//! Provides subcommands for:
|
||||
//! - Running the agent (`run`)
|
||||
//! - Managing WASM tools (`tool install`, `tool list`, `tool remove`)
|
||||
//! - Managing secrets (`secret set`, `secret list`, `secret remove`)
|
||||
|
||||
mod tool;
|
||||
|
||||
pub use tool::{ToolCommand, run_tool_command};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "near-agent")]
|
||||
#[command(about = "LLM-powered autonomous agent for the NEAR AI marketplace")]
|
||||
#[command(version)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
|
||||
/// Run in interactive CLI mode only (disable other channels)
|
||||
#[arg(long, global = true)]
|
||||
pub cli_only: bool,
|
||||
|
||||
/// Skip database connection (for testing)
|
||||
#[arg(long, global = true)]
|
||||
pub no_db: bool,
|
||||
|
||||
/// Configuration file path (optional, uses env vars by default)
|
||||
#[arg(short, long, global = true)]
|
||||
pub config: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Command {
|
||||
/// Run the agent (default if no subcommand given)
|
||||
Run,
|
||||
|
||||
/// Manage WASM tools
|
||||
#[command(subcommand)]
|
||||
Tool(ToolCommand),
|
||||
// Future: Secret management
|
||||
// #[command(subcommand)]
|
||||
// Secret(SecretCommand),
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
/// Check if we should run the agent (default behavior or explicit `run` command).
|
||||
pub fn should_run_agent(&self) -> bool {
|
||||
matches!(self.command, None | Some(Command::Run))
|
||||
}
|
||||
}
|
||||
+639
@@ -0,0 +1,639 @@
|
||||
//! Tool management CLI commands.
|
||||
//!
|
||||
//! Commands for installing, listing, and removing WASM tools.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command as ProcessCommand;
|
||||
|
||||
use clap::Subcommand;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
|
||||
|
||||
/// Default tools directory.
|
||||
fn default_tools_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".near-agent").join("tools"))
|
||||
.unwrap_or_else(|| PathBuf::from(".near-agent/tools"))
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug, Clone)]
|
||||
pub enum ToolCommand {
|
||||
/// Install a WASM tool from source directory or .wasm file
|
||||
Install {
|
||||
/// Path to tool source directory (with Cargo.toml) or .wasm file
|
||||
path: PathBuf,
|
||||
|
||||
/// Tool name (defaults to directory/file name)
|
||||
#[arg(short, long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// Path to capabilities JSON file (auto-detected if not specified)
|
||||
#[arg(long)]
|
||||
capabilities: Option<PathBuf>,
|
||||
|
||||
/// Target directory for installation (default: ~/.near-agent/tools/)
|
||||
#[arg(short, long)]
|
||||
target: Option<PathBuf>,
|
||||
|
||||
/// Build in release mode (default: true)
|
||||
#[arg(long, default_value = "true")]
|
||||
release: bool,
|
||||
|
||||
/// Skip compilation (use existing .wasm file)
|
||||
#[arg(long)]
|
||||
skip_build: bool,
|
||||
|
||||
/// Force overwrite if tool already exists
|
||||
#[arg(short, long)]
|
||||
force: bool,
|
||||
},
|
||||
|
||||
/// List installed tools
|
||||
List {
|
||||
/// Directory to list tools from (default: ~/.near-agent/tools/)
|
||||
#[arg(short, long)]
|
||||
dir: Option<PathBuf>,
|
||||
|
||||
/// Show detailed information
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
},
|
||||
|
||||
/// Remove an installed tool
|
||||
Remove {
|
||||
/// Name of the tool to remove
|
||||
name: String,
|
||||
|
||||
/// Directory to remove tool from (default: ~/.near-agent/tools/)
|
||||
#[arg(short, long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// Show information about a tool
|
||||
Info {
|
||||
/// Name of the tool or path to .wasm file
|
||||
name_or_path: String,
|
||||
|
||||
/// Directory to look for tool (default: ~/.near-agent/tools/)
|
||||
#[arg(short, long)]
|
||||
dir: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Run a tool command.
|
||||
pub async fn run_tool_command(cmd: ToolCommand) -> anyhow::Result<()> {
|
||||
match cmd {
|
||||
ToolCommand::Install {
|
||||
path,
|
||||
name,
|
||||
capabilities,
|
||||
target,
|
||||
release,
|
||||
skip_build,
|
||||
force,
|
||||
} => install_tool(path, name, capabilities, target, release, skip_build, force).await,
|
||||
ToolCommand::List { dir, verbose } => list_tools(dir, verbose).await,
|
||||
ToolCommand::Remove { name, dir } => remove_tool(name, dir).await,
|
||||
ToolCommand::Info { name_or_path, dir } => show_tool_info(name_or_path, dir).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a WASM tool.
|
||||
async fn install_tool(
|
||||
path: PathBuf,
|
||||
name: Option<String>,
|
||||
capabilities: Option<PathBuf>,
|
||||
target: Option<PathBuf>,
|
||||
release: bool,
|
||||
skip_build: bool,
|
||||
force: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let target_dir = target.unwrap_or_else(default_tools_dir);
|
||||
|
||||
// Determine if path is a directory (source) or .wasm file
|
||||
let metadata = fs::metadata(&path).await?;
|
||||
|
||||
let (wasm_path, tool_name, caps_path) = if metadata.is_dir() {
|
||||
// Source directory, need to build
|
||||
let cargo_toml = path.join("Cargo.toml");
|
||||
if !cargo_toml.exists() {
|
||||
anyhow::bail!(
|
||||
"No Cargo.toml found in {}. Expected a Rust WASM tool source directory.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Extract tool name from Cargo.toml or use provided name
|
||||
let tool_name = if let Some(n) = name {
|
||||
n
|
||||
} else {
|
||||
extract_crate_name(&cargo_toml).await?
|
||||
};
|
||||
|
||||
// Build the WASM component if not skipping
|
||||
let wasm_path = if skip_build {
|
||||
// Look for existing wasm file
|
||||
find_wasm_artifact(&path, &tool_name, release)?
|
||||
} else {
|
||||
build_wasm_component(&path, release)?
|
||||
};
|
||||
|
||||
// Look for capabilities file
|
||||
let caps_path = capabilities.or_else(|| {
|
||||
let candidates = [
|
||||
path.join(format!("{}.capabilities.json", tool_name)),
|
||||
path.join("capabilities.json"),
|
||||
];
|
||||
candidates.into_iter().find(|p| p.exists())
|
||||
});
|
||||
|
||||
(wasm_path, tool_name, caps_path)
|
||||
} else if path.extension().map(|e| e == "wasm").unwrap_or(false) {
|
||||
// Direct .wasm file
|
||||
let tool_name = name.unwrap_or_else(|| {
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
});
|
||||
|
||||
// Look for capabilities file next to wasm
|
||||
let caps_path = capabilities.or_else(|| {
|
||||
let candidates = [
|
||||
path.with_extension("capabilities.json"),
|
||||
path.parent()
|
||||
.map(|p| p.join(format!("{}.capabilities.json", tool_name)))
|
||||
.unwrap_or_default(),
|
||||
];
|
||||
candidates.into_iter().find(|p| p.exists())
|
||||
});
|
||||
|
||||
(path, tool_name, caps_path)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Expected a directory with Cargo.toml or a .wasm file, got: {}",
|
||||
path.display()
|
||||
);
|
||||
};
|
||||
|
||||
// Ensure target directory exists
|
||||
fs::create_dir_all(&target_dir).await?;
|
||||
|
||||
// Target paths
|
||||
let target_wasm = target_dir.join(format!("{}.wasm", tool_name));
|
||||
let target_caps = target_dir.join(format!("{}.capabilities.json", tool_name));
|
||||
|
||||
// Check if already exists
|
||||
if target_wasm.exists() && !force {
|
||||
anyhow::bail!(
|
||||
"Tool '{}' already exists at {}. Use --force to overwrite.",
|
||||
tool_name,
|
||||
target_wasm.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Validate capabilities file if provided
|
||||
if let Some(ref caps) = caps_path {
|
||||
let content = fs::read_to_string(caps).await?;
|
||||
CapabilitiesFile::from_json(&content)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid capabilities file {}: {}", caps.display(), e))?;
|
||||
}
|
||||
|
||||
// Copy WASM file
|
||||
println!("Installing {} to {}", tool_name, target_wasm.display());
|
||||
fs::copy(&wasm_path, &target_wasm).await?;
|
||||
|
||||
// Copy capabilities file if present
|
||||
if let Some(caps) = caps_path {
|
||||
println!(" Copying capabilities from {}", caps.display());
|
||||
fs::copy(&caps, &target_caps).await?;
|
||||
} else {
|
||||
println!(" Warning: No capabilities file found. Tool will have no permissions.");
|
||||
}
|
||||
|
||||
// Calculate and display hash
|
||||
let wasm_bytes = fs::read(&target_wasm).await?;
|
||||
let hash = compute_binary_hash(&wasm_bytes);
|
||||
let hash_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
|
||||
println!("\nInstalled successfully:");
|
||||
println!(" Name: {}", tool_name);
|
||||
println!(" WASM: {}", target_wasm.display());
|
||||
println!(" Size: {} bytes", wasm_bytes.len());
|
||||
println!(" Hash: {}", &hash_hex[..16]); // Show first 16 chars
|
||||
|
||||
if target_caps.exists() {
|
||||
println!(" Caps: {}", target_caps.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a WASM component using cargo-component.
|
||||
fn build_wasm_component(source_dir: &Path, release: bool) -> anyhow::Result<PathBuf> {
|
||||
println!("Building WASM component in {}...", source_dir.display());
|
||||
|
||||
// Check if cargo-component is available
|
||||
let check = ProcessCommand::new("cargo")
|
||||
.args(["component", "--version"])
|
||||
.output();
|
||||
|
||||
if check.is_err() || !check.unwrap().status.success() {
|
||||
anyhow::bail!(
|
||||
"cargo-component not found. Install with: cargo install cargo-component\n\
|
||||
Or use --skip-build with an existing .wasm file."
|
||||
);
|
||||
}
|
||||
|
||||
// Build command
|
||||
let mut cmd = ProcessCommand::new("cargo");
|
||||
cmd.current_dir(source_dir).args(["component", "build"]);
|
||||
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
|
||||
println!(
|
||||
" Running: cargo component build{}",
|
||||
if release { " --release" } else { "" }
|
||||
);
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Build failed:\n{}", stderr);
|
||||
}
|
||||
|
||||
// Find the output wasm file
|
||||
let profile = if release { "release" } else { "debug" };
|
||||
let target_dir = source_dir
|
||||
.join("target")
|
||||
.join("wasm32-wasip2")
|
||||
.join(profile);
|
||||
|
||||
// Look for .wasm files in target dir
|
||||
let entries: Vec<_> = std::fs::read_dir(&target_dir)?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.map(|ext| ext == "wasm")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if entries.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No .wasm file found in {}. Build may have failed.",
|
||||
target_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
if entries.len() > 1 {
|
||||
println!(
|
||||
" Warning: Multiple .wasm files found, using first: {}",
|
||||
entries[0].path().display()
|
||||
);
|
||||
}
|
||||
|
||||
let wasm_path = entries[0].path();
|
||||
println!(" Built: {}", wasm_path.display());
|
||||
|
||||
Ok(wasm_path)
|
||||
}
|
||||
|
||||
/// Find an existing WASM artifact without building.
|
||||
fn find_wasm_artifact(source_dir: &Path, name: &str, release: bool) -> anyhow::Result<PathBuf> {
|
||||
let profile = if release { "release" } else { "debug" };
|
||||
let target_dir = source_dir
|
||||
.join("target")
|
||||
.join("wasm32-wasip2")
|
||||
.join(profile);
|
||||
|
||||
// Try exact name match first
|
||||
let snake_name = name.replace('-', "_");
|
||||
let candidates = [
|
||||
target_dir.join(format!("{}.wasm", name)),
|
||||
target_dir.join(format!("{}.wasm", snake_name)),
|
||||
];
|
||||
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return Ok(candidate.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to any .wasm file
|
||||
let entries: Vec<_> = std::fs::read_dir(&target_dir)
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Target directory not found: {}. Run without --skip-build.",
|
||||
target_dir.display()
|
||||
)
|
||||
})?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.map(|ext| ext == "wasm")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if entries.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No .wasm file found in {}. Build the project first or remove --skip-build.",
|
||||
target_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(entries[0].path())
|
||||
}
|
||||
|
||||
/// Extract crate name from Cargo.toml.
|
||||
async fn extract_crate_name(cargo_toml: &Path) -> anyhow::Result<String> {
|
||||
let content = fs::read_to_string(cargo_toml).await?;
|
||||
|
||||
// Simple TOML parsing for [package] name
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("name") {
|
||||
if let Some((_, value)) = line.split_once('=') {
|
||||
let name = value.trim().trim_matches('"').trim_matches('\'');
|
||||
return Ok(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"Could not extract package name from {}",
|
||||
cargo_toml.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// List installed tools.
|
||||
async fn list_tools(dir: Option<PathBuf>, verbose: bool) -> anyhow::Result<()> {
|
||||
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
||||
|
||||
if !tools_dir.exists() {
|
||||
println!("No tools directory found at {}", tools_dir.display());
|
||||
println!("Install a tool with: near-agent tool install <path>");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut entries = fs::read_dir(&tools_dir).await?;
|
||||
let mut tools = Vec::new();
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "wasm").unwrap_or(false) {
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let caps_path = path.with_extension("capabilities.json");
|
||||
let has_caps = caps_path.exists();
|
||||
|
||||
let size = fs::metadata(&path).await.map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
tools.push((name, path, has_caps, size));
|
||||
}
|
||||
}
|
||||
|
||||
if tools.is_empty() {
|
||||
println!("No tools installed in {}", tools_dir.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tools.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
println!("Installed tools in {}:", tools_dir.display());
|
||||
println!();
|
||||
|
||||
for (name, path, has_caps, size) in tools {
|
||||
if verbose {
|
||||
let wasm_bytes = fs::read(&path).await?;
|
||||
let hash = compute_binary_hash(&wasm_bytes);
|
||||
let hash_hex: String = hash.iter().take(8).map(|b| format!("{:02x}", b)).collect();
|
||||
|
||||
println!(" {} ({})", name, format_size(size));
|
||||
println!(" Path: {}", path.display());
|
||||
println!(" Hash: {}", hash_hex);
|
||||
println!(" Caps: {}", if has_caps { "yes" } else { "no" });
|
||||
|
||||
if has_caps {
|
||||
let caps_path = path.with_extension("capabilities.json");
|
||||
if let Ok(content) = fs::read_to_string(&caps_path).await {
|
||||
if let Ok(caps) = CapabilitiesFile::from_json(&content) {
|
||||
print_capabilities_summary(&caps);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
} else {
|
||||
let caps_indicator = if has_caps { "✓" } else { "✗" };
|
||||
println!(
|
||||
" {} ({}, caps: {})",
|
||||
name,
|
||||
format_size(size),
|
||||
caps_indicator
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove an installed tool.
|
||||
async fn remove_tool(name: String, dir: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
||||
|
||||
let wasm_path = tools_dir.join(format!("{}.wasm", name));
|
||||
let caps_path = tools_dir.join(format!("{}.capabilities.json", name));
|
||||
|
||||
if !wasm_path.exists() {
|
||||
anyhow::bail!("Tool '{}' not found in {}", name, tools_dir.display());
|
||||
}
|
||||
|
||||
fs::remove_file(&wasm_path).await?;
|
||||
println!("Removed {}", wasm_path.display());
|
||||
|
||||
if caps_path.exists() {
|
||||
fs::remove_file(&caps_path).await?;
|
||||
println!("Removed {}", caps_path.display());
|
||||
}
|
||||
|
||||
println!("\nTool '{}' removed.", name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Show information about a tool.
|
||||
async fn show_tool_info(name_or_path: String, dir: Option<PathBuf>) -> anyhow::Result<()> {
|
||||
let wasm_path = if name_or_path.ends_with(".wasm") {
|
||||
PathBuf::from(&name_or_path)
|
||||
} else {
|
||||
let tools_dir = dir.unwrap_or_else(default_tools_dir);
|
||||
tools_dir.join(format!("{}.wasm", name_or_path))
|
||||
};
|
||||
|
||||
if !wasm_path.exists() {
|
||||
anyhow::bail!("Tool not found: {}", wasm_path.display());
|
||||
}
|
||||
|
||||
let wasm_bytes = fs::read(&wasm_path).await?;
|
||||
let hash = compute_binary_hash(&wasm_bytes);
|
||||
let hash_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
|
||||
let name = wasm_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
println!("Tool: {}", name);
|
||||
println!("Path: {}", wasm_path.display());
|
||||
println!(
|
||||
"Size: {} bytes ({})",
|
||||
wasm_bytes.len(),
|
||||
format_size(wasm_bytes.len() as u64)
|
||||
);
|
||||
println!("Hash: {}", hash_hex);
|
||||
|
||||
let caps_path = wasm_path.with_extension("capabilities.json");
|
||||
if caps_path.exists() {
|
||||
println!("\nCapabilities ({}):", caps_path.display());
|
||||
let content = fs::read_to_string(&caps_path).await?;
|
||||
match CapabilitiesFile::from_json(&content) {
|
||||
Ok(caps) => print_capabilities_detail(&caps),
|
||||
Err(e) => println!(" Error parsing: {}", e),
|
||||
}
|
||||
} else {
|
||||
println!("\nNo capabilities file found.");
|
||||
println!("Tool will have no permissions (default deny).");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Format bytes as human-readable size.
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = KB * 1024;
|
||||
|
||||
if bytes >= MB {
|
||||
format!("{:.1} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{:.1} KB", bytes as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{} B", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a brief capabilities summary.
|
||||
fn print_capabilities_summary(caps: &CapabilitiesFile) {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if let Some(ref http) = caps.http {
|
||||
let hosts: Vec<_> = http.allowlist.iter().map(|e| e.host.as_str()).collect();
|
||||
if !hosts.is_empty() {
|
||||
parts.push(format!("http: {}", hosts.join(", ")));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref secrets) = caps.secrets {
|
||||
if !secrets.allowed_names.is_empty() {
|
||||
parts.push(format!("secrets: {}", secrets.allowed_names.len()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref ws) = caps.workspace {
|
||||
if !ws.allowed_prefixes.is_empty() {
|
||||
parts.push("workspace: read".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !parts.is_empty() {
|
||||
println!(" Perms: {}", parts.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
/// Print detailed capabilities.
|
||||
fn print_capabilities_detail(caps: &CapabilitiesFile) {
|
||||
if let Some(ref http) = caps.http {
|
||||
println!(" HTTP:");
|
||||
for endpoint in &http.allowlist {
|
||||
let methods = if endpoint.methods.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
endpoint.methods.join(", ")
|
||||
};
|
||||
let path = endpoint.path_prefix.as_deref().unwrap_or("/*");
|
||||
println!(" {} {} {}", methods, endpoint.host, path);
|
||||
}
|
||||
|
||||
if !http.credentials.is_empty() {
|
||||
println!(" Credentials:");
|
||||
for (key, cred) in &http.credentials {
|
||||
println!(" {}: {} -> {:?}", key, cred.secret_name, cred.location);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref rate) = http.rate_limit {
|
||||
println!(
|
||||
" Rate limit: {}/min, {}/hour",
|
||||
rate.requests_per_minute, rate.requests_per_hour
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref secrets) = caps.secrets {
|
||||
if !secrets.allowed_names.is_empty() {
|
||||
println!(" Secrets (existence check only):");
|
||||
for name in &secrets.allowed_names {
|
||||
println!(" {}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref tool_invoke) = caps.tool_invoke {
|
||||
if !tool_invoke.aliases.is_empty() {
|
||||
println!(" Tool aliases:");
|
||||
for (alias, real_name) in &tool_invoke.aliases {
|
||||
println!(" {} -> {}", alias, real_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref ws) = caps.workspace {
|
||||
if !ws.allowed_prefixes.is_empty() {
|
||||
println!(" Workspace read prefixes:");
|
||||
for prefix in &ws.allowed_prefixes {
|
||||
println!(" {}", prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_size() {
|
||||
assert_eq!(format_size(500), "500 B");
|
||||
assert_eq!(format_size(1024), "1.0 KB");
|
||||
assert_eq!(format_size(1536), "1.5 KB");
|
||||
assert_eq!(format_size(1048576), "1.0 MB");
|
||||
assert_eq!(format_size(2621440), "2.5 MB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_tools_dir() {
|
||||
let dir = default_tools_dir();
|
||||
assert!(dir.to_string_lossy().contains(".near-agent"));
|
||||
assert!(dir.to_string_lossy().contains("tools"));
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
|
||||
pub mod agent;
|
||||
pub mod channels;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod context;
|
||||
pub mod error;
|
||||
|
||||
+21
-21
@@ -8,6 +8,7 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx
|
||||
use near_agent::{
|
||||
agent::Agent,
|
||||
channels::{ChannelManager, HttpChannel, TuiChannel},
|
||||
cli::{Cli, Command, run_tool_command},
|
||||
config::Config,
|
||||
history::Store,
|
||||
llm::create_llm_provider,
|
||||
@@ -15,27 +16,26 @@ use near_agent::{
|
||||
tools::ToolRegistry,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "near-agent")]
|
||||
#[command(about = "LLM-powered autonomous agent for the NEAR AI marketplace")]
|
||||
#[command(version)]
|
||||
struct Args {
|
||||
/// Run in interactive CLI mode only (disable other channels)
|
||||
#[arg(long)]
|
||||
cli_only: bool,
|
||||
|
||||
/// Skip database connection (for testing)
|
||||
#[arg(long)]
|
||||
no_db: bool,
|
||||
|
||||
/// Configuration file path (optional, uses env vars by default)
|
||||
#[arg(short, long)]
|
||||
config: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Handle non-agent commands first (they don't need TUI/full setup)
|
||||
match &cli.command {
|
||||
Some(Command::Tool(tool_cmd)) => {
|
||||
// Simple logging for CLI commands
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
|
||||
)
|
||||
.init();
|
||||
|
||||
return run_tool_command(tool_cmd.clone()).await;
|
||||
}
|
||||
None | Some(Command::Run) => {
|
||||
// Continue to run agent
|
||||
}
|
||||
}
|
||||
|
||||
// Create TUI channel early so we can hook up logging
|
||||
// (channel is created but not started until agent.run())
|
||||
@@ -65,7 +65,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("Loaded configuration for agent: {}", config.agent.name);
|
||||
|
||||
// Initialize database store (optional for testing)
|
||||
let store = if args.no_db {
|
||||
let store = if cli.no_db {
|
||||
tracing::warn!("Running without database connection");
|
||||
None
|
||||
} else {
|
||||
@@ -98,7 +98,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Add HTTP channel if configured and not CLI-only mode
|
||||
if !args.cli_only {
|
||||
if !cli.cli_only {
|
||||
if let Some(ref http_config) = config.channels.http {
|
||||
channels.add(Box::new(HttpChannel::new(http_config.clone())));
|
||||
tracing::info!(
|
||||
|
||||
Reference in New Issue
Block a user