diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec502e9..a68402f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive @@ -66,7 +66,7 @@ jobs: shell: bash run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" - name: Cache dist - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: cargo-dist-cache path: ~/.cargo/bin/dist @@ -82,7 +82,7 @@ jobs: cat plan-dist-manifest.json echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: artifacts-plan-dist-manifest path: plan-dist-manifest.json @@ -116,7 +116,7 @@ jobs: - name: enable windows longpaths run: | git config --global core.longpaths true - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive @@ -131,7 +131,7 @@ jobs: run: ${{ matrix.install_dist.run }} # Get the dist-manifest - name: Fetch local artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: pattern: artifacts-* path: target/distrib/ @@ -158,7 +158,7 @@ jobs: cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: artifacts-build-local-${{ join(matrix.targets, '_') }} path: | @@ -175,19 +175,19 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive - name: Install cached dist - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Get all the local artifacts for the global tasks to use (for e.g. checksums) - name: Fetch local artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: pattern: artifacts-* path: target/distrib/ @@ -205,7 +205,7 @@ jobs: cp dist-manifest.json "$BUILD_MANIFEST_NAME" - name: "Upload artifacts" - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: name: artifacts-build-global path: | @@ -225,19 +225,19 @@ jobs: outputs: val: ${{ steps.host.outputs.manifest }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive - name: Install cached dist - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: cargo-dist-cache path: ~/.cargo/bin/ - run: chmod +x ~/.cargo/bin/dist # Fetch artifacts from scratch-storage - name: Fetch artifacts - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: pattern: artifacts-* path: target/distrib/ @@ -250,14 +250,14 @@ jobs: cat dist-manifest.json echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f with: # Overwrite the previous copy name: artifacts-dist-manifest path: dist-manifest.json # Create a GitHub Release while uploading all files to it - name: "Download GitHub Artifacts" - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: pattern: artifacts-* path: artifacts @@ -290,14 +290,14 @@ jobs: GITHUB_EMAIL: "admin+bot@axo.dev" if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: true repository: "64bit/homebrew-tap" token: ${{ secrets.HOMEBREW_TAP_TOKEN }} # So we have access to the formula - name: Fetch homebrew formulae - uses: actions/download-artifact@v7 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: pattern: artifacts-* path: Formula/ @@ -337,7 +337,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: persist-credentials: false submodules: recursive diff --git a/README.md b/README.md index 55674e0..ff2e301 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,18 @@ **commandOK** is a Spotlight-like command generator for your terminal. Pops up when you need it and gets out of the way when you don't. -Built with [Ratatui](https://ratatui.rs) and powered by your choice of LLM provider. +Built with [Ratatui](https://ratatui.rs) and powered by your choice of public, private or local LLM provider. **WARN**: you must always verify the generated command before accepting it ## Install +```bash +brew install 64bit/tap/commandok +``` + +OR + ```bash cargo install commandok ``` diff --git a/dist-workspace.toml b/dist-workspace.toml index b5005ae..0211087 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -15,3 +15,11 @@ tap = "64bit/homebrew-tap" targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] # Publish jobs to run in CI publish-jobs = ["homebrew"] + + +# https://axodotdev.github.io/cargo-dist/book/ci/customizing.html#pinned-actions-commits +[dist.github-action-commits] +"actions/checkout" = "de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6.0.2 +"actions/upload-artifact" = "bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" # v7.0.0 +"actions/download-artifact" = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" # v8.0.1 +"actions/attest-build-provenance" = "00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8" # v3.1.0 diff --git a/src/config.rs b/src/config.rs index d264990..740a3b6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,7 @@ pub struct Config { pub ollama: Option, pub openrouter: Option, pub xai: Option, + pub litert_lm: Option, } #[derive(Deserialize)] @@ -29,6 +30,8 @@ pub struct ProviderConfig { pub model: String, #[serde(default)] pub api_url: String, + #[serde(default)] + pub huggingface_repo: String, } fn config_dir() -> PathBuf { @@ -41,8 +44,14 @@ fn config_path() -> PathBuf { } const DEFAULT_CONFIG: &str = r#"[commandok] -provider = "anthropic" # Options: anthropic, openai, google, mistral, ollama, openrouter, xai -system_prompt = "You are a terminal command generator. Given a natural language description, output ONLY the shell command appropriate for the user's OS and shell. No explanation, no markdown, no code blocks, no backticks. Just the raw command." +# Options: anthropic, openai, google, mistral, ollama, +# openrouter, xai, litert_lm +provider = "anthropic" +system_prompt = """\ +You are a terminal command generator. Given a natural language description, output ONLY \ +the shell command appropriate for the user's OS and shell. No explanation, no markdown, no code blocks, \ +no backticks. Just the raw command.\ +""" [anthropic] api_key = "" @@ -74,6 +83,10 @@ model = "qwen/qwen3.6-plus:free" api_key = "" model = "grok-4.20-0309-reasoning" # api_url = "https://api.x.ai/v1" # default + +[litert_lm] +model = "gemma-4-E2B-it.litertlm" +huggingface_repo = "litert-community/gemma-4-E2B-it-litert-lm" "#; pub fn load() -> Result { @@ -85,6 +98,12 @@ pub fn load() -> Result { fs::File::create(&path) .and_then(|mut f| f.write_all(DEFAULT_CONFIG.as_bytes())) .map_err(|e| format!("Failed to write {}: {e}", path.display()))?; + } else if let Some(migrated) = migrate_config( + &fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {e}", path.display()))?, + ) { + fs::write(&path, &migrated) + .map_err(|e| format!("Failed to update {}: {e}", path.display()))?; } let content = @@ -96,6 +115,33 @@ pub fn load() -> Result { Ok(config) } +/// Compares the user's existing config against DEFAULT_CONFIG and returns an +/// updated string with any missing provider sections appended at the end. +/// Returns `None` when nothing is missing (no write needed). +fn migrate_config(existing: &str) -> Option { + let existing_table: toml::Table = toml::from_str(existing).ok()?; + let default_table: toml::Table = toml::from_str(DEFAULT_CONFIG).ok()?; + + let mut missing = toml::Table::new(); + for (key, value) in &default_table { + if !existing_table.contains_key(key) { + missing.insert(key.clone(), value.clone()); + } + } + + if missing.is_empty() { + return None; + } + + let mut result = existing.to_string(); + if !result.ends_with('\n') { + result.push('\n'); + } + result.push('\n'); + result.push_str(&toml::to_string_pretty(&missing).ok()?); + Some(result) +} + const PROVIDER_ORDER: &[&str] = &[ "anthropic", "openai", @@ -104,6 +150,7 @@ const PROVIDER_ORDER: &[&str] = &[ "ollama", "openrouter", "xai", + "litert_lm", ]; impl Config { @@ -116,6 +163,7 @@ impl Config { "ollama" => self.ollama.as_ref(), "openrouter" => self.openrouter.as_ref(), "xai" => self.xai.as_ref(), + "litert_lm" => self.litert_lm.as_ref(), _ => None, } } diff --git a/src/provider/litert_lm.rs b/src/provider/litert_lm.rs new file mode 100644 index 0000000..74fff29 --- /dev/null +++ b/src/provider/litert_lm.rs @@ -0,0 +1,114 @@ +use crate::config::ProviderConfig; +use super::ApiEvent; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio::sync::mpsc; + +const PROFILE_FILE: &str = if cfg!(windows) { "nul" } else { "/dev/null" }; + +pub async fn stream( + cfg: &ProviderConfig, + query: &str, + system_prompt: &str, + tx: mpsc::UnboundedSender, +) { + let model_id = &cfg.model; + let repo = &cfg.huggingface_repo; + + if repo.is_empty() || model_id.is_empty() { + let _ = tx.send(ApiEvent::Error( + "litert_lm requires both 'huggingface_repo' and 'model' in config".into(), + )); + return; + } + + // Use `litert-lm list` to check CLI availability and whether the model is already imported. + let list = Command::new("litert-lm") + .arg("list") + .env("LLVM_PROFILE_FILE", PROFILE_FILE) + .output() + .await; + + let needs_import = match list { + Ok(o) if o.status.success() => { + let stdout = String::from_utf8_lossy(&o.stdout); + !stdout.lines().any(|line| line.contains(model_id.as_str())) + } + Ok(_) => true, + Err(_) => { + let _ = tx.send(ApiEvent::Error( + "litert-lm CLI not found. Install: https://ai.google.dev/edge/litert-lm/cli" + .into(), + )); + return; + } + }; + + if needs_import { + let import = Command::new("litert-lm") + .args(["import", "--from-huggingface-repo", repo, model_id]) + .env("LLVM_PROFILE_FILE", PROFILE_FILE) + .output() + .await; + + match import { + Ok(o) if o.status.success() => {} + Ok(o) => { + let msg = String::from_utf8_lossy(&o.stderr); + let _ = tx.send(ApiEvent::Error(format!("litert-lm import failed: {msg}"))); + return; + } + Err(e) => { + let _ = tx.send(ApiEvent::Error(format!("litert-lm import error: {e}"))); + return; + } + } + } + + let prompt = format!("{system_prompt}\n\n{query}"); + + let child = Command::new("litert-lm") + .arg("run") + .arg(model_id) + .arg(format!("--prompt={prompt}")) + .env("LLVM_PROFILE_FILE", PROFILE_FILE) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + + let mut child = match child { + Ok(c) => c, + Err(e) => { + let _ = tx.send(ApiEvent::Error(format!("Failed to run litert-lm: {e}"))); + return; + } + }; + + if let Some(stdout) = child.stdout.take() { + let mut lines = BufReader::new(stdout).lines(); + let mut first = true; + while let Ok(Some(line)) = lines.next_line().await { + if first { + first = false; + } else if tx.send(ApiEvent::Delta("\n".into())).is_err() { + return; + } + if tx.send(ApiEvent::Delta(line)).is_err() { + return; + } + } + } + + match child.wait().await { + Ok(s) if s.success() => { + let _ = tx.send(ApiEvent::Done); + } + Ok(_) => { + let _ = tx.send(ApiEvent::Error("litert-lm exited with an error".into())); + } + Err(e) => { + let _ = tx.send(ApiEvent::Error(format!("litert-lm error: {e}"))); + } + } +} diff --git a/src/provider/mod.rs b/src/provider/mod.rs index 2fc2bcd..577ef9d 100644 --- a/src/provider/mod.rs +++ b/src/provider/mod.rs @@ -1,5 +1,6 @@ pub mod claude; pub mod gemini; +pub mod litert_lm; pub mod mistral; pub mod ollama; pub mod openai; @@ -24,6 +25,7 @@ pub enum Provider { Ollama(ProviderConfig), OpenRouter(ProviderConfig), Xai(ProviderConfig), + LitertLm(ProviderConfig), } impl Provider { @@ -36,6 +38,7 @@ impl Provider { "ollama" => Provider::Ollama(cfg.clone()), "openrouter" => Provider::OpenRouter(cfg.clone()), "xai" => Provider::Xai(cfg.clone()), + "litert_lm" => Provider::LitertLm(cfg.clone()), _ => unreachable!("validated in config"), } } @@ -54,6 +57,7 @@ impl Provider { Provider::Ollama(cfg) => ollama::stream(cfg, query, system_prompt, tx).await, Provider::OpenRouter(cfg) => openrouter::stream(cfg, query, system_prompt, tx).await, Provider::Xai(cfg) => xai::stream(cfg, query, system_prompt, tx).await, + Provider::LitertLm(cfg) => litert_lm::stream(cfg, query, system_prompt, tx).await, } } }