diff --git a/.changeset/calm-action-models.md b/.changeset/calm-action-models.md
new file mode 100644
index 000000000..7a4581956
--- /dev/null
+++ b/.changeset/calm-action-models.md
@@ -0,0 +1,7 @@
+---
+"helmor": patch
+---
+
+Fix model settings for action helpers:
+- Keep Opus 4.7 selections for Review and Action models after restarting Helmor.
+- Rename the PR/MR model setting to Action model and use it for create PR/MR, reopen PR/MR, and commit-and-push helpers.
diff --git a/.changeset/linux-support.md b/.changeset/linux-support.md
new file mode 100644
index 000000000..390cb63f6
--- /dev/null
+++ b/.changeset/linux-support.md
@@ -0,0 +1,10 @@
+---
+"helmor": minor
+---
+
+Add native Linux desktop support (Ubuntu 22.04+) as a community fork:
+
+- Ship Linux x86_64 `.deb` and `.AppImage` release artifacts from the fork's release pipeline.
+- Read Claude OAuth credentials from `~/.claude/.credentials.json` on Linux instead of the macOS Keychain (no libsecret/D-Bus dependency).
+- Wire "reveal in file manager" through `xdg-open`, and route clipboard actions through `wl-copy` (Wayland) / `xclip` (X11).
+- Keep macOS releases unaffected — Linux changes are gated behind `#[cfg(target_os = "linux")]` and a separate publish workflow.
diff --git a/.github/actions/setup-rust-tauri/action.yml b/.github/actions/setup-rust-tauri/action.yml
index 0174bc5a8..5567cb877 100644
--- a/.github/actions/setup-rust-tauri/action.yml
+++ b/.github/actions/setup-rust-tauri/action.yml
@@ -1,5 +1,5 @@
name: Setup Rust Tauri
-description: Setup Rust toolchain and cache for macOS Tauri builds
+description: Setup Rust toolchain and cache for Tauri builds (macOS + Linux)
runs:
using: composite
@@ -12,6 +12,14 @@ runs:
# are host-local and invalidate sccache lookups, so sccache never
# hits. Disable incremental in CI only.
echo "CARGO_INCREMENTAL=0" >> "$GITHUB_ENV"
+ # sccache object cache lives in different default dirs per OS.
+ # Pin via SCCACHE_DIR so actions/cache below can target it
+ # without an OS-conditional path expression.
+ if [ "$RUNNER_OS" = "Linux" ]; then
+ echo "SCCACHE_DIR=$HOME/.cache/sccache" >> "$GITHUB_ENV"
+ else
+ echo "SCCACHE_DIR=$HOME/Library/Caches/Mozilla.sccache" >> "$GITHUB_ENV"
+ fi
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -21,7 +29,7 @@ runs:
with:
cache-on-failure: true
cache-workspace-crates: true
- shared-key: macos-tauri-v1
+ shared-key: ${{ runner.os }}-tauri-v1
workspaces: src-tauri -> target
# Persist sccache's default object cache to complement rust-cache's
@@ -30,9 +38,9 @@ runs:
- name: Cache sccache objects
uses: actions/cache@v4
with:
- path: ~/Library/Caches/Mozilla.sccache
- key: sccache-macos-${{ hashFiles('src-tauri/Cargo.lock') }}
- restore-keys: sccache-macos-
+ path: ${{ env.SCCACHE_DIR }}
+ key: sccache-${{ runner.os }}-${{ hashFiles('src-tauri/Cargo.lock') }}
+ restore-keys: sccache-${{ runner.os }}-
- name: Install cargo-nextest
uses: taiki-e/install-action@nextest
diff --git a/.github/workflows/publish-linux.yml b/.github/workflows/publish-linux.yml
new file mode 100644
index 000000000..95734fe4b
--- /dev/null
+++ b/.github/workflows/publish-linux.yml
@@ -0,0 +1,226 @@
+name: Publish Release (Linux)
+
+# Linux-only publish for the jcadmin/helmor fork. Matrix produces:
+# - Linux x86_64 .deb + .AppImage updater artifact
+# Triggered separately from the upstream macOS publish so they don't
+# collide on the same tag namespace — Linux releases use `v*-linux*`
+# (e.g. v0.21.3-linux1), upstream uses plain `v*`.
+
+on:
+ workflow_dispatch:
+ inputs:
+ draft:
+ description: "Create the GitHub release as a draft"
+ required: false
+ default: true
+ type: boolean
+ push:
+ tags:
+ - "v*-linux*"
+
+concurrency:
+ group: publish-linux-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions:
+ contents: write
+
+env:
+ CI: true
+
+jobs:
+ preflight:
+ name: Release Preflight
+ # ubuntu-22.04 (glibc 2.35) — wider runtime compat than 24.04. Match
+ # this against the runners used in build-and-publish to keep glibc
+ # symbol versions consistent between toolchain and bundled binary.
+ runs-on: ubuntu-22.04
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ release_body: ${{ steps.notes.outputs.release_body }}
+ draft: ${{ steps.release-mode.outputs.draft }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup JS toolchain
+ uses: ./.github/actions/setup-js
+
+ - name: Verify release config
+ run: |
+ bun run release:verify
+ test -n "${HELMOR_UPDATER_PUBKEY}"
+ test -n "${HELMOR_UPDATER_ENDPOINTS}"
+ test -n "${TAURI_SIGNING_PRIVATE_KEY}"
+ env:
+ HELMOR_UPDATER_PUBKEY: ${{ secrets.HELMOR_UPDATER_PUBKEY }}
+ HELMOR_UPDATER_ENDPOINTS: ${{ secrets.HELMOR_UPDATER_ENDPOINTS }}
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+
+ - name: Read version
+ id: version
+ run: |
+ version=$(node -p "require('./package.json').version")
+ echo "version=${version}" >> "$GITHUB_OUTPUT"
+
+ - name: Validate pushed tag matches app version
+ if: github.ref_type == 'tag'
+ run: |
+ # Linux tags follow `v-linux` so strip the `-linux*`
+ # suffix before comparing against the app version in package.json.
+ stripped="${GITHUB_REF_NAME%-linux*}"
+ expected="v${{ steps.version.outputs.version }}"
+ if [ "${stripped}" != "${expected}" ]; then
+ echo "Tag ${GITHUB_REF_NAME} (stripped: ${stripped}) does not match app version ${expected}"
+ exit 1
+ fi
+
+ - name: Extract release notes
+ id: notes
+ run: |
+ {
+ echo 'release_body<> "$GITHUB_OUTPUT"
+
+ - name: Resolve release mode
+ id: release-mode
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ echo "draft=${{ inputs.draft }}" >> "$GITHUB_OUTPUT"
+ else
+ echo "draft=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ build-and-publish:
+ name: Build and Publish (${{ matrix.target-triple }})
+ needs: preflight
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - target-triple: x86_64-unknown-linux-gnu
+ tauri-args: "--target x86_64-unknown-linux-gnu --bundles deb,appimage"
+ runs-on: ubuntu-22.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Install Linux Tauri system dependencies
+ # Tauri v2 on Linux needs WebKit + GTK headers to compile and a
+ # FUSE2 runtime for the AppImage bundler (linuxdeploy). patchelf
+ # is required by the AppImage post-processor; rsvg loads the
+ # tray icon at runtime; libayatana-appindicator hosts the tray.
+ # On 22.04 the FUSE2 package is `libfuse2`; 24.04 renamed it
+ # to `libfuse2t64`. Pin to 22.04 so we can use the stable name.
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ libwebkit2gtk-4.1-dev \
+ libgtk-3-dev \
+ libsoup-3.0-dev \
+ librsvg2-dev \
+ libayatana-appindicator3-dev \
+ patchelf \
+ libfuse2
+
+ - name: Setup JS toolchain
+ uses: ./.github/actions/setup-js
+
+ - name: Setup Rust Tauri
+ uses: ./.github/actions/setup-rust-tauri
+
+ - name: Build + stage bundle binaries
+ uses: ./.github/actions/build-sidecar
+ with:
+ target-triple: ${{ matrix.target-triple }}
+
+ # TODO(linux-appimage): tauri-action will fail on `--bundles appimage`
+ # because linuxdeploy aborts on our self-contained binaries
+ # (helmor-sidecar, vendor/claude-code, vendor/codex are bun
+ # --compile; vendor/gh, vendor/glab are static Go). The .deb
+ # bundle in the same step succeeds. Before this workflow goes
+ # live, swap in scripts/bundle-appimage-linux.sh:
+ # 1. Run tauri-action with `--bundles deb` only (or this step
+ # with `|| true` so the appimage failure doesn't fail the
+ # job).
+ # 2. Add a follow-up `run:` step that invokes
+ # scripts/bundle-appimage-linux.sh on the AppDir tauri
+ # leaves behind, producing the final .AppImage.
+ # 3. Manually upload both artifacts to the GitHub release
+ # (since tauri-action would otherwise miss the .AppImage
+ # we built outside its flow).
+ # See LINUX_BUILD.md "AppImage may need a manual repackage step"
+ # for the local equivalent.
+ - name: Build and publish (${{ matrix.target-triple }})
+ id: tauri
+ uses: tauri-apps/tauri-action@v0.6
+ env:
+ TAURI_TARGET_TRIPLE: ${{ matrix.target-triple }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+ HELMOR_UPDATER_PUBKEY: ${{ secrets.HELMOR_UPDATER_PUBKEY }}
+ HELMOR_UPDATER_ENDPOINTS: ${{ secrets.HELMOR_UPDATER_ENDPOINTS }}
+ with:
+ tagName: v__VERSION__-linux1
+ releaseName: Helmor v__VERSION__ (Linux)
+ releaseBody: ${{ needs.preflight.outputs.release_body }}
+ releaseDraft: ${{ needs.preflight.outputs.draft == 'true' }}
+ prerelease: false
+ includeUpdaterJson: true
+ args: ${{ matrix.tauri-args }}
+ # Force tauri-action to use bun instead of auto-detecting (it only knows npm/yarn/pnpm)
+ tauriScript: bun run tauri
+
+ - name: Verify Linux bundles (${{ matrix.target-triple }})
+ shell: bash
+ run: |
+ bundle_root="src-tauri/target/${{ matrix.target-triple }}/release/bundle"
+
+ deb_path=$(ls "${bundle_root}"/deb/Helmor_*_amd64.deb 2>/dev/null | head -1)
+ appimage_path=$(ls "${bundle_root}"/appimage/Helmor_*_amd64.AppImage 2>/dev/null | head -1)
+
+ if [ -z "${deb_path}" ] || [ ! -f "${deb_path}" ]; then
+ echo "::error::.deb bundle missing under ${bundle_root}/deb/"
+ exit 1
+ fi
+ if [ -z "${appimage_path}" ] || [ ! -f "${appimage_path}" ]; then
+ echo "::error::.AppImage bundle missing under ${bundle_root}/appimage/"
+ exit 1
+ fi
+
+ echo ".deb:"
+ file "${deb_path}"
+ ls -lh "${deb_path}"
+ dpkg-deb --info "${deb_path}" | head -20
+
+ echo ".AppImage:"
+ file "${appimage_path}"
+ ls -lh "${appimage_path}"
+
+ notify-marketing:
+ name: Notify marketing site
+ needs: [preflight, build-and-publish]
+ if: needs.preflight.outputs.draft == 'false'
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+ steps:
+ - name: Revalidate helmor.ai
+ env:
+ SECRET: ${{ secrets.HELMOR_MARKETING_REVALIDATE_SECRET }}
+ run: |
+ if [ -z "${SECRET}" ]; then
+ echo "HELMOR_MARKETING_REVALIDATE_SECRET not set; skipping"
+ exit 0
+ fi
+ curl --fail --silent --show-error \
+ --retry 3 --retry-delay 10 --retry-connrefused \
+ -X POST "https://helmor.ai/api/revalidate" \
+ -H "x-revalidate-secret: ${SECRET}" \
+ -H "content-type: application/json"
diff --git a/LINUX_BUILD.md b/LINUX_BUILD.md
new file mode 100644
index 000000000..c8bc8dd6d
--- /dev/null
+++ b/LINUX_BUILD.md
@@ -0,0 +1,147 @@
+# Building Helmor on Linux
+
+This guide covers building and running Helmor on Linux from source. For an installer-only path, see the `.deb` / `.AppImage` artifacts on the [fork's Releases page](https://github.com/jcadmin/helmor/releases).
+
+## Supported Distributions
+
+- **Ubuntu 22.04+** / **Debian 12+** — primary target, fully tested
+- **Fedora 39+** — untested but should work with the equivalent dependencies (`webkit2gtk4.1-devel`, `gtk3-devel`, `libsoup3-devel`, `librsvg2-devel`, `libayatana-appindicator3-devel`, `patchelf`)
+
+WebKit2GTK 4.1 is required. Ubuntu 20.04 and other distros that only ship 4.0 are not supported.
+
+## System Dependencies
+
+On Debian/Ubuntu:
+
+```bash
+sudo apt update
+sudo apt install -y \
+ libwebkit2gtk-4.1-dev \
+ libgtk-3-dev \
+ libsoup-3.0-dev \
+ librsvg2-dev \
+ libayatana-appindicator3-dev \
+ patchelf \
+ build-essential \
+ curl \
+ wget \
+ file
+```
+
+## Toolchain
+
+- **Rust stable 1.95+** — install via [rustup](https://rustup.rs/)
+- **Bun 1.3+** — install via `curl -fsSL https://bun.sh/install | bash`
+- **Node 20+** — required by a few sidecar scripts
+
+If you prefer a reproducible environment, the repo ships a `flake.nix` with all of the above pinned. See [NIX_SETUP.md](./NIX_SETUP.md).
+
+## Build Steps
+
+```bash
+git clone https://github.com/jcadmin/helmor.git
+cd helmor
+
+bun install
+bun run tauri build --bundles deb,appimage --target x86_64-unknown-linux-gnu
+```
+
+Artifacts land in:
+
+- `src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/Helmor_*.deb`
+- `src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Helmor*.AppImage`
+
+For development, `bun run dev` works the same as on macOS.
+
+### AppImage may need a manual repackage step
+
+`tauri build --bundles appimage` invokes `linuxdeploy`, which by default
+walks every executable in the AppDir and runs `ldd` / `patchelf` on
+each one to deploy shared-library deps. Helmor ships several binaries
+linuxdeploy chokes on:
+
+- `helmor-sidecar`, `vendor/claude-code/claude`, `vendor/codex/codex`
+ are produced by `bun build --compile`. The runtime+bytecode embedding
+ format upsets `ldd` (exits 1 with no output) and linuxdeploy aborts
+ with `Failed to run ldd: exited with code 1`.
+- `vendor/gh/gh`, `vendor/glab/glab` are static Go binaries with no
+ `.dynamic` section, so linuxdeploy's patchelf step aborts with
+ `cannot find section .dynamic`.
+
+When this happens the `.deb` bundle from the same `tauri build`
+invocation succeeds, but `.AppImage` is never written. Repackage with
+`scripts/bundle-appimage-linux.sh`, which hides those binaries from
+linuxdeploy and squashes them back in afterwards:
+
+```bash
+# Initial build — .deb succeeds, AppImage step fails (we'll redo it).
+bun run tauri build --bundles deb,appimage --target x86_64-unknown-linux-gnu || true
+
+# Repackage the AppDir tauri left behind into a working AppImage.
+version=$(node -p "require('./package.json').version")
+appimage_dir="src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage"
+
+scripts/bundle-appimage-linux.sh \
+ "${appimage_dir}/Helmor.AppDir" \
+ "${appimage_dir}/Helmor_${version}_amd64.AppImage"
+```
+
+The `.deb` flow is unaffected — Debian packaging doesn't use linuxdeploy.
+
+## Install
+
+`.deb`:
+
+```bash
+sudo dpkg -i src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/Helmor_*.deb
+sudo apt -f install # if dpkg complains about missing runtime deps
+```
+
+`.AppImage`:
+
+```bash
+chmod +x src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Helmor*.AppImage
+./src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Helmor*.AppImage
+```
+
+## Known Differences vs macOS
+
+This fork ports Helmor to Linux but does not try to emulate every macOS-specific behavior. Notable differences:
+
+- **No macOS Keychain integration.** Claude OAuth credentials are read directly from `~/.claude/.credentials.json` (the same file the `claude` CLI writes). There is no libsecret/D-Bus fallback.
+- **No Finder "Reveal in Finder".** The equivalent action opens the containing directory in the system default file manager via `xdg-open`.
+- **No native macOS menu bar.** Linux uses GTK's default window menu; menu items wired to the macOS app menu (e.g. global Quit, Edit submenu) are not duplicated.
+- **Clipboard backend is platform-detected.** On Wayland the app shells out to `wl-copy` / `wl-paste`; on X11 it uses `xclip`. Install whichever one matches your session if clipboard actions stop working.
+
+These differences are gated in source behind `#[cfg(target_os = "linux")]` blocks so upstream macOS code stays untouched.
+
+## Troubleshooting
+
+**`could not execute process sccache (No such file or directory)`**
+
+The repo's `.cargo/config.toml` only sets `rustc-wrapper = "sccache"` when `sccache` is on `PATH`. If you see this anyway, install sccache (`cargo install sccache`) or unset the env var that's forcing it.
+
+**`Package webkit2gtk-4.1 was not found`**
+
+You're on a distro that only has 4.0. Upgrade to Ubuntu 22.04+ / Debian 12+ — Tauri v2 dropped 4.0 support upstream and we don't backport it.
+
+**AppImage refuses to start / exits silently**
+
+Run with `--appimage-extract-and-run` to bypass FUSE, or extract and inspect:
+
+```bash
+./Helmor*.AppImage --appimage-extract
+./squashfs-root/AppRun
+```
+
+Most "silent exit" cases are missing `libfuse2` (install `sudo apt install libfuse2t64` on 24.04+).
+
+**`deb` install reports missing dependencies**
+
+Run `sudo apt -f install` after `dpkg -i` to pull in transitive runtime deps. The `.deb` declares the same `libwebkit2gtk-4.1-0`, `libgtk-3-0`, etc. that the build needs.
+
+## Updating from Upstream
+
+This repository is a fork of [dohooo/helmor](https://github.com/dohooo/helmor) and rebases onto upstream `main` periodically. Linux-specific code is intentionally kept inside `#[cfg(target_os = "linux")]` blocks (or platform-suffixed files like `linux_credentials.rs`) so upstream merges produce minimal conflicts.
+
+If you're contributing a Linux fix, please keep the same convention — don't touch shared code paths unless the change benefits macOS as well.
diff --git a/README.md b/README.md
index 23412591d..c4a76959c 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,15 @@
Helmor is an open-source local workbench for multi-agent software development.
+> **🐧 Linux Fork** — This is a community fork of [dohooo/helmor](https://github.com/dohooo/helmor) maintained by [@jcadmin](https://github.com/jcadmin) specifically for Linux desktop support. Upstream macOS releases continue to be published by the original author.
+>
+> **For Linux users:**
+> - Download `.deb` / `.AppImage` from this fork's [Releases page](https://github.com/jcadmin/helmor/releases).
+> - Build from source: see [LINUX_BUILD.md](./LINUX_BUILD.md).
+> - Report Linux-specific issues to [this fork's issue tracker](https://github.com/jcadmin/helmor/issues), not upstream.
+>
+> **For macOS users:** please use the [official upstream release](https://github.com/dohooo/helmor/releases) — this fork does not maintain macOS builds.
+
diff --git a/scripts/bundle-appimage-linux.sh b/scripts/bundle-appimage-linux.sh
new file mode 100755
index 000000000..f89f6c7e0
--- /dev/null
+++ b/scripts/bundle-appimage-linux.sh
@@ -0,0 +1,179 @@
+#!/usr/bin/env bash
+# Wrap linuxdeploy + plugin-appimage to package Helmor's AppDir as a .AppImage
+# while sidestepping the bug where linuxdeploy crashes on our self-contained
+# binaries.
+#
+# Background
+# ----------
+# `tauri build --bundles appimage` invokes linuxdeploy which, by default,
+# walks every ELF executable under /usr/bin/ and /usr/lib/
+# to compute and copy in their shared-library dependencies. For most
+# binaries this is fine, but Helmor ships several that linuxdeploy chokes
+# on:
+#
+# * helmor-sidecar (Bun `bun build --compile` output)
+# * vendor/codex/codex (Bun --compile)
+# * vendor/claude-code/claude (Bun --compile)
+# The Bun-compile format embeds a JS runtime + bytecode after the
+# ELF section table, and the resulting binary upsets `ldd` (exits 1
+# with no output). linuxdeploy then aborts with a C++
+# runtime_error: "Failed to run ldd: exited with code 1".
+#
+# * vendor/gh/gh (Go static binary)
+# * vendor/glab/glab(Go static binary)
+# Static Go binaries have no .dynamic section. linuxdeploy still
+# calls patchelf on them and aborts with "cannot find section
+# .dynamic".
+#
+# All five are self-contained — they have no shared-library dependencies
+# linuxdeploy could supply anyway. The fix is to hide them from
+# linuxdeploy during dependency deployment, then put them back before
+# squashing the AppDir into the final .AppImage.
+#
+# Workflow
+# --------
+# 1. Stash the problem binaries outside the AppDir.
+# 2. Run linuxdeploy --output appimage so it can ldd the well-behaved
+# binaries (helmor, helmor-cli, …) and copy in WebKit/GTK runtime
+# libs.
+# 3. Restore the stashed binaries.
+# 4. Re-invoke linuxdeploy-plugin-appimage on the now-complete AppDir.
+# This step only mksquashfs's the directory and prepends the
+# AppImage runtime — it does not re-run dependency analysis.
+# 5. Rename the AppImage to the requested output name.
+#
+# Usage
+# -----
+# scripts/bundle-appimage-linux.sh
+#
+# Example
+# scripts/bundle-appimage-linux.sh \
+# src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Helmor.AppDir \
+# src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/Helmor_0.21.3_amd64.AppImage
+#
+# Prerequisites: `linuxdeploy-x86_64.AppImage` and
+# `linuxdeploy-plugin-appimage.AppImage` are downloaded under
+# ~/.cache/tauri/ by a prior `tauri build` (or by `tauri-action` in CI).
+#
+# Exit status: 0 on success, non-zero with a stderr message on failure.
+# A trap restores any stashed binaries even if a step fails.
+
+set -euo pipefail
+
+if [ "$#" -ne 2 ]; then
+ echo "usage: $0 " >&2
+ exit 64
+fi
+
+APPDIR="$1"
+OUTPUT="$2"
+
+if [ ! -d "$APPDIR" ]; then
+ echo "error: AppDir not found: $APPDIR" >&2
+ exit 1
+fi
+
+LINUXDEPLOY="${LINUXDEPLOY_BIN:-$HOME/.cache/tauri/linuxdeploy-x86_64.AppImage}"
+LINUXDEPLOY_PLUGIN_APPIMAGE="${LINUXDEPLOY_PLUGIN_APPIMAGE_BIN:-$HOME/.cache/tauri/linuxdeploy-plugin-appimage.AppImage}"
+
+for tool in "$LINUXDEPLOY" "$LINUXDEPLOY_PLUGIN_APPIMAGE"; do
+ if [ ! -x "$tool" ]; then
+ echo "error: missing tool: $tool" >&2
+ echo " run \`tauri build --bundles appimage\` once to populate ~/.cache/tauri/" >&2
+ exit 1
+ fi
+done
+
+# Self-contained binaries that break linuxdeploy. Paths are relative to
+# the AppDir root. If a path doesn't exist (e.g. a future build drops
+# one of these tools), we silently skip it — no need to fail the bundle
+# over a missing optional vendor binary.
+STASH_PATHS=(
+ "usr/bin/helmor-sidecar"
+ "usr/lib/Helmor/vendor/claude-code"
+ "usr/lib/Helmor/vendor/codex"
+ "usr/lib/Helmor/vendor/gh"
+ "usr/lib/Helmor/vendor/glab"
+)
+
+STASH_DIR="$(mktemp -d -t helmor-appimage-stash.XXXXXX)"
+
+restore_stash() {
+ local rc=$?
+ # Best-effort rehydrate of the AppDir on failure. Idempotent — if the
+ # success path already moved a stashed entry back, we skip it.
+ if [ -d "$STASH_DIR" ]; then
+ for rel in "${STASH_PATHS[@]}"; do
+ local stashed_name
+ stashed_name="$(printf '%s' "$rel" | tr '/' '__')"
+ local src="$STASH_DIR/$stashed_name"
+ local dst="$APPDIR/$rel"
+ if [ -e "$src" ] && [ ! -e "$dst" ]; then
+ mkdir -p "$(dirname "$dst")"
+ cp -a "$src" "$dst"
+ fi
+ done
+ rm -rf -- "$STASH_DIR"
+ fi
+ exit "$rc"
+}
+trap restore_stash EXIT
+
+# Step 1 — stash.
+for rel in "${STASH_PATHS[@]}"; do
+ src="$APPDIR/$rel"
+ if [ -e "$src" ]; then
+ stashed_name="$(printf '%s' "$rel" | tr '/' '__')"
+ mv "$src" "$STASH_DIR/$stashed_name"
+ fi
+done
+
+# Step 2 — let linuxdeploy compute and deploy real shared-lib deps for
+# the remaining (well-behaved) binaries. NO_STRIP=true keeps debug
+# symbols in helmor-cli for crash analysis.
+NO_STRIP=true \
+ "$LINUXDEPLOY" \
+ --appdir "$APPDIR" \
+ --plugin gtk \
+ --output appimage \
+ > /dev/null
+
+# Step 2 produces an .AppImage in the cwd that's missing the binaries we
+# stashed. Throw it away — we'll repackage in step 4.
+rm -f -- *.AppImage
+
+# Step 3 — restore stashed binaries before squashing the final AppImage.
+# We do this here (rather than waiting for the EXIT trap) so the binaries
+# are in place before plugin-appimage runs. The trap's `! -e dst` guard
+# makes the duplicate restore on success a no-op.
+for rel in "${STASH_PATHS[@]}"; do
+ stashed_name="$(printf '%s' "$rel" | tr '/' '__')"
+ src="$STASH_DIR/$stashed_name"
+ dst="$APPDIR/$rel"
+ if [ -e "$src" ]; then
+ mkdir -p "$(dirname "$dst")"
+ mv "$src" "$dst"
+ fi
+done
+
+# Step 4 — repackage the now-complete AppDir. plugin-appimage only runs
+# mksquashfs + prepends the runtime; it doesn't re-traverse with ldd, so
+# our self-contained binaries pass through untouched. APPIMAGE_EXTRACT_AND_RUN
+# avoids needing libfuse2 just to run the plugin itself.
+APPIMAGE_EXTRACT_AND_RUN=1 \
+ "$LINUXDEPLOY_PLUGIN_APPIMAGE" \
+ --appdir "$APPDIR" \
+ > /dev/null
+
+# Step 5 — plugin-appimage names the output `-x86_64.AppImage` in
+# the cwd. Move it to the requested output path.
+produced="$(ls *.AppImage 2>/dev/null | head -n 1 || true)"
+if [ -z "$produced" ] || [ ! -f "$produced" ]; then
+ echo "error: plugin-appimage did not produce an .AppImage in $(pwd)" >&2
+ exit 1
+fi
+
+mkdir -p "$(dirname "$OUTPUT")"
+mv "$produced" "$OUTPUT"
+
+echo "$OUTPUT"
diff --git a/sidecar/scripts/stage-vendor.ts b/sidecar/scripts/stage-vendor.ts
index d72d2b084..2a1f6a0ba 100644
--- a/sidecar/scripts/stage-vendor.ts
+++ b/sidecar/scripts/stage-vendor.ts
@@ -1,5 +1,5 @@
// Stage claude-code + codex + gh + glab into `sidecar/dist/vendor/`
-// for Tauri to ship as bundle resources. macOS host only.
+// for Tauri to ship as bundle resources. macOS + Linux hosts.
//
// Cross-arch staging: in CI the host is always Apple Silicon (macos-26
// runner), but we publish both aarch64-apple-darwin and x86_64-apple-darwin
@@ -9,8 +9,8 @@
//
// Claude Code and Codex are each shipped as a single self-contained native
// binary, pulled from the platform-specific npm sub-package
-// (@anthropic-ai/claude-code-darwin-{arm64,x64}/claude,
-// @openai/codex-darwin-{arm64,x64}/.../codex).
+// (@anthropic-ai/claude-code-{darwin,linux}-{arm64,x64}/claude,
+// @openai/codex-{darwin,linux}-{arm64,x64}/.../codex).
import { execFileSync } from "node:child_process";
import {
@@ -45,31 +45,81 @@ const GH_SHA256 = {
amd64: "8806784f93603fe6d3f95c3583a08df38f175df9ebc123dc8b15f919329980e2",
} as const;
+// Linux release tarballs for `gh` (separate table because Linux uses .tar.gz,
+// macOS uses .zip; SHAs come from the same upstream `gh_${VER}_checksums.txt`).
+// To bump: refresh from
+// https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_checksums.txt
+// Look for `gh_${VERSION}_linux_amd64.tar.gz` and `_linux_arm64.tar.gz`.
+const GH_SHA256_LINUX = {
+ arm64: "ccbed39c472d3dc1c501d1e164a9cffd934c5f6fce1012811a1a59d24cb7d7c6",
+ amd64: "304a0d2460f4a8847d2f192bad4e2a32cd9420d28716e7ae32198181b65b5f9c",
+} as const;
+
const GLAB_VERSION = "1.93.0";
const GLAB_SHA256 = {
arm64: "6d6ffa97d430b5e7ff912e64dbac14703acc57967df654be1950ae71858d5b6f",
amd64: "79d1a4f933919689c5fb7774feb1dd08f30b9c896dff4283b4a7387689ee0531",
} as const;
+// Linux glab uses the lowercase `linux` slug
+// (glab_${VER}_linux_{amd64,arm64}.tar.gz) — confirmed against the asset
+// list returned by GitLab's release API. To bump, fetch each tarball from
+// https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/glab_${GLAB_VERSION}_linux_${arch}.tar.gz
+// and run `sha256sum` on the cached file.
+const GLAB_SHA256_LINUX = {
+ arm64: "8d336072e190e540c1ecb187c17d039b3ab408cbb6f1b3c843327a77033145d0",
+ amd64: "300f3c12bd75f298747364f382f978bbe63809ef660bb2969925f343f9c20ae4",
+} as const;
+
// Codex version is whatever sidecar/package.json pulled in. The SHAs below
// must match THAT version — bump them together (or staging cross-arch will
// abort with a clear error).
-const CODEX_SHA256: Readonly> = {
+//
+// `linux` entries are placeholders until we run the first Linux build.
+// TODO(linux): compute via `shasum -a 256` (or `sha256sum`) on the cached
+// .tgz at sidecar/.bundle-cache/codex-${version}-linux-{arm64,x64}.tgz.
+const CODEX_SHA256: Readonly<
+ Record<
+ string,
+ {
+ arm64: string;
+ x64: string;
+ linux?: { arm64: string; x64: string };
+ }
+ >
+> = {
"0.130.0": {
arm64: "f6fef2ceee8977079ad3b3296b4c14c2707934e6b4ec1aa1a32d6e512196b12d",
x64: "21f161ffd79fab88c5bd91e40d14c894fe6d4ad61ea4ebc80d4fcf20130960c2",
+ linux: {
+ arm64: "3f217260abe83c6f0fd7a83e056cb453537e435c6754cd335ecd935823d8f3de",
+ x64: "91e12a56c49c702c86c5c42811cfa3d515b6d6bc196d70ba9cea25227302aa8f",
+ },
},
};
// Same versioning rule as Codex: must match whatever sidecar/package.json
// pulled in (`@anthropic-ai/claude-code`). Cross-arch staging downloads
// straight from the npm registry and verifies against this table.
+//
+// TODO(linux): replace placeholders with real digests on first Linux build.
const CLAUDE_CODE_SHA256: Readonly<
- Record
+ Record<
+ string,
+ {
+ arm64: string;
+ x64: string;
+ linux?: { arm64: string; x64: string };
+ }
+ >
> = {
"2.1.139": {
arm64: "ed9a4c64c8b5374da8389ff6aa4b58fce7a792f90ef2261a14445d9082a80799",
x64: "71d18ce1d457f37b427bdcb5933424c83bf22b39b2b7628415028585b832fe6c",
+ linux: {
+ arm64: "91d54cb3f13f884823f343206ac0de2edcce75ab8958d21460d500e40b692d3c",
+ x64: "671e12d58883de4bc6db3654884dcafc74143598da9798f23d6b77c5627b14e5",
+ },
},
};
@@ -79,19 +129,21 @@ const CLAUDE_CODE_SHA256: Readonly<
// staging where no env var is set.
// ---------------------------------------------------------------------------
-type DarwinArch = "arm64" | "x64";
+type SupportedArch = "arm64" | "x64";
+type SupportedPlatform = "darwin" | "linux";
interface TargetInfo {
- arch: DarwinArch;
- /** `@anthropic-ai/claude-code-darwin-` is the platform sub-package. */
+ platform: SupportedPlatform;
+ arch: SupportedArch;
+ /** `@anthropic-ai/claude-code--` is the platform sub-package. */
claudeCodePkg: string;
- /** claude-code npm tarball suffix: `darwin-arm64` / `darwin-x64`. */
+ /** claude-code npm tarball suffix: `darwin-arm64` / `linux-x64` / etc. */
claudeCodeNpmSuffix: string;
- /** `@openai/codex-darwin-` is the npm optional-dep package. */
+ /** `@openai/codex--` is the npm optional-dep package. */
codexPkg: string;
/** Target triple inside the codex platform package. */
codexTriple: string;
- /** Codex npm tarball suffix: `darwin-arm64` / `darwin-x64`. */
+ /** Codex npm tarball suffix: `darwin-arm64` / `linux-x64` / etc. */
codexNpmSuffix: string;
/** `gh` release naming: `arm64` / `amd64`. */
ghArch: "arm64" | "amd64";
@@ -99,35 +151,71 @@ interface TargetInfo {
glabArch: "arm64" | "amd64";
}
-function infoForArch(arch: DarwinArch): TargetInfo {
+function infoForPlatform(
+ platform: SupportedPlatform,
+ arch: SupportedArch,
+): TargetInfo {
+ if (platform === "darwin") {
+ if (arch === "arm64") {
+ return {
+ platform,
+ arch,
+ claudeCodePkg: "@anthropic-ai/claude-code-darwin-arm64",
+ claudeCodeNpmSuffix: "darwin-arm64",
+ codexPkg: "@openai/codex-darwin-arm64",
+ codexTriple: "aarch64-apple-darwin",
+ codexNpmSuffix: "darwin-arm64",
+ ghArch: "arm64",
+ glabArch: "arm64",
+ };
+ }
+ return {
+ platform,
+ arch,
+ claudeCodePkg: "@anthropic-ai/claude-code-darwin-x64",
+ claudeCodeNpmSuffix: "darwin-x64",
+ codexPkg: "@openai/codex-darwin-x64",
+ codexTriple: "x86_64-apple-darwin",
+ codexNpmSuffix: "darwin-x64",
+ ghArch: "amd64",
+ glabArch: "amd64",
+ };
+ }
+
+ // Linux. Codex publishes its Linux binaries as statically-linked musl,
+ // so the npm tarball nests under `vendor/-unknown-linux-musl/`
+ // even when host/target rust toolchain is `*-linux-gnu`.
if (arch === "arm64") {
return {
+ platform,
arch,
- claudeCodePkg: "@anthropic-ai/claude-code-darwin-arm64",
- claudeCodeNpmSuffix: "darwin-arm64",
- codexPkg: "@openai/codex-darwin-arm64",
- codexTriple: "aarch64-apple-darwin",
- codexNpmSuffix: "darwin-arm64",
+ claudeCodePkg: "@anthropic-ai/claude-code-linux-arm64",
+ claudeCodeNpmSuffix: "linux-arm64",
+ codexPkg: "@openai/codex-linux-arm64",
+ codexTriple: "aarch64-unknown-linux-musl",
+ codexNpmSuffix: "linux-arm64",
ghArch: "arm64",
glabArch: "arm64",
};
}
return {
+ platform,
arch,
- claudeCodePkg: "@anthropic-ai/claude-code-darwin-x64",
- claudeCodeNpmSuffix: "darwin-x64",
- codexPkg: "@openai/codex-darwin-x64",
- codexTriple: "x86_64-apple-darwin",
- codexNpmSuffix: "darwin-x64",
+ claudeCodePkg: "@anthropic-ai/claude-code-linux-x64",
+ claudeCodeNpmSuffix: "linux-x64",
+ codexPkg: "@openai/codex-linux-x64",
+ codexTriple: "x86_64-unknown-linux-musl",
+ codexNpmSuffix: "linux-x64",
ghArch: "amd64",
glabArch: "amd64",
};
}
function detectTarget(): TargetInfo {
- if (process.platform !== "darwin") {
+ const hostPlatform = process.platform;
+ if (hostPlatform !== "darwin" && hostPlatform !== "linux") {
throw new Error(
- `[stage-vendor] Helmor only builds on macOS; host platform is ${process.platform}`,
+ `[stage-vendor] Helmor only builds on macOS or Linux; host platform is ${hostPlatform}`,
);
}
@@ -138,17 +226,26 @@ function detectTarget(): TargetInfo {
process.env.CARGO_BUILD_TARGET?.trim();
if (triple) {
- if (triple === "aarch64-apple-darwin") return infoForArch("arm64");
- if (triple === "x86_64-apple-darwin") return infoForArch("x64");
+ if (triple === "aarch64-apple-darwin")
+ return infoForPlatform("darwin", "arm64");
+ if (triple === "x86_64-apple-darwin")
+ return infoForPlatform("darwin", "x64");
+ if (triple === "x86_64-unknown-linux-gnu")
+ return infoForPlatform("linux", "x64");
+ if (triple === "aarch64-unknown-linux-gnu")
+ return infoForPlatform("linux", "arm64");
throw new Error(
- `[stage-vendor] unsupported TAURI_TARGET_TRIPLE for macOS: ${triple}`,
+ `[stage-vendor] unsupported TAURI_TARGET_TRIPLE: ${triple}`,
);
}
const arch = process.arch;
- if (arch === "arm64") return infoForArch("arm64");
- if (arch === "x64") return infoForArch("x64");
- throw new Error(`[stage-vendor] unsupported macOS host arch: ${arch}`);
+ if (arch !== "arm64" && arch !== "x64") {
+ throw new Error(
+ `[stage-vendor] unsupported host arch on ${hostPlatform}: ${arch}`,
+ );
+ }
+ return infoForPlatform(hostPlatform, arch);
}
// ---------------------------------------------------------------------------
@@ -202,9 +299,12 @@ function ensureCacheDir(): void {
}
function sha256OfFile(path: string): string {
- const out = execFileSync("shasum", ["-a", "256", path], {
- encoding: "utf8",
- });
+ // macOS ships `shasum`; most Linux distros only ship `sha256sum`. Both
+ // emit ` ` so the parsing is identical.
+ const useSha256sum = process.platform === "linux";
+ const out = useSha256sum
+ ? execFileSync("sha256sum", [path], { encoding: "utf8" })
+ : execFileSync("shasum", ["-a", "256", path], { encoding: "utf8" });
const digest = out.split(/\s+/)[0];
if (!digest) throw new Error(`[stage-vendor] empty shasum for ${path}`);
return digest;
@@ -244,6 +344,9 @@ function freshExtractDir(path: string): void {
}
function maybeSignMacBinary(path: string, withEntitlements: boolean): void {
+ // codesign is macOS-only; on any other host this is a no-op even if the
+ // caller forgets to gate it.
+ if (process.platform !== "darwin") return;
const identity = process.env.APPLE_SIGNING_IDENTITY?.trim();
if (!identity) return;
@@ -288,8 +391,30 @@ function locateExtractedBin(extractDir: string, name: string): string {
);
}
-function stageGhBinary(arch: "arm64" | "amd64"): string {
+function stageGhBinary(target: TargetInfo): string {
ensureCacheDir();
+ const arch = target.ghArch;
+ if (target.platform === "linux") {
+ // Linux releases are tar.gz, named `gh_${VER}_linux_{amd64,arm64}.tar.gz`.
+ const slug = `gh_${GH_VERSION}_linux_${arch}`;
+ const archive = join(BUNDLE_CACHE, `${slug}.tar.gz`);
+ const url = `https://github.com/cli/cli/releases/download/v${GH_VERSION}/${slug}.tar.gz`;
+ downloadAndVerify(url, archive, GH_SHA256_LINUX[arch]);
+
+ const extractDir = join(BUNDLE_CACHE, slug);
+ freshExtractDir(extractDir);
+ execFileSync("tar", ["-xzf", archive, "-C", extractDir], {
+ stdio: "inherit",
+ });
+
+ const binSrc = locateExtractedBin(extractDir, "gh");
+ const binDest = join(DIST_VENDOR, "gh", "gh");
+ copyFile(binSrc, binDest);
+ chmodSync(binDest, 0o755);
+ return binDest;
+ }
+
+ // macOS releases are .zip with the slug `gh_${VER}_macOS_{arm64,amd64}`.
const slug = `gh_${GH_VERSION}_macOS_${arch}`;
const archive = join(BUNDLE_CACHE, `${slug}.zip`);
const url = `https://github.com/cli/cli/releases/download/v${GH_VERSION}/${slug}.zip`;
@@ -309,12 +434,19 @@ function stageGhBinary(arch: "arm64" | "amd64"): string {
return binDest;
}
-function stageGlabBinary(arch: "arm64" | "amd64"): string {
+function stageGlabBinary(target: TargetInfo): string {
ensureCacheDir();
- const slug = `glab_${GLAB_VERSION}_darwin_${arch}`;
+ const arch = target.glabArch;
+ // glab uses lowercase OS slugs (`linux`/`darwin`) — confirmed against the
+ // asset list returned by GitLab's release API. Same archive shape
+ // (tar.gz with bin/glab inside) on both.
+ const osSlug = target.platform === "linux" ? "linux" : "darwin";
+ const slug = `glab_${GLAB_VERSION}_${osSlug}_${arch}`;
const archive = join(BUNDLE_CACHE, `${slug}.tar.gz`);
const url = `https://gitlab.com/gitlab-org/cli/-/releases/v${GLAB_VERSION}/downloads/${slug}.tar.gz`;
- downloadAndVerify(url, archive, GLAB_SHA256[arch]);
+ const sha =
+ target.platform === "linux" ? GLAB_SHA256_LINUX[arch] : GLAB_SHA256[arch];
+ downloadAndVerify(url, archive, sha);
const extractDir = join(BUNDLE_CACHE, slug);
freshExtractDir(extractDir);
@@ -387,11 +519,20 @@ function stageClaudeCodeBinary(target: TargetInfo): string {
`[stage-vendor] no pinned SHA256 for claude-code ${version} — add it to CLAUDE_CODE_SHA256 in stage-vendor.ts`,
);
}
+ const expectedSha =
+ target.platform === "linux"
+ ? shaTable.linux?.[target.arch]
+ : shaTable[target.arch];
+ if (!expectedSha) {
+ throw new Error(
+ `[stage-vendor] no pinned SHA256 for claude-code ${version} on ${target.platform}/${target.arch} — add it to CLAUDE_CODE_SHA256`,
+ );
+ }
ensureCacheDir();
const slug = `claude-code-${target.claudeCodeNpmSuffix}-${version}`;
const archive = join(BUNDLE_CACHE, `${slug}.tgz`);
const url = `https://registry.npmjs.org/${target.claudeCodePkg}/-/claude-code-${target.claudeCodeNpmSuffix}-${version}.tgz`;
- downloadAndVerify(url, archive, shaTable[target.arch]);
+ downloadAndVerify(url, archive, expectedSha);
const extractDir = join(BUNDLE_CACHE, slug);
freshExtractDir(extractDir);
@@ -485,11 +626,20 @@ function stageCodexBinary(target: TargetInfo): void {
`[stage-vendor] no pinned SHA256 for codex ${version} — add it to CODEX_SHA256 in stage-vendor.ts`,
);
}
+ const expectedSha =
+ target.platform === "linux"
+ ? shaTable.linux?.[target.arch]
+ : shaTable[target.arch];
+ if (!expectedSha) {
+ throw new Error(
+ `[stage-vendor] no pinned SHA256 for codex ${version} on ${target.platform}/${target.arch} — add it to CODEX_SHA256`,
+ );
+ }
ensureCacheDir();
const slug = `codex-${version}-${target.codexNpmSuffix}`;
const archive = join(BUNDLE_CACHE, `${slug}.tgz`);
const url = `https://registry.npmjs.org/@openai/codex/-/${slug}.tgz`;
- downloadAndVerify(url, archive, shaTable[target.arch]);
+ downloadAndVerify(url, archive, expectedSha);
const extractDir = join(BUNDLE_CACHE, slug);
freshExtractDir(extractDir);
@@ -514,7 +664,7 @@ function stageCodexBinary(target: TargetInfo): void {
const target = detectTarget();
console.log(
- `[stage-vendor] host=darwin/${process.arch} target=darwin/${target.arch} (${target.codexTriple})`,
+ `[stage-vendor] host=${process.platform}/${process.arch} target=${target.platform}/${target.arch} (${target.codexTriple})`,
);
// Clean
@@ -528,8 +678,8 @@ stageClaudeCodeBinary(target);
stageCodexBinary(target);
// ----- gh + glab (forge CLIs) -----
-stageGhBinary(target.ghArch);
-stageGlabBinary(target.glabArch);
+stageGhBinary(target);
+stageGlabBinary(target);
// ----- Summary -----
console.log(`[stage-vendor] ✓ staged → ${DIST_VENDOR}`);
diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml
index 7cf37df38..e5b6124bd 100644
--- a/src-tauri/.cargo/config.toml
+++ b/src-tauri/.cargo/config.toml
@@ -1,5 +1,2 @@
-[build]
-rustc-wrapper = "sccache"
-
[target.aarch64-apple-darwin]
rustflags = ["-C", "link-arg=-Wl,-no_deduplicate"]
diff --git a/src-tauri/src/commands/editors.rs b/src-tauri/src/commands/editors.rs
index f8a6204d0..d0d8f92ea 100644
--- a/src-tauri/src/commands/editors.rs
+++ b/src-tauri/src/commands/editors.rs
@@ -30,6 +30,9 @@ pub struct EditorSpec {
pub id: &'static str,
pub name: &'static str,
/// macOS `CFBundleIdentifier`s. Multiple entries cover stable/preview/CE variants.
+ /// Only `mdfind_candidate_paths` (macOS-only) reads this in the lib build;
+ /// catalog tests read it on every platform via `#[cfg(test)]`.
+ #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub bundle_ids: &'static [&'static str],
/// Well-known install paths. `$HOME` is expanded at runtime.
pub known_paths: &'static [&'static str],
@@ -448,13 +451,26 @@ fn launch_with_open(
cmd.spawn().map(|_| ()).context("open command failed")
}
-#[cfg(not(target_os = "macos"))]
+/// Linux: hand the workspace directory to the user's default handler via
+/// `xdg-open`. Selecting a *specific* editor (the macOS `open -a` behavior)
+/// would require parsing `.desktop` files and per-DE conventions; we let the
+/// user configure their preferred handler at the OS level instead.
+#[cfg(target_os = "linux")]
+fn launch_with_open(
+ _app_path: Option<&str>,
+ _app_name: &str,
+ dir: &std::path::Path,
+) -> anyhow::Result<()> {
+ xdg_open(dir)
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn launch_with_open(
_app_path: Option<&str>,
_app_name: &str,
_dir: &std::path::Path,
) -> anyhow::Result<()> {
- anyhow::bail!("Opening third-party editors is only supported on macOS")
+ anyhow::bail!("Opening third-party editors is only supported on macOS and Linux")
}
#[cfg(target_os = "macos")]
@@ -466,9 +482,37 @@ fn reveal_in_finder(dir: &std::path::Path) -> anyhow::Result<()> {
.context("open command failed")
}
-#[cfg(not(target_os = "macos"))]
+/// Linux: `xdg-open` on the parent directory so the workspace is highlighted
+/// in the default file manager, matching the "reveal" semantics implied by
+/// the function name.
+#[cfg(target_os = "linux")]
+fn reveal_in_finder(dir: &std::path::Path) -> anyhow::Result<()> {
+ let target = dir.parent().unwrap_or(dir);
+ xdg_open(target)
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn reveal_in_finder(_dir: &std::path::Path) -> anyhow::Result<()> {
- anyhow::bail!("Opening Finder is only supported on macOS")
+ anyhow::bail!("Opening a file manager is only supported on macOS and Linux")
+}
+
+/// Spawn `xdg-open `. On Linux this is the canonical "open this in the
+/// user's default handler" entry point; we deliberately don't probe Wayland
+/// vs X11 ourselves — `xdg-open` is the abstraction designed for that.
+///
+/// Detached spawn (no wait): the file manager / editor lives well past the
+/// point where the user expects this Tauri command to return.
+#[cfg(target_os = "linux")]
+fn xdg_open(path: &std::path::Path) -> anyhow::Result<()> {
+ use std::process::Command;
+
+ match Command::new("xdg-open").arg(path).spawn() {
+ Ok(_) => Ok(()),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+ anyhow::bail!("xdg-open not found, please install xdg-utils")
+ }
+ Err(error) => Err(anyhow::Error::new(error).context("xdg-open spawn failed")),
+ }
}
#[tauri::command]
diff --git a/src-tauri/src/commands/system_commands.rs b/src-tauri/src/commands/system_commands.rs
index 8ce794d99..2b0e2bc7f 100644
--- a/src-tauri/src/commands/system_commands.rs
+++ b/src-tauri/src/commands/system_commands.rs
@@ -67,7 +67,26 @@ pub struct HelmorSkillsStatus {
pub command: String,
}
-/// Where Helmor installs its managed CLI entrypoint on macOS.
+/// Where Helmor installs its managed CLI entrypoint.
+///
+/// macOS: `/usr/local/bin/` — the canonical Homebrew-style path that
+/// `$PATH` already covers for most users.
+///
+/// Linux: `~/.local/bin/` — the XDG-recommended user-local bindir,
+/// which avoids needing `sudo` to drop a symlink in a system directory.
+/// The freedesktop.org `user-dirs` spec ensures this is on `$PATH` for
+/// modern distros; on older setups the user is told to add it.
+#[cfg(target_os = "macos")]
+fn cli_install_target() -> std::path::PathBuf {
+ std::path::PathBuf::from(format!("/usr/local/bin/{}", installed_cli_name()))
+}
+
+#[cfg(target_os = "linux")]
+fn cli_install_target() -> std::path::PathBuf {
+ home_dir().join(".local/bin").join(installed_cli_name())
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn cli_install_target() -> std::path::PathBuf {
std::path::PathBuf::from(format!("/usr/local/bin/{}", installed_cli_name()))
}
@@ -196,7 +215,19 @@ fn install_cli_symlink(
{
install_cli_symlink_elevated(bundled_cli, install_path)
}
- #[cfg(not(target_os = "macos"))]
+ #[cfg(target_os = "linux")]
+ {
+ // Linux installs to ~/.local/bin (user-writable) so the unprivileged
+ // path above should normally succeed. Hitting this branch means the
+ // user-local install dir itself is not writable — likely a permissions
+ // problem on $HOME, not something `sudo` would fix.
+ anyhow::bail!(
+ "Failed to install the CLI to {}. Check write permissions on the directory, or run:\n {}",
+ install_path.display(),
+ cli_install_remediation(bundled_cli, install_path)
+ )
+ }
+ #[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
anyhow::bail!(
"Installing the CLI requires elevated privileges. Run:\n {}",
@@ -310,6 +341,10 @@ fn build_elevated_install_script(
/// Quote a path so it survives both `do shell script "..."` (AppleScript string
/// literal) and the shell that AppleScript hands the script to.
+///
+/// Only `build_elevated_install_script` (macOS-only) calls this in the lib build;
+/// its unit tests run on every platform via `#[cfg(test)]`.
+#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn applescript_shell_arg(path: &std::path::Path) -> String {
let raw = path.display().to_string();
// 1. Single-quote for the shell, escaping embedded single quotes via `'\''`.
@@ -1067,9 +1102,27 @@ fn reveal_file_in_finder(path: &std::path::Path) -> anyhow::Result<()> {
.context("open command failed")
}
-#[cfg(not(target_os = "macos"))]
+/// Linux: file managers vary widely in their "select this exact file" RPC
+/// surface (Nautilus, Dolphin, Nemo all differ). Falling back to opening
+/// the parent directory in the default file manager keeps a single code
+/// path that works everywhere — the user can spot the file there.
+#[cfg(target_os = "linux")]
+fn reveal_file_in_finder(path: &std::path::Path) -> anyhow::Result<()> {
+ let target = path.parent().unwrap_or(path);
+ let status = std::process::Command::new("xdg-open").arg(target).status();
+ match status {
+ Ok(s) if s.success() => Ok(()),
+ Ok(s) => anyhow::bail!("xdg-open exited with status {s}"),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+ anyhow::bail!("xdg-open not found, please install xdg-utils")
+ }
+ Err(error) => Err(anyhow::Error::new(error).context("xdg-open spawn failed")),
+ }
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn reveal_file_in_finder(_path: &std::path::Path) -> anyhow::Result<()> {
- anyhow::bail!("Showing images in Finder is only supported on macOS")
+ anyhow::bail!("Showing images in a file manager is only supported on macOS and Linux")
}
#[cfg(target_os = "macos")]
@@ -1081,9 +1134,23 @@ fn open_directory_in_finder(path: &std::path::Path) -> anyhow::Result<()> {
.context("open command failed")
}
-#[cfg(not(target_os = "macos"))]
+/// Linux: hand the directory to the default file manager via `xdg-open`.
+#[cfg(target_os = "linux")]
+fn open_directory_in_finder(path: &std::path::Path) -> anyhow::Result<()> {
+ let status = std::process::Command::new("xdg-open").arg(path).status();
+ match status {
+ Ok(s) if s.success() => Ok(()),
+ Ok(s) => anyhow::bail!("xdg-open exited with status {s}"),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+ anyhow::bail!("xdg-open not found, please install xdg-utils")
+ }
+ Err(error) => Err(anyhow::Error::new(error).context("xdg-open spawn failed")),
+ }
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn open_directory_in_finder(_path: &std::path::Path) -> anyhow::Result<()> {
- anyhow::bail!("Opening Finder is only supported on macOS")
+ anyhow::bail!("Opening a file manager is only supported on macOS and Linux")
}
#[cfg(target_os = "macos")]
@@ -1117,11 +1184,100 @@ fn copy_image_file_to_clipboard(path: &std::path::Path) -> anyhow::Result<()> {
}
}
-#[cfg(not(target_os = "macos"))]
+/// Linux: pipe the image bytes into a clipboard utility. Prefer `wl-copy`
+/// under Wayland and `xclip` under X11; treat the absence of both as a
+/// hard error rather than silently dropping the copy.
+#[cfg(target_os = "linux")]
+fn copy_image_file_to_clipboard(path: &std::path::Path) -> anyhow::Result<()> {
+ use std::io::Write;
+ use std::process::{Command, Stdio};
+
+ let mime = mime_for_image_path(path);
+ let bytes = std::fs::read(path)
+ .with_context(|| format!("Failed to read image at {}", path.display()))?;
+
+ let prefer_wayland = std::env::var("XDG_SESSION_TYPE")
+ .map(|value| value.eq_ignore_ascii_case("wayland"))
+ .unwrap_or(false);
+
+ let attempts: &[(&str, &[&str])] = if prefer_wayland {
+ &[
+ ("wl-copy", &["--type", mime]),
+ ("xclip", &["-selection", "clipboard", "-t", mime, "-i"]),
+ ]
+ } else {
+ &[
+ ("xclip", &["-selection", "clipboard", "-t", mime, "-i"]),
+ ("wl-copy", &["--type", mime]),
+ ]
+ };
+
+ let mut last_spawn_error: Option = None;
+ for (program, args) in attempts {
+ let mut command = Command::new(program);
+ command.args(*args);
+ command.stdin(Stdio::piped());
+ command.stdout(Stdio::null());
+ command.stderr(Stdio::piped());
+
+ let mut child = match command.spawn() {
+ Ok(child) => child,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+ last_spawn_error = Some(error);
+ continue;
+ }
+ Err(error) => {
+ return Err(anyhow::Error::new(error).context(format!("{program} spawn failed")))
+ }
+ };
+
+ if let Some(stdin) = child.stdin.as_mut() {
+ stdin
+ .write_all(&bytes)
+ .with_context(|| format!("Failed to write image bytes to {program}"))?;
+ }
+ // Closing stdin signals EOF to wl-copy / xclip so they finish reading.
+ drop(child.stdin.take());
+
+ let output = child
+ .wait_with_output()
+ .with_context(|| format!("Failed to wait on {program}"))?;
+ if output.status.success() {
+ return Ok(());
+ }
+ anyhow::bail!(
+ "{program} exited with {}: {}",
+ output.status,
+ String::from_utf8_lossy(&output.stderr).trim()
+ );
+ }
+
+ let _ = last_spawn_error;
+ anyhow::bail!("wl-copy or xclip required to copy images on Linux")
+}
+
+#[cfg(target_os = "linux")]
+fn mime_for_image_path(path: &std::path::Path) -> &'static str {
+ match path
+ .extension()
+ .and_then(|ext| ext.to_str())
+ .map(str::to_ascii_lowercase)
+ .as_deref()
+ {
+ Some("jpg" | "jpeg") => "image/jpeg",
+ Some("gif") => "image/gif",
+ Some("webp") => "image/webp",
+ Some("bmp") => "image/bmp",
+ _ => "image/png",
+ }
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn copy_image_file_to_clipboard(_path: &std::path::Path) -> anyhow::Result<()> {
- anyhow::bail!("Copying images is only supported on macOS")
+ anyhow::bail!("Copying images is only supported on macOS and Linux")
}
+#[cfg(target_os = "macos")]
fn applescript_escape(input: &str) -> String {
input.replace('\\', "\\\\").replace('"', "\\\"")
}
diff --git a/src-tauri/src/forge/bundled.rs b/src-tauri/src/forge/bundled.rs
index 94ae8a004..159b9e578 100644
--- a/src-tauri/src/forge/bundled.rs
+++ b/src-tauri/src/forge/bundled.rs
@@ -71,20 +71,41 @@ fn resolve_from_running_exe() -> BundledForgeCliPaths {
}
fn resolve_for_exe(exe: &Path) -> Option {
- let exe_dir = exe.parent()?;
- let contents_dir = exe_dir.parent()?;
- let resources_dir = contents_dir.join("Resources");
+ // Linux dpkg / AppImage layout: `/bin/helmor` with vendored
+ // binaries under `/lib/Helmor/vendor/`. On a system .deb the
+ // prefix is `/usr`; under an AppImage AppDir it's `/usr`.
+ #[cfg(target_os = "linux")]
+ {
+ let exe_dir = exe.parent()?;
+ let prefix = exe_dir.parent()?;
+ let vendor = prefix.join("lib").join("Helmor").join("vendor");
+ let gh = vendor.join("gh/gh");
+ let glab = vendor.join("glab/glab");
+ return Some(BundledForgeCliPaths {
+ gh: gh.is_file().then_some(gh),
+ glab: glab.is_file().then_some(glab),
+ });
+ }
- let gh_name = if cfg!(windows) { "gh.exe" } else { "gh" };
- let glab_name = if cfg!(windows) { "glab.exe" } else { "glab" };
+ // macOS: `/Helmor.app/Contents/MacOS/Helmor` → `Resources/vendor/`.
+ // Windows: NSIS bundle uses the same `/Resources/vendor/` shape.
+ #[cfg_attr(target_os = "linux", allow(unreachable_code))]
+ {
+ let exe_dir = exe.parent()?;
+ let contents_dir = exe_dir.parent()?;
+ let resources_dir = contents_dir.join("Resources");
- let gh = resources_dir.join(format!("vendor/gh/{gh_name}"));
- let glab = resources_dir.join(format!("vendor/glab/{glab_name}"));
+ let gh_name = if cfg!(windows) { "gh.exe" } else { "gh" };
+ let glab_name = if cfg!(windows) { "glab.exe" } else { "glab" };
- Some(BundledForgeCliPaths {
- gh: gh.is_file().then_some(gh),
- glab: glab.is_file().then_some(glab),
- })
+ let gh = resources_dir.join(format!("vendor/gh/{gh_name}"));
+ let glab = resources_dir.join(format!("vendor/glab/{glab_name}"));
+
+ Some(BundledForgeCliPaths {
+ gh: gh.is_file().then_some(gh),
+ glab: glab.is_file().then_some(glab),
+ })
+ }
}
#[cfg(debug_assertions)]
@@ -124,6 +145,7 @@ fn resolve_for_dev_workspace(workspace_root: &Path) -> BundledForgeCliPaths {
mod tests {
use super::*;
+ #[cfg(target_os = "macos")]
#[test]
fn resolve_finds_binaries_under_resources_vendor() {
let root = tempfile::tempdir().unwrap();
@@ -148,6 +170,27 @@ mod tests {
);
}
+ #[cfg(target_os = "linux")]
+ #[test]
+ fn linux_dpkg_layout_resolves_vendor() {
+ let root = tempfile::tempdir().unwrap();
+ let usr = root.path().join("usr");
+ let bin = usr.join("bin");
+ let vendor = usr.join("lib/Helmor/vendor");
+ std::fs::create_dir_all(&bin).unwrap();
+ std::fs::create_dir_all(vendor.join("gh")).unwrap();
+ std::fs::create_dir_all(vendor.join("glab")).unwrap();
+ let exe = bin.join("helmor");
+ std::fs::write(&exe, b"x").unwrap();
+ std::fs::write(vendor.join("gh/gh"), b"x").unwrap();
+ std::fs::write(vendor.join("glab/glab"), b"x").unwrap();
+
+ let paths = resolve_for_exe(&exe).unwrap();
+
+ assert_eq!(paths.gh.unwrap(), vendor.join("gh/gh"));
+ assert_eq!(paths.glab.unwrap(), vendor.join("glab/glab"));
+ }
+
#[test]
fn resolve_returns_none_when_binaries_missing() {
let root = tempfile::tempdir().unwrap();
@@ -173,7 +216,7 @@ mod tests {
assert_eq!(paths.glab.unwrap(), vendor.join("glab/glab"));
}
- #[cfg(debug_assertions)]
+ #[cfg(all(debug_assertions, target_os = "macos"))]
#[test]
fn app_bundle_paths_win_over_debug_vendor() {
let root = tempfile::tempdir().unwrap();
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 77a7f6d94..1a157d398 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -462,7 +462,9 @@ fn emit_quit_requested(app_handle: &tauri::AppHandle) {
}
}
+#[cfg(target_os = "macos")]
const HELMOR_QUIT_MENU_ID: &str = "helmor-quit";
+#[cfg(target_os = "macos")]
const HELMOR_CLOSE_CURRENT_SESSION_MENU_ID: &str = "helmor-close-current-session";
#[cfg(target_os = "macos")]
@@ -530,6 +532,7 @@ fn install_macos_menu(app: &tauri::AppHandle) -> tauri::Result<()> {
Ok(())
}
+#[cfg(target_os = "macos")]
fn emit_close_current_session_requested(app_handle: &tauri::AppHandle) {
if let Err(e) = app_handle.emit("helmor://close-current-session", ()) {
tracing::warn!(error = %e, "Failed to emit close-current-session event");
diff --git a/src-tauri/src/rate_limits/claude/keychain.rs b/src-tauri/src/rate_limits/claude/keychain.rs
index 95a73b558..0474c4cac 100644
--- a/src-tauri/src/rate_limits/claude/keychain.rs
+++ b/src-tauri/src/rate_limits/claude/keychain.rs
@@ -1,252 +1,26 @@
-//! Reading Claude OAuth credentials from the macOS keychain.
+//! Cross-platform Claude OAuth credentials loader.
//!
-//! Metadata probe (no UI) enumerates accounts; then `/usr/bin/security
-//! find-generic-password -w` reads the password for each. Routing the
-//! read through the system binary attaches the user's "Always Allow"
-//! grant to a signature that never changes, instead of Helmor's
-//! (which changes on every upgrade and dev rebuild).
-
-use anyhow::{anyhow, Result};
-
-use super::credentials::{now_ms, parse_credentials, sort_credentials, ClaudeOAuthCredentials};
-
-pub(super) const CLAUDE_KEYCHAIN_SERVICE: &str = "Claude Code-credentials";
-
-#[cfg(target_os = "macos")]
-const SECURITY_BINARY_PATH: &str = "/usr/bin/security";
-
-/// Per-candidate keychain CLI timeout. On the happy path the read
-/// returns in single-digit ms; this only matters when a macOS prompt
-/// is up and waiting for the user. 5 min is intentionally generous so
-/// users who get up to grab coffee mid-prompt don't come back to a
-/// killed dialog.
-#[cfg(target_os = "macos")]
-const SECURITY_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5 * 60);
-
-/// How long we wait between SIGTERM-ing the process group and
-/// escalating to SIGKILL. Long enough for `/usr/bin/security` to
-/// release its keychain handle and tear down the dialog cleanly.
-#[cfg(target_os = "macos")]
-const SECURITY_CLI_SIGTERM_GRACE: std::time::Duration = std::time::Duration::from_millis(400);
-
-/// Poll interval while waiting for `/usr/bin/security`. Tighter than
-/// `process::run_with_timeout`'s 50ms because we want to react to a
-/// timeout fast (the user might be staring at a stuck prompt) and the
-/// extra wakeups are negligible for a process that lives ms to s.
-#[cfg(target_os = "macos")]
-const SECURITY_CLI_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20);
-
-/// Pick the best credential entry available across all matching
-/// keychain items.
-pub(super) fn load_best_credentials() -> Result {
- let mut credentials = load_keychain_credentials()?;
- let now = now_ms();
- sort_credentials(&mut credentials, now);
- credentials
- .into_iter()
- .rev()
- .find(|credential| !credential.access_token.trim().is_empty())
- .ok_or_else(|| anyhow!("No Claude Code OAuth credentials found in Keychain"))
-}
-
-#[cfg(target_os = "macos")]
-fn load_keychain_credentials() -> Result> {
- let mut credentials = Vec::new();
- for account in keychain_account_candidates().into_iter().take(3) {
- let Some(data) = read_via_security_cli(CLAUDE_KEYCHAIN_SERVICE, Some(&account)) else {
- continue;
- };
- if let Some(credential) = parse_credentials(&data) {
- credentials.push(credential);
- }
- }
- Ok(credentials)
-}
-
-#[cfg(not(target_os = "macos"))]
-fn load_keychain_credentials() -> Result> {
- Ok(Vec::new())
-}
-
-fn keychain_account_candidates() -> Vec {
- // The metadata probe lists every account name actually present in
- // the keychain for our service — when it succeeds we know exactly
- // which accounts to try and don't need the env-var guesses or the
- // "Claude Code" fallback.
- let probed = keychain_accounts_without_prompt();
- if !probed.is_empty() {
- return probed;
- }
-
- let mut accounts = Vec::new();
- for key in ["USER", "LOGNAME"] {
- if let Ok(value) = std::env::var(key) {
- push_unique_account(&mut accounts, value);
- }
- }
- push_unique_account(&mut accounts, "Claude Code".to_string());
- accounts
-}
+//! Each platform has its own backend:
+//! - macOS: Keychain via `/usr/bin/security` + the Security framework
+//! (in `macos_keychain`).
+//! - Linux: the on-disk `~/.claude/.credentials.json` file Claude CLI
+//! maintains itself (in `super::linux_credentials`).
+//! - Other targets: hard error (no Claude credentials path defined).
+//!
+//! This module's only job is to expose `load_best_credentials` on every
+//! supported target and dispatch to the right backend.
#[cfg(target_os = "macos")]
-fn keychain_accounts_without_prompt() -> Vec {
- use core_foundation::base::{CFTypeRef, TCFType};
- use core_foundation::string::CFString;
- use security_framework::item::{ItemClass, ItemSearchOptions, Limit, SearchResult};
- use security_framework_sys::item::kSecAttrAccount;
-
- let results = match ItemSearchOptions::new()
- .class(ItemClass::generic_password())
- .service(CLAUDE_KEYCHAIN_SERVICE)
- .load_attributes(true)
- .skip_authenticated_items(true)
- .limit(Limit::All)
- .search()
- {
- Ok(results) => results,
- Err(error) => {
- tracing::debug!("Claude Keychain account probe failed: {error}");
- return Vec::new();
- }
- };
-
- // SAFETY: `kSecAttrAccount` is a static `CFStringRef` exported by
- // the Security framework. Casting it to `CFTypeRef` is the standard
- // way to use it as a dictionary key — the underlying object is
- // immortal so no retain/release dance is required.
- let account_key = unsafe { kSecAttrAccount as CFTypeRef };
- results
- .into_iter()
- .filter_map(|result| {
- let SearchResult::Dict(attrs) = result else {
- return None;
- };
- let account = attrs.find(account_key)?;
- // SAFETY: `attrs` returned a `CFTypeRef` we know is a
- // `CFStringRef` (account attribute). `wrap_under_get_rule`
- // takes a borrowed reference and increments the retain
- // count, balanced by `CFString`'s `Drop`.
- let account = unsafe { CFString::wrap_under_get_rule(*account as _) };
- let account = account.to_string();
- (!account.trim().is_empty()).then_some(account)
- })
- .collect()
-}
-
-#[cfg(not(target_os = "macos"))]
-fn keychain_accounts_without_prompt() -> Vec {
- Vec::new()
-}
-
-fn push_unique_account(accounts: &mut Vec, account: String) {
- let trimmed = account.trim();
- if trimmed.is_empty() || accounts.iter().any(|existing| existing == trimmed) {
- return;
- }
- accounts.push(trimmed.to_string());
-}
+mod macos_keychain;
-/// `/usr/bin/security find-generic-password -s -a -w`.
-///
-/// Returns the keychain item's password bytes on success, `None`
-/// otherwise (binary missing, non-zero exit, timeout, hung prompt).
-/// On timeout the entire process group is SIGTERM'd and then SIGKILL'd
-/// to avoid leaking a hung subprocess; macOS dismisses the keychain
-/// dialog when the requesting process dies.
#[cfg(target_os = "macos")]
-fn read_via_security_cli(service: &str, account: Option<&str>) -> Option> {
- use std::io::Read;
- use std::os::unix::process::CommandExt;
- use std::process::{Command, Stdio};
- use std::time::Instant;
-
- if !std::path::Path::new(SECURITY_BINARY_PATH).exists() {
- tracing::debug!("/usr/bin/security not present, skipping CLI read");
- return None;
- }
-
- let mut cmd = Command::new(SECURITY_BINARY_PATH);
- cmd.arg("find-generic-password").args(["-s", service]);
- if let Some(a) = account.filter(|a| !a.is_empty()) {
- cmd.args(["-a", a]);
- }
- cmd.arg("-w");
- cmd.stdout(Stdio::piped())
- .stderr(Stdio::null())
- .stdin(Stdio::null());
-
- // SAFETY: `setpgid` is async-signal-safe, which is the only family
- // of calls allowed between fork and exec.
- unsafe {
- cmd.pre_exec(|| {
- if libc::setpgid(0, 0) != 0 {
- return Err(std::io::Error::last_os_error());
- }
- Ok(())
- });
- }
+pub(super) use macos_keychain::load_best_credentials;
- let mut child = match cmd.spawn() {
- Ok(c) => c,
- Err(error) => {
- tracing::debug!("Failed to spawn /usr/bin/security: {error}");
- return None;
- }
- };
- let pgid = child.id() as libc::pid_t;
- let deadline = Instant::now() + SECURITY_CLI_TIMEOUT;
+#[cfg(target_os = "linux")]
+pub(super) use super::linux_credentials::load_best_credentials;
- loop {
- match child.try_wait() {
- Ok(Some(status)) => {
- if !status.success() {
- // Common cases: item not found, user denied, etc.
- // Logged at debug since "not found" is normal on
- // first launch before the user has run claude login.
- tracing::debug!(
- "/usr/bin/security exited with status {status:?} for service {service}"
- );
- return None;
- }
- let mut buf = Vec::new();
- child.stdout.as_mut()?.read_to_end(&mut buf).ok()?;
- while matches!(buf.last(), Some(b'\n' | b'\r')) {
- buf.pop();
- }
- if buf.is_empty() {
- return None;
- }
- return Some(buf);
- }
- Ok(None) if Instant::now() >= deadline => {
- tracing::warn!(
- "/usr/bin/security read timed out after {:?}, killing process group",
- SECURITY_CLI_TIMEOUT
- );
- // SAFETY: passing a negative pid to `kill` signals the
- // whole process group rooted at `pgid`. `pgid` is the
- // child we just `setpgid(0, 0)`'d, so the only members
- // are `/usr/bin/security` and any subprocess it forked.
- unsafe {
- libc::kill(-pgid, libc::SIGTERM);
- }
- std::thread::sleep(SECURITY_CLI_SIGTERM_GRACE);
- if matches!(child.try_wait(), Ok(None)) {
- // SAFETY: same as above. SIGKILL is the escalation
- // when the SIGTERM grace period didn't reap.
- unsafe {
- libc::kill(-pgid, libc::SIGKILL);
- }
- let _ = child.kill();
- }
- let _ = child.wait();
- return None;
- }
- Ok(None) => std::thread::sleep(SECURITY_CLI_POLL_INTERVAL),
- Err(error) => {
- tracing::debug!("/usr/bin/security try_wait failed: {error}");
- return None;
- }
- }
- }
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
+pub(super) fn load_best_credentials() -> anyhow::Result
+{
+ anyhow::bail!("Claude OAuth credentials not supported on this platform")
}
diff --git a/src-tauri/src/rate_limits/claude/linux_credentials.rs b/src-tauri/src/rate_limits/claude/linux_credentials.rs
new file mode 100644
index 000000000..f720e583e
--- /dev/null
+++ b/src-tauri/src/rate_limits/claude/linux_credentials.rs
@@ -0,0 +1,222 @@
+//! Reading Claude OAuth credentials from the on-disk file Claude CLI
+//! uses on Linux.
+//!
+//! Unlike macOS (Keychain) and Windows (DPAPI), Linux Claude CLI does
+//! not call out to libsecret / GNOME Keyring / kwallet. It writes the
+//! credentials to `~/.claude/.credentials.json` (or the directory in
+//! `$CLAUDE_CONFIG_DIR`) as a plain JSON file. We mirror the same
+//! lookup order so Helmor reads from exactly the file Claude CLI
+//! itself reads from — no IPC, no daemon, no extra dependencies.
+
+use std::fs;
+use std::path::PathBuf;
+
+use anyhow::{anyhow, bail, Context, Result};
+
+use super::credentials::{now_ms, parse_credentials, sort_credentials, ClaudeOAuthCredentials};
+
+const CREDENTIALS_FILE_NAME: &str = ".credentials.json";
+const CLAUDE_DIR_NAME: &str = ".claude";
+
+/// Pick the best credential entry available in the on-disk file.
+///
+/// Mirrors `keychain::load_best_credentials` — same return type, same
+/// "non-empty access_token, best by sort order" selection.
+pub(super) fn load_best_credentials() -> Result {
+ let mut credentials = load_file_credentials()?;
+ let now = now_ms();
+ sort_credentials(&mut credentials, now);
+ credentials
+ .into_iter()
+ .rev()
+ .find(|credential| !credential.access_token.trim().is_empty())
+ .ok_or_else(|| anyhow!("No Claude Code OAuth credentials found in credentials file"))
+}
+
+fn load_file_credentials() -> Result> {
+ let path = credentials_path()?;
+ if !path.exists() {
+ bail!("Claude credentials file not found at {}", path.display());
+ }
+ let data = fs::read(&path).with_context(|| {
+ format!(
+ "Failed to read Claude credentials file at {}",
+ path.display()
+ )
+ })?;
+ let credential = parse_credentials(&data).ok_or_else(|| {
+ anyhow!(
+ "Failed to parse Claude credentials file at {} as JSON",
+ path.display()
+ )
+ })?;
+ Ok(vec![credential])
+}
+
+fn credentials_path() -> Result {
+ let dir = claude_config_dir()?;
+ Ok(dir.join(CREDENTIALS_FILE_NAME))
+}
+
+fn claude_config_dir() -> Result {
+ if let Some(value) = std::env::var_os("CLAUDE_CONFIG_DIR") {
+ let path = PathBuf::from(value);
+ if !path.as_os_str().is_empty() {
+ return Ok(path);
+ }
+ }
+ let home = home_dir()
+ .ok_or_else(|| anyhow!("Unable to locate home directory for Claude credentials lookup"))?;
+ Ok(home.join(CLAUDE_DIR_NAME))
+}
+
+/// Resolve `$HOME` without pulling in the `dirs` crate. On Linux the
+/// `HOME` env var is the canonical answer; we never run as a service
+/// without it set, and we do not want to add a getpwuid dependency
+/// just for this single call site.
+fn home_dir() -> Option {
+ std::env::var_os("HOME")
+ .map(PathBuf::from)
+ .filter(|path| !path.as_os_str().is_empty())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+ use std::sync::Mutex;
+
+ /// Env vars are process-global; the tests in this module mutate
+ /// `HOME` and `CLAUDE_CONFIG_DIR`. Serialize them so cargo's
+ /// per-binary parallelism doesn't corrupt each other's state.
+ static ENV_LOCK: Mutex<()> = Mutex::new(());
+
+ struct EnvGuard {
+ key: &'static str,
+ previous: Option,
+ }
+
+ impl EnvGuard {
+ fn set(key: &'static str, value: &std::path::Path) -> Self {
+ let previous = std::env::var_os(key);
+ // SAFETY: protected by ENV_LOCK; no other thread in this
+ // test binary touches env while the guard is alive.
+ unsafe { std::env::set_var(key, value) };
+ Self { key, previous }
+ }
+
+ fn unset(key: &'static str) -> Self {
+ let previous = std::env::var_os(key);
+ // SAFETY: see `set`.
+ unsafe { std::env::remove_var(key) };
+ Self { key, previous }
+ }
+ }
+
+ impl Drop for EnvGuard {
+ fn drop(&mut self) {
+ // SAFETY: see `set`.
+ unsafe {
+ match self.previous.take() {
+ Some(value) => std::env::set_var(self.key, value),
+ None => std::env::remove_var(self.key),
+ }
+ }
+ }
+ }
+
+ fn temp_dir(label: &str) -> PathBuf {
+ let mut path = std::env::temp_dir();
+ let pid = std::process::id();
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|duration| duration.subsec_nanos())
+ .unwrap_or(0);
+ path.push(format!("helmor-linux-creds-{label}-{pid}-{nanos}"));
+ fs::create_dir_all(&path).expect("create temp dir");
+ path
+ }
+
+ #[test]
+ fn returns_error_when_file_is_missing() {
+ let _lock = ENV_LOCK.lock().unwrap();
+ let dir = temp_dir("missing");
+ let _guard = EnvGuard::set("CLAUDE_CONFIG_DIR", &dir);
+ let _home_guard = EnvGuard::unset("HOME");
+
+ let error = load_best_credentials().expect_err("missing file should fail");
+ let message = error.to_string();
+ assert!(
+ message.contains("not found"),
+ "expected not-found error, got: {message}"
+ );
+
+ fs::remove_dir_all(dir).ok();
+ }
+
+ #[test]
+ fn returns_error_when_json_is_corrupted() {
+ let _lock = ENV_LOCK.lock().unwrap();
+ let dir = temp_dir("corrupt");
+ let path = dir.join(CREDENTIALS_FILE_NAME);
+ fs::write(&path, b"this is not json").expect("write corrupt file");
+ let _guard = EnvGuard::set("CLAUDE_CONFIG_DIR", &dir);
+ let _home_guard = EnvGuard::unset("HOME");
+
+ let error = load_best_credentials().expect_err("corrupt JSON should fail");
+ let message = error.to_string();
+ assert!(
+ message.contains("parse"),
+ "expected parse error, got: {message}"
+ );
+
+ fs::remove_dir_all(dir).ok();
+ }
+
+ #[test]
+ fn parses_single_valid_credential() {
+ let _lock = ENV_LOCK.lock().unwrap();
+ let dir = temp_dir("valid");
+ let path = dir.join(CREDENTIALS_FILE_NAME);
+ let body = br#"{"claudeAiOauth":{"accessToken":"acc-123","refreshToken":"ref-456","expiresAt":9999999999999,"scopes":["user:profile"],"subscriptionType":"pro"}}"#;
+ fs::write(&path, body).expect("write valid file");
+ let _guard = EnvGuard::set("CLAUDE_CONFIG_DIR", &dir);
+ let _home_guard = EnvGuard::unset("HOME");
+
+ let credentials = load_best_credentials().expect("valid credentials should parse");
+ assert_eq!(credentials.access_token, "acc-123");
+ assert!(credentials.has_required_scope());
+ assert!(!credentials.is_expired(0));
+
+ fs::remove_dir_all(dir).ok();
+ }
+
+ #[test]
+ fn falls_back_to_home_when_env_var_unset() {
+ let _lock = ENV_LOCK.lock().unwrap();
+ let home = temp_dir("home-fallback");
+ let claude_dir = home.join(CLAUDE_DIR_NAME);
+ fs::create_dir_all(&claude_dir).expect("create .claude dir");
+ let path = claude_dir.join(CREDENTIALS_FILE_NAME);
+ let body =
+ br#"{"accessToken":"home-tok","expiresAt":9999999999999,"scopes":["user:profile"]}"#;
+ fs::write(&path, body).expect("write valid file");
+ let _env_guard = EnvGuard::unset("CLAUDE_CONFIG_DIR");
+ let _home_guard = EnvGuard::set("HOME", &home);
+
+ let credentials = load_best_credentials().expect("home fallback should resolve");
+ assert_eq!(credentials.access_token, "home-tok");
+
+ fs::remove_dir_all(home).ok();
+ }
+
+ #[test]
+ fn errors_when_neither_env_nor_home_is_set() {
+ let _lock = ENV_LOCK.lock().unwrap();
+ let _env_guard = EnvGuard::unset("CLAUDE_CONFIG_DIR");
+ let _home_guard = EnvGuard::unset("HOME");
+
+ let error = load_best_credentials().expect_err("no env should fail");
+ assert!(error.to_string().contains("home directory"));
+ }
+}
diff --git a/src-tauri/src/rate_limits/claude/macos_keychain.rs b/src-tauri/src/rate_limits/claude/macos_keychain.rs
new file mode 100644
index 000000000..56de4fb23
--- /dev/null
+++ b/src-tauri/src/rate_limits/claude/macos_keychain.rs
@@ -0,0 +1,235 @@
+//! Reading Claude OAuth credentials from the macOS Keychain.
+//!
+//! Metadata probe (no UI) enumerates accounts; then `/usr/bin/security
+//! find-generic-password -w` reads the password for each. Routing the
+//! read through the system binary attaches the user's "Always Allow"
+//! grant to a signature that never changes, instead of Helmor's
+//! (which changes on every upgrade and dev rebuild).
+
+use anyhow::{anyhow, Result};
+
+use super::credentials::{now_ms, parse_credentials, sort_credentials, ClaudeOAuthCredentials};
+
+const CLAUDE_KEYCHAIN_SERVICE: &str = "Claude Code-credentials";
+
+const SECURITY_BINARY_PATH: &str = "/usr/bin/security";
+
+/// Per-candidate keychain CLI timeout. On the happy path the read
+/// returns in single-digit ms; this only matters when a macOS prompt
+/// is up and waiting for the user. 5 min is intentionally generous so
+/// users who get up to grab coffee mid-prompt don't come back to a
+/// killed dialog.
+const SECURITY_CLI_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5 * 60);
+
+/// How long we wait between SIGTERM-ing the process group and
+/// escalating to SIGKILL. Long enough for `/usr/bin/security` to
+/// release its keychain handle and tear down the dialog cleanly.
+const SECURITY_CLI_SIGTERM_GRACE: std::time::Duration = std::time::Duration::from_millis(400);
+
+/// Poll interval while waiting for `/usr/bin/security`. Tighter than
+/// `process::run_with_timeout`'s 50ms because we want to react to a
+/// timeout fast (the user might be staring at a stuck prompt) and the
+/// extra wakeups are negligible for a process that lives ms to s.
+const SECURITY_CLI_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20);
+
+/// Pick the best credential entry available across all matching
+/// keychain items.
+pub(super) fn load_best_credentials() -> Result {
+ let mut credentials = load_keychain_credentials()?;
+ let now = now_ms();
+ sort_credentials(&mut credentials, now);
+ credentials
+ .into_iter()
+ .rev()
+ .find(|credential| !credential.access_token.trim().is_empty())
+ .ok_or_else(|| anyhow!("No Claude Code OAuth credentials found in Keychain"))
+}
+
+fn load_keychain_credentials() -> Result> {
+ let mut credentials = Vec::new();
+ for account in keychain_account_candidates().into_iter().take(3) {
+ let Some(data) = read_via_security_cli(CLAUDE_KEYCHAIN_SERVICE, Some(&account)) else {
+ continue;
+ };
+ if let Some(credential) = parse_credentials(&data) {
+ credentials.push(credential);
+ }
+ }
+ Ok(credentials)
+}
+
+fn keychain_account_candidates() -> Vec {
+ // The metadata probe lists every account name actually present in
+ // the keychain for our service — when it succeeds we know exactly
+ // which accounts to try and don't need the env-var guesses or the
+ // "Claude Code" fallback.
+ let probed = keychain_accounts_without_prompt();
+ if !probed.is_empty() {
+ return probed;
+ }
+
+ let mut accounts = Vec::new();
+ for key in ["USER", "LOGNAME"] {
+ if let Ok(value) = std::env::var(key) {
+ push_unique_account(&mut accounts, value);
+ }
+ }
+ push_unique_account(&mut accounts, "Claude Code".to_string());
+ accounts
+}
+
+fn keychain_accounts_without_prompt() -> Vec {
+ use core_foundation::base::{CFTypeRef, TCFType};
+ use core_foundation::string::CFString;
+ use security_framework::item::{ItemClass, ItemSearchOptions, Limit, SearchResult};
+ use security_framework_sys::item::kSecAttrAccount;
+
+ let results = match ItemSearchOptions::new()
+ .class(ItemClass::generic_password())
+ .service(CLAUDE_KEYCHAIN_SERVICE)
+ .load_attributes(true)
+ .skip_authenticated_items(true)
+ .limit(Limit::All)
+ .search()
+ {
+ Ok(results) => results,
+ Err(error) => {
+ tracing::debug!("Claude Keychain account probe failed: {error}");
+ return Vec::new();
+ }
+ };
+
+ // SAFETY: `kSecAttrAccount` is a static `CFStringRef` exported by
+ // the Security framework. Casting it to `CFTypeRef` is the standard
+ // way to use it as a dictionary key — the underlying object is
+ // immortal so no retain/release dance is required.
+ let account_key = unsafe { kSecAttrAccount as CFTypeRef };
+ results
+ .into_iter()
+ .filter_map(|result| {
+ let SearchResult::Dict(attrs) = result else {
+ return None;
+ };
+ let account = attrs.find(account_key)?;
+ // SAFETY: `attrs` returned a `CFTypeRef` we know is a
+ // `CFStringRef` (account attribute). `wrap_under_get_rule`
+ // takes a borrowed reference and increments the retain
+ // count, balanced by `CFString`'s `Drop`.
+ let account = unsafe { CFString::wrap_under_get_rule(*account as _) };
+ let account = account.to_string();
+ (!account.trim().is_empty()).then_some(account)
+ })
+ .collect()
+}
+
+fn push_unique_account(accounts: &mut Vec, account: String) {
+ let trimmed = account.trim();
+ if trimmed.is_empty() || accounts.iter().any(|existing| existing == trimmed) {
+ return;
+ }
+ accounts.push(trimmed.to_string());
+}
+
+/// `/usr/bin/security find-generic-password -s -a -w`.
+///
+/// Returns the keychain item's password bytes on success, `None`
+/// otherwise (binary missing, non-zero exit, timeout, hung prompt).
+/// On timeout the entire process group is SIGTERM'd and then SIGKILL'd
+/// to avoid leaking a hung subprocess; macOS dismisses the keychain
+/// dialog when the requesting process dies.
+fn read_via_security_cli(service: &str, account: Option<&str>) -> Option> {
+ use std::io::Read;
+ use std::os::unix::process::CommandExt;
+ use std::process::{Command, Stdio};
+ use std::time::Instant;
+
+ if !std::path::Path::new(SECURITY_BINARY_PATH).exists() {
+ tracing::debug!("/usr/bin/security not present, skipping CLI read");
+ return None;
+ }
+
+ let mut cmd = Command::new(SECURITY_BINARY_PATH);
+ cmd.arg("find-generic-password").args(["-s", service]);
+ if let Some(a) = account.filter(|a| !a.is_empty()) {
+ cmd.args(["-a", a]);
+ }
+ cmd.arg("-w");
+ cmd.stdout(Stdio::piped())
+ .stderr(Stdio::null())
+ .stdin(Stdio::null());
+
+ // SAFETY: `setpgid` is async-signal-safe, which is the only family
+ // of calls allowed between fork and exec.
+ unsafe {
+ cmd.pre_exec(|| {
+ if libc::setpgid(0, 0) != 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+ });
+ }
+
+ let mut child = match cmd.spawn() {
+ Ok(c) => c,
+ Err(error) => {
+ tracing::debug!("Failed to spawn /usr/bin/security: {error}");
+ return None;
+ }
+ };
+ let pgid = child.id() as libc::pid_t;
+ let deadline = Instant::now() + SECURITY_CLI_TIMEOUT;
+
+ loop {
+ match child.try_wait() {
+ Ok(Some(status)) => {
+ if !status.success() {
+ // Common cases: item not found, user denied, etc.
+ // Logged at debug since "not found" is normal on
+ // first launch before the user has run claude login.
+ tracing::debug!(
+ "/usr/bin/security exited with status {status:?} for service {service}"
+ );
+ return None;
+ }
+ let mut buf = Vec::new();
+ child.stdout.as_mut()?.read_to_end(&mut buf).ok()?;
+ while matches!(buf.last(), Some(b'\n' | b'\r')) {
+ buf.pop();
+ }
+ if buf.is_empty() {
+ return None;
+ }
+ return Some(buf);
+ }
+ Ok(None) if Instant::now() >= deadline => {
+ tracing::warn!(
+ "/usr/bin/security read timed out after {:?}, killing process group",
+ SECURITY_CLI_TIMEOUT
+ );
+ // SAFETY: passing a negative pid to `kill` signals the
+ // whole process group rooted at `pgid`. `pgid` is the
+ // child we just `setpgid(0, 0)`'d, so the only members
+ // are `/usr/bin/security` and any subprocess it forked.
+ unsafe {
+ libc::kill(-pgid, libc::SIGTERM);
+ }
+ std::thread::sleep(SECURITY_CLI_SIGTERM_GRACE);
+ if matches!(child.try_wait(), Ok(None)) {
+ // SAFETY: same as above. SIGKILL is the escalation
+ // when the SIGTERM grace period didn't reap.
+ unsafe {
+ libc::kill(-pgid, libc::SIGKILL);
+ }
+ let _ = child.kill();
+ }
+ let _ = child.wait();
+ return None;
+ }
+ Ok(None) => std::thread::sleep(SECURITY_CLI_POLL_INTERVAL),
+ Err(error) => {
+ tracing::debug!("/usr/bin/security try_wait failed: {error}");
+ return None;
+ }
+ }
+ }
+}
diff --git a/src-tauri/src/rate_limits/claude/mod.rs b/src-tauri/src/rate_limits/claude/mod.rs
index 30fb3992b..9acd586aa 100644
--- a/src-tauri/src/rate_limits/claude/mod.rs
+++ b/src-tauri/src/rate_limits/claude/mod.rs
@@ -23,6 +23,8 @@
mod cache;
mod credentials;
mod keychain;
+#[cfg(target_os = "linux")]
+mod linux_credentials;
mod process;
mod refresh;
mod user_agent;
diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs
index 89bf4dd5a..5d695fcc5 100644
--- a/src-tauri/src/sidecar.rs
+++ b/src-tauri/src/sidecar.rs
@@ -100,23 +100,43 @@ pub fn load_cursor_api_key() -> Option {
}
fn resolve_bundled_agent_paths_for_exe(exe: &std::path::Path) -> Option {
- let exe_dir = exe.parent()?;
- let contents_dir = exe_dir.parent()?;
- let resources_dir = contents_dir.join("Resources");
- let claude_bin_name = if cfg!(windows) {
- "claude.exe"
- } else {
- "claude"
- };
- let codex_bin_name = if cfg!(windows) { "codex.exe" } else { "codex" };
+ // Linux dpkg / AppImage layout: `/bin/helmor` with vendored
+ // agent binaries under `/lib/Helmor/vendor/`. Same shape as
+ // `forge::bundled::resolve_for_exe` — keep them aligned.
+ #[cfg(target_os = "linux")]
+ {
+ let exe_dir = exe.parent()?;
+ let vendor = exe_dir.parent()?.join("lib").join("Helmor").join("vendor");
+ let claude_bin = vendor.join("claude-code/claude");
+ let codex_bin = vendor.join("codex/codex");
+ return Some(BundledAgentPaths {
+ claude_bin: claude_bin.is_file().then_some(claude_bin),
+ codex_bin: codex_bin.is_file().then_some(codex_bin),
+ });
+ }
- let claude_bin = resources_dir.join(format!("vendor/claude-code/{claude_bin_name}"));
- let codex_bin = resources_dir.join(format!("vendor/codex/{codex_bin_name}"));
+ // macOS: `/Helmor.app/Contents/MacOS/Helmor` → `Resources/vendor/`.
+ // Windows: NSIS bundle uses the same `Resources/vendor/` shape.
+ #[cfg_attr(target_os = "linux", allow(unreachable_code))]
+ {
+ let exe_dir = exe.parent()?;
+ let contents_dir = exe_dir.parent()?;
+ let resources_dir = contents_dir.join("Resources");
+ let claude_bin_name = if cfg!(windows) {
+ "claude.exe"
+ } else {
+ "claude"
+ };
+ let codex_bin_name = if cfg!(windows) { "codex.exe" } else { "codex" };
+
+ let claude_bin = resources_dir.join(format!("vendor/claude-code/{claude_bin_name}"));
+ let codex_bin = resources_dir.join(format!("vendor/codex/{codex_bin_name}"));
- Some(BundledAgentPaths {
- claude_bin: claude_bin.is_file().then_some(claude_bin),
- codex_bin: codex_bin.is_file().then_some(codex_bin),
- })
+ Some(BundledAgentPaths {
+ claude_bin: claude_bin.is_file().then_some(claude_bin),
+ codex_bin: codex_bin.is_file().then_some(codex_bin),
+ })
+ }
}
impl SidecarProcess {
@@ -860,6 +880,7 @@ mod tests {
.is_err());
}
+ #[cfg(target_os = "macos")]
#[test]
fn bundled_agent_paths_resolve_from_running_app() {
let root = tempfile::tempdir().unwrap();
@@ -883,4 +904,25 @@ mod tests {
.join("Helmor.app/Contents/Resources/vendor/codex/codex")
);
}
+
+ #[cfg(target_os = "linux")]
+ #[test]
+ fn linux_dpkg_layout_resolves_bundled_agents() {
+ let root = tempfile::tempdir().unwrap();
+ let usr = root.path().join("usr");
+ let bin = usr.join("bin");
+ let vendor = usr.join("lib/Helmor/vendor");
+ std::fs::create_dir_all(&bin).unwrap();
+ std::fs::create_dir_all(vendor.join("claude-code")).unwrap();
+ std::fs::create_dir_all(vendor.join("codex")).unwrap();
+ let exe = bin.join("helmor");
+ std::fs::write(&exe, b"x").unwrap();
+ std::fs::write(vendor.join("claude-code/claude"), b"x").unwrap();
+ std::fs::write(vendor.join("codex/codex"), b"x").unwrap();
+
+ let paths = resolve_bundled_agent_paths_for_exe(&exe).unwrap();
+
+ assert_eq!(paths.claude_bin.unwrap(), vendor.join("claude-code/claude"));
+ assert_eq!(paths.codex_bin.unwrap(), vendor.join("codex/codex"));
+ }
}
diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json
new file mode 100644
index 000000000..1b1d4b4f8
--- /dev/null
+++ b/src-tauri/tauri.linux.conf.json
@@ -0,0 +1,36 @@
+{
+ "$schema": "https://schema.tauri.app/config/2",
+ "app": {
+ "windows": [
+ {
+ "label": "main",
+ "title": "Helmor",
+ "titleBarStyle": "Visible",
+ "decorations": true,
+ "transparent": false
+ }
+ ]
+ },
+ "bundle": {
+ "linux": {
+ "deb": {
+ "depends": [
+ "libwebkit2gtk-4.1-0",
+ "libsoup-3.0-0",
+ "libgtk-3-0t64",
+ "librsvg2-2",
+ "libayatana-appindicator3-1"
+ ]
+ },
+ "appimage": {
+ "bundleMediaFramework": true
+ }
+ },
+ "icon": [
+ "icons/32x32.png",
+ "icons/128x128.png",
+ "icons/128x128@2x.png",
+ "icons/icon.png"
+ ]
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
index fee3a24ab..948e8a760 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -57,6 +57,7 @@ import {
type WorkspaceDetail,
type WorkspaceSessionSummary,
} from "./lib/api";
+import { usesActionModelOverride } from "./lib/commit-button-prompts";
import { ComposerInsertProvider } from "./lib/composer-insert-context";
import {
activeStreamsQueryOptions,
@@ -798,12 +799,11 @@ function AppShell({
pushToast: pushWorkspaceToast,
});
- // Wrapper that injects the configured PR/MR model overrides for the
- // "create-pr" mode so the action runs on the user's preferred PR model
- // (with effort + fast-mode falling back to defaults when null).
+ // Action model covers simple, bounded helper sessions. More involved
+ // fix/resolve flows keep following the default model.
const handleCommitAction = useCallback(
(mode: WorkspaceCommitButtonMode) => {
- if (mode === "create-pr") {
+ if (usesActionModelOverride(mode)) {
return handleInspectorCommitAction(mode, {
modelId: appSettings.prModelId ?? appSettings.defaultModelId,
effort: appSettings.prEffort ?? appSettings.defaultEffort,
@@ -1080,7 +1080,7 @@ function AppShell({
},
{
id: "action.commitAndPush" as const,
- callback: () => void handleInspectorCommitAction("commit-and-push"),
+ callback: () => void handleCommitAction("commit-and-push"),
},
{
id: "action.pullLatest" as const,
diff --git a/src/features/settings/index.tsx b/src/features/settings/index.tsx
index feee2c8e9..991cdc4a7 100644
--- a/src/features/settings/index.tsx
+++ b/src/features/settings/index.tsx
@@ -531,15 +531,15 @@ export const SettingsDialog = memo(function SettingsDialog({
}}
/>
{
const patch: Partial = {};
if (p.modelId !== undefined) patch.prModelId = p.modelId;
diff --git a/src/lib/commit-button-prompts.test.ts b/src/lib/commit-button-prompts.test.ts
index a9e38314b..b49649ec9 100644
--- a/src/lib/commit-button-prompts.test.ts
+++ b/src/lib/commit-button-prompts.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import type { ForgeDetection } from "./api";
-import { buildCommitButtonPrompt } from "./commit-button-prompts";
+import {
+ buildCommitButtonPrompt,
+ usesActionModelOverride,
+} from "./commit-button-prompts";
const GITLAB_FORGE: ForgeDetection = {
provider: "gitlab",
@@ -35,6 +38,15 @@ const GITHUB_FORGE: ForgeDetection = {
};
describe("buildCommitButtonPrompt", () => {
+ it("uses the action model only for simple bounded action sessions", () => {
+ expect(usesActionModelOverride("create-pr")).toBe(true);
+ expect(usesActionModelOverride("commit-and-push")).toBe(true);
+ expect(usesActionModelOverride("open-pr")).toBe(true);
+ expect(usesActionModelOverride("fix")).toBe(false);
+ expect(usesActionModelOverride("resolve-conflicts")).toBe(false);
+ expect(usesActionModelOverride("push")).toBe(false);
+ });
+
it("appends create-pr preferences after the built-in prompt", () => {
expect(
buildCommitButtonPrompt(
diff --git a/src/lib/commit-button-prompts.ts b/src/lib/commit-button-prompts.ts
index a38d34fba..30c6b899e 100644
--- a/src/lib/commit-button-prompts.ts
+++ b/src/lib/commit-button-prompts.ts
@@ -87,6 +87,14 @@ export function isActionSessionMode(
);
}
+export function usesActionModelOverride(
+ mode: WorkspaceCommitButtonMode,
+): mode is "create-pr" | "commit-and-push" | "open-pr" {
+ return (
+ mode === "create-pr" || mode === "commit-and-push" || mode === "open-pr"
+ );
+}
+
/** Whether a session created with this `ActionKind` is eligible for the
* auto-hide flow (i.e. can be silently hidden once its post-stream verifier
* passes). Auto-created action sessions still get fixed titles, but only a
diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts
index 3d34c0dfa..7321c416b 100644
--- a/src/lib/settings.test.ts
+++ b/src/lib/settings.test.ts
@@ -122,4 +122,18 @@ describe("settings", () => {
}),
);
});
+
+ it("keeps default as a valid model id", async () => {
+ invokeMock.mockResolvedValue({
+ "app.default_model_id": "gpt-5.5",
+ "app.review_model_id": "default",
+ "app.pr_model_id": "default",
+ });
+
+ const settings = await loadSettings();
+
+ expect(settings.defaultModelId).toBe("gpt-5.5");
+ expect(settings.reviewModelId).toBe("default");
+ expect(settings.prModelId).toBe("default");
+ });
});
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index 1b3865d0c..5656d300b 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -213,14 +213,13 @@ export type AppSettings = {
/** Fast-mode flag for the Review helper. When null, falls back to
* `defaultFastMode`. */
reviewFastMode: boolean | null;
- /** Model used when the inspector "Create PR/MR" action starts a session.
- * Applies to both GitHub PRs and GitLab MRs. When null, falls back to
- * `defaultModelId`. */
+ /** Model used by simple action sessions: create/reopen PR/MR and
+ * commit-and-push. When null, falls back to `defaultModelId`. */
prModelId: string | null;
- /** Effort level for the Create PR/MR helper. When null, falls back to
+ /** Effort level for simple action sessions. When null, falls back to
* `defaultEffort`. */
prEffort: string | null;
- /** Fast-mode flag for the Create PR/MR helper. When null, falls back to
+ /** Fast-mode flag for simple action sessions. When null, falls back to
* `defaultFastMode`. */
prFastMode: boolean | null;
defaultEffort: string | null;
@@ -791,6 +790,10 @@ function readClampedInt(
return Math.min(max, Math.max(min, Math.round(n)));
}
+function readModelId(value: string | undefined): string | null {
+ return value && value !== "" ? value : null;
+}
+
export async function loadSettings(): Promise {
try {
const raw = await invoke>("get_app_settings");
@@ -849,14 +852,8 @@ export async function loadSettings(): Promise {
raw[SETTINGS_KEY_MAP.workspaceRightSidebarMode] === "context"
? "context"
: DEFAULT_SETTINGS.workspaceRightSidebarMode,
- defaultModelId:
- rawDefaultModelId && rawDefaultModelId !== "default"
- ? rawDefaultModelId
- : DEFAULT_SETTINGS.defaultModelId,
- reviewModelId:
- rawReviewModelId && rawReviewModelId !== "default"
- ? rawReviewModelId
- : DEFAULT_SETTINGS.reviewModelId,
+ defaultModelId: readModelId(rawDefaultModelId),
+ reviewModelId: readModelId(rawReviewModelId),
reviewEffort:
rawReviewEffort && rawReviewEffort !== ""
? rawReviewEffort
@@ -867,10 +864,7 @@ export async function loadSettings(): Promise {
: rawReviewFastMode === "false"
? false
: DEFAULT_SETTINGS.reviewFastMode,
- prModelId:
- rawPrModelId && rawPrModelId !== "default"
- ? rawPrModelId
- : DEFAULT_SETTINGS.prModelId,
+ prModelId: readModelId(rawPrModelId),
prEffort:
rawPrEffort && rawPrEffort !== ""
? rawPrEffort