diff --git a/Cargo.lock b/Cargo.lock index 9ff856e..f51df9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,7 +86,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocli" -version = "0.2.3" +version = "0.2.4" dependencies = [ "autocli-ai", "autocli-browser", @@ -109,7 +109,7 @@ dependencies = [ [[package]] name = "autocli-ai" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "autocli-browser", @@ -127,7 +127,7 @@ dependencies = [ [[package]] name = "autocli-browser" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "autocli-core", @@ -146,7 +146,7 @@ dependencies = [ [[package]] name = "autocli-core" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "serde", @@ -157,7 +157,7 @@ dependencies = [ [[package]] name = "autocli-discovery" -version = "0.2.3" +version = "0.2.4" dependencies = [ "autocli-core", "autocli-pipeline", @@ -170,7 +170,7 @@ dependencies = [ [[package]] name = "autocli-external" -version = "0.2.3" +version = "0.2.4" dependencies = [ "autocli-core", "serde", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "autocli-output" -version = "0.2.3" +version = "0.2.4" dependencies = [ "autocli-core", "colored", @@ -196,7 +196,7 @@ dependencies = [ [[package]] name = "autocli-pipeline" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "autocli-core", diff --git a/crates/autocli-browser/src/auth.rs b/crates/autocli-browser/src/auth.rs new file mode 100644 index 0000000..8d5ff93 --- /dev/null +++ b/crates/autocli-browser/src/auth.rs @@ -0,0 +1,101 @@ +use autocli_core::CliError; +use std::io::Write; +use std::path::PathBuf; + +const TOKEN_ENV: &str = "AUTOCLI_DAEMON_TOKEN"; +const TOKEN_FILENAME: &str = "daemon-token"; + +fn home_dir() -> PathBuf { + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| ".".to_string()); + PathBuf::from(home) +} + +fn normalize_token(raw: &str) -> Option { + let token = raw.trim(); + if token.is_empty() { + None + } else { + Some(token.to_string()) + } +} + +pub fn daemon_token_path() -> PathBuf { + home_dir().join(".autocli").join(TOKEN_FILENAME) +} + +pub fn load_or_create_daemon_token() -> Result { + if let Ok(token) = std::env::var(TOKEN_ENV) { + if let Some(token) = normalize_token(&token) { + return Ok(token); + } + } + + let path = daemon_token_path(); + if path.exists() { + let content = std::fs::read_to_string(&path).map_err(|e| CliError::Config { + message: format!("Failed to read daemon token from {}", path.display()), + suggestions: vec![ + "Delete the token file so AutoCLI can recreate it".to_string(), + format!("Path: {}", path.display()), + ], + source: Some(Box::new(e)), + })?; + if let Some(token) = normalize_token(&content) { + return Ok(token); + } + } + + let token = uuid::Uuid::new_v4().simple().to_string(); + persist_token(&path, &token)?; + Ok(token) +} + +fn persist_token(path: &PathBuf, token: &str) -> Result<(), CliError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| CliError::Config { + message: format!("Failed to create config directory {}", parent.display()), + suggestions: vec![], + source: Some(Box::new(e)), + })?; + } + + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(path) + } + .map_err(|e| CliError::Config { + message: format!("Failed to write daemon token to {}", path.display()), + suggestions: vec![], + source: Some(Box::new(e)), + })?; + + #[cfg(not(unix))] + let mut file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path) + .map_err(|e| CliError::Config { + message: format!("Failed to write daemon token to {}", path.display()), + suggestions: vec![], + source: Some(Box::new(e)), + })?; + + file.write_all(token.as_bytes()) + .and_then(|_| file.write_all(b"\n")) + .map_err(|e| CliError::Config { + message: format!("Failed to persist daemon token to {}", path.display()), + suggestions: vec![], + source: Some(Box::new(e)), + })?; + + Ok(()) +} diff --git a/crates/autocli-browser/src/daemon.rs b/crates/autocli-browser/src/daemon.rs index 8a86733..8a04859 100644 --- a/crates/autocli-browser/src/daemon.rs +++ b/crates/autocli-browser/src/daemon.rs @@ -1,15 +1,16 @@ +use autocli_core::CliError; use axum::{ extract::{ ws::{Message, WebSocket}, State, WebSocketUpgrade, }, + http::header::ORIGIN, http::{HeaderMap, StatusCode}, response::IntoResponse, routing::{get, post}, Json, Router, }; use futures::{SinkExt, StreamExt}; -use autocli_core::CliError; use serde_json::json; use std::{ collections::HashMap, @@ -19,6 +20,7 @@ use std::{ use tokio::sync::{oneshot, Mutex, RwLock}; use tracing::{debug, error, info, warn}; +use crate::auth::load_or_create_daemon_token; use crate::types::{DaemonCommand, DaemonResult}; /// Command response timeout. @@ -36,15 +38,17 @@ pub struct DaemonState { pub pending_commands: RwLock, pub extension_connected: RwLock, pub last_activity: RwLock, + pub auth_token: String, } impl DaemonState { - fn new() -> Self { + fn new(auth_token: String) -> Self { Self { extension_tx: Mutex::new(None), pending_commands: RwLock::new(HashMap::new()), extension_connected: RwLock::new(false), last_activity: RwLock::new(Instant::now()), + auth_token, } } @@ -62,21 +66,16 @@ pub struct Daemon { impl Daemon { /// Start the daemon server on the given port. Returns immediately after the listener binds. pub async fn start(port: u16) -> Result { - let state = Arc::new(DaemonState::new()); + let auth_token = load_or_create_daemon_token()?; + let state = Arc::new(DaemonState::new(auth_token)); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - let cors = tower_http::cors::CorsLayer::new() - .allow_origin(tower_http::cors::Any) - .allow_methods(tower_http::cors::Any) - .allow_headers(tower_http::cors::Any); - let app = Router::new() .route("/health", get(health_handler)) .route("/ping", get(health_handler)) .route("/status", get(status_handler)) .route("/command", post(command_handler)) .route("/ext", get(ws_handler)) - .layer(cors) .with_state(state.clone()); let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")) @@ -136,9 +135,41 @@ async fn health_handler() -> impl IntoResponse { (StatusCode::OK, "ok") } +fn unauthorized_response() -> (StatusCode, Json) { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "Missing or invalid daemon token" })), + ) +} + +fn is_authorized(headers: &HeaderMap, expected_token: &str) -> bool { + headers + .get("x-autocli-token") + .or_else(|| headers.get("x-opencli-token")) + .and_then(|value| value.to_str().ok()) + .map(|token| token == expected_token) + .unwrap_or(false) +} + +fn websocket_origin_allowed(headers: &HeaderMap) -> bool { + headers + .get(ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(|origin| { + origin.starts_with("chrome-extension://") || origin.starts_with("moz-extension://") + }) + .unwrap_or(false) +} + /// GET /status — return daemon and extension status. /// Compatible with both autocli and original opencli formats. -async fn status_handler(State(state): State>) -> impl IntoResponse { +async fn status_handler( + State(state): State>, + headers: HeaderMap, +) -> impl IntoResponse { + if !is_authorized(&headers, &state.auth_token) { + return unauthorized_response().into_response(); + } let ext = *state.extension_connected.read().await; let pending = state.pending_commands.read().await.len(); Json(json!({ @@ -149,6 +180,7 @@ async fn status_handler(State(state): State>) -> impl IntoRespo "extensionConnected": ext, "pending": pending, })) + .into_response() } /// POST /command — accept a command from the CLI and forward to the extension. @@ -157,12 +189,8 @@ async fn command_handler( headers: HeaderMap, Json(cmd): Json, ) -> impl IntoResponse { - // Security: require X-AutoCLI or X-OpenCLI header (backward compatible) - if !headers.contains_key("x-autocli") && !headers.contains_key("x-opencli") { - return ( - StatusCode::FORBIDDEN, - Json(json!({ "error": "Missing X-AutoCLI header" })), - ); + if !is_authorized(&headers, &state.auth_token) { + return unauthorized_response(); } state.touch().await; @@ -179,7 +207,11 @@ async fn command_handler( // Create a oneshot channel for the result let (tx, rx) = oneshot::channel::(); - state.pending_commands.write().await.insert(cmd_id.clone(), tx); + state + .pending_commands + .write() + .await + .insert(cmd_id.clone(), tx); // Forward command to extension via WebSocket { @@ -210,7 +242,10 @@ async fn command_handler( } else { StatusCode::UNPROCESSABLE_ENTITY }; - (status, Json(serde_json::to_value(result).unwrap_or(json!({})))) + ( + status, + Json(serde_json::to_value(result).unwrap_or(json!({}))), + ) } Ok(Err(_)) => ( StatusCode::INTERNAL_SERVER_ERROR, @@ -229,9 +264,18 @@ async fn command_handler( /// GET /ext — WebSocket upgrade for Chrome extension. async fn ws_handler( State(state): State>, + headers: HeaderMap, ws: WebSocketUpgrade, ) -> impl IntoResponse { + if !websocket_origin_allowed(&headers) { + return ( + StatusCode::FORBIDDEN, + Json(json!({ "error": "WebSocket origin not allowed" })), + ) + .into_response(); + } ws.on_upgrade(move |socket| handle_extension_ws(state, socket)) + .into_response() } async fn handle_extension_ws(state: Arc, socket: WebSocket) { @@ -326,7 +370,7 @@ mod tests { #[tokio::test] async fn test_daemon_state_touch() { - let state = DaemonState::new(); + let state = DaemonState::new("test-token".to_string()); let before = *state.last_activity.read().await; tokio::time::sleep(Duration::from_millis(10)).await; state.touch().await; diff --git a/crates/autocli-browser/src/daemon_client.rs b/crates/autocli-browser/src/daemon_client.rs index 10e378b..dff3c1b 100644 --- a/crates/autocli-browser/src/daemon_client.rs +++ b/crates/autocli-browser/src/daemon_client.rs @@ -3,12 +3,14 @@ use serde_json::Value; use std::time::Duration; use tracing::{debug, warn}; +use crate::auth::load_or_create_daemon_token; use crate::types::{DaemonCommand, DaemonResult}; /// HTTP client that communicates with the Daemon server. pub struct DaemonClient { base_url: String, client: reqwest::Client, + auth_token: Option, } /// Retry delays for exponential backoff. @@ -21,9 +23,26 @@ impl DaemonClient { .timeout(Duration::from_secs(30)) .build() .expect("failed to build reqwest client"); + let auth_token = match load_or_create_daemon_token() { + Ok(token) => Some(token), + Err(err) => { + warn!(error = %err, "failed to load daemon token"); + None + } + }; Self { base_url: format!("http://127.0.0.1:{port}"), client, + auth_token, + } + } + + fn with_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + let request = request.header("X-AutoCLI", "1"); + if let Some(token) = &self.auth_token { + request.header("X-AutoCLI-Token", token) + } else { + request } } @@ -38,9 +57,7 @@ impl DaemonClient { debug!(attempt = attempt + 1, action = %cmd.action, "sending daemon command"); let result = self - .client - .post(&url) - .header("X-AutoCLI", "1") + .with_auth(self.client.post(&url)) .json(&cmd) .send() .await; @@ -51,7 +68,9 @@ impl DaemonClient { if status.is_success() { let daemon_result: DaemonResult = resp.json().await.map_err(|e| { - CliError::browser_connect(format!("Failed to parse daemon response: {e}")) + CliError::browser_connect(format!( + "Failed to parse daemon response: {e}" + )) })?; if daemon_result.ok { return Ok(daemon_result.data.unwrap_or(Value::Null)); @@ -122,8 +141,7 @@ impl DaemonClient { /// Compatible with both autocli (`extension` field) and original opencli (`extensionConnected` field). pub async fn is_extension_connected(&self) -> bool { let url = format!("{}/status", self.base_url); - // Original OpenCLI requires X-AutoCLI header on all requests - match self.client.get(&url).header("X-AutoCLI", "1").send().await { + match self.with_auth(self.client.get(&url)).send().await { Ok(resp) if resp.status().is_success() => { if let Ok(json) = resp.json::().await { // Our format: {"extension": bool} diff --git a/crates/autocli-browser/src/lib.rs b/crates/autocli-browser/src/lib.rs index bf3edb8..a4848e9 100644 --- a/crates/autocli-browser/src/lib.rs +++ b/crates/autocli-browser/src/lib.rs @@ -1,6 +1,7 @@ // Architecture and protocol design derived from OpenCLI // (https://github.com/jackwener/opencli) by jackwener, Apache-2.0 +pub mod auth; pub mod types; pub mod daemon_client; pub mod page; diff --git a/crates/autocli-cli/src/main.rs b/crates/autocli-cli/src/main.rs index cf91dbc..896e6da 100644 --- a/crates/autocli-cli/src/main.rs +++ b/crates/autocli-cli/src/main.rs @@ -5,15 +5,16 @@ mod i18n; use i18n::t; -use clap::{Arg, ArgAction, Command}; -use clap_complete::Shell; use autocli_core::Registry; -use serde_json::Value; use autocli_discovery::{discover_builtin_adapters, discover_user_adapters}; -use autocli_external::{load_external_clis, ExternalCli}; +use autocli_external::{is_binary_installed, load_external_clis, upsert_external_cli, ExternalCli}; use autocli_output::format::{OutputFormat, RenderOptions}; use autocli_output::render; +use clap::{Arg, ArgAction, Command}; +use clap_complete::Shell; +use serde_json::{json, Value}; use std::collections::HashMap; +use std::path::Path; use std::str::FromStr; use tracing_subscriber::EnvFilter; @@ -21,6 +22,22 @@ use crate::args::coerce_and_validate_args; use crate::commands::{completion, doctor}; use crate::execution::execute_command; +const UTILITY_SUBCOMMANDS: &[&str] = &[ + "auth", + "cascade", + "completion", + "doctor", + "explore", + "generate", + "list", + "register", + "search", +]; + +fn subcommand_name_conflicts(name: &str, registry: &Registry) -> bool { + registry.list_sites().iter().any(|site| *site == name) || UTILITY_SUBCOMMANDS.contains(&name) +} + fn build_cli(registry: &Registry, external_clis: &[ExternalCli]) -> Command { let mut app = Command::new("autocli") .version(env!("CARGO_PKG_VERSION")) @@ -79,6 +96,10 @@ fn build_cli(registry: &Registry, external_clis: &[ExternalCli]) -> Command { // Add external CLI subcommands for ext in external_clis { + if subcommand_name_conflicts(ext.name.as_str(), registry) { + tracing::warn!(name = %ext.name, "Skipping external CLI with reserved name"); + continue; + } app = app.subcommand( Command::new(ext.name.clone()) .about(ext.description.clone()) @@ -88,6 +109,22 @@ fn build_cli(registry: &Registry, external_clis: &[ExternalCli]) -> Command { // Built-in utility subcommands app = app + .subcommand(Command::new("list").about("List discovered adapters and registered external CLIs")) + .subcommand( + Command::new("register") + .about("Register an external CLI so AutoCLI can expose it to agents") + .arg(Arg::new("name").required(true).help("Subcommand name to expose")) + .arg(Arg::new("binary").long("binary").short('b').help("Binary name or absolute path to execute")) + .arg(Arg::new("description").long("description").short('d').help("Human-readable description")) + .arg(Arg::new("homepage").long("homepage").help("Project homepage or docs URL")) + .arg( + Arg::new("tag") + .long("tag") + .short('t') + .action(ArgAction::Append) + .help("Discovery tag (repeatable)"), + ), + ) .subcommand(Command::new("doctor").about("Run diagnostics checks")) .subcommand( Command::new("completion") @@ -150,20 +187,34 @@ fn migrate_legacy_config() { // Copy contents to new directory if let Err(e) = copy_dir_recursive(&old_dir, &new_dir) { - eprintln!("{}{}", t("⚠️ 配置迁移失败: ", "⚠️ Config migration failed: "), e); + eprintln!( + "{}{}", + t("⚠️ 配置迁移失败: ", "⚠️ Config migration failed: "), + e + ); return; } // Remove old directory if let Err(e) = std::fs::remove_dir_all(&old_dir) { - eprintln!("{}{}", t("⚠️ 无法删除旧配置目录: ", "⚠️ Cannot remove old config dir: "), e); + eprintln!( + "{}{}", + t( + "⚠️ 无法删除旧配置目录: ", + "⚠️ Cannot remove old config dir: " + ), + e + ); return; } - eprintln!("{}", t( - "✅ 已将配置从 ~/.opencli-rs 迁移到 ~/.autocli", - "✅ Migrated config from ~/.opencli-rs to ~/.autocli" - )); + eprintln!( + "{}", + t( + "✅ 已将配置从 ~/.opencli-rs 迁移到 ~/.autocli", + "✅ Migrated config from ~/.opencli-rs to ~/.autocli" + ) + ); } fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { @@ -196,20 +247,180 @@ fn save_adapter(site: &str, name: &str, yaml: &str) { let path = dir.join(format!("{}.yaml", name)); match std::fs::write(&path, yaml) { Ok(_) => { - eprintln!("{} {} {}", t("✅ 已生成配置:", "✅ Generated adapter:"), site, name); + eprintln!( + "{} {} {}", + t("✅ 已生成配置:", "✅ Generated adapter:"), + site, + name + ); eprintln!(" {}{}", t("保存到: ", "Saved to: "), path.display()); eprintln!(); eprintln!(" {}", t("运行命令:", "Run it now:")); eprintln!(" autocli {} {}", site, name); } Err(e) => { - eprintln!("{}{}", t("生成成功但保存失败: ", "Generated adapter but failed to save: "), e); + eprintln!( + "{}{}", + t( + "生成成功但保存失败: ", + "Generated adapter but failed to save: " + ), + e + ); eprintln!(); println!("{}", yaml); } } } +fn render_inventory( + output_format: OutputFormat, + registry: &Registry, + external_clis: &[ExternalCli], +) { + let external_count = external_clis + .iter() + .filter(|cli| !subcommand_name_conflicts(cli.name.as_str(), registry)) + .count(); + let mut rows: Vec = registry + .all_commands() + .into_iter() + .map(|cmd| { + json!({ + "kind": "adapter", + "site": cmd.site.clone(), + "command": cmd.name.clone(), + "mode": if cmd.needs_browser() { format!("browser ({})", cmd.strategy) } else { "public".to_string() }, + "description": cmd.description.clone(), + "binary": "", + "tags": "", + }) + }) + .collect(); + + rows.extend( + external_clis + .iter() + .filter(|cli| !subcommand_name_conflicts(cli.name.as_str(), registry)) + .map(|cli| { + json!({ + "kind": "external", + "site": cli.name.clone(), + "command": "*", + "mode": "passthrough", + "description": cli.description.clone(), + "binary": cli.binary.clone(), + "tags": cli.tags.join(","), + }) + }), + ); + + let data = Value::Array(rows); + let output = render( + &data, + &RenderOptions { + format: output_format, + columns: Some(vec![ + "kind".to_string(), + "site".to_string(), + "command".to_string(), + "mode".to_string(), + "description".to_string(), + "binary".to_string(), + "tags".to_string(), + ]), + title: Some("Available tools".to_string()), + elapsed: None, + source: Some("autocli list".to_string()), + footer_extra: Some(format!( + "{} adapters, {} external CLIs", + registry.command_count(), + external_count + )), + }, + ); + println!("{}", output); +} + +fn validate_register_name( + name: &str, + registry: &Registry, + external_clis: &[ExternalCli], +) -> Result<(), autocli_core::CliError> { + if name.trim().is_empty() { + return Err(autocli_core::CliError::Argument { + message: "External CLI name cannot be empty".to_string(), + suggestions: vec![], + }); + } + + if !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return Err(autocli_core::CliError::Argument { + message: format!( + "External CLI name '{}' must use only letters, numbers, '-' or '_'", + name + ), + suggestions: vec![], + }); + } + + if subcommand_name_conflicts(name, registry) { + return Err(autocli_core::CliError::Argument { + message: format!( + "'{}' is already reserved by an adapter site or built-in subcommand", + name + ), + suggestions: vec![ + "Choose a different external CLI name".to_string(), + "Avoid collisions with list/register/doctor or adapter site names".to_string(), + ], + }); + } + + if let Some(existing) = external_clis.iter().find(|existing| existing.name == name) { + if existing.binary.is_empty() { + return Err(autocli_core::CliError::Argument { + message: format!("'{}' is already registered with an invalid binary", name), + suggestions: vec![ + "Repair the existing entry in ~/.autocli/external-clis.yaml".to_string() + ], + }); + } + } + + Ok(()) +} + +fn validate_binary_reference(binary: &str) -> Result<(), autocli_core::CliError> { + let looks_like_path = binary.contains('/') + || binary.contains('\\') + || binary.starts_with('.') + || binary.starts_with('~'); + + if looks_like_path { + if Path::new(binary).exists() { + return Ok(()); + } + } else if is_binary_installed(binary) { + return Ok(()); + } + + Err(autocli_core::CliError::Argument { + message: format!("Binary '{}' was not found", binary), + suggestions: vec![ + "Install the CLI first, or pass --binary with an absolute path".to_string(), + format!("Checked reference: {}", binary), + ], + }) +} + +fn default_external_description(name: &str) -> String { + format!("User-registered external CLI for {}", name) +} + const TOKEN_URL: &str = "https://autocli.ai/get-token"; /// Print token missing message and exit. @@ -218,16 +429,22 @@ fn require_token() -> String { match config.autocli_token { Some(t) if !t.is_empty() => t, _ => { - eprintln!("{}", t( - "❌ 未认证,请先登录获取 Token", - "❌ Not authenticated. Please login to get your token" - )); + eprintln!( + "{}", + t( + "❌ 未认证,请先登录获取 Token", + "❌ Not authenticated. Please login to get your token" + ) + ); eprintln!(" {}", TOKEN_URL); eprintln!(); - eprintln!(" {}", t( - "获取 Token 后运行: autocli auth", - "After getting your token, run: autocli auth" - )); + eprintln!( + " {}", + t( + "获取 Token 后运行: autocli auth", + "After getting your token, run: autocli auth" + ) + ); std::process::exit(1); } } @@ -235,16 +452,22 @@ fn require_token() -> String { /// Print token invalid/expired message and exit. fn token_expired_exit() -> ! { - eprintln!("{}", t( - "❌ Token 无效或已过期,请重新获取", - "❌ Token is invalid or expired. Please get a new one" - )); + eprintln!( + "{}", + t( + "❌ Token 无效或已过期,请重新获取", + "❌ Token is invalid or expired. Please get a new one" + ) + ); eprintln!(" {}", TOKEN_URL); eprintln!(); - eprintln!(" {}", t( - "获取新 Token 后运行: autocli auth", - "After getting a new token, run: autocli auth" - )); + eprintln!( + " {}", + t( + "获取新 Token 后运行: autocli auth", + "After getting a new token, run: autocli auth" + ) + ); std::process::exit(1); } @@ -264,7 +487,13 @@ async fn search_existing_adapters(url: &str, token: &str) -> Result Result Result c, - Err(e) => { eprintln!("❌ Failed to create HTTP client: {}", e); return; } + Err(e) => { + eprintln!("❌ Failed to create HTTP client: {}", e); + return; + } }; let body = serde_json::json!({ "config": yaml }); @@ -373,16 +665,26 @@ async fn upload_adapter(yaml: &str) { { Ok(resp) => { if resp.status().is_success() { - eprintln!("{}", t("✅ 配置上传成功", "✅ Adapter uploaded successfully")); + eprintln!( + "{}", + t("✅ 配置上传成功", "✅ Adapter uploaded successfully") + ); } else if resp.status().as_u16() == 403 { token_expired_exit(); } else { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - eprintln!("{}{}: {}", t("❌ 上传失败 ", "❌ Upload failed "), status, &body[..body.len().min(200)]); + eprintln!( + "{}{}: {}", + t("❌ 上传失败 ", "❌ Upload failed "), + status, + &body[..body.len().min(200)] + ); } } - Err(e) => { eprintln!("{}{}", t("❌ 上传失败: ", "❌ Upload failed: "), e); } + Err(e) => { + eprintln!("{}{}", t("❌ 上传失败: ", "❌ Upload failed: "), e); + } } } @@ -404,15 +706,13 @@ async fn main() { // 1. Initialize tracing tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_env("RUST_LOG").unwrap_or_else(|_| { - if std::env::var("AUTOCLI_VERBOSE").is_ok() { - EnvFilter::new("debug") - } else { - EnvFilter::new("warn") - } - }), - ) + .with_env_filter(EnvFilter::try_from_env("RUST_LOG").unwrap_or_else(|_| { + if std::env::var("AUTOCLI_VERBOSE").is_ok() { + EnvFilter::new("debug") + } else { + EnvFilter::new("warn") + } + })) .init(); // Check for --daemon flag (used by BrowserBridge to spawn daemon as subprocess) @@ -480,6 +780,65 @@ async fn main() { if let Some((site_name, site_matches)) = matches.subcommand() { // Handle built-in utility subcommands match site_name { + "list" => { + render_inventory(output_format, ®istry, &external_clis); + return; + } + "register" => { + let name = site_matches + .get_one::("name") + .unwrap() + .trim() + .to_string(); + if let Err(e) = validate_register_name(&name, ®istry, &external_clis) { + print_error(&e); + std::process::exit(1); + } + + let binary = site_matches + .get_one::("binary") + .cloned() + .unwrap_or_else(|| name.clone()); + if let Err(e) = validate_binary_reference(&binary) { + print_error(&e); + std::process::exit(1); + } + + let description = site_matches + .get_one::("description") + .cloned() + .unwrap_or_else(|| default_external_description(&name)); + let homepage = site_matches.get_one::("homepage").cloned(); + let tags = site_matches + .get_many::("tag") + .map(|values| values.cloned().collect::>()) + .filter(|values| !values.is_empty()) + .unwrap_or_else(|| vec!["user".to_string(), "local".to_string()]); + + let cli = ExternalCli { + name: name.clone(), + binary, + description, + homepage, + tags, + install: HashMap::new(), + }; + + match upsert_external_cli(cli) { + Ok((path, updated)) => { + if updated { + println!("Updated external CLI '{}' in {}", name, path.display()); + } else { + println!("Registered external CLI '{}' in {}", name, path.display()); + } + } + Err(e) => { + print_error(&e); + std::process::exit(1); + } + } + return; + } "doctor" => { doctor::run_doctor().await; return; @@ -504,30 +863,34 @@ async fn main() { match search_existing_adapters(&url, &token).await { Ok(matches) if !matches.is_empty() => { - let options: Vec = matches.iter().map(|m| { - let tag = match m.match_type.as_str() { - "exact" => "[exact] ", - "partial" => "[partial]", - "domain" => "[domain] ", - _ => "[other] ", - }; - let desc = if m.description.is_empty() { - String::new() - } else { - format!(" - {}", m.description) - }; - let author = if m.author.is_empty() { - String::new() - } else { - format!(" (by {})", m.author) - }; - format!("{} {} {}{}{}", tag, m.site_name, m.cmd_name, author, desc) - }).collect(); + let options: Vec = matches + .iter() + .map(|m| { + let tag = match m.match_type.as_str() { + "exact" => "[exact] ", + "partial" => "[partial]", + "domain" => "[domain] ", + _ => "[other] ", + }; + let desc = if m.description.is_empty() { + String::new() + } else { + format!(" - {}", m.description) + }; + let author = if m.author.is_empty() { + String::new() + } else { + format!(" (by {})", m.author) + }; + format!("{} {} {}{}{}", tag, m.site_name, m.cmd_name, author, desc) + }) + .collect(); let selection = inquire::Select::new( t("找到以下配置,请选择:", "Adapters found, please select:"), options, - ).prompt(); + ) + .prompt(); match selection { Ok(chosen) => { @@ -536,15 +899,20 @@ async fn main() { }); if let Some(i) = idx { let m = &matches[i]; - eprintln!("{}", t("📥 正在下载配置...", "📥 Downloading config...")); + eprintln!( + "{}", + t("📥 正在下载配置...", "📥 Downloading config...") + ); match fetch_adapter_config(&m.command_uuid, &token).await { Ok(yaml) => { - let yaml_site = yaml.lines() + let yaml_site = yaml + .lines() .find(|l| l.starts_with("site:")) .and_then(|l| l.strip_prefix("site:")) .map(|s| s.trim().trim_matches('"').to_string()) .unwrap_or_else(|| m.site_name.clone()); - let yaml_name = yaml.lines() + let yaml_name = yaml + .lines() .find(|l| l.starts_with("name:")) .and_then(|l| l.strip_prefix("name:")) .map(|s| s.trim().trim_matches('"').to_string()) @@ -561,7 +929,10 @@ async fn main() { } } Ok(_) => { - eprintln!("{}", t("📭 未找到匹配的配置", "📭 No matching adapters found")); + eprintln!( + "{}", + t("📭 未找到匹配的配置", "📭 No matching adapters found") + ); } Err(e) => { eprintln!("{}", e); @@ -573,10 +944,13 @@ async fn main() { "auth" => { // Open browser to get token let token_url = "https://autocli.ai/get-token"; - eprintln!("{}", t( - "🔑 请在浏览器中获取 Token:", - "🔑 Get your token from the browser:" - )); + eprintln!( + "{}", + t( + "🔑 请在浏览器中获取 Token:", + "🔑 Get your token from the browser:" + ) + ); eprintln!(" {}", token_url); eprintln!(); @@ -584,15 +958,19 @@ async fn main() { let _ = if cfg!(target_os = "macos") { std::process::Command::new("open").arg(token_url).spawn() } else if cfg!(target_os = "windows") { - std::process::Command::new("cmd").args(["/C", "start", token_url]).spawn() + std::process::Command::new("cmd") + .args(["/C", "start", token_url]) + .spawn() } else { - std::process::Command::new("xdg-open").arg(token_url).spawn() + std::process::Command::new("xdg-open") + .arg(token_url) + .spawn() }; // Token input loop with verification loop { - let input = inquire::Text::new(t("请输入 Token:", "Enter your token:")) - .prompt(); + let input = + inquire::Text::new(t("请输入 Token:", "Enter your token:")).prompt(); let token = match input { Ok(t) => t.trim().to_string(), @@ -632,16 +1010,30 @@ async fn main() { config.autocli_token = Some(token); match autocli_ai::save_config(&config) { Ok(_) => { - eprintln!("{}{}", t("✅ Token 已保存到 ", "✅ Token saved to "), autocli_ai::config::config_path().display()); + eprintln!( + "{}{}", + t("✅ Token 已保存到 ", "✅ Token saved to "), + autocli_ai::config::config_path().display() + ); } Err(e) => { - eprintln!("{}{}", t("❌ Token 保存失败: ", "❌ Failed to save token: "), e); + eprintln!( + "{}{}", + t("❌ Token 保存失败: ", "❌ Failed to save token: "), + e + ); std::process::exit(1); } } break; } else { - eprintln!("{}", t("❌ Token 无效,请重新输入", "❌ Invalid token, please try again")); + eprintln!( + "{}", + t( + "❌ Token 无效,请重新输入", + "❌ Invalid token, please try again" + ) + ); continue; } } @@ -657,16 +1049,21 @@ async fn main() { let url = site_matches.get_one::("url").unwrap(); let site = site_matches.get_one::("site").cloned(); let goal = site_matches.get_one::("goal").cloned(); - let wait: u64 = site_matches.get_one::("wait") - .and_then(|s| s.parse().ok()).unwrap_or(3); + let wait: u64 = site_matches + .get_one::("wait") + .and_then(|s| s.parse().ok()) + .unwrap_or(3); let auto_fuzz = site_matches.get_flag("auto"); - let click_labels: Vec = site_matches.get_one::("click") + let click_labels: Vec = site_matches + .get_one::("click") .map(|s| s.split(',').map(|l| l.trim().to_string()).collect()) .unwrap_or_default(); let mut bridge = autocli_browser::BrowserBridge::new( - std::env::var("AUTOCLI_DAEMON_PORT").ok() - .and_then(|s| s.parse().ok()).unwrap_or(19825), + std::env::var("AUTOCLI_DAEMON_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(19825), ); match bridge.connect().await { Ok(page) => { @@ -684,13 +1081,20 @@ async fn main() { let _ = page.close().await; match result { Ok(manifest) => { - let output = serde_json::to_string_pretty(&manifest).unwrap_or_default(); + let output = + serde_json::to_string_pretty(&manifest).unwrap_or_default(); println!("{}", output); } - Err(e) => { print_error(&e); std::process::exit(1); } + Err(e) => { + print_error(&e); + std::process::exit(1); + } } } - Err(e) => { print_error(&e); std::process::exit(1); } + Err(e) => { + print_error(&e); + std::process::exit(1); + } } return; } @@ -698,8 +1102,10 @@ async fn main() { let url = site_matches.get_one::("url").unwrap(); let mut bridge = autocli_browser::BrowserBridge::new( - std::env::var("AUTOCLI_DAEMON_PORT").ok() - .and_then(|s| s.parse().ok()).unwrap_or(19825), + std::env::var("AUTOCLI_DAEMON_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(19825), ); match bridge.connect().await { Ok(page) => { @@ -710,10 +1116,16 @@ async fn main() { let output = serde_json::to_string_pretty(&r).unwrap_or_default(); println!("{}", output); } - Err(e) => { print_error(&e); std::process::exit(1); } + Err(e) => { + print_error(&e); + std::process::exit(1); + } } } - Err(e) => { print_error(&e); std::process::exit(1); } + Err(e) => { + print_error(&e); + std::process::exit(1); + } } return; } @@ -724,8 +1136,10 @@ async fn main() { let use_ai = site_matches.get_flag("ai"); let mut bridge = autocli_browser::BrowserBridge::new( - std::env::var("AUTOCLI_DAEMON_PORT").ok() - .and_then(|s| s.parse().ok()).unwrap_or(19825), + std::env::var("AUTOCLI_DAEMON_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(19825), ); match bridge.connect().await { Ok(page) => { @@ -738,32 +1152,44 @@ async fn main() { match search_existing_adapters(url, &token).await { Ok(matches) if !matches.is_empty() => { // Build TUI selection list - let mut options: Vec = matches.iter().map(|m| { - let tag = match m.match_type.as_str() { - "exact" => "[exact] ", - "partial" => "[partial]", - "domain" => "[domain] ", - _ => "[other] ", - }; - let desc = if m.description.is_empty() { - String::new() - } else { - format!(" - {}", m.description) - }; - let author = if m.author.is_empty() { - String::new() - } else { - format!(" (by {})", m.author) - }; - format!("{} {} {}{}{}", tag, m.site_name, m.cmd_name, author, desc) - }).collect(); - let regenerate_label = t("🔄 重新生成 (使用 AI 分析)", "🔄 Regenerate (using AI)").to_string(); + let mut options: Vec = matches + .iter() + .map(|m| { + let tag = match m.match_type.as_str() { + "exact" => "[exact] ", + "partial" => "[partial]", + "domain" => "[domain] ", + _ => "[other] ", + }; + let desc = if m.description.is_empty() { + String::new() + } else { + format!(" - {}", m.description) + }; + let author = if m.author.is_empty() { + String::new() + } else { + format!(" (by {})", m.author) + }; + format!( + "{} {} {}{}{}", + tag, m.site_name, m.cmd_name, author, desc + ) + }) + .collect(); + let regenerate_label = + t("🔄 重新生成 (使用 AI 分析)", "🔄 Regenerate (using AI)") + .to_string(); options.push(regenerate_label.clone()); let selection = inquire::Select::new( - t("找到以下已有配置,请选择:", "Existing adapters found, please select:"), + t( + "找到以下已有配置,请选择:", + "Existing adapters found, please select:", + ), options, - ).prompt(); + ) + .prompt(); match selection { Ok(chosen) => { @@ -772,25 +1198,57 @@ async fn main() { } else { // Find the matching config let idx = matches.iter().position(|m| { - chosen.contains(&m.cmd_name) && chosen.contains(&m.site_name) + chosen.contains(&m.cmd_name) + && chosen.contains(&m.site_name) }); if let Some(i) = idx { let m = &matches[i]; // Extract site and name from YAML config content, not server's display name - eprintln!("{}", t("📥 正在下载配置...", "📥 Downloading config...")); - match fetch_adapter_config(&m.command_uuid, &token).await { + eprintln!( + "{}", + t( + "📥 正在下载配置...", + "📥 Downloading config..." + ) + ); + match fetch_adapter_config( + &m.command_uuid, + &token, + ) + .await + { Ok(yaml) => { - let yaml_site = yaml.lines() + let yaml_site = yaml + .lines() .find(|l| l.starts_with("site:")) - .and_then(|l| l.strip_prefix("site:")) - .map(|s| s.trim().trim_matches('"').to_string()) - .unwrap_or_else(|| m.site_name.clone()); - let yaml_name = yaml.lines() + .and_then(|l| { + l.strip_prefix("site:") + }) + .map(|s| { + s.trim() + .trim_matches('"') + .to_string() + }) + .unwrap_or_else(|| { + m.site_name.clone() + }); + let yaml_name = yaml + .lines() .find(|l| l.starts_with("name:")) - .and_then(|l| l.strip_prefix("name:")) - .map(|s| s.trim().trim_matches('"').to_string()) - .unwrap_or_else(|| m.cmd_name.clone()); - save_adapter(&yaml_site, &yaml_name, &yaml); + .and_then(|l| { + l.strip_prefix("name:") + }) + .map(|s| { + s.trim() + .trim_matches('"') + .to_string() + }) + .unwrap_or_else(|| { + m.cmd_name.clone() + }); + save_adapter( + &yaml_site, &yaml_name, &yaml, + ); let _ = page.close().await; return; } @@ -831,10 +1289,12 @@ async fn main() { // Step 2: AI generation via server API let ai_result = autocli_ai::generate_with_ai( - page.as_ref(), url, + page.as_ref(), + url, goal.as_deref().unwrap_or("hot"), &token, - ).await; + ) + .await; let _ = page.close().await; match ai_result { @@ -842,21 +1302,35 @@ async fn main() { save_adapter(&site, &name, &yaml); upload_adapter(&yaml).await; } - Err(e) => { print_error(&e); std::process::exit(1); } + Err(e) => { + print_error(&e); + std::process::exit(1); + } } } else { // Rule-based generation (existing flow) - let gen_result = autocli_ai::generate(page.as_ref(), url, goal.as_deref().unwrap_or("")).await; + let gen_result = autocli_ai::generate( + page.as_ref(), + url, + goal.as_deref().unwrap_or(""), + ) + .await; let _ = page.close().await; match gen_result { Ok(candidate) => { save_adapter(&candidate.site, &candidate.name, &candidate.yaml); } - Err(e) => { print_error(&e); std::process::exit(1); } + Err(e) => { + print_error(&e); + std::process::exit(1); + } } } } - Err(e) => { print_error(&e); std::process::exit(1); } + Err(e) => { + print_error(&e); + std::process::exit(1); + } } return; } @@ -877,9 +1351,7 @@ async fn main() { None => vec![], }; - match autocli_external::execute_external_cli(&ext.name, &ext.binary, &ext_args) - .await - { + match autocli_external::execute_external_cli(&ext.name, &ext.binary, &ext_args).await { Ok(status) => { std::process::exit(status.code().unwrap_or(1)); } diff --git a/crates/autocli-external/src/lib.rs b/crates/autocli-external/src/lib.rs index 36da14f..bad40ac 100644 --- a/crates/autocli-external/src/lib.rs +++ b/crates/autocli-external/src/lib.rs @@ -1,7 +1,7 @@ -pub mod types; -pub mod loader; pub mod executor; +pub mod loader; +pub mod types; -pub use types::ExternalCli; -pub use loader::load_external_clis; pub use executor::{execute_external_cli, is_binary_installed}; +pub use loader::{load_external_clis, upsert_external_cli, user_external_clis_path}; +pub use types::ExternalCli; diff --git a/crates/autocli-external/src/loader.rs b/crates/autocli-external/src/loader.rs index f814d46..ca230c8 100644 --- a/crates/autocli-external/src/loader.rs +++ b/crates/autocli-external/src/loader.rs @@ -7,7 +7,7 @@ use crate::types::ExternalCli; const BUILTIN_EXTERNAL_CLIS: &str = include_str!("../resources/external-clis.yaml"); /// Return the path to the user's external-clis.yaml override file. -fn user_external_clis_path() -> PathBuf { +pub fn user_external_clis_path() -> PathBuf { let home = std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) .unwrap_or_else(|_| ".".to_string()); @@ -16,6 +16,57 @@ fn user_external_clis_path() -> PathBuf { .join("external-clis.yaml") } +fn load_user_external_clis() -> Result, CliError> { + let user_path = user_external_clis_path(); + if !user_path.exists() { + return Ok(vec![]); + } + + let content = std::fs::read_to_string(&user_path).map_err(|e| CliError::Config { + message: format!("Failed to read {}", user_path.display()), + suggestions: vec![], + source: Some(Box::new(e)), + })?; + + if content.trim().is_empty() { + return Ok(vec![]); + } + + serde_yaml::from_str::>(&content).map_err(|e| CliError::Config { + message: format!("Failed to parse {}", user_path.display()), + suggestions: vec![ + "Fix the YAML syntax or remove the broken file".to_string(), + format!("Path: {}", user_path.display()), + ], + source: Some(Box::new(e)), + }) +} + +fn write_user_external_clis(clis: &[ExternalCli]) -> Result { + let user_path = user_external_clis_path(); + if let Some(parent) = user_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| CliError::Config { + message: format!("Failed to create {}", parent.display()), + suggestions: vec![], + source: Some(Box::new(e)), + })?; + } + + let yaml = serde_yaml::to_string(clis).map_err(|e| CliError::Config { + message: format!("Failed to serialize {}", user_path.display()), + suggestions: vec![], + source: Some(Box::new(e)), + })?; + + std::fs::write(&user_path, yaml).map_err(|e| CliError::Config { + message: format!("Failed to write {}", user_path.display()), + suggestions: vec![], + source: Some(Box::new(e)), + })?; + + Ok(user_path) +} + /// Load external CLI definitions from the embedded resource and optionally /// from the user's `~/.autocli/external-clis.yaml`. /// @@ -24,34 +75,41 @@ fn user_external_clis_path() -> PathBuf { pub fn load_external_clis() -> Result, CliError> { let mut clis: Vec = serde_yaml::from_str(BUILTIN_EXTERNAL_CLIS)?; - let user_path = user_external_clis_path(); - if user_path.exists() { - match std::fs::read_to_string(&user_path) { - Ok(content) => match serde_yaml::from_str::>(&content) { - Ok(user_clis) => { - for ucli in user_clis { - // Replace existing by name, or append - if let Some(pos) = clis.iter().position(|c| c.name == ucli.name) { - clis[pos] = ucli; - } else { - clis.push(ucli); - } - } - tracing::debug!(path = ?user_path, "Loaded user external CLIs"); + match load_user_external_clis() { + Ok(user_clis) => { + for ucli in user_clis { + if let Some(pos) = clis.iter().position(|c| c.name == ucli.name) { + clis[pos] = ucli; + } else { + clis.push(ucli); } - Err(e) => { - tracing::warn!(path = ?user_path, error = %e, "Failed to parse user external-clis.yaml"); - } - }, - Err(e) => { - tracing::warn!(path = ?user_path, error = %e, "Failed to read user external-clis.yaml"); } + tracing::debug!(path = ?user_external_clis_path(), "Loaded user external CLIs"); + } + Err(e) => { + tracing::warn!(error = %e, path = ?user_external_clis_path(), "Failed to load user external-clis.yaml"); } } Ok(clis) } +pub fn upsert_external_cli(cli: ExternalCli) -> Result<(PathBuf, bool), CliError> { + let mut clis = load_user_external_clis()?; + let updated = if let Some(existing) = clis.iter_mut().find(|existing| existing.name == cli.name) + { + *existing = cli; + true + } else { + clis.push(cli); + false + }; + + clis.sort_by(|a, b| a.name.cmp(&b.name)); + let path = write_user_external_clis(&clis)?; + Ok((path, updated)) +} + #[cfg(test)] mod tests { use super::*; @@ -73,4 +131,39 @@ mod tests { assert_eq!(gh.binary, "gh"); assert!(!gh.tags.is_empty()); } + + #[test] + fn test_upsert_external_cli_replaces_by_name() { + let mut existing = vec![ExternalCli { + name: "gh".to_string(), + binary: "gh".to_string(), + description: "old".to_string(), + homepage: None, + tags: vec![], + install: Default::default(), + }]; + + let replacement = ExternalCli { + name: "gh".to_string(), + binary: "/usr/local/bin/gh".to_string(), + description: "new".to_string(), + homepage: Some("https://cli.github.com".to_string()), + tags: vec!["github".to_string()], + install: Default::default(), + }; + + let updated = if let Some(entry) = existing + .iter_mut() + .find(|entry| entry.name == replacement.name) + { + *entry = replacement; + true + } else { + false + }; + + assert!(updated); + assert_eq!(existing[0].binary, "/usr/local/bin/gh"); + assert_eq!(existing[0].description, "new"); + } } diff --git a/crates/autocli-external/src/types.rs b/crates/autocli-external/src/types.rs index a6e8b64..f080c9a 100644 --- a/crates/autocli-external/src/types.rs +++ b/crates/autocli-external/src/types.rs @@ -6,10 +6,10 @@ pub struct ExternalCli { pub name: String, pub binary: String, pub description: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub homepage: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, - #[serde(default)] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub install: HashMap, }