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