فهرست منبع

Desktop: Install CLI (#6526)

Co-authored-by: Brendan Allan <[email protected]>
Daniel Polito 3 ماه پیش
والد
کامیت
8e9a0c4ad0

+ 132 - 102
install

@@ -16,16 +16,19 @@ Usage: install.sh [options]
 Options:
     -h, --help              Display this help message
     -v, --version <version> Install a specific version (e.g., 1.0.180)
+    -b, --binary <path>     Install from a local binary instead of downloading
         --no-modify-path    Don't modify shell config files (.zshrc, .bashrc, etc.)
 
 Examples:
     curl -fsSL https://opencode.ai/install | bash
     curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
+    ./install --binary /path/to/opencode
 EOF
 }
 
 requested_version=${VERSION:-}
 no_modify_path=false
+binary_path=""
 
 while [[ $# -gt 0 ]]; do
     case "$1" in
@@ -42,6 +45,15 @@ while [[ $# -gt 0 ]]; do
                 exit 1
             fi
             ;;
+        -b|--binary)
+            if [[ -n "${2:-}" ]]; then
+                binary_path="$2"
+                shift 2
+            else
+                echo -e "${RED}Error: --binary requires a path argument${NC}"
+                exit 1
+            fi
+            ;;
         --no-modify-path)
             no_modify_path=true
             shift
@@ -53,119 +65,128 @@ while [[ $# -gt 0 ]]; do
     esac
 done
 
-raw_os=$(uname -s)
-os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
-case "$raw_os" in
-  Darwin*) os="darwin" ;;
-  Linux*) os="linux" ;;
-  MINGW*|MSYS*|CYGWIN*) os="windows" ;;
-esac
-
-arch=$(uname -m)
-if [[ "$arch" == "aarch64" ]]; then
-  arch="arm64"
-fi
-if [[ "$arch" == "x86_64" ]]; then
-  arch="x64"
-fi
+INSTALL_DIR=$HOME/.opencode/bin
+mkdir -p "$INSTALL_DIR"
 
-if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
-  rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
-  if [ "$rosetta_flag" = "1" ]; then
-    arch="arm64"
-  fi
-fi
+# If --binary is provided, skip all download/detection logic
+if [ -n "$binary_path" ]; then
+    if [ ! -f "$binary_path" ]; then
+        echo -e "${RED}Error: Binary not found at ${binary_path}${NC}"
+        exit 1
+    fi
+    specific_version="local"
+else
+    raw_os=$(uname -s)
+    os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
+    case "$raw_os" in
+      Darwin*) os="darwin" ;;
+      Linux*) os="linux" ;;
+      MINGW*|MSYS*|CYGWIN*) os="windows" ;;
+    esac
 
-combo="$os-$arch"
-case "$combo" in
-  linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
-    ;;
-  *)
-    echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
-    exit 1
-    ;;
-esac
+    arch=$(uname -m)
+    if [[ "$arch" == "aarch64" ]]; then
+      arch="arm64"
+    fi
+    if [[ "$arch" == "x86_64" ]]; then
+      arch="x64"
+    fi
 
-archive_ext=".zip"
-if [ "$os" = "linux" ]; then
-  archive_ext=".tar.gz"
-fi
+    if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
+      rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
+      if [ "$rosetta_flag" = "1" ]; then
+        arch="arm64"
+      fi
+    fi
 
-is_musl=false
-if [ "$os" = "linux" ]; then
-  if [ -f /etc/alpine-release ]; then
-    is_musl=true
-  fi
+    combo="$os-$arch"
+    case "$combo" in
+      linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
+        ;;
+      *)
+        echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
+        exit 1
+        ;;
+    esac
 
-  if command -v ldd >/dev/null 2>&1; then
-    if ldd --version 2>&1 | grep -qi musl; then
-      is_musl=true
+    archive_ext=".zip"
+    if [ "$os" = "linux" ]; then
+      archive_ext=".tar.gz"
     fi
-  fi
-fi
 
-needs_baseline=false
-if [ "$arch" = "x64" ]; then
-  if [ "$os" = "linux" ]; then
-    if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
-      needs_baseline=true
-    fi
-  fi
+    is_musl=false
+    if [ "$os" = "linux" ]; then
+      if [ -f /etc/alpine-release ]; then
+        is_musl=true
+      fi
 
