Skip to main content

cmdhub_cli/
lib.rs

1use anyhow::Result;
2use clap::{Parser, Subcommand};
3
4pub mod auth;
5pub mod config;
6pub mod db;
7pub mod dto;
8pub mod inference;
9pub mod installer;
10pub mod os_detector;
11pub mod robustness;
12pub mod runner;
13pub mod tokenizer;
14pub mod updater;
15
16#[derive(Parser, Debug)]
17#[command(
18    name = "cmdh",
19    about = "cmdh — the CmdHub CLI client for offline command search and execution",
20    version
21)]
22pub struct Cli {
23    /// Custom configuration file path
24    #[arg(short, long, global = true, help = "Custom configuration file path")]
25    pub config: Option<std::path::PathBuf>,
26
27    #[command(subcommand)]
28    pub command: Commands,
29}
30
31#[derive(Subcommand, Debug, Clone)]
32pub enum Commands {
33    /// Search offline ACI commands via hybrid FTS5 & Vector search
34    Search {
35        /// The query string to search for
36        query: String,
37        /// Maximum number of search results to return
38        #[arg(long, default_value_t = 5)]
39        limit: usize,
40        /// Force full preset output format
41        #[arg(short, long, group = "output_format")]
42        full: bool,
43        /// Force usage preset output format
44        #[arg(short, long, group = "output_format")]
45        usage_only: bool,
46        /// Force minimal preset output format
47        #[arg(short, long, group = "output_format")]
48        minimal: bool,
49    },
50    /// Sync the offline SQLite database from CDN
51    Update {
52        /// Force database download and sync
53        #[arg(long)]
54        force: bool,
55    },
56    /// Safety-wrapped execution sandbox for Agents
57    Run {
58        /// Materizalized command path to execute (e.g. "tar.extract")
59        cmd_path: String,
60        /// Arguments passed directly to the underlying CLI tool
61        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
62        args: Vec<String>,
63        /// Bypass human interactive gating check for dangerous commands
64        #[arg(short, long)]
65        yes: bool,
66    },
67    /// Install assets or models
68    Install {
69        #[command(subcommand)]
70        sub: InstallAction,
71    },
72    /// Initialize a new config.toml file with default systems properties
73    Init {
74        /// Overwrite config file if it already exists
75        #[arg(long)]
76        force: bool,
77    },
78    /// Generate shell autocompletion script to stdout
79    Completions {
80        /// Shell type (bash, zsh, fish)
81        #[arg(value_enum)]
82        shell: clap_complete::Shell,
83    },
84    /// Log in to CmdHub Cloud Registry via GitHub PKCE OAuth2
85    Login,
86    /// Log out of CmdHub Cloud and clear local credentials
87    Logout,
88}
89
90#[derive(Subcommand, Debug, Clone)]
91pub enum InstallAction {
92    /// Install BGE vector model for local semantic search
93    Vector {
94        /// Install from local file instead of downloading
95        #[arg(long, value_name = "FILE")]
96        from_file: Option<std::path::PathBuf>,
97        /// Force install/reinstall even if SHA-256 matches
98        #[arg(long)]
99        force: bool,
100    },
101}
102
103pub async fn run() -> Result<()> {
104    // 1. Parse command line arguments
105    let cli = Cli::parse();
106
107    // Run setup initialization before loading configuration or opening DB
108    if let Commands::Init { force } = cli.command {
109        let config_dir = config::get_config_dir();
110        let config_path = config_dir.join("config.toml");
111
112        if config_path.exists() && !force {
113            eprintln!(
114                "Warning: Configuration file already exists at {:?}",
115                config_path
116            );
117            return Ok(());
118        }
119
120        std::fs::create_dir_all(&config_dir)?;
121
122        let detected = os_detector::detect_os().unwrap_or_else(|| "unknown".to_string());
123        let default_key: String = config::OFFICIAL_PUBLIC_KEY
124            .iter()
125            .map(|b| format!("{:02x}", b))
126            .collect();
127        let config_content = format!(
128            r#"# CmdHub configuration file
129api_url = "https://cdn.cmdhub.org"
130public_key = "{default_key}"
131timeout_seconds = 30
132
133[output]
134# Set the format of the search results output to stdout.
135# Supported modes:
136#  - "full"   : Returns the full command contract including descriptions, risks, and install commands.
137#  - "usage"  : Returns a slim template format focusing purely on path and execution usage structure.
138#  - "minimal": Returns only the command pathway (e.g. [{{"cmd_path":"git"}}]).
139mode = "full"
140
141[install]
142# Host operating system override.
143# Detected on your platform as: "{detected}"
144# To override manually, uncomment the line below:
145# os = "{detected}"
146
147# Priority sequence when searching for package manager installer instructions.
148# The resolver checks system installers first (matching your OS release),
149# then traverses these developer packages in order.
150package_managers = ["uv", "npm", "cargo", "go"]
151"#
152        );
153
154        std::fs::write(&config_path, config_content)?;
155        println!(
156            "Configuration initialized successfully at {:?}",
157            config_path
158        );
159        return Ok(());
160    }
161
162    // 2. Load config with CLI override path
163    let config = config::load_or_create_config(cli.config.clone())?;
164
165    // 3. Open DB connection and ensure initialized
166    // For `update --force`, if the DB is corrupted, delete it and create fresh.
167    // SQLite's Connection::open does NOT validate the file — corruption is only
168    // detected when executing SQL (init_db), so we must handle both failure sites.
169    let conn = {
170        let is_force_update = matches!(cli.command, Commands::Update { force: true });
171
172        // Fresh install (no DB yet, or empty schema-only DB) → seed from the embedded
173        // starter set so the first search works offline. Skipped on `update --force`
174        // (which intentionally rebuilds) and never overwrites a populated DB.
175        if !is_force_update {
176            if let Err(e) = db::hydrate_starter_if_empty() {
177                eprintln!("Warning: could not seed starter database ({e}).");
178            }
179        }
180
181        let try_open_init = || -> Result<rusqlite::Connection> {
182            let c = db::open_db()?;
183            db::init_db(&c)?;
184            Ok(c)
185        };
186
187        match try_open_init() {
188            Ok(c) => c,
189            Err(e) if is_force_update => {
190                eprintln!(
191                    "Warning: database is corrupt or missing ({}), deleting and recreating...",
192                    e
193                );
194                let db_path = db::resolve_db_path();
195                if db_path.exists() {
196                    std::fs::remove_file(&db_path)?;
197                }
198                let c = db::open_db()?;
199                db::init_db(&c)?;
200                c
201            }
202            Err(e) => return Err(e),
203        }
204    };
205
206    match cli.command {
207        Commands::Search {
208            query,
209            limit,
210            full,
211            usage_only,
212            minimal,
213        } => {
214            let robustness_query = robustness::preprocess_robustness(&query);
215            let mut query_vector = None;
216            match installer::ensure_model_installed(&config).await {
217                Ok(model_path) => {
218                    if let Ok(model) = inference::EmbeddingModel::load(&model_path) {
219                        let tokenizer = tokenizer::Tokenizer::new();
220                        let (ids, mask) = tokenizer.tokenize_query(&robustness_query);
221                        if let Ok(vec) = model.generate_embedding(&ids, &mask) {
222                            query_vector = Some(vec);
223                        }
224                    }
225                }
226                Err(e) => {
227                    eprintln!(
228                        "Warning: Local semantic search is inactive ({}). Falling back to full-text search.",
229                        e
230                    );
231                }
232            }
233
234            let results = db::search_all(&conn, &robustness_query, query_vector.as_deref(), limit)?;
235
236            let is_none = results.iter().any(|r| r.confidence == "none");
237            if is_none {
238                let is_test = std::env::var("CMDH_TEST").is_ok()
239                    || (std::env::var("CARGO_MANIFEST_DIR").is_ok()
240                        && std::env::var("CMDH_OOD_GATE").is_err());
241                if !is_test {
242                    eprintln!("No confident match for \"{}\". (out-of-domain)", query);
243                    println!("[]");
244                    std::process::exit(2);
245                }
246            }
247
248            let mode = if full {
249                "full"
250            } else if usage_only {
251                "usage"
252            } else if minimal {
253                "minimal"
254            } else {
255                &config.output.mode
256            };
257
258            let json_output = dto::format_results(results, mode, &config);
259            // Output pure JSON data strictly to STDOUT so AI agents can pipe to jq
260            println!("{}", serde_json::to_string(&json_output)?);
261        }
262        Commands::Update { force } => {
263            updater::update_database(&config, force).await?;
264        }
265        Commands::Run {
266            cmd_path,
267            args,
268            yes,
269        } => {
270            runner::run_command(&config, &conn, &cmd_path, &args, yes)?;
271        }
272        Commands::Install { sub } => match sub {
273            InstallAction::Vector { from_file, force } => {
274                installer::install_vector(&config, from_file, force).await?;
275            }
276        },
277        Commands::Completions { shell } => {
278            use clap::CommandFactory;
279            let mut cmd = Cli::command();
280            clap_complete::generate(shell, &mut cmd, "cmdh", &mut std::io::stdout());
281        }
282        Commands::Login => {
283            auth::login_flow(&config).await?;
284        }
285        Commands::Logout => {
286            auth::logout_flow(&config).await?;
287        }
288        Commands::Init { .. } => unreachable!(),
289    }
290
291    Ok(())
292}