From 1c7dca5259595e0e785762aef514b2020fad4260 Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Mon, 6 Apr 2026 15:25:54 -0700 Subject: [PATCH 1/8] add support litert-lm --- src/config.rs | 11 ++++- src/provider/litert_lm.rs | 98 +++++++++++++++++++++++++++++++++++++++ src/provider/mod.rs | 4 ++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/provider/litert_lm.rs diff --git a/src/config.rs b/src/config.rs index d264990..e67b8d6 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,7 +44,7 @@ fn config_path() -> PathBuf { } const DEFAULT_CONFIG: &str = r#"[commandok] -provider = "anthropic" # Options: anthropic, openai, google, mistral, ollama, openrouter, xai +provider = "anthropic" # Options: anthropic, openai, google, mistral, ollama, openrouter, xai, litert_lm 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] @@ -74,6 +77,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 { @@ -104,6 +111,7 @@ const PROVIDER_ORDER: &[&str] = &[ "ollama", "openrouter", "xai", + "litert_lm", ]; impl Config { @@ -116,6 +124,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..00150ed --- /dev/null +++ b/src/provider/litert_lm.rs @@ -0,0 +1,98 @@ +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, +) { + if Command::new("litert-lm").arg("--version").output().await.is_err() { + let _ = tx.send(ApiEvent::Error( + "litert-lm CLI not found. Install: https://ai.google.dev/edge/litert-lm/cli".into(), + )); + return; + } + + 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; + } + + // Download / import the model (idempotent if already present). + 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, } } } From fe84e748ca48de005bba4dd2d21e412ab7e33482 Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Mon, 6 Apr 2026 15:36:06 -0700 Subject: [PATCH 2/8] migrate config file - to add new providers --- src/config.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/config.rs b/src/config.rs index e67b8d6..11d9f20 100644 --- a/src/config.rs +++ b/src/config.rs @@ -92,6 +92,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 = @@ -103,6 +109,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", From 4f3e84ba85c84bceae65c4a54add87078f727982 Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Mon, 6 Apr 2026 15:40:37 -0700 Subject: [PATCH 3/8] fix LLVM_PROFILE_FILE --- src/provider/litert_lm.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/provider/litert_lm.rs b/src/provider/litert_lm.rs index 00150ed..846a4dd 100644 --- a/src/provider/litert_lm.rs +++ b/src/provider/litert_lm.rs @@ -13,7 +13,13 @@ pub async fn stream( system_prompt: &str, tx: mpsc::UnboundedSender, ) { - if Command::new("litert-lm").arg("--version").output().await.is_err() { + if Command::new("litert-lm") + .arg("--version") + .env("LLVM_PROFILE_FILE", PROFILE_FILE) + .output() + .await + .is_err() + { let _ = tx.send(ApiEvent::Error( "litert-lm CLI not found. Install: https://ai.google.dev/edge/litert-lm/cli".into(), )); From 1074f9e3403179381c87f03c2b9eed08cb06a1ac Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Mon, 6 Apr 2026 15:45:16 -0700 Subject: [PATCH 4/8] optimize import when model is not present --- src/provider/litert_lm.rs | 58 +++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/src/provider/litert_lm.rs b/src/provider/litert_lm.rs index 846a4dd..74fff29 100644 --- a/src/provider/litert_lm.rs +++ b/src/provider/litert_lm.rs @@ -13,19 +13,6 @@ pub async fn stream( system_prompt: &str, tx: mpsc::UnboundedSender, ) { - if Command::new("litert-lm") - .arg("--version") - .env("LLVM_PROFILE_FILE", PROFILE_FILE) - .output() - .await - .is_err() - { - let _ = tx.send(ApiEvent::Error( - "litert-lm CLI not found. Install: https://ai.google.dev/edge/litert-lm/cli".into(), - )); - return; - } - let model_id = &cfg.model; let repo = &cfg.huggingface_repo; @@ -36,24 +23,47 @@ pub async fn stream( return; } - // Download / import the model (idempotent if already present). - let import = Command::new("litert-lm") - .args(["import", "--from-huggingface-repo", repo, model_id]) + // 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; - 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; + 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())) } - Err(e) => { - let _ = tx.send(ApiEvent::Error(format!("litert-lm import error: {e}"))); + 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}"); From f42dbffbbdf81f535c0e5044754b1f7ca4ba5355 Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Mon, 6 Apr 2026 15:49:54 -0700 Subject: [PATCH 5/8] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 55674e0..538e1c8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **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 From aca942120e05281f598cd36164feec2ff8ed900e Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Mon, 6 Apr 2026 15:52:21 -0700 Subject: [PATCH 6/8] pin actions commit --- .github/workflows/release.yml | 36 +++++++++++++++++------------------ dist-workspace.toml | 8 ++++++++ 2 files changed, 26 insertions(+), 18 deletions(-) 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/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 From c91e17fbd29adca3e108f838ee70f940f7b6ec78 Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Mon, 6 Apr 2026 16:01:01 -0700 Subject: [PATCH 7/8] cleanup config --- src/config.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index 11d9f20..740a3b6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,8 +44,14 @@ fn config_path() -> PathBuf { } const DEFAULT_CONFIG: &str = r#"[commandok] -provider = "anthropic" # Options: anthropic, openai, google, mistral, ollama, openrouter, xai, litert_lm -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 = "" From 34835eb67abbb7754ddb22fb18731f8647a1196b Mon Sep 17 00:00:00 2001 From: Himanshu Neema Date: Mon, 6 Apr 2026 16:03:35 -0700 Subject: [PATCH 8/8] brew install 64bit/tap/commandok --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 538e1c8..ff2e301 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ Built with [Ratatui](https://ratatui.rs) and powered by your choice of public, p ## Install +```bash +brew install 64bit/tap/commandok +``` + +OR + ```bash cargo install commandok ```