-  if [ "$os" = "darwin" ]; then
-    avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
-    if [ "$avx2" != "1" ]; then
-      needs_baseline=true
+      if command -v ldd >/dev/null 2>&1; then
+        if ldd --version 2>&1 | grep -qi musl; then
+          is_musl=true
+        fi
+      fi
     fi
-  fi
-fi
 
-target="$os-$arch"
-if [ "$needs_baseline" = "true" ]; then
-  target="$target-baseline"
-fi
-if [ "$is_musl" = "true" ]; then
-  target="$target-musl"
-fi
-
-filename="$APP-$target$archive_ext"
+    needs_baseline=false
+    if [ "$arch" = "x64" ]; then
+      if [ "$os" = "linux" ]; then
+        if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
+          needs_baseline=true
+        fi
+      fi
 
+      if [ "$os" = "darwin" ]; then
+        avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
+        if [ "$avx2" != "1" ]; then
+          needs_baseline=true
+        fi
+      fi
+    fi
 
-if [ "$os" = "linux" ]; then
-    if ! command -v tar >/dev/null 2>&1; then
-         echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
-         exit 1
+    target="$os-$arch"
+    if [ "$needs_baseline" = "true" ]; then
+      target="$target-baseline"
     fi
-else
-    if ! command -v unzip >/dev/null 2>&1; then
-        echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
-        exit 1
+    if [ "$is_musl" = "true" ]; then
+      target="$target-musl"
     fi
-fi
 
-INSTALL_DIR=$HOME/.opencode/bin
-mkdir -p "$INSTALL_DIR"
+    filename="$APP-$target$archive_ext"
 
-if [ -z "$requested_version" ]; then
-    url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
-    specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
 
-    if [[ $? -ne 0 || -z "$specific_version" ]]; then
-        echo -e "${RED}Failed to fetch version information${NC}"
-        exit 1
+    if [ "$os" = "linux" ]; then
+        if ! command -v tar >/dev/null 2>&1; then
+             echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
+             exit 1
+        fi
+    else
+        if ! command -v unzip >/dev/null 2>&1; then
+            echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
+            exit 1
+        fi
     fi
-else
-    # Strip leading 'v' if present
-    requested_version="${requested_version#v}"
-    url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
-    specific_version=$requested_version
-    
-    # Verify the release exists before downloading
-    http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
-    if [ "$http_status" = "404" ]; then
-        echo -e "${RED}Error: Release v${requested_version} not found${NC}"
-        echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
-        exit 1
+
+    if [ -z "$requested_version" ]; then
+        url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
+        specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
+
+        if [[ $? -ne 0 || -z "$specific_version" ]]; then
+            echo -e "${RED}Failed to fetch version information${NC}"
+            exit 1
+        fi
+    else
+        # Strip leading 'v' if present
+        requested_version="${requested_version#v}"
+        url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
+        specific_version=$requested_version
+
+        # Verify the release exists before downloading
+        http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
+        if [ "$http_status" = "404" ]; then
+            echo -e "${RED}Error: Release v${requested_version} not found${NC}"
+            echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
+            exit 1
+        fi
     fi
 fi
 
@@ -267,11 +288,11 @@ download_with_progress() {
     {
         local length=0
         local bytes=0
-        
+
         while IFS=" " read -r -a line; do
             [ "${#line[@]}" -lt 2 ] && continue
             local tag="${line[0]} ${line[1]}"
-            
+
             if [ "$tag" = "0000: content-length:" ]; then
                 length="${line[2]}"
                 length=$(echo "$length" | tr -d '\r')
@@ -296,7 +317,7 @@ download_and_install() {
     print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
     local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$"
     mkdir -p "$tmp_dir"
-    
+
     if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then
         # Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
         curl -# -L -o "$tmp_dir/$filename" "$url"
@@ -307,14 +328,24 @@ download_and_install() {
     else
         unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
     fi
-    
+
     mv "$tmp_dir/opencode" "$INSTALL_DIR"
     chmod 755 "${INSTALL_DIR}/opencode"
     rm -rf "$tmp_dir"
 }
 
-check_version
-download_and_install
+install_from_binary() {
+    print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}from: ${NC}$binary_path"
+    cp "$binary_path" "${INSTALL_DIR}/opencode"
+    chmod 755 "${INSTALL_DIR}/opencode"
+}
+
+if [ -n "$binary_path" ]; then
+    install_from_binary
+else
+    check_version
+    download_and_install
+fi
 
 
 add_to_path() {
@@ -416,4 +447,3 @@ echo -e ""
 echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
 echo -e ""
 echo -e ""
-

+ 1 - 0
packages/desktop/src-tauri/Cargo.lock

@@ -2777,6 +2777,7 @@ version = "0.0.0"
 dependencies = [
  "gtk",
  "listeners",
+ "semver",
  "serde",
  "serde_json",
  "tauri",

+ 1 - 0
packages/desktop/src-tauri/Cargo.toml

@@ -35,6 +35,7 @@ serde_json = "1"
 tokio = "1.48.0"
 listeners = "0.3"
 tauri-plugin-os = "2"
+semver = "1.0.27"
 
 [target.'cfg(target_os = "linux")'.dependencies]
 gtk = "0.18.2"

+ 116 - 0
packages/desktop/src-tauri/src/cli.rs

@@ -0,0 +1,116 @@
+const CLI_INSTALL_DIR: &str = ".opencode/bin";
+const CLI_BINARY_NAME: &str = "opencode";
+
+fn get_cli_install_path() -> Option<std::path::PathBuf> {
+    std::env::var("HOME").ok().map(|home| {
+        std::path::PathBuf::from(home)
+            .join(CLI_INSTALL_DIR)
+            .join(CLI_BINARY_NAME)
+    })
+}
+
+pub fn get_sidecar_path() -> std::path::PathBuf {
+    tauri::utils::platform::current_exe()
+        .expect("Failed to get current exe")
+        .parent()
+        .expect("Failed to get parent dir")
+        .join("opencode-cli")
+}
+
+fn is_cli_installed() -> bool {
+    get_cli_install_path()
+        .map(|path| path.exists())
+        .unwrap_or(false)
+}
+
+const INSTALL_SCRIPT: &str = include_str!("../../../../install");
+
+#[tauri::command]
+pub fn install_cli() -> Result<String, String> {
+    if cfg!(not(unix)) {
+        return Err("CLI installation is only supported on macOS & Linux".to_string());
+    }
+
+    let sidecar = get_sidecar_path();
+    if !sidecar.exists() {
+        return Err("Sidecar binary not found".to_string());
+    }
+
+    let temp_script = std::env::temp_dir().join("opencode-install.sh");
+    std::fs::write(&temp_script, INSTALL_SCRIPT)
+        .map_err(|e| format!("Failed to write install script: {}", e))?;
+
+    #[cfg(unix)]
+    {
+        use std::os::unix::fs::PermissionsExt;
+        std::fs::set_permissions(&temp_script, std::fs::Permissions::from_mode(0o755))
+            .map_err(|e| format!("Failed to set script permissions: {}", e))?;
+    }
+
+    let output = std::process::Command::new(&temp_script)
+        .arg("--binary")
+        .arg(&sidecar)
+        .output()
+        .map_err(|e| format!("Failed to run install script: {}", e))?;
+
+    let _ = std::fs::remove_file(&temp_script);
+
+    if !output.status.success() {
+        let stderr = String::from_utf8_lossy(&output.stderr);
+        return Err(format!("Install script failed: {}", stderr));
+    }
+
+    let install_path =
+        get_cli_install_path().ok_or_else(|| "Could not determine install path".to_string())?;
+
+    Ok(install_path.to_string_lossy().to_string())
+}
+
+pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
+    if cfg!(debug_assertions) {
+        println!("Skipping CLI sync for debug build");
+        return Ok(());
+    }
+
+    if !is_cli_installed() {
+        println!("No CLI installation found, skipping sync");
+        return Ok(());
+    }
+
+    let cli_path =
+        get_cli_install_path().ok_or_else(|| "Could not determine CLI install path".to_string())?;
+
+    let output = std::process::Command::new(&cli_path)
+        .arg("--version")
+        .output()
+        .map_err(|e| format!("Failed to get CLI version: {}", e))?;
+
+    if !output.status.success() {
+        return Err("Failed to get CLI version".to_string());
+    }
+
+    let cli_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
+    let cli_version = semver::Version::parse(&cli_version_str)
+        .map_err(|e| format!("Failed to parse CLI version '{}': {}", cli_version_str, e))?;
+
+    let app_version = app.package_info().version.clone();
+
+    if cli_version >= app_version {
+        println!(
+            "CLI version {} is up to date (app version: {}), skipping sync",
+            cli_version, app_version
+        );
+        return Ok(());
+    }
+
+    println!(
+        "CLI version {} is older than app version {}, syncing",
+        cli_version, app_version
+    );
+
+    install_cli()?;
+
+    println!("Synced installed CLI");
+
+    Ok(())
+}

+ 96 - 83
packages/desktop/src-tauri/src/lib.rs

@@ -1,12 +1,16 @@
+mod cli;
 mod window_customizer;
 
+use cli::{get_sidecar_path, install_cli, sync_cli};
 use std::{
     collections::VecDeque,
     net::{SocketAddr, TcpListener},
     sync::{Arc, Mutex},
     time::{Duration, Instant},
 };
-use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory};
+use tauri::{
+    path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow,
+};
 use tauri_plugin_clipboard_manager::ClipboardExt;
 use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
 use tauri_plugin_shell::process::{CommandChild, CommandEvent};
@@ -116,11 +120,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
 
     #[cfg(not(target_os = "windows"))]
     let (mut rx, child) = {
-        let sidecar_path = tauri::utils::platform::current_exe()
-            .expect("Failed to get current exe")
-            .parent()
-            .expect("Failed to get parent dir")
-            .join("opencode-cli");
+        let sidecar = get_sidecar_path();
         let shell = get_user_shell();
         app.shell()
             .command(&shell)
@@ -130,7 +130,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
             .args([
                 "-il",
                 "-c",
-                &format!("{} serve --port={}", sidecar_path.display(), port),
+                &format!("{} serve --port={}", sidecar.display(), port),
             ])
             .spawn()
             .expect("Failed to spawn opencode")
@@ -203,7 +203,8 @@ pub fn run() {
         .invoke_handler(tauri::generate_handler![
             kill_sidecar,
             copy_logs_to_clipboard,
-            get_logs
+            get_logs,
+            install_cli
         ])
         .setup(move |app| {
             let app = app.handle().clone();
@@ -211,83 +212,95 @@ pub fn run() {
             // Initialize log state
             app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
 
-            tauri::async_runtime::spawn(async move {
-                let port = get_sidecar_port();
-
-                let should_spawn_sidecar = !is_server_running(port).await;
-
-                let child = if should_spawn_sidecar {
-                    let child = spawn_sidecar(&app, port);
-
-                    let timestamp = Instant::now();
-                    loop {
-                        if timestamp.elapsed() > Duration::from_secs(7) {
-                            let res = app.dialog()
-                              .message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
-                              .title("Startup Failed")
-                              .buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
-                              .blocking_show_with_result();
-
-                            if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
-                                match copy_logs_to_clipboard(app.clone()).await {
-                                    Ok(()) => println!("Logs copied to clipboard successfully"),
-                                    Err(e) => println!("Failed to copy logs to clipboard: {}", e),
-                                }
-                            }
-
-                            app.exit(1);
-
-                            return;
-                        }
-
-                        tokio::time::sleep(Duration::from_millis(10)).await;
-
-                        if is_server_running(port).await {
-                            // give the server a little bit more time to warm up
-                            tokio::time::sleep(Duration::from_millis(10)).await;
-
-                            break;
-                        }
-                    }
+            {
+              let app = app.clone();
+              tauri::async_runtime::spawn(async move {
+                  let port = get_sidecar_port();
+
+                  let should_spawn_sidecar = !is_server_running(port).await;
+
+                  let child = if should_spawn_sidecar {
+                      let child = spawn_sidecar(&app, port);
+
+                      let timestamp = Instant::now();
+                      loop {
+                          if timestamp.elapsed() > Duration::from_secs(7) {
+                              let res = app.dialog()
+                                .message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
+                                .title("Startup Failed")
+                                .buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
+                                .blocking_show_with_result();
+
+                              if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
+                                  match copy_logs_to_clipboard(app.clone()).await {
+                                      Ok(()) => println!("Logs copied to clipboard successfully"),
+                                      Err(e) => println!("Failed to copy logs to clipboard: {}", e),
+                                  }
+                              }
+
+                              app.exit(1);
+
+                              return;
+                          }
+
+                          tokio::time::sleep(Duration::from_millis(10)).await;
+
+                          if is_server_running(port).await {
+                              // give the server a little bit more time to warm up
+                              tokio::time::sleep(Duration::from_millis(10)).await;
+
+                              break;
+                          }
+                      }
+
+                      println!("Server ready after {:?}", timestamp.elapsed());
+
+                      Some(child)
+                  } else {
+                      None
+                  };
+
+                  let primary_monitor = app.primary_monitor().ok().flatten();
+                  let size = primary_monitor
+                      .map(|m| m.size().to_logical(m.scale_factor()))
+                      .unwrap_or(LogicalSize::new(1920, 1080));
+
+                  let mut window_builder =
+                      WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
+                          .title("OpenCode")
+                          .inner_size(size.width as f64, size.height as f64)
+                          .decorations(true)
+                          .zoom_hotkeys_enabled(true)
+                          .disable_drag_drop_handler()
+                          .initialization_script(format!(
+                              r#"
+                            window.__OPENCODE__ ??= {{}};
+                            window.__OPENCODE__.updaterEnabled = {updater_enabled};
+                            window.__OPENCODE__.port = {port};
+                          "#
+                          ));
+
+                  #[cfg(target_os = "macos")]
+                  {
+                      window_builder = window_builder
+                          .title_bar_style(tauri::TitleBarStyle::Overlay)
+                          .hidden_title(true);
+                  }
+
+                  window_builder.build().expect("Failed to create window");
+
+                  app.manage(ServerState(Arc::new(Mutex::new(child))));
+              });
+            }
 
-                    println!("Server ready after {:?}", timestamp.elapsed());
-
-                    Some(child)
-                } else {
-                    None
-                };
-
-                let primary_monitor = app.primary_monitor().ok().flatten();
-                let size = primary_monitor
-                    .map(|m| m.size().to_logical(m.scale_factor()))
-                    .unwrap_or(LogicalSize::new(1920, 1080));
-
-                let mut window_builder =
-                    WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
-                        .title("OpenCode")
-                        .inner_size(size.width as f64, size.height as f64)
-                        .decorations(true)
-                        .zoom_hotkeys_enabled(true)
-                        .disable_drag_drop_handler()
-                        .initialization_script(format!(
-                            r#"
-                          window.__OPENCODE__ ??= {{}};
-                          window.__OPENCODE__.updaterEnabled = {updater_enabled};
-                          window.__OPENCODE__.port = {port};
-                        "#
-                        ));
-
-                #[cfg(target_os = "macos")]
-                {
-                    window_builder = window_builder
-                        .title_bar_style(tauri::TitleBarStyle::Overlay)
-                        .hidden_title(true);
+            {
+              let app = app.clone();
+              tauri::async_runtime::spawn(async move {
+                if let Err(e) = sync_cli(app) {
+                  eprintln!("Failed to sync CLI: {e}");
                 }
-
-                window_builder.build().expect("Failed to create window");
-
-                app.manage(ServerState(Arc::new(Mutex::new(child))));
-            });
+              });
+            }
 
             Ok(())
         });

+ 13 - 0
packages/desktop/src/cli.ts

@@ -0,0 +1,13 @@
+import { invoke } from "@tauri-apps/api/core"
+import { message } from "@tauri-apps/plugin-dialog"
+
+export async function installCli(): Promise<void> {
+  try {
+    const path = await invoke<string>("install_cli")
+    await message(`CLI installed to ${path}\n\nRestart your terminal to use the 'opencode' command.`, {
+      title: "CLI Installed",
+    })
+  } catch (e) {
+    await message(`Failed to install CLI: ${e}`, { title: "Installation Failed" })
+  }
+}

+ 5 - 0
packages/desktop/src/menu.ts

@@ -2,6 +2,7 @@ import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/men
 import { type as ostype } from "@tauri-apps/plugin-os"
 
 import { runUpdater, UPDATER_ENABLED } from "./updater"
+import { installCli } from "./cli"
 
 export async function createMenu() {
   if (ostype() !== "macos") return
@@ -19,6 +20,10 @@ export async function createMenu() {
             action: () => runUpdater({ alertOnFail: true }),
             text: "Check For Updates...",
           }),
+          await MenuItem.new({
+            action: () => installCli(),
+            text: "Install CLI...",
+          }),
           await PredefinedMenuItem.new({
             item: "Separator",
           }),