Skip to main content

cmdhub_cli/
lib.rs

1use anyhow::Result;
2use clap::{Parser, Subcommand};
3
4pub mod config;
5pub mod db;
6pub mod dto;
7pub mod inference;
8pub mod installer;
9pub mod os_detector;
10pub mod runner;
11pub mod tokenizer;
12pub mod updater;
13
14#[derive(Parser, Debug)]
15#[command(
16    name = "cmdh",
17    about = "cmdh — the CmdHub CLI client for offline command search and execution",
18    version
19)]
20pub struct Cli {
21    /// Custom configuration file path
22    #[arg(short, long, global = true, help = "Custom configuration file path")]
23    pub config: Option<std::path::PathBuf>,
24
25    #[command(subcommand)]
26    pub command: Commands,
27}
28
29#[derive(Subcommand, Debug, Clone)]
30pub enum Commands {
31    /// Search offline ACI commands via hybrid FTS5 & Vector search
32    Search {
33        /// The query string to search for
34        query: String,
35        /// Maximum number of search results to return
36        #[arg(long, default_value_t = 1)]
37        limit: usize,
38        /// Force full preset output format
39        #[arg(short, long, group = "output_format")]
40        full: bool,
41        /// Force usage preset output format
42        #[arg(short, long, group = "output_format")]
43        usage_only: bool,
44        /// Force minimal preset output format
45        #[arg(short, long, group = "output_format")]
46        minimal: bool,
47    },
48    /// Sync the offline SQLite database from CDN
49    Update {
50        /// Force database download and sync
51        #[arg(long)]
52        force: bool,
53    },
54    /// Safety-wrapped execution sandbox for Agents
55    Run {
56        /// Materizalized command path to execute (e.g. "tar.extract")
57        cmd_path: String,
58        /// Arguments passed directly to the underlying CLI tool
59        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
60        args: Vec<String>,
61        /// Bypass human interactive gating check for dangerous commands
62        #[arg(short, long)]
63        yes: bool,
64    },
65    /// Install assets or models
66    Install {
67        #[command(subcommand)]
68        sub: InstallAction,
69    },
70    /// Initialize a new config.toml file with default systems properties
71    Init {
72        /// Overwrite config file if it already exists
73        #[arg(long)]
74        force: bool,
75    },
76}
77
78#[derive(Subcommand, Debug, Clone)]
79pub enum InstallAction {
80    /// Install BGE vector model for local semantic search
81    Vector {
82        /// Install from local file instead of downloading
83        #[arg(long, value_name = "FILE")]
84        from_file: Option<std::path::PathBuf>,
85        /// Force install/reinstall even if SHA-256 matches
86        #[arg(long)]
87        force: bool,
88    },
89}
90
91pub async fn run() -> Result<()> {
92    // 1. Parse command line arguments
93    let cli = Cli::parse();
94
95    // Run setup initialization before loading configuration or opening DB
96    if let Commands::Init { force } = cli.command {
97        let config_dir = config::get_config_dir();
98        let config_path = config_dir.join("config.toml");
99
100        if config_path.exists() && !force {
101            eprintln!(
102                "Warning: Configuration file already exists at {:?}",
103                config_path
104            );
105            return Ok(());
106        }
107
108        std::fs::create_dir_all(&config_dir)?;
109
110        let detected = os_detector::detect_os().unwrap_or_else(|| "unknown".to_string());
111        let config_content = format!(
112            r#"# CmdHub configuration file
113api_url = "https://api.cmdhub.xyz"
114
115[output]
116# Set the format of the search results output to stdout.
117# Supported modes:
118#  - "full"   : Returns the full command contract including descriptions, risks, and install commands.
119#  - "usage"  : Returns a slim template format focusing purely on path and execution usage structure.
120#  - "minimal": Returns only the command pathway (e.g. [{{"cmd_path":"git"}}]).
121mode = "full"
122
123[install]
124# Host operating system override.
125# Detected on your platform as: "{detected}"
126# To override manually, uncomment the line below:
127# os = "{detected}"
128
129# Priority sequence when searching for package manager installer instructions.
130# The resolver checks system installers first (matching your OS release),
131# then traverses these developer packages in order.
132package_managers = ["uv", "npm", "cargo", "go"]
133"#
134        );
135
136        std::fs::write(&config_path, config_content)?;
137        println!(
138            "Configuration initialized successfully at {:?}",
139            config_path
140        );
141        return Ok(());
142    }
143
144    // 2. Load config with CLI override path
145    let config = config::load_or_create_config(cli.config.clone())?;
146
147    // 3. Open DB connection and ensure initialized
148    let conn = db::open_db()?;
149    db::init_db(&conn)?;
150
151    match cli.command {
152        Commands::Search {
153            query,
154            limit,
155            full,
156            usage_only,
157            minimal,
158        } => {
159            let default_path = config::get_data_dir().join("models/bge-micro-v2.onnx");
160            let model_path = config
161                .vector
162                .model_path
163                .as_ref()
164                .map(std::path::PathBuf::from)
165                .unwrap_or(default_path);
166
167            let mut query_vector = None;
168            if model_path.exists() {
169                if let Ok(model) = inference::EmbeddingModel::load(&model_path) {
170                    let tokenizer = tokenizer::Tokenizer::new();
171                    let (ids, mask) = tokenizer.tokenize_query(&query);
172                    if let Ok(vec) = model.generate_embedding(&ids, &mask) {
173                        query_vector = Some(vec);
174                    }
175                }
176            } else {
177                eprintln!(
178                    "Tip: Semantic search is inactive. Run 'cmdh install vector' to activate."
179                );
180            }
181
182            let results = db::search_all(&conn, &query, query_vector.as_deref(), limit)?;
183
184            let mode = if full {
185                "full"
186            } else if usage_only {
187                "usage"
188            } else if minimal {
189                "minimal"
190            } else {
191                &config.output.mode
192            };
193
194            let json_output = dto::format_results(results, mode, &config);
195            // Output pure JSON data strictly to STDOUT so AI agents can pipe to jq
196            println!("{}", serde_json::to_string(&json_output)?);
197        }
198        Commands::Update { force } => {
199            updater::update_database(&config, force).await?;
200        }
201        Commands::Run {
202            cmd_path,
203            args,
204            yes,
205        } => {
206            runner::run_command(&conn, &cmd_path, &args, yes)?;
207        }
208        Commands::Install { sub } => match sub {
209            InstallAction::Vector { from_file, force } => {
210                installer::install_vector(&config, from_file, force).await?;
211            }
212        },
213        Commands::Init { .. } => unreachable!(),
214    }
215
216    Ok(())
217}