diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0291af2..58acfdf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -178,3 +178,55 @@ jobs: See `checksums.txt` in the release assets. env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ─── Publish to npm ──────────────────────────────────────────────────────── + # @diffmind/cli is a launcher; the binaries ship in five per-platform packages + # declared as its optionalDependencies, so npm installs only the one matching + # the host. All six publish together at the tag version — a partial publish + # would leave @diffmind/cli pointing at versions that do not exist. + npm: + name: Publish npm packages + runs-on: ubuntu-latest + needs: release + if: github.repository_owner == 'thinkgrid-labs' && !contains(github.ref_name, '-') + permissions: + contents: read + id-token: write # required for npm provenance attestations + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Stamp versions and unpack binaries + run: node npm/scripts/prepare.mjs --version "${{ github.ref_name }}" --artifacts artifacts + + - name: Verify the Linux binary runs + run: | + chmod +x npm/platform/linux-x64/bin/diffmind + npm/platform/linux-x64/bin/diffmind --version + + # Platform packages first: @diffmind/cli depends on them, and publishing + # it first would briefly resolve to nothing. + - name: Publish platform packages + run: | + for dir in npm/platform/*/; do + echo "publishing ${dir}" + npm publish "${dir}" --provenance --access public + done + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish @diffmind/cli + run: npm publish npm/cli --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index f1d307f..de910bb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,10 @@ pkg/ .npm/ .pnpm-debug.log* -# Wasm build output -packages/core-wasm/pkg/ +# npm packaging — release binaries are injected by npm/scripts/prepare.mjs +# at publish time and must never be committed +npm/platform/*/bin/ +*.tgz # Model cache (never commit the GGUF file) *.gguf diff --git a/README.md b/README.md index 1ab52a6..455910e 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,17 @@ VERSION=v.x.x curl -fsSL https://github.com/thinkgrid-labs/diffmind/releases/lat Download `diffmind-x86_64-pc-windows-msvc.zip` from [GitHub Releases](https://github.com/thinkgrid-labs/diffmind/releases), extract it, and place `diffmind.exe` anywhere on your `PATH`. +### npm + +```bash +npx @diffmind/cli --help + +# or install globally +npm install -g @diffmind/cli +``` + +`@diffmind/cli` is a launcher — the binary ships in a per-platform package (`@diffmind/cli-darwin-arm64`, `@diffmind/cli-linux-x64`, …) declared as an optional dependency, so npm downloads only the one matching your machine. Linux binaries are glibc-linked; on musl (Alpine) use a glibc base image or build from source. + ### Build from source (Rust) ```bash diff --git a/apps/tui-cli/Cargo.toml b/apps/tui-cli/Cargo.toml index e987ae1..62688d2 100644 --- a/apps/tui-cli/Cargo.toml +++ b/apps/tui-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "diffmind" -version = "0.7.0" +version = "0.7.1" edition = "2024" description = "Local-first AI code review agent — powered by on-device inference" diff --git a/apps/tui-cli/src/main.rs b/apps/tui-cli/src/main.rs index 0f919f9..6f7f5a9 100644 --- a/apps/tui-cli/src/main.rs +++ b/apps/tui-cli/src/main.rs @@ -875,20 +875,20 @@ where }; app_lock.state.select(Some(i)); } - KeyCode::Char('a') => { - if !app_lock.analyzing { - app_lock.analyzing = true; - app_lock.status = "Analyzing...".to_string(); - let app_clone = Arc::clone(&app); - tokio::spawn(async move { - let app_err = Arc::clone(&app_clone); - if let Err(e) = background_analysis(app_clone).await { - let mut app = app_err.lock().await; - app.status = format!("Error: {}", e); - app.analyzing = false; - } - }); - } + // Guard rather than an inner `if`: pressing 'a' while a run is + // already in flight falls through to the catch-all and is ignored. + KeyCode::Char('a') if !app_lock.analyzing => { + app_lock.analyzing = true; + app_lock.status = "Analyzing...".to_string(); + let app_clone = Arc::clone(&app); + tokio::spawn(async move { + let app_err = Arc::clone(&app_clone); + if let Err(e) = background_analysis(app_clone).await { + let mut app = app_err.lock().await; + app.status = format!("Error: {}", e); + app.analyzing = false; + } + }); } _ => {} } diff --git a/npm/cli/README.md b/npm/cli/README.md new file mode 100644 index 0000000..1454fb0 --- /dev/null +++ b/npm/cli/README.md @@ -0,0 +1,54 @@ +# diffmind + +Local-first AI code review for your git diffs. Runs entirely on your machine — no cloud, no API keys, no subscription. + +This package is a thin installer for the [diffmind](https://github.com/thinkgrid-labs/diffmind) binary. The actual executable is Rust, shipped as a prebuilt binary in a per-platform package; npm downloads only the one matching your OS and CPU. + +```bash +npx @diffmind/cli --help + +# or install globally +npm install -g @diffmind/cli +``` + +## Quick start + +```bash +# 1. Download a model (one-time; pick the size that suits your hardware) +diffmind download + +# 2. Review the current branch against main +diffmind --branch main +``` + +`diffmind download` with no arguments shows an interactive picker with the RAM and disk requirements for each model, from Qwen2.5-Coder-0.5B up to 32B. Pick whatever your machine can run — bigger models give better reviews. + +## Supported platforms + +| Platform | Package | +| --- | --- | +| macOS Apple Silicon | `@diffmind/cli-darwin-arm64` | +| macOS Intel | `@diffmind/cli-darwin-x64` | +| Linux x86_64 | `@diffmind/cli-linux-x64` | +| Linux ARM64 | `@diffmind/cli-linux-arm64` | +| Windows x86_64 | `@diffmind/cli-win32-x64` | + +Linux binaries are glibc-linked. On musl (Alpine), use a glibc base image or build from source. + +## Not using npm? + +npm is one of several install paths, and the others skip Node entirely: + +```bash +# macOS / Linux +curl -fsSL https://github.com/thinkgrid-labs/diffmind/releases/latest/download/install.sh | bash + +# From source +cargo install --git https://github.com/thinkgrid-labs/diffmind diffmind +``` + +Full documentation, model list, TUI keybindings, CI usage and `.diffmind/rules.toml` reference: **https://github.com/thinkgrid-labs/diffmind** + +## License + +MIT diff --git a/npm/cli/bin/diffmind.js b/npm/cli/bin/diffmind.js new file mode 100644 index 0000000..a4ce67e --- /dev/null +++ b/npm/cli/bin/diffmind.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node +"use strict"; + +// Thin launcher for the diffmind binary. +// +// The real executable ships in a per-platform package (see optionalDependencies +// in ../package.json). npm installs only the one matching the host's os/cpu, so +// a macOS user never downloads the Windows build. This shim finds it and execs +// it, forwarding argv, stdio and the exit code untouched — `diffmind --tui` and +// the CI gate's exit-1-on-findings both depend on that being transparent. + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const PLATFORMS = { + "darwin arm64": "@diffmind/cli-darwin-arm64", + "darwin x64": "@diffmind/cli-darwin-x64", + "linux x64": "@diffmind/cli-linux-x64", + "linux arm64": "@diffmind/cli-linux-arm64", + "win32 x64": "@diffmind/cli-win32-x64", +}; + +const RELEASES = "https://github.com/thinkgrid-labs/diffmind/releases"; +const INSTALL_SH = + "curl -fsSL https://github.com/thinkgrid-labs/diffmind/releases/latest/download/install.sh | bash"; + +function fail(lines) { + console.error("\ndiffmind: " + lines.join("\ndiffmind: ") + "\n"); + process.exit(1); +} + +// The published Linux binaries are glibc-linked (the release matrix has no musl +// target). Detect musl up front — otherwise exec fails with a bare ENOENT that +// looks like a missing file rather than a missing loader. +function isMusl() { + if (process.platform !== "linux") return false; + const report = + typeof process.report?.getReport === "function" + ? process.report.getReport() + : null; + if (report && report.header && typeof report.header.glibcVersionRuntime === "string") { + return false; + } + // No glibcVersionRuntime in the report means a non-glibc libc. + return report !== null; +} + +function binaryName() { + return process.platform === "win32" ? "diffmind.exe" : "diffmind"; +} + +// Resolve the platform package. require.resolve handles npm, pnpm and yarn +// node_modules layouts; the relative fallback covers the case where the shim is +// run straight out of a checkout with the packages laid out side by side. +function findBinary(pkg) { + const rel = path.join("bin", binaryName()); + + try { + const manifest = require.resolve(pkg + "/package.json", { paths: [__dirname] }); + const candidate = path.join(path.dirname(manifest), rel); + if (fs.existsSync(candidate)) return candidate; + } catch { + // fall through to the sibling lookup + } + + const sibling = path.join(__dirname, "..", "..", "platform", pkg.split("/")[1], rel); + if (fs.existsSync(sibling)) return sibling; + + return null; +} + +function main() { + const key = process.platform + " " + process.arch; + const pkg = PLATFORMS[key]; + + if (!pkg) { + fail([ + `no prebuilt binary for ${process.platform}-${process.arch}.`, + `Supported: ${Object.keys(PLATFORMS).join(", ")}.`, + `Build from source instead: cargo install --git https://github.com/thinkgrid-labs/diffmind diffmind`, + ]); + } + + if (isMusl()) { + fail([ + "the published Linux binaries are glibc-linked and will not run on musl (Alpine).", + "Use a glibc image (e.g. node:22-slim), or build from source:", + " cargo install --git https://github.com/thinkgrid-labs/diffmind diffmind", + ]); + } + + const binary = findBinary(pkg); + if (!binary) { + fail([ + `the platform package ${pkg} is missing.`, + "This usually means the install ran with --no-optional or --omit=optional.", + "Reinstall with optional dependencies enabled:", + ` npm install ${pkg}`, + "", + `Or skip npm entirely: ${INSTALL_SH}`, + `Binaries: ${RELEASES}`, + ]); + } + + // chmod is a no-op on a correctly packed tarball, but npm has historically + // dropped the executable bit in some install paths; cheap to make sure. + if (process.platform !== "win32") { + try { + fs.chmodSync(binary, 0o755); + } catch { + // Read-only store (pnpm, Nix). If the bit is already set this is fine. + } + } + + const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" }); + + if (result.error) { + fail([`failed to run ${binary}: ${result.error.message}`]); + } + + // Re-raise a fatal signal so the parent shell sees the real cause (Ctrl-C in + // the TUI must not look like a clean exit 0). + if (result.signal) { + process.kill(process.pid, result.signal); + return; + } + + process.exit(result.status === null ? 1 : result.status); +} + +main(); diff --git a/npm/cli/package.json b/npm/cli/package.json new file mode 100644 index 0000000..efcfc50 --- /dev/null +++ b/npm/cli/package.json @@ -0,0 +1,48 @@ +{ + "name": "@diffmind/cli", + "version": "0.7.1", + "description": "Local-first AI code review for your git diffs — on-device inference, no cloud, no API keys", + "author": "Thinkgrid Labs ", + "license": "MIT", + "homepage": "https://github.com/thinkgrid-labs/diffmind#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/thinkgrid-labs/diffmind.git", + "directory": "npm/cli" + }, + "bugs": { + "url": "https://github.com/thinkgrid-labs/diffmind/issues" + }, + "keywords": [ + "ai", + "code-review", + "local-first", + "offline", + "privacy", + "security", + "git", + "diff", + "cli", + "rust" + ], + "publishConfig": { + "access": "public" + }, + "bin": { + "diffmind": "./bin/diffmind.js" + }, + "files": [ + "bin/diffmind.js", + "README.md" + ], + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@diffmind/cli-darwin-arm64": "0.7.1", + "@diffmind/cli-darwin-x64": "0.7.1", + "@diffmind/cli-linux-arm64": "0.7.1", + "@diffmind/cli-linux-x64": "0.7.1", + "@diffmind/cli-win32-x64": "0.7.1" + } +} diff --git a/npm/platform/darwin-arm64/package.json b/npm/platform/darwin-arm64/package.json new file mode 100644 index 0000000..f29f080 --- /dev/null +++ b/npm/platform/darwin-arm64/package.json @@ -0,0 +1,29 @@ +{ + "name": "@diffmind/cli-darwin-arm64", + "version": "0.7.1", + "description": "diffmind prebuilt binary for darwin-arm64 (aarch64-apple-darwin)", + "author": "Thinkgrid Labs ", + "license": "MIT", + "homepage": "https://github.com/thinkgrid-labs/diffmind#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/thinkgrid-labs/diffmind.git", + "directory": "npm/platform/darwin-arm64" + }, + "publishConfig": { + "access": "public" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "files": [ + "bin/diffmind" + ], + "preferUnplugged": true, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/npm/platform/darwin-x64/package.json b/npm/platform/darwin-x64/package.json new file mode 100644 index 0000000..f6552c7 --- /dev/null +++ b/npm/platform/darwin-x64/package.json @@ -0,0 +1,29 @@ +{ + "name": "@diffmind/cli-darwin-x64", + "version": "0.7.1", + "description": "diffmind prebuilt binary for darwin-x64 (x86_64-apple-darwin)", + "author": "Thinkgrid Labs ", + "license": "MIT", + "homepage": "https://github.com/thinkgrid-labs/diffmind#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/thinkgrid-labs/diffmind.git", + "directory": "npm/platform/darwin-x64" + }, + "publishConfig": { + "access": "public" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin/diffmind" + ], + "preferUnplugged": true, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/npm/platform/linux-arm64/package.json b/npm/platform/linux-arm64/package.json new file mode 100644 index 0000000..df87f71 --- /dev/null +++ b/npm/platform/linux-arm64/package.json @@ -0,0 +1,29 @@ +{ + "name": "@diffmind/cli-linux-arm64", + "version": "0.7.1", + "description": "diffmind prebuilt binary for linux-arm64 (aarch64-unknown-linux-gnu)", + "author": "Thinkgrid Labs ", + "license": "MIT", + "homepage": "https://github.com/thinkgrid-labs/diffmind#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/thinkgrid-labs/diffmind.git", + "directory": "npm/platform/linux-arm64" + }, + "publishConfig": { + "access": "public" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "files": [ + "bin/diffmind" + ], + "preferUnplugged": true, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/npm/platform/linux-x64/package.json b/npm/platform/linux-x64/package.json new file mode 100644 index 0000000..c64ba22 --- /dev/null +++ b/npm/platform/linux-x64/package.json @@ -0,0 +1,29 @@ +{ + "name": "@diffmind/cli-linux-x64", + "version": "0.7.1", + "description": "diffmind prebuilt binary for linux-x64 (x86_64-unknown-linux-gnu)", + "author": "Thinkgrid Labs ", + "license": "MIT", + "homepage": "https://github.com/thinkgrid-labs/diffmind#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/thinkgrid-labs/diffmind.git", + "directory": "npm/platform/linux-x64" + }, + "publishConfig": { + "access": "public" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin/diffmind" + ], + "preferUnplugged": true, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/npm/platform/win32-x64/package.json b/npm/platform/win32-x64/package.json new file mode 100644 index 0000000..d8bc831 --- /dev/null +++ b/npm/platform/win32-x64/package.json @@ -0,0 +1,29 @@ +{ + "name": "@diffmind/cli-win32-x64", + "version": "0.7.1", + "description": "diffmind prebuilt binary for win32-x64 (x86_64-pc-windows-msvc)", + "author": "Thinkgrid Labs ", + "license": "MIT", + "homepage": "https://github.com/thinkgrid-labs/diffmind#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/thinkgrid-labs/diffmind.git", + "directory": "npm/platform/win32-x64" + }, + "publishConfig": { + "access": "public" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin/diffmind.exe" + ], + "preferUnplugged": true, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/npm/scripts/prepare.mjs b/npm/scripts/prepare.mjs new file mode 100644 index 0000000..bfa6a29 --- /dev/null +++ b/npm/scripts/prepare.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// Prepares the npm packages for publishing. +// +// node npm/scripts/prepare.mjs --version 0.7.0 --artifacts +// +// 1. Stamps into @diffmind/cli, its optionalDependencies, and every +// platform package, so all six publish as one matched set. +// 2. Copies each release binary out of the built artifacts into its platform +// package's bin/ directory. +// +// The artifacts directory is what the release workflow downloads: one +// `diffmind-.tar.gz` per Unix target and a `.zip` for Windows. Pass +// --skip-binaries to stamp versions only (useful for a dry run). + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const NPM_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +// platform package dir -> release target triple +const TARGETS = { + "darwin-arm64": "aarch64-apple-darwin", + "darwin-x64": "x86_64-apple-darwin", + "linux-x64": "x86_64-unknown-linux-gnu", + "linux-arm64": "aarch64-unknown-linux-gnu", + "win32-x64": "x86_64-pc-windows-msvc", +}; + +function arg(name) { + const i = process.argv.indexOf(`--${name}`); + return i === -1 ? null : process.argv[i + 1]; +} + +const version = (arg("version") || "").replace(/^v/, ""); +const artifacts = arg("artifacts"); +const skipBinaries = process.argv.includes("--skip-binaries"); + +if (!/^\d+\.\d+\.\d+/.test(version)) { + console.error("prepare: --version must be a semver like 0.7.0 (got: " + version + ")"); + process.exit(1); +} +if (!skipBinaries && !artifacts) { + console.error("prepare: --artifacts is required unless --skip-binaries is passed"); + process.exit(1); +} + +function writeJson(file, data) { + fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n"); +} + +// ─── 1. Stamp versions ─────────────────────────────────────────────────────── + +const cliManifest = path.join(NPM_DIR, "cli", "package.json"); +const cli = JSON.parse(fs.readFileSync(cliManifest, "utf8")); +cli.version = version; +for (const dir of Object.keys(TARGETS)) { + cli.optionalDependencies[`@diffmind/cli-${dir}`] = version; +} +writeJson(cliManifest, cli); +console.log(`@diffmind/cli -> ${version}`); + +for (const dir of Object.keys(TARGETS)) { + const manifest = path.join(NPM_DIR, "platform", dir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(manifest, "utf8")); + pkg.version = version; + writeJson(manifest, pkg); + console.log(`@diffmind/cli-${dir} -> ${version}`); +} + +if (skipBinaries) { + console.log("\nversions stamped; skipping binaries"); + process.exit(0); +} + +// ─── 2. Unpack binaries into the platform packages ─────────────────────────── + +let placed = 0; + +for (const [dir, target] of Object.entries(TARGETS)) { + const isWindows = dir.startsWith("win32"); + const binName = isWindows ? "diffmind.exe" : "diffmind"; + const archive = path.join( + artifacts, + `diffmind-${target}.${isWindows ? "zip" : "tar.gz"}`, + ); + + if (!fs.existsSync(archive)) { + console.error(`prepare: missing release archive ${archive}`); + process.exit(1); + } + + const binDir = path.join(NPM_DIR, "platform", dir, "bin"); + fs.rmSync(binDir, { recursive: true, force: true }); + fs.mkdirSync(binDir, { recursive: true }); + + // Each archive holds the binary plus README/LICENSE; extract only the binary. + if (isWindows) { + execFileSync("unzip", ["-j", "-o", archive, binName, "-d", binDir], { + stdio: "inherit", + }); + } else { + execFileSync("tar", ["-xzf", archive, "-C", binDir, `./${binName}`], { + stdio: "inherit", + }); + } + + const binary = path.join(binDir, binName); + if (!fs.existsSync(binary)) { + console.error(`prepare: ${binName} not found in ${archive}`); + process.exit(1); + } + // npm preserves the executable bit from the packed tarball. + if (!isWindows) fs.chmodSync(binary, 0o755); + + const mb = (fs.statSync(binary).size / 1024 / 1024).toFixed(1); + console.log(`@diffmind/cli-${dir}: bin/${binName} (${mb} MB)`); + placed++; +} + +if (placed !== Object.keys(TARGETS).length) { + console.error(`prepare: expected ${Object.keys(TARGETS).length} binaries, placed ${placed}`); + process.exit(1); +} + +console.log(`\nready to publish ${placed + 1} packages at ${version}`); diff --git a/packages/core-engine/Cargo.toml b/packages/core-engine/Cargo.toml index b24575a..75e9b66 100644 --- a/packages/core-engine/Cargo.toml +++ b/packages/core-engine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "core-engine" -version = "0.7.0" +version = "0.7.1" edition = "2024" description = "Diffmind shared AI engine core" diff --git a/packages/core-engine/src/lib.rs b/packages/core-engine/src/lib.rs index 3bef216..d22f03a 100644 --- a/packages/core-engine/src/lib.rs +++ b/packages/core-engine/src/lib.rs @@ -12,6 +12,8 @@ pub enum EngineError { GgufError(String), #[error("failed to load model weights: {0}")] ModelLoadError(String), + #[error("{0}")] + DeviceUnavailable(String), #[error("tensor error: {0}")] TensorError(#[from] candle_core::Error), #[error("forward pass error: {0}")] @@ -152,37 +154,81 @@ const MAX_CONTEXT_TOKENS: usize = 4096; /// Which compute device to use for inference. #[derive(Debug, Clone, Default)] pub enum DevicePreference { - /// Try Metal on macOS, fall back to CPU everywhere else. + /// Metal on Apple Silicon, CPU everywhere else. #[default] Auto, /// Force CPU inference on all platforms. Cpu, - /// Force Metal (macOS / Apple Silicon). Returns an error on other platforms. + /// Force Metal. Errors on any build that cannot use it, rather than + /// failing later during inference. Metal, } +/// Whether this build can safely run candle's Metal backend. +/// +/// `Device::new_metal(0)` succeeding is NOT sufficient. An Intel Mac reports +/// Metal 3 support and opens the device fine, but candle's matmul kernels are +/// written against Apple-GPU-only SIMD-group matrix intrinsics. Probed on an +/// Intel Iris Plus 655 (macOS 15.7.7): +/// +/// ```text +/// Device::new_metal(0) -> OK +/// OK f32 alloc +/// OK f32 add +/// PANIC f32 matmul +/// ``` +/// +/// Allocation and elementwise ops work; the first `matmul` — which every +/// forward pass needs — dies building its compute pipeline: +/// +/// ```text +/// thread 'main' panicked at candle-metal-kernels-0.10.2/src/metal/device.rs:111 +/// NSError { code: 2, "AIR builtin function was called but no definition was +/// found.", domain: "CompilerError" } +/// ``` +/// +/// That is an `unwrap()` inside candle, and the release profile sets +/// `panic = "abort"`, so it cannot be caught and turned into a CPU fallback +/// at runtime. The only reliable fix is to not select Metal on hardware whose +/// kernels do not exist. Gating on the target architecture does that: the +/// release matrix ships a native `aarch64-apple-darwin` build for Apple +/// Silicon (which keeps Metal) and `x86_64-apple-darwin` for Intel Macs +/// (which get CPU + Accelerate BLAS). +const METAL_SUPPORTED: bool = cfg!(all(target_os = "macos", target_arch = "aarch64")); + +/// Open a Metal device, or explain precisely why this build cannot. +fn metal_device() -> Result { + if !METAL_SUPPORTED { + let reason = if cfg!(target_os = "macos") { + "Metal inference requires an Apple Silicon GPU. This is the Intel build, \ + and candle's matmul kernels need Apple-GPU-only intrinsics. Use --device cpu." + } else { + "Metal is only available on macOS. Use --device cpu." + }; + return Err(EngineError::DeviceUnavailable(reason.into())); + } + + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + { + return Device::new_metal(0) + .map_err(|e| EngineError::DeviceUnavailable(format!("Metal unavailable: {e}"))); + } + + #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] + unreachable!("guarded by METAL_SUPPORTED") +} + /// Select the best available device according to the caller's preference. /// Prints a one-line status to stderr so the user knows what's being used. pub fn resolve_device(pref: &DevicePreference) -> Result { match pref { DevicePreference::Cpu => Ok(Device::Cpu), - DevicePreference::Metal => { - #[cfg(target_os = "macos")] - { - Device::new_metal(0) - .map_err(|e| EngineError::ModelLoadError(format!("Metal unavailable: {e}"))) - } - #[cfg(not(target_os = "macos"))] - Err(EngineError::ModelLoadError( - "Metal is only available on macOS".into(), - )) - } + DevicePreference::Metal => metal_device(), DevicePreference::Auto => { - #[cfg(target_os = "macos")] - { - match Device::new_metal(0) { + if METAL_SUPPORTED { + match metal_device() { Ok(d) => { eprintln!(" Device Metal (Apple Silicon GPU)"); return Ok(d); @@ -191,9 +237,13 @@ pub fn resolve_device(pref: &DevicePreference) -> Result { eprintln!(" Device CPU (Metal unavailable, using Accelerate BLAS)"); } } + } else if cfg!(target_os = "macos") { + eprintln!( + " Device CPU (Intel Mac — Metal needs Apple Silicon, using Accelerate BLAS)" + ); + } else { + eprintln!(" Device CPU"); } - #[cfg(not(target_os = "macos"))] - eprintln!(" Device CPU"); Ok(Device::Cpu) } @@ -1174,6 +1224,40 @@ fn detect_custom_rule_violations(diff: &str, rules: &[CustomRule]) -> Vec