diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d2f7161..1e95f41 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -31,6 +31,9 @@ jobs:
- target: aarch64-unknown-linux-gnu
os: ubuntu-22.04
artifact: graphiq-aarch64-unknown-linux-gnu
+ - target: x86_64-pc-windows-msvc
+ os: windows-2022
+ artifact: graphiq-x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
@@ -61,23 +64,44 @@ jobs:
- name: Build release
run: cargo build --release --target ${{ matrix.target }} --features gpu
- - name: Package
+ - name: Package (Unix)
+ if: runner.os != 'Windows'
run: |
+ set -euo pipefail
mkdir -p dist
for bin in graphiq graphiq-mcp graphiq-bench; do
- if [ -f "target/${{ matrix.target }}/release/$bin" ]; then
- cp "target/${{ matrix.target }}/release/$bin" dist/
- fi
+ test -f "target/${{ matrix.target }}/release/$bin"
+ cp "target/${{ matrix.target }}/release/$bin" dist/
done
cd dist && tar czf "${{ matrix.artifact }}.tar.gz" -- *
shasum -a 256 "${{ matrix.artifact }}.tar.gz" > "${{ matrix.artifact }}.sha256"
+ - name: Package (Windows)
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $dist = Join-Path $PWD 'dist'
+ New-Item -ItemType Directory -Path $dist -Force | Out-Null
+ $binaries = @('graphiq.exe', 'graphiq-mcp.exe', 'graphiq-bench.exe')
+ foreach ($binary in $binaries) {
+ $source = Join-Path $PWD "target\${{ matrix.target }}\release\$binary"
+ if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
+ throw "missing release binary: $source"
+ }
+ Copy-Item -LiteralPath $source -Destination (Join-Path $dist $binary)
+ }
+ $archiveName = '${{ matrix.artifact }}.zip'
+ $archive = Join-Path $dist $archiveName
+ Compress-Archive -Path (Join-Path $dist 'graphiq.exe'), (Join-Path $dist 'graphiq-mcp.exe'), (Join-Path $dist 'graphiq-bench.exe') -DestinationPath $archive -Force
+ $hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
+ "$hash $archiveName" | Set-Content -LiteralPath (Join-Path $dist '${{ matrix.artifact }}.sha256')
+
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
- path: |
- dist/${{ matrix.artifact }}.tar.gz
- dist/${{ matrix.artifact }}.sha256
+ path: dist/*
+ if-no-files-found: error
release:
needs: [build]
@@ -94,4 +118,6 @@ jobs:
with:
files: |
artifacts/**/*.tar.gz
+ artifacts/**/*.zip
+ artifacts/**/*.sha256
generate_release_notes: true
diff --git a/.github/workflows/windows-smoke.yml b/.github/workflows/windows-smoke.yml
new file mode 100644
index 0000000..9a3331f
--- /dev/null
+++ b/.github/workflows/windows-smoke.yml
@@ -0,0 +1,174 @@
+name: Windows smoke
+
+# Validates the native Windows build, Windows path handling, setup/update
+# integration points, and the MCP stdio protocol. GPU availability is not a
+# property of the hosted runner, so the deterministic smoke path forces the
+# documented CPU fallback while the release build still includes the gpu feature.
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ smoke:
+ runs-on: windows-2022
+ timeout-minutes: 30
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Build release (gpu feature)
+ run: cargo build --workspace --release --features gpu
+
+ - name: Run workspace tests
+ run: cargo test --workspace --features gpu
+
+ - name: Run Windows CLI and setup smoke
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $root = (Get-Location).Path
+ $graphiq = Join-Path $root 'target\release\graphiq.exe'
+ $mcp = Join-Path $root 'target\release\graphiq-mcp.exe'
+ $env:GRAPHIQ_DISABLE_GPU = '1'
+
+ $version = & $graphiq --version
+ if ($LASTEXITCODE -ne 0 -or $version -notmatch '^graphiq ') {
+ throw "graphiq --version failed: $version"
+ }
+
+ $fixture = Join-Path $env:RUNNER_TEMP 'graphiq Windows fixture with spaces'
+ Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue
+ New-Item -ItemType Directory -Path (Join-Path $fixture 'src') -Force | Out-Null
+ @'
+ pub struct RateLimiter { max: u32 }
+ impl RateLimiter {
+ pub fn check_rate(&self) -> bool { true }
+ pub fn reset(&self) {}
+ }
+ pub fn handle_request(rl: &RateLimiter) -> bool { rl.check_rate() }
+ '@ | Set-Content -LiteralPath (Join-Path $fixture 'src\lib.rs') -Encoding utf8
+
+ & git -C $fixture init --quiet
+ & git -C $fixture config user.email ci@example.invalid
+ & git -C $fixture config user.name 'GraphIQ CI'
+ & git -C $fixture add --all
+ & git -C $fixture commit --quiet -m init
+ if ($LASTEXITCODE -ne 0) { throw 'failed to create the Windows fixture repository' }
+
+ $db = Join-Path $fixture '.graphiq\graphiq.db'
+ & $graphiq index $fixture --db $db
+ if ($LASTEXITCODE -ne 0) { throw 'graphiq index failed on Windows' }
+
+ $search = (& $graphiq search 'rate limit' --db $db --top 3 | Out-String)
+ if ($LASTEXITCODE -ne 0 -or $search -notmatch 'RateLimiter') {
+ throw "Windows search did not find RateLimiter: $search"
+ }
+
+ $status = (& $graphiq status --db $db | Out-String)
+ if ($LASTEXITCODE -ne 0 -or $status -notmatch 'GPU: unavailable \(disabled by GRAPHIQ_DISABLE_GPU\)') {
+ throw "Windows status did not report the CPU fallback: $status"
+ }
+
+ # This exercises sibling graphiq-mcp.exe discovery, Windows command
+ # lookup, and the no-Unix-package-manager setup path.
+ & $graphiq setup --project $fixture --skip-index --persistent --harness claude-code
+ if ($LASTEXITCODE -ne 0) { throw 'graphiq setup failed on Windows' }
+ $configPath = Join-Path $fixture '.claude\.mcp.json'
+ $config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
+ if ($null -eq $config.mcpServers.graphiq) { throw 'setup did not write the Claude Code MCP entry' }
+ $configuredProject = [string]$config.mcpServers.graphiq.args[0]
+ # Rust canonicalize may use the Windows extended-path prefix; both
+ # spellings identify the same project and are accepted by the MCP.
+ if ($configuredProject.StartsWith('\\?\')) {
+ $configuredProject = $configuredProject.Substring(4)
+ }
+ if ($configuredProject -ne $fixture) { throw "setup wrote the wrong Windows project path: $configuredProject" }
+
+ $sync = (& $graphiq sync --project $fixture --harness claude-code | Out-String)
+ if ($LASTEXITCODE -ne 0 -or $sync -notmatch 'connected:') {
+ throw "graphiq sync did not verify the Windows MCP entry: $sync"
+ }
+
+ function Read-McpJsonLine([System.Diagnostics.Process]$Process) {
+ $read = $Process.StandardOutput.ReadLineAsync()
+ if (-not $read.Wait(10000)) { throw 'timed out waiting for MCP response' }
+ if ($null -eq $read.Result) { throw 'MCP exited before returning a response' }
+ return ($read.Result | ConvertFrom-Json)
+ }
+
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
+ $psi.FileName = $mcp
+ $quotedFixture = '"' + $fixture.Replace('"', '\"') + '"'
+ $quotedDb = '"' + $db.Replace('"', '\"') + '"'
+ $psi.Arguments = "$quotedFixture --db $quotedDb"
+ $psi.WorkingDirectory = $root
+ $psi.UseShellExecute = $false
+ $psi.CreateNoWindow = $true
+ $psi.RedirectStandardInput = $true
+ $psi.RedirectStandardOutput = $true
+ $psi.RedirectStandardError = $true
+ $mcpProcess = New-Object System.Diagnostics.Process
+ $mcpProcess.StartInfo = $psi
+ [void]$mcpProcess.Start()
+ try {
+ $mcpProcess.StandardInput.WriteLine('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"1"}}}')
+ $mcpProcess.StandardInput.WriteLine('{"jsonrpc":"2.0","method":"notifications/initialized"}')
+ $mcpProcess.StandardInput.WriteLine('{"jsonrpc":"2.0","id":2,"method":"ping"}')
+ $mcpProcess.StandardInput.WriteLine('{"jsonrpc":"2.0","id":3,"method":"shutdown"}')
+
+ $initialize = Read-McpJsonLine $mcpProcess
+ $ping = Read-McpJsonLine $mcpProcess
+ $shutdown = Read-McpJsonLine $mcpProcess
+ if ($initialize.id -ne 1 -or $initialize.result.serverInfo.name -ne 'graphiq') { throw 'invalid MCP initialize response on Windows' }
+ if ($ping.id -ne 2 -or $null -eq $ping.result) { throw 'invalid MCP ping response on Windows' }
+ if ($shutdown.id -ne 3) { throw 'invalid MCP shutdown response on Windows' }
+ } finally {
+ $mcpProcess.StandardInput.Close()
+ if (-not $mcpProcess.WaitForExit(10000)) {
+ $mcpProcess.Kill()
+ throw 'MCP process did not exit after stdin closed'
+ }
+ $mcpProcess.Dispose()
+ }
+
+ - name: Test PowerShell installer with the local release archive
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $root = (Get-Location).Path
+ $stage = Join-Path $env:RUNNER_TEMP 'graphiq Windows package'
+ $installDir = Join-Path $env:RUNNER_TEMP 'graphiq Windows install'
+ Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue
+ Remove-Item -LiteralPath $installDir -Recurse -Force -ErrorAction SilentlyContinue
+ New-Item -ItemType Directory -Path $stage -Force | Out-Null
+ foreach ($binary in @('graphiq.exe', 'graphiq-mcp.exe', 'graphiq-bench.exe')) {
+ Copy-Item -LiteralPath (Join-Path $root "target\release\$binary") -Destination (Join-Path $stage $binary)
+ }
+ $archiveName = 'graphiq-x86_64-pc-windows-msvc.zip'
+ $archive = Join-Path $stage $archiveName
+ Compress-Archive -Path (Join-Path $stage 'graphiq.exe'), (Join-Path $stage 'graphiq-mcp.exe'), (Join-Path $stage 'graphiq-bench.exe') -DestinationPath $archive -Force
+ $hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
+ "$hash $archiveName" | Set-Content -LiteralPath ([IO.Path]::ChangeExtension($archive, 'sha256'))
+
+ & (Join-Path $root 'install.ps1') -ArchivePath $archive -InstallDir $installDir -NoPathUpdate -Version 'ci'
+ if (-not (Test-Path -LiteralPath (Join-Path $installDir 'graphiq.exe'))) {
+ throw 'PowerShell installer did not install graphiq.exe'
+ }
+ $installedVersion = & (Join-Path $installDir 'graphiq.exe') --version
+ if ($LASTEXITCODE -ne 0 -or $installedVersion -notmatch '^graphiq ') {
+ throw "installed Windows binary failed: $installedVersion"
+ }
+
+ & (Join-Path $root 'install.ps1') -Uninstall -InstallDir $installDir -NoPathUpdate
+ if (Test-Path -LiteralPath (Join-Path $installDir 'graphiq.exe')) {
+ throw 'PowerShell installer did not uninstall graphiq.exe'
+ }
diff --git a/Cargo.lock b/Cargo.lock
index 9ee9af0..5a19406 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1362,7 +1362,7 @@ dependencies = [
[[package]]
name = "graphiq-bench"
-version = "4.4.0"
+version = "4.5.0"
dependencies = [
"graphiq-core",
"serde",
@@ -1371,7 +1371,7 @@ dependencies = [
[[package]]
name = "graphiq-cli"
-version = "4.4.0"
+version = "4.5.0"
dependencies = [
"clap",
"dirs",
@@ -1383,7 +1383,7 @@ dependencies = [
[[package]]
name = "graphiq-core"
-version = "4.4.0"
+version = "4.5.0"
dependencies = [
"aho-corasick",
"bincode",
@@ -1428,7 +1428,7 @@ dependencies = [
[[package]]
name = "graphiq-mcp"
-version = "4.4.0"
+version = "4.5.0"
dependencies = [
"graphiq-core",
"notify",
diff --git a/README.md b/README.md
index 97c02e5..cd15907 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@
+
@@ -35,6 +36,18 @@ Or install a prebuilt binary on macOS or Linux:
curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | bash
```
+On Windows, run the PowerShell installer:
+
+```powershell
+$installer = Join-Path $env:TEMP "graphiq-install.ps1"
+Invoke-WebRequest https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.ps1 -OutFile $installer
+powershell -ExecutionPolicy Bypass -File $installer
+Remove-Item $installer
+```
+
+The Windows installer places `graphiq.exe` and `graphiq-mcp.exe` in
+`%LOCALAPPDATA%\GraphIQ\bin` and adds that directory to the user PATH.
+
### Index and search
```bash
diff --git a/crates/graphiq-bench/Cargo.toml b/crates/graphiq-bench/Cargo.toml
index 5e36454..62612ce 100644
--- a/crates/graphiq-bench/Cargo.toml
+++ b/crates/graphiq-bench/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "graphiq-bench"
-version = "4.4.0"
+version = "4.5.0"
edition = "2021"
[[bin]]
diff --git a/crates/graphiq-cli/Cargo.toml b/crates/graphiq-cli/Cargo.toml
index deb986e..87f725c 100644
--- a/crates/graphiq-cli/Cargo.toml
+++ b/crates/graphiq-cli/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "graphiq-cli"
-version = "4.4.0"
+version = "4.5.0"
edition = "2021"
[[bin]]
diff --git a/crates/graphiq-cli/src/main.rs b/crates/graphiq-cli/src/main.rs
index f5cfbb2..84fd076 100644
--- a/crates/graphiq-cli/src/main.rs
+++ b/crates/graphiq-cli/src/main.rs
@@ -1252,16 +1252,7 @@ struct DepCheck {
}
fn cmd_exists(cmd: &str) -> bool {
- std::process::Command::new("which")
- .arg(cmd)
- .output()
- .map(|o| o.status.success())
- .unwrap_or(false)
- || std::process::Command::new("command")
- .args(["-v", cmd])
- .output()
- .map(|o| o.status.success())
- .unwrap_or(false)
+ binary_on_path(cmd).is_some()
}
fn confirm(prompt: &str) -> bool {
@@ -1276,6 +1267,15 @@ fn confirm(prompt: &str) -> bool {
}
fn check_build_dependencies() {
+ // A release install has no source-build dependencies. In particular, a
+ // Windows install uses the system MSVC runtime and DirectX 12 when a GPU
+ // is available; it must not try to run the Unix/Linux package-manager
+ // probes below.
+ if cfg!(windows) {
+ println!(" Windows runtime: bundled SQLite; optional GPU acceleration uses DirectX 12");
+ return;
+ }
+
let is_macos = std::env::consts::OS == "macos";
let deps = [
@@ -1426,6 +1426,28 @@ fn check_build_dependencies() {
}
}
+/// Escape a value for a TOML basic string. Windows paths contain backslashes,
+/// which TOML treats as escape characters, so writing a raw `C:\\...` path
+/// would produce an invalid Codex configuration.
+fn toml_basic_string(value: &str) -> String {
+ let mut escaped = String::with_capacity(value.len());
+ for ch in value.chars() {
+ match ch {
+ '\\' => escaped.push_str("\\\\"),
+ '"' => escaped.push_str("\\\""),
+ '\n' => escaped.push_str("\\n"),
+ '\r' => escaped.push_str("\\r"),
+ '\t' => escaped.push_str("\\t"),
+ ch if ch.is_control() => {
+ use std::fmt::Write;
+ let _ = write!(escaped, "\\u{:04x}", ch as u32);
+ }
+ ch => escaped.push(ch),
+ }
+ }
+ escaped
+}
+
/// Strip JSONC comments (`// line` and `/* block */`) from text so it can be
/// parsed by a strict JSON parser (serde_json). String literals are preserved —
/// a `//` or `/*` inside a string value (e.g. a URL) is NOT treated as a
@@ -1519,7 +1541,7 @@ fn parse_jsonc_config(content: &str) -> Option {
/// correctly detect an existing graphiq entry, we prefer the file that already
/// exists (`.jsonc` first), and create `.jsonc` when neither exists.
fn opencode_config_path(home: &std::path::Path) -> std::path::PathBuf {
- let dir = home.join(".config").join("opencode");
+ let dir = opencode_config_dir(home);
let jsonc = dir.join("opencode.jsonc");
let json = dir.join("opencode.json");
if jsonc.exists() {
@@ -1531,6 +1553,39 @@ fn opencode_config_path(home: &std::path::Path) -> std::path::PathBuf {
}
}
+/// Return OpenCode's per-user configuration directory on the current OS.
+/// Linux/macOS use the XDG-style location already supported by GraphIQ;
+/// Windows applications conventionally use `%APPDATA%` instead.
+fn opencode_config_dir(home: &std::path::Path) -> std::path::PathBuf {
+ let base = if cfg!(windows) {
+ windows_roaming_dir(home)
+ } else {
+ home.join(".config")
+ };
+ base.join("opencode")
+}
+
+/// Resolve Windows' roaming application-data directory while keeping helpers
+/// that accept an explicit home path deterministic in tests. The real home
+/// directory uses `%APPDATA%` (which may be redirected); synthetic homes use
+/// the conventional `AppData\Roaming` location beneath the supplied path.
+fn windows_roaming_dir(home: &std::path::Path) -> std::path::PathBuf {
+ if let (Some(actual_home), Some(appdata)) = (dirs::home_dir(), std::env::var_os("APPDATA")) {
+ if actual_home == home {
+ return PathBuf::from(appdata);
+ }
+ }
+ home.join("AppData").join("Roaming")
+}
+
+fn platform_config_dir(home: &std::path::Path) -> Option {
+ if cfg!(windows) {
+ Some(windows_roaming_dir(home))
+ } else {
+ dirs::config_dir()
+ }
+}
+
// ─── graphiq sync: verify harness attach + reconcile graphiq storage ─────────
//
// `graphiq sync` reports the TRUE attach state of every supported harness
@@ -1685,7 +1740,7 @@ struct HarnessTarget {
fn harness_targets(home: &std::path::Path, project: &std::path::Path) -> Vec {
let mut out = Vec::new();
if let Some(claude_desktop) =
- dirs::config_dir().map(|d| d.join("Claude").join("claude_desktop_config.json"))
+ platform_config_dir(home).map(|d| d.join("Claude").join("claude_desktop_config.json"))
{
out.push(HarnessTarget {
name: "claude-desktop",
@@ -1699,7 +1754,7 @@ fn harness_targets(home: &std::path::Path, project: &std::path::Path) -> Vec Vec Option {
- if let Some(sibling) = which_graphiq() {
- return Some(sibling);
- }
- // Fallback: PATH lookup. `which` is Unix; on Windows `where` is the tool.
- let probe = if cfg!(windows) { "where" } else { "which" };
- std::process::Command::new(probe)
- .arg("graphiq-mcp")
- .output()
- .ok()
- .filter(|o| o.status.success())
- .and_then(|o| String::from_utf8(o.stdout).ok())
- .and_then(|s| {
- let line = s.lines().next()?.trim().to_string();
- if line.is_empty() {
- None
+ which_graphiq().or_else(|| binary_on_path("graphiq-mcp"))
+}
+
+/// Re-enter the CLI using the exact executable that is currently running.
+/// This matters for `sync --apply` when a user launched `graphiq.exe` by path
+/// before adding its directory to PATH.
+fn current_graphiq_command() -> std::process::Command {
+ std::env::current_exe()
+ .map(|path| std::process::Command::new(path))
+ .unwrap_or_else(|_| {
+ std::process::Command::new(if cfg!(windows) {
+ "graphiq.exe"
} else {
- Some(PathBuf::from(line))
- }
+ "graphiq"
+ })
})
}
@@ -1788,6 +1840,7 @@ fn cmd_sync(project: Option<&std::path::Path>, harness_filter: Option<&str>, app
println!("╭──────────────────────────────────────────────╮");
println!("│ GraphIQ Sync │");
println!("╰──────────────────────────────────────────────╯");
+
println!(" Project: {}", project_path.display());
// 1. Is graphiq-mcp reachable? This is the #1 attach failure.
@@ -1873,7 +1926,8 @@ fn cmd_sync(project: Option<&std::path::Path>, harness_filter: Option<&str>, app
println!(" Re-applying graphiq config to missing harnesses via setup...");
// setup is idempotent and scoped to installed harnesses; --skip-index
// keeps it fast (sync never indexes).
- let setup_status = std::process::Command::new("graphiq")
+ let mut setup_command = current_graphiq_command();
+ let setup_status = setup_command
.arg("setup")
.arg("--project")
.arg(&project_path)
@@ -2135,10 +2189,15 @@ fn detect_signature(
/// Resolve the full path for a harness's config-dir signal, given a home dir.
/// Returns None if the signature has no config-dir signal.
fn config_dir_path(home: &std::path::Path, sig: &HarnessSignature) -> Option {
- if sig.config_dir.is_empty() {
- None
- } else {
- Some(home.join(sig.config_dir))
+ match sig.name {
+ // Claude Desktop stores its user config in %APPDATA% on Windows;
+ // keep the catalog's macOS-relative path for the other platforms.
+ "claude-desktop" if cfg!(windows) => platform_config_dir(home).map(|d| d.join("Claude")),
+ // OpenCode follows the same platform-specific roaming config root as
+ // setup/sync rather than the Unix ~/.config path in the catalog.
+ "opencode" => Some(opencode_config_dir(home)),
+ _ if sig.config_dir.is_empty() => None,
+ _ => Some(home.join(sig.config_dir)),
}
}
@@ -2149,12 +2208,32 @@ fn binary_on_path(binary: &str) -> Option {
return None;
}
let probe = if cfg!(windows) { "where" } else { "which" };
- std::process::Command::new(probe)
+ let from_probe = std::process::Command::new(probe)
.arg(binary)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
+ .and_then(|s| {
+ let line = s.lines().next()?.trim().to_string();
+ if line.is_empty() {
+ None
+ } else {
+ Some(PathBuf::from(line))
+ }
+ });
+ if from_probe.is_some() || cfg!(windows) {
+ return from_probe;
+ }
+
+ // Keep the old Unix fallback for minimal installations that have the
+ // shell builtin but not the standalone `which` utility.
+ std::process::Command::new("sh")
+ .args(["-c", "command -v \"$1\"", "graphiq-path", binary])
+ .output()
+ .ok()
+ .filter(|o| o.status.success())
+ .and_then(|o| String::from_utf8(o.stdout).ok())
.and_then(|s| {
let line = s.lines().next()?.trim().to_string();
if line.is_empty() {
@@ -2860,6 +2939,8 @@ fn cmd_setup(
},
};
+ let home_dir = dirs::home_dir().unwrap_or_else(|| std::path::Path::new(".").to_path_buf());
+
println!(" Project: {}", project_path.display());
if ephemeral {
println!(" Index storage: temp MCP database (--persistent opts into .graphiq)");
@@ -2869,19 +2950,16 @@ fn cmd_setup(
println!();
let graphiq_bin = which_graphiq();
- if graphiq_bin.is_none() {
- let found = std::process::Command::new("which")
- .arg("graphiq-mcp")
- .output()
- .ok()
- .filter(|o| o.status.success());
- if found.is_none() {
- eprintln!(" error: graphiq-mcp not found on PATH.");
- eprintln!(" Install with: cargo install --path . --bin graphiq-mcp");
+ if graphiq_bin.is_none() && binary_on_path("graphiq-mcp").is_none() {
+ eprintln!(" error: graphiq-mcp not found on PATH.");
+ eprintln!(" Install with: cargo install --path . --bin graphiq-mcp");
+ if cfg!(windows) {
+ eprintln!(" Or: download and run install.ps1 from the GraphIQ release.");
+ } else {
eprintln!(" Or: curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | bash");
- eprintln!(" Then re-run setup.");
- std::process::exit(1);
}
+ eprintln!(" Then re-run setup.");
+ std::process::exit(1);
}
check_build_dependencies();
@@ -2921,18 +2999,10 @@ fn cmd_setup(
}
};
- let _binary_on_path = |name: &str| -> bool {
- std::process::Command::new("which")
- .arg(name)
- .output()
- .map(|o| o.status.success())
- .unwrap_or(false)
- };
-
// Claude Desktop
if should_configure("claude-desktop") {
- let claude_config =
- dirs::config_dir().map(|d| d.join("Claude").join("claude_desktop_config.json"));
+ let claude_config = platform_config_dir(&home_dir)
+ .map(|d| d.join("Claude").join("claude_desktop_config.json"));
if let Some(ref config_path) = claude_config {
if config_path.exists() || config_path.parent().map_or(false, |p| p.exists()) {
@@ -2995,18 +3065,16 @@ fn cmd_setup(
// and merges them. Write to whichever already exists (prefer .jsonc) so
// we don't spawn a second file, and parse JSONC safely so a commented
// config is preserved rather than destroyed.
- let opencode_config = dirs::home_dir()
- .map(|h| opencode_config_path(&h))
- .and_then(|p| {
- // Only proceed if the opencode config directory itself exists —
- // creating a brand-new opencode.jsonc in a missing dir would
- // imply opencode is installed when it may not be.
- if p.parent().map_or(false, |d| d.exists()) {
- Some(p)
- } else {
- None
- }
- });
+ let opencode_config = Some(opencode_config_path(&home_dir)).and_then(|p| {
+ // Only proceed if the opencode config directory itself exists —
+ // creating a brand-new opencode.jsonc in a missing dir would
+ // imply opencode is installed when it may not be.
+ if p.parent().map_or(false, |d| d.exists()) {
+ Some(p)
+ } else {
+ None
+ }
+ });
if let Some(ref config_path) = opencode_config {
// Wrapped in a labeled block so recoverable opencode errors
@@ -3112,13 +3180,14 @@ fn cmd_setup(
// Codex CLI
if should_configure("codex") {
- let codex_config = dirs::home_dir().map(|d| d.join(".codex").join("config.toml"));
+ let codex_config = Some(home_dir.join(".codex").join("config.toml"));
if let Some(ref config_path) = codex_config {
// Labeled block so a codex read failure skips ONLY codex, not the
// rest of cmd_setup (Hermes/Cursor/Windsurf/Gemini/Aider/index).
'codex: {
let project_str = project_path.display().to_string();
+ let project_arg = toml_basic_string(&project_str);
let args_suffix = if ephemeral { ", \"--ephemeral\"" } else { "" };
let (content, written) = if config_path.exists() {
@@ -3132,14 +3201,15 @@ fn cmd_setup(
|| t.starts_with("[mcp_servers.graphiq.")
|| t.starts_with("[mcp_servers.graphiq ")
});
- let same_project = existing.contains(&project_str);
+ let same_project =
+ existing.contains(&project_str) || existing.contains(&project_arg);
if has_graphiq && same_project {
(existing, false)
} else {
let mut cleaned = existing;
let section = format!(
"\n[mcp_servers.graphiq]\ncommand = \"graphiq-mcp\"\nargs = [\"{}\"{}]\nenabled = true\n",
- project_str, args_suffix
+ project_arg, args_suffix
);
cleaned.push_str(§ion);
(cleaned, true)
@@ -3154,7 +3224,7 @@ fn cmd_setup(
} else {
let section = format!(
"[mcp_servers.graphiq]\ncommand = \"graphiq-mcp\"\nargs = [\"{}\"{}]\nenabled = true\n",
- project_str, args_suffix
+ project_arg, args_suffix
);
(section, true)
};
@@ -3299,7 +3369,7 @@ fn cmd_setup(
// Gemini CLI
if should_configure("gemini") {
- let gemini_config = dirs::home_dir().map(|d| d.join(".gemini").join("settings.json"));
+ let gemini_config = Some(home_dir.join(".gemini").join("settings.json"));
if let Some(ref config_path) = gemini_config {
let project_str = project_path.display().to_string();
let mut cmd = vec!["graphiq-mcp".to_string(), project_str.clone()];
@@ -3587,22 +3657,20 @@ fn write_agents_md(graphiq_dir: &std::path::Path) {
}
fn which_graphiq() -> Option {
- let graphiq_mcp = std::env::current_exe().ok()?;
- let bin_name = graphiq_mcp.file_name()?.to_str()?.to_string();
- if bin_name == "graphiq" {
- let mut p = graphiq_mcp.clone();
- p.set_file_name("graphiq-mcp");
- if p.exists() {
- return Some(p);
- }
- if let Some(parent) = graphiq_mcp.parent() {
- let alt = parent.join("graphiq-mcp");
- if alt.exists() {
- return Some(alt);
- }
- }
+ let current_exe = std::env::current_exe().ok()?;
+ let bin_name = current_exe.file_stem()?.to_str()?;
+ if bin_name != "graphiq" {
+ return None;
}
- None
+
+ let parent = current_exe.parent()?;
+ let sibling_name = if cfg!(windows) {
+ "graphiq-mcp.exe"
+ } else {
+ "graphiq-mcp"
+ };
+ let sibling = parent.join(sibling_name);
+ sibling.exists().then_some(sibling)
}
fn cmd_context(symbol_name: &str, db_path: &std::path::Path) {
@@ -4815,7 +4883,7 @@ fn cmd_update() {
}
};
- // fetch_latest_version returns a v-prefixed tag (e.g. "v4.3.3").
+ // fetch_latest_version returns a v-prefixed tag (e.g. "v4.5.0").
let latest_version = latest_tag.strip_prefix('v').unwrap_or(&latest_tag);
if latest_version == current_version {
@@ -4824,6 +4892,33 @@ fn cmd_update() {
}
println!(" Update available: {} -> {}", current_version, latest_tag);
+ if cfg!(windows) {
+ let install_dir = match std::env::current_exe()
+ .ok()
+ .and_then(|path| path.parent().map(|parent| parent.to_path_buf()))
+ {
+ Some(path) => path,
+ None => {
+ eprintln!(" error: could not determine the current install directory");
+ std::process::exit(1);
+ }
+ };
+
+ println!(" Launching the official PowerShell installer...");
+ match launch_windows_update(&latest_version, &install_dir) {
+ Ok(()) => {
+ println!(
+ " updater started; it will replace the binaries after this process exits."
+ );
+ return;
+ }
+ Err(e) => {
+ eprintln!(" error: failed to launch PowerShell installer: {e}");
+ std::process::exit(1);
+ }
+ }
+ }
+
println!(" Running the official install script...");
// Delegate to install.sh, which handles download, SHA-256 verification,
@@ -4858,10 +4953,8 @@ fn cmd_update() {
// update takes effect for MCP servers and connector configs immediately.
println!();
println!(" Re-linking harnesses (graphiq sync --apply)...");
- match std::process::Command::new("graphiq")
- .args(["sync", "--apply"])
- .status()
- {
+ let mut sync_command = current_graphiq_command();
+ match sync_command.args(["sync", "--apply"]).status() {
Ok(s) if s.success() => println!(" harnesses re-linked."),
Ok(s) => eprintln!(
" warning: graphiq sync exited {} - run `graphiq sync --apply` manually",
@@ -4874,22 +4967,88 @@ fn cmd_update() {
println!(" Done.");
}
-fn fetch_latest_version(_client: &str) -> Result {
- let output = std::process::Command::new("curl")
+const WINDOWS_INSTALLER_URL: &str =
+ "https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.ps1";
+
+fn powershell_single_quote(value: &str) -> String {
+ format!("'{}'", value.replace('\'', "''"))
+}
+
+/// Start the Windows updater without waiting for it. Windows keeps an
+/// executable locked while it is running, so the installer waits for this
+/// process ID before atomically replacing the installed binaries.
+fn launch_windows_update(version: &str, install_dir: &std::path::Path) -> Result<(), String> {
+ let command = format!(
+ "$script = (Invoke-WebRequest -UseBasicParsing -Uri {}).Content; & ([scriptblock]::Create($script)) -Version {} -InstallDir {} -WaitForPid {}",
+ powershell_single_quote(WINDOWS_INSTALLER_URL),
+ powershell_single_quote(version),
+ powershell_single_quote(&install_dir.display().to_string()),
+ std::process::id(),
+ );
+
+ std::process::Command::new("powershell.exe")
.args([
- "-fsSL",
- "--max-time",
- "15",
- "https://api.github.com/repos/aaf2tbz/graphiq/releases/latest",
+ "-NoProfile",
+ "-NonInteractive",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-Command",
+ &command,
])
- .output()
- .map_err(|e| format!("curl failed: {e}"))?;
+ .spawn()
+ .map(|_| ())
+ .map_err(|e| e.to_string())
+}
+
+fn fetch_latest_version(_client: &str) -> Result {
+ let output = (if cfg!(windows) {
+ std::process::Command::new("powershell.exe")
+ .args([
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ "(Invoke-RestMethod -Uri 'https://api.github.com/repos/aaf2tbz/graphiq/releases/latest').tag_name",
+ ])
+ .output()
+ } else {
+ std::process::Command::new("curl")
+ .args([
+ "-fsSL",
+ "--max-time",
+ "15",
+ "https://api.github.com/repos/aaf2tbz/graphiq/releases/latest",
+ ])
+ .output()
+ })
+ .map_err(|e| {
+ if cfg!(windows) {
+ format!("PowerShell failed: {e}")
+ } else {
+ format!("curl failed: {e}")
+ }
+ })?;
if !output.status.success() {
return Err("failed to fetch latest release info".into());
}
- let body = String::from_utf8_lossy(&output.stdout);
+ parse_release_tag(&String::from_utf8_lossy(&output.stdout))
+}
+
+fn parse_release_tag(body: &str) -> Result {
+ let trimmed = body.trim();
+ if trimmed.starts_with('v') && !trimmed.contains('{') && !trimmed.contains('"') {
+ return Ok(trimmed.to_string());
+ }
+
+ if let Ok(value) = serde_json::from_str::(trimmed) {
+ if let Some(version) = value.get("tag_name").and_then(|tag| tag.as_str()) {
+ if !version.is_empty() {
+ return Ok(version.to_string());
+ }
+ }
+ }
+
for line in body.lines() {
if line.contains("\"tag_name\"") {
let version = line
@@ -5028,7 +5187,7 @@ mod tests {
.unwrap()
.as_nanos()
));
- let dir = tmp.join(".config").join("opencode");
+ let dir = opencode_config_dir(&tmp);
std::fs::create_dir_all(&dir).unwrap();
// Neither exists -> default to .jsonc (modern canonical).
@@ -5455,4 +5614,34 @@ mod tests {
// Unix epoch itself.
assert_eq!(format_unix_ts_iso(0), "1970-01-01T00:00:00Z");
}
+
+ #[test]
+ fn toml_basic_string_escapes_windows_paths() {
+ assert_eq!(
+ toml_basic_string(r#"C:\Users\Avery\GraphIQ"#),
+ r#"C:\\Users\\Avery\\GraphIQ"#
+ );
+ assert_eq!(
+ toml_basic_string("line\nnext\tvalue"),
+ "line\\nnext\\tvalue"
+ );
+ }
+
+ #[test]
+ fn parse_release_tag_accepts_json_and_powershell_output() {
+ assert_eq!(
+ parse_release_tag(r#"{ "tag_name": "v4.5.0" }"#).unwrap(),
+ "v4.5.0"
+ );
+ assert_eq!(parse_release_tag("v4.5.0\r\n").unwrap(), "v4.5.0");
+ assert!(parse_release_tag("not a release").is_err());
+ }
+
+ #[test]
+ fn powershell_single_quote_escapes_embedded_quotes() {
+ assert_eq!(
+ powershell_single_quote("C:\\Avery's GraphIQ"),
+ "'C:\\Avery''s GraphIQ'"
+ );
+ }
}
diff --git a/crates/graphiq-core/Cargo.toml b/crates/graphiq-core/Cargo.toml
index 8c38623..af4bca2 100644
--- a/crates/graphiq-core/Cargo.toml
+++ b/crates/graphiq-core/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "graphiq-core"
-version = "4.4.0"
+version = "4.5.0"
edition = "2021"
[dependencies]
diff --git a/crates/graphiq-core/src/index.rs b/crates/graphiq-core/src/index.rs
index 17a33d5..2c48e65 100644
--- a/crates/graphiq-core/src/index.rs
+++ b/crates/graphiq-core/src/index.rs
@@ -99,6 +99,18 @@ impl ParsedFile {
}
}
+/// Store project-relative paths with the same slash-separated representation
+/// on every platform. This keeps persisted paths, import resolution, search
+/// filters, and user-facing results portable between Windows and Unix.
+fn stored_relative_path(path: &Path) -> String {
+ let path = path.to_string_lossy();
+ if cfg!(windows) {
+ path.replace('\\', "/")
+ } else {
+ path.into_owned()
+ }
+}
+
impl<'a> Indexer<'a> {
pub fn new(db: &'a GraphDb) -> Self {
Self {
@@ -235,10 +247,12 @@ impl<'a> Indexer<'a> {
let current_paths: HashSet = files
.iter()
.filter_map(|path| path.strip_prefix(root).ok())
- .map(|path| path.to_string_lossy().into_owned())
+ .map(stored_relative_path)
.collect();
for path in self.db.file_paths()? {
- if !current_paths.contains(&path) && self.db.delete_file(&path)? {
+ if !current_paths.contains(&stored_relative_path(Path::new(&path)))
+ && self.db.delete_file(&path)?
+ {
files_deleted += 1;
}
}
@@ -295,7 +309,7 @@ impl<'a> Indexer<'a> {
let file_plans: Vec = file_data
.iter()
.map(|(rel_path, _, _, hash, _, _)| {
- let path_str = rel_path.to_string_lossy();
+ let path_str = stored_relative_path(rel_path);
let existing = self.db.get_file_by_path(&path_str)?;
let changed = existing
.as_ref()
@@ -334,7 +348,7 @@ impl<'a> Indexer<'a> {
return ParsedFile::empty();
}
- let path_str = rel_path.to_string_lossy();
+ let path_str = stored_relative_path(rel_path);
let chunker = get_chunker(*lang);
let mut result = chunker.parse(source, &path_str);
// Call extraction only needs the tree during this worker call.
@@ -378,7 +392,7 @@ impl<'a> Indexer<'a> {
.zip(file_plans.iter())
.zip(parsed_files.iter())
{
- let path_str = rel_path.to_string_lossy();
+ let path_str = stored_relative_path(rel_path);
if let Some(ref f) = plan.existing {
if !plan.changed {
diff --git a/crates/graphiq-mcp/Cargo.toml b/crates/graphiq-mcp/Cargo.toml
index 26499e2..3cd8948 100644
--- a/crates/graphiq-mcp/Cargo.toml
+++ b/crates/graphiq-mcp/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "graphiq-mcp"
-version = "4.4.0"
+version = "4.5.0"
edition = "2021"
[[bin]]
diff --git a/docs/contributing.md b/docs/contributing.md
index 81ba59b..7109885 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -49,8 +49,10 @@ The CLI surfaces index/search/blast plus lifecycle commands: `setup`, `sync`
- **`macos-smoke.yml`** — builds natively on Apple Silicon, proves Metal index
acceleration and hybrid search scoring, then verifies CPU fallback and result
parity.
+- **`windows-smoke.yml`** — builds natively with MSVC, runs the CLI and MCP
+ protocol on Windows paths, and verifies the PowerShell installer.
- **`release.yml`** — cross-builds macOS (arm64 + x86_64) and Linux (x86_64 +
- aarch64) releases with SHA-256 checksums.
+ aarch64) plus Windows (x86_64 MSVC) releases with SHA-256 checksums.
- **`auto-release.yml`** — tags the next version on every push to `main`.
For Apple Silicon search measurements, build with the GPU feature and run:
diff --git a/docs/install.md b/docs/install.md
index c34133d..827326a 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -1,6 +1,6 @@
# Install
-GraphIQ runs on macOS (Apple Silicon + Intel), Linux (x86_64 + aarch64), and Windows (builds from source).
+GraphIQ runs on macOS (Apple Silicon + Intel), Linux (x86_64 + aarch64), and Windows (x86_64).
## Homebrew
@@ -17,6 +17,21 @@ curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | b
The script downloads a prebuilt binary for your platform, verifies the SHA-256 checksum, and places `graphiq` + `graphiq-mcp` on your PATH. On Linux it detects a missing Vulkan loader and offers to install one (optional — GraphIQ runs on CPU without it).
+## Windows (PowerShell)
+
+```powershell
+$installer = Join-Path $env:TEMP "graphiq-install.ps1"
+Invoke-WebRequest https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.ps1 -OutFile $installer
+powershell -ExecutionPolicy Bypass -File $installer
+Remove-Item $installer
+```
+
+The installer downloads the `x86_64-pc-windows-msvc` release, verifies its
+SHA-256 checksum, installs `graphiq.exe`, `graphiq-mcp.exe`, and
+`graphiq-bench.exe` under `%LOCALAPPDATA%\GraphIQ\bin`, and adds that directory
+to the current user's PATH. Open a new terminal after installation. To remove
+it, run the same script with `-Uninstall`.
+
## From Source
```bash
@@ -25,7 +40,7 @@ cd graphiq
cargo build --release
```
-For GPU acceleration add `--features gpu`. On macOS this selects the system Metal backend (no Vulkan or extra runtime is required); on Linux it uses Vulkan when a compatible loader/GPU is available and otherwise falls back to CPU. Indexing and large search score batches use the GPU automatically; small searches stay on CPU to avoid transfer overhead. Set `GRAPHIQ_DISABLE_GPU=1` to force the fallback path.
+For GPU acceleration add `--features gpu`. On macOS this selects the system Metal backend (no Vulkan or extra runtime is required); on Linux it uses Vulkan when a compatible loader/GPU is available; on Windows it uses DirectX 12 when available. Every platform falls back to CPU when GPU initialization is unavailable. Indexing and large search score batches use the GPU automatically; small searches stay on CPU to avoid transfer overhead. Set `GRAPHIQ_DISABLE_GPU=1` to force the fallback path.
## Requirements
@@ -34,6 +49,9 @@ For GPU acceleration add `--features gpu`. On macOS this selects the system Meta
- `pkg-config`
- SQLite is bundled.
+Prebuilt Windows releases do not require Rust, a C compiler, or `pkg-config`.
+Windows source builds require the Visual Studio C++ build tools.
+
## Platform Support
| Platform | Status | Notes |
@@ -42,7 +60,7 @@ For GPU acceleration add `--features gpu`. On macOS this selects the system Meta
| **macOS** (Intel) | ✅ Supported | prebuilt release; Metal acceleration depends on the OS/GPU |
| **Linux** (x86_64) | ✅ Supported | prebuilt release; [smoke-tested in CI](https://github.com/aaf2tbz/graphiq/actions/workflows/linux-smoke.yml) with zero Vulkan installed |
| **Linux** (aarch64) | ✅ Built | prebuilt release; built + RPATH-asserted in CI |
-| **Windows** | ⚠️ Builds | not prebuilt; `which`/path checks fall back to `where` |
+| **Windows** | ✅ Supported | prebuilt x86_64 release; DirectX 12 when available, CPU fallback otherwise |
## Uninstall
@@ -51,3 +69,12 @@ curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | b
```
This removes `graphiq`, `graphiq-mcp`, and `graphiq-bench`. Project-local `.graphiq/` indexes are left in place (delete them manually if desired).
+
+On Windows:
+
+```powershell
+$installer = Join-Path $env:TEMP "graphiq-install.ps1"
+Invoke-WebRequest https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.ps1 -OutFile $installer
+powershell -ExecutionPolicy Bypass -File $installer -Uninstall
+Remove-Item $installer
+```
diff --git a/install.ps1 b/install.ps1
new file mode 100644
index 0000000..22c2765
--- /dev/null
+++ b/install.ps1
@@ -0,0 +1,228 @@
+# GraphIQ Windows installer
+#
+# Usage:
+# powershell -ExecutionPolicy Bypass -File .\install.ps1
+# powershell -ExecutionPolicy Bypass -File .\install.ps1 -Version 4.5.0
+# powershell -ExecutionPolicy Bypass -File .\install.ps1 -Uninstall
+#
+# The default install location is %LOCALAPPDATA%\GraphIQ\bin. The installer
+# adds that directory to the current user's PATH without requiring elevation.
+# -ArchivePath and -NoPathUpdate are intended for CI and offline installs.
+
+[CmdletBinding()]
+param(
+ [string]$Version = "",
+ [string]$InstallDir = "",
+ [string]$ArchivePath = "",
+ [switch]$Uninstall,
+ [switch]$NoPathUpdate,
+ [int]$WaitForPid = 0
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+# Windows PowerShell 5.1 may default to older TLS versions; GitHub requires TLS 1.2.
+try {
+ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+} catch {
+ # PowerShell 7/.NET Core does not need this compatibility setting.
+}
+
+$Repository = "aaf2tbz/GraphIQ"
+$Target = "x86_64-pc-windows-msvc"
+$BinaryNames = @("graphiq.exe", "graphiq-mcp.exe", "graphiq-bench.exe")
+$ApiHeaders = @{ "User-Agent" = "GraphIQ installer" }
+
+if ([string]::IsNullOrWhiteSpace($InstallDir)) {
+ $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)
+ if ([string]::IsNullOrWhiteSpace($localAppData)) {
+ $localAppData = $env:LOCALAPPDATA
+ }
+ if ([string]::IsNullOrWhiteSpace($localAppData)) {
+ throw "Could not determine the Windows local application-data directory. Pass -InstallDir explicitly."
+ }
+ $InstallDir = Join-Path $localAppData "GraphIQ\bin"
+}
+$InstallDir = [IO.Path]::GetFullPath($InstallDir)
+
+function Write-Info([string]$Message) {
+ Write-Host " [GraphIQ] $Message"
+}
+
+function Find-ReleaseAsset($Release, [string]$Name) {
+ @($Release.assets | Where-Object { $_.name -eq $Name }) | Select-Object -First 1
+}
+
+function Get-Release([string]$ReleaseVersion) {
+ if ([string]::IsNullOrWhiteSpace($ReleaseVersion)) {
+ return Invoke-RestMethod -Uri "https://api.github.com/repos/$Repository/releases/latest" -Headers $ApiHeaders
+ }
+
+ $normalized = $ReleaseVersion.Trim()
+ if ($normalized.StartsWith("v")) {
+ $normalized = $normalized.Substring(1)
+ }
+ return Invoke-RestMethod -Uri "https://api.github.com/repos/$Repository/releases/tags/v$normalized" -Headers $ApiHeaders
+}
+
+function Normalize-PathForCompare([string]$Path) {
+ return ($Path.TrimEnd('\')).TrimEnd('/')
+}
+
+function Add-InstallDirToUserPath {
+ if ($NoPathUpdate) {
+ return
+ }
+
+ $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
+ $parts = @()
+ if (-not [string]::IsNullOrWhiteSpace($userPath)) {
+ $parts = @($userPath -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
+ }
+
+ $normalizedInstallDir = Normalize-PathForCompare $InstallDir
+ $alreadyPresent = @($parts | Where-Object {
+ (Normalize-PathForCompare $_) -ieq $normalizedInstallDir
+ }).Count -gt 0
+
+ if (-not $alreadyPresent) {
+ $parts += $InstallDir
+ [Environment]::SetEnvironmentVariable("Path", ($parts -join ';'), "User")
+ $env:Path = "$InstallDir;$env:Path"
+ Write-Info "added $InstallDir to the current user's PATH"
+ }
+}
+
+function Remove-InstallDirFromUserPath {
+ if ($NoPathUpdate) {
+ return
+ }
+
+ $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
+ if ([string]::IsNullOrWhiteSpace($userPath)) {
+ return
+ }
+
+ $normalizedInstallDir = Normalize-PathForCompare $InstallDir
+ $parts = @($userPath -split ';' | Where-Object {
+ -not [string]::IsNullOrWhiteSpace($_) -and
+ (Normalize-PathForCompare $_) -ine $normalizedInstallDir
+ })
+ [Environment]::SetEnvironmentVariable("Path", ($parts -join ';'), "User")
+}
+
+if ($Uninstall) {
+ foreach ($binary in $BinaryNames) {
+ $destination = Join-Path $InstallDir $binary
+ if (Test-Path -LiteralPath $destination) {
+ Remove-Item -LiteralPath $destination -Force
+ Write-Info "removed $destination"
+ }
+ }
+ Remove-InstallDirFromUserPath
+ Write-Info "uninstall complete"
+ exit 0
+}
+
+$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("graphiq-install-" + [Guid]::NewGuid().ToString("N"))
+New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
+
+try {
+ $release = $null
+ if ([string]::IsNullOrWhiteSpace($ArchivePath)) {
+ $release = Get-Release $Version
+ $tagName = [string]$release.tag_name
+ if ([string]::IsNullOrWhiteSpace($tagName)) {
+ throw "GitHub release did not contain a tag name."
+ }
+ $Version = $tagName.TrimStart('v')
+
+ $archiveName = "graphiq-$Target.zip"
+ $archiveAsset = Find-ReleaseAsset $release $archiveName
+ if ($null -eq $archiveAsset) {
+ throw "Release v$Version does not contain $archiveName."
+ }
+
+ $archiveFile = Join-Path $tempRoot $archiveName
+ Write-Info "downloading GraphIQ v$Version"
+ Invoke-WebRequest -UseBasicParsing -Uri $archiveAsset.browser_download_url -Headers $ApiHeaders -OutFile $archiveFile
+
+ $checksumName = [IO.Path]::ChangeExtension($archiveName, "sha256")
+ $checksumAsset = Find-ReleaseAsset $release $checksumName
+ if ($null -ne $checksumAsset) {
+ Invoke-WebRequest -UseBasicParsing -Uri $checksumAsset.browser_download_url -Headers $ApiHeaders -OutFile (Join-Path $tempRoot $checksumName)
+ }
+ } else {
+ $archiveFile = (Resolve-Path -LiteralPath $ArchivePath).Path
+ $archiveName = Split-Path -Leaf $archiveFile
+ if ([string]::IsNullOrWhiteSpace($Version)) {
+ $Version = "local archive"
+ }
+ Write-Info "installing $archiveName"
+ }
+
+ $checksumFile = [IO.Path]::ChangeExtension($archiveFile, "sha256")
+ $expectedHash = $null
+ if (Test-Path -LiteralPath $checksumFile) {
+ $checksumText = (Get-Content -LiteralPath $checksumFile -Raw).TrimStart([char]0xFEFF)
+ $pattern = '(?im)^\s*([0-9a-fA-F]{64})\s+\*?' + [Regex]::Escape($archiveName) + '\s*$'
+ $match = [Regex]::Match($checksumText, $pattern)
+ if ($match.Success) {
+ $expectedHash = $match.Groups[1].Value
+ }
+ }
+ if ([string]::IsNullOrWhiteSpace($expectedHash) -and $null -ne $release) {
+ $archiveAsset = Find-ReleaseAsset $release $archiveName
+ if ($null -ne $archiveAsset -and -not [string]::IsNullOrWhiteSpace([string]$archiveAsset.digest)) {
+ $expectedHash = ([string]$archiveAsset.digest) -replace '^sha256:', ''
+ }
+ }
+ if ([string]::IsNullOrWhiteSpace($expectedHash)) {
+ throw "No SHA-256 checksum was available for $archiveName; refusing to install it."
+ }
+
+ $actualHash = (Get-FileHash -LiteralPath $archiveFile -Algorithm SHA256).Hash
+ if ($actualHash -ine $expectedHash.Trim()) {
+ throw "SHA-256 checksum mismatch for $archiveName. Expected $expectedHash, got $actualHash."
+ }
+ Write-Info "checksum verified"
+
+ $extractDir = Join-Path $tempRoot "extracted"
+ Expand-Archive -LiteralPath $archiveFile -DestinationPath $extractDir -Force
+ foreach ($binary in $BinaryNames) {
+ $source = Join-Path $extractDir $binary
+ if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
+ throw "Archive is missing required binary $binary."
+ }
+ }
+
+ if ($WaitForPid -gt 0) {
+ $parent = Get-Process -Id $WaitForPid -ErrorAction SilentlyContinue
+ if ($null -ne $parent) {
+ Write-Info "waiting for process $WaitForPid to exit before replacing locked binaries"
+ Wait-Process -Id $WaitForPid
+ }
+ Start-Sleep -Milliseconds 500
+ }
+
+ New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
+ foreach ($binary in $BinaryNames) {
+ $source = Join-Path $extractDir $binary
+ $destination = Join-Path $InstallDir $binary
+ $temporaryDestination = "$destination.tmp.$PID"
+ if (Test-Path -LiteralPath $temporaryDestination) {
+ Remove-Item -LiteralPath $temporaryDestination -Force
+ }
+ Copy-Item -LiteralPath $source -Destination $temporaryDestination -Force
+ Move-Item -LiteralPath $temporaryDestination -Destination $destination -Force
+ Write-Info "$binary -> $destination"
+ }
+
+ Add-InstallDirToUserPath
+ Write-Info "installed GraphIQ v$Version"
+ Write-Host " Open a new terminal for PATH changes to take effect."
+} finally {
+ if (Test-Path -LiteralPath $tempRoot) {
+ Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
+ }
+}
diff --git a/integrations/pi/graphiq-pi.ts b/integrations/pi/graphiq-pi.ts
index 0de3e3f..83cb547 100644
--- a/integrations/pi/graphiq-pi.ts
+++ b/integrations/pi/graphiq-pi.ts
@@ -24,6 +24,9 @@ import { join } from "node:path";
// Resolve the graphiq binary: explicit override, then PATH lookup, then sibling.
const GRAPHIQ_BIN = process.env.GRAPHIQ_BIN ?? "graphiq";
+const INSTALL_HINT = process.platform === "win32"
+ ? "GraphIQ not installed. Run the PowerShell installer from https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.ps1"
+ : "GraphIQ not installed. Run: curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | bash";
function graphiq(ctx: any, args: string[], opts: { timeoutMs?: number } = {}): Promise {
return new Promise((resolve) => {
@@ -81,7 +84,7 @@ export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
if (!isGraphiqInstalled()) {
ctx.ui.notify(
- "GraphIQ not installed. Run: curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | bash",
+ INSTALL_HINT,
"info",
);
return;
@@ -185,7 +188,7 @@ export default function (pi: ExtensionAPI) {
handler: async (args, ctx) => {
if (!isGraphiqInstalled()) {
ctx.ui.notify(
- "GraphIQ not installed. Run: curl -fsSL https://raw.githubusercontent.com/aaf2tbz/graphiq/main/install.sh | bash",
+ INSTALL_HINT,
"warning",
);
return;
diff --git a/signet-plugin/integration.json b/signet-plugin/integration.json
index d911598..afd1d64 100644
--- a/signet-plugin/integration.json
+++ b/signet-plugin/integration.json
@@ -1,6 +1,6 @@
{
"signetSourceInstallRev": "fa6c1a3",
- "graphiqVersion": "4.3.3",
+ "graphiqVersion": "4.5.0",
"pluginApi": "1.x",
"minSignetVersion": "0.103.0",
"updated": "2026-06-22"
diff --git a/signet-plugin/manifest.json b/signet-plugin/manifest.json
index 4f0b2cc..e2a2c08 100644
--- a/signet-plugin/manifest.json
+++ b/signet-plugin/manifest.json
@@ -1,7 +1,7 @@
{
"id": "signet.graphiq",
"name": "GraphIQ Code Retrieval",
- "version": "4.3.3",
+ "version": "4.5.0",
"publisher": "aaf2tbz",
"description": "Optional verified managed plugin for fast local structural code retrieval through GraphIQ.",
"runtime": {