aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-23 17:34:01 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-23 17:42:49 +0300
commitec2924d0d1f21417802c3efd0ed46b9db2c00582 (patch)
tree00432d4dbb46bb82328dfb583e36293c1603bc81
parent0d2c20a46235ae2535d4fdef59b9529e44faa98f (diff)
feat: add optional nix flake support
refs #208 refs #221 refs #264
-rw-r--r--.github/workflows/nix.yml34
-rw-r--r--.gitignore2
-rw-r--r--build.rs16
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/README.md14
-rw-r--r--docs/next/website/src/content/docs/install.mdx29
-rw-r--r--flake.lock27
-rw-r--r--flake.nix82
-rw-r--r--nix/package.nix76
-rw-r--r--src/update.rs51
10 files changed, 329 insertions, 3 deletions
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
new file mode 100644
index 00000000..c8e8e6ff
--- /dev/null
+++ b/.github/workflows/nix.yml
@@ -0,0 +1,34 @@
+name: Nix
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+ push:
+ branches: [master]
+ paths-ignore:
+ - "website/**"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: nix-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ flake-check:
+ name: flake check
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Run Nix flake check
+ run: |
+ docker run --rm \
+ -v "$PWD:/work" \
+ -w /work \
+ docker.io/nixos/nix:2.31.2 \
+ sh -c 'nix --extra-experimental-features "nix-command flakes" flake check --print-build-logs && nix --extra-experimental-features "nix-command flakes" flake check --all-systems --no-build --print-build-logs'
diff --git a/.gitignore b/.gitignore
index d2eaa147..51110c52 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,5 @@ __pycache__/
/.pi/todos
/.pi/issues
/.pi/cache
+/result
+/result-*
diff --git a/build.rs b/build.rs
index c68d420a..cda19840 100644
--- a/build.rs
+++ b/build.rs
@@ -32,9 +32,14 @@ fn main() {
println!("cargo:rerun-if-changed=vendor/libghostty-vt.vendor.json");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/build.zig");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/build.zig.zon");
+ println!("cargo:rerun-if-changed=vendor/libghostty-vt/include");
+ println!("cargo:rerun-if-changed=vendor/libghostty-vt/pkg");
+ println!("cargo:rerun-if-changed=vendor/libghostty-vt/src");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/VERSION");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_OPTIMIZE");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_SIMD");
+ println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_ZIG_SYSTEM_DIR");
+ println!("cargo:rerun-if-env-changed=ZIG");
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let vendored_dir = manifest_dir.join("vendor/libghostty-vt");
@@ -47,13 +52,20 @@ fn main() {
.trim()
.to_string();
- let status = Command::new("zig")
+ let zig = env::var("ZIG").unwrap_or_else(|_| "zig".into());
+ let mut command = Command::new(zig);
+ command
.arg("build")
.arg("-Demit-lib-vt")
.arg(format!("-Doptimize={optimize}"))
.arg(format!("-Dsimd={simd}"))
.arg(format!("-Dtarget={zig_target}"))
- .arg(format!("-Dversion-string={version_string}"))
+ .arg(format!("-Dversion-string={version_string}"));
+ if let Ok(system_dir) = env::var("LIBGHOSTTY_VT_ZIG_SYSTEM_DIR") {
+ command.arg("--system").arg(system_dir);
+ }
+
+ let status = command
.current_dir(&vendored_dir)
.status()
.expect("failed to execute zig build for vendored libghostty-vt");
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index f4a8a6de..de3ada07 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -3,6 +3,7 @@
## Unreleased
### Added
+- Added optional Nix flake support for building, running, installing, and developing Herdr with Nix. (#208, #221, #264)
- Added `terminal.new_cwd` to choose whether new panes, tabs, and workspaces follow the source pane/workspace, start in `$HOME`, use Herdr's process directory, or use a fixed path.
- Added `herdr integration install omp` for OMP's `.omp` extension directory. The extension reports OMP pane state through Herdr's socket API without relying on native `omp` process detection.
- Added CLI and socket API support for Git worktrees with `herdr worktree list/create/open/remove`, optional worktree provenance on workspace responses, and client/server protocol version 10.
diff --git a/docs/next/README.md b/docs/next/README.md
index 16a9fd1c..99e99e6c 100644
--- a/docs/next/README.md
+++ b/docs/next/README.md
@@ -295,6 +295,20 @@ cargo build --release
./target/release/herdr
```
+## nix
+
+Herdr provides optional Nix flake outputs for users who already use Nix. The flake builds Herdr from source.
+
+```bash
+nix run github:ogulcancelik/herdr
+nix build github:ogulcancelik/herdr
+nix develop github:ogulcancelik/herdr
+```
+
+The flake exposes `packages.<system>.default`, `apps.<system>.default`, `devShells.<system>.default`, and `overlays.default`.
+
+Update through the same Nix workflow you used to install Herdr. For profile installs, run `nix profile list` and then `nix profile upgrade <index-or-name>`. For flake inputs, run `nix flake update herdr` in your own flake and rebuild.
+
## testing
```bash
diff --git a/docs/next/website/src/content/docs/install.mdx b/docs/next/website/src/content/docs/install.mdx
index dda2dc27..69834567 100644
--- a/docs/next/website/src/content/docs/install.mdx
+++ b/docs/next/website/src/content/docs/install.mdx
@@ -33,6 +33,35 @@ Herdr checks for new releases and notifies you in the app. You can update manual
herdr update
```
+## Install with Nix
+
+If you already use Nix, Herdr provides a flake that builds Herdr from source:
+
+```bash
+nix run github:ogulcancelik/herdr
+nix build github:ogulcancelik/herdr
+nix profile install github:ogulcancelik/herdr
+```
+
+The flake also exposes a development shell:
+
+```bash
+nix develop github:ogulcancelik/herdr
+```
+
+Update through the same Nix workflow you used to install Herdr. For a profile install, list your profile entries and upgrade the Herdr entry:
+
+```bash
+nix profile list
+nix profile upgrade <index-or-name>
+```
+
+If Herdr is an input in your own flake, update that input and rebuild your system, Home Manager, or development environment:
+
+```bash
+nix flake update herdr
+```
+
## Download manually
You can also download a binary from [GitHub releases](https://github.com/ogulcancelik/herdr/releases).
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 00000000..421a64fc
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1779357205,
+ "narHash": "sha256-cCO8aTqss5x9Ky8GWkpY0Hy5fyTZEbtifSUV8QjSzic=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "f83fc3c307e74bc5fd5adb7eb6b8b13ffd2a36e1",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 00000000..3a91a3ca
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,82 @@
+{
+ description = "herdr — terminal workspace manager for AI coding agents";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ };
+
+ outputs =
+ { self, nixpkgs }:
+ let
+ lib = nixpkgs.lib;
+ systems = [
+ "x86_64-linux"
+ "aarch64-linux"
+ "x86_64-darwin"
+ "aarch64-darwin"
+ ];
+ forAllSystems = lib.genAttrs systems;
+ pkgsFor = system: import nixpkgs { inherit system; };
+ in
+ {
+ packages = forAllSystems (
+ system:
+ let
+ pkgs = pkgsFor system;
+ herdr = pkgs.callPackage ./nix/package.nix { };
+ in
+ {
+ inherit herdr;
+ default = herdr;
+ }
+ );
+
+ apps = forAllSystems (system: {
+ default = {
+ type = "app";
+ program = "${self.packages.${system}.default}/bin/herdr";
+ meta.description = "Run Herdr";
+ };
+ });
+
+ checks = forAllSystems (system: {
+ herdr = self.packages.${system}.default;
+ default = self.checks.${system}.herdr;
+ });
+
+ devShells = forAllSystems (
+ system:
+ let
+ pkgs = pkgsFor system;
+ in
+ {
+ default = pkgs.mkShell {
+ name = "herdr-dev";
+ packages = with pkgs; [
+ cargo
+ cargo-nextest
+ clippy
+ cmake
+ just
+ ninja
+ pkg-config
+ rustc
+ rustfmt
+ zig_0_15
+ ];
+
+ env = {
+ LIBGHOSTTY_VT_OPTIMIZE = "Debug";
+ LIBGHOSTTY_VT_SIMD = "true";
+ };
+ };
+ }
+ );
+
+ formatter = forAllSystems (system: (pkgsFor system).nixfmt);
+
+ overlays.default = final: _prev: {
+ herdr = final.callPackage ./nix/package.nix { };
+ };
+ };
+}
diff --git a/nix/package.nix b/nix/package.nix
new file mode 100644
index 00000000..ba9206d8
--- /dev/null
+++ b/nix/package.nix
@@ -0,0 +1,76 @@
+{
+ lib,
+ rustPlatform,
+ callPackage,
+ runCommand,
+ zig_0_15,
+ zstd,
+ pkg-config,
+ git,
+}:
+
+let
+ manifest = lib.importTOML ../Cargo.toml;
+ zigDeps = callPackage ../vendor/libghostty-vt/build.zig.zon.nix {
+ name = "herdr-libghostty-vt-zig-cache";
+ inherit zstd;
+ linkFarm = name: entries:
+ runCommand name { } ''
+ mkdir -p $out
+ ${lib.concatMapStringsSep "\n" (entry: ''
+ cp -rL ${entry.path} $out/${entry.name}
+ '') entries}
+ '';
+ };
+in
+rustPlatform.buildRustPackage {
+ pname = "herdr";
+ version = manifest.package.version;
+
+ src = lib.fileset.toSource {
+ root = ./..;
+ fileset = lib.fileset.intersection (lib.fileset.fromSource (lib.sources.cleanSource ./..)) (
+ lib.fileset.unions [
+ ../assets
+ ../src
+ ../vendor/libghostty-vt
+ ../vendor/libghostty-vt.vendor.json
+ ../build.rs
+ ../Cargo.lock
+ ../Cargo.toml
+ ]
+ );
+ };
+
+ cargoLock.lockFile = ../Cargo.lock;
+
+ nativeBuildInputs = [
+ git
+ pkg-config
+ ];
+
+ env = {
+ LIBGHOSTTY_VT_OPTIMIZE = "ReleaseFast";
+ LIBGHOSTTY_VT_SIMD = "true";
+ LIBGHOSTTY_VT_ZIG_SYSTEM_DIR = zigDeps;
+ ZIG = lib.getExe zig_0_15;
+ };
+
+ preBuild = ''
+ export ZIG_GLOBAL_CACHE_DIR="$TMPDIR/zig-global-cache"
+ export ZIG_LOCAL_CACHE_DIR="$TMPDIR/zig-local-cache"
+ '';
+
+ # Rust tests are covered by the normal CI workflow. The Nix check is
+ # intentionally build-only so it validates packaging inputs without
+ # duplicating the full Rust test suite.
+ doCheck = false;
+
+ meta = {
+ description = "Terminal workspace manager for AI coding agents";
+ homepage = "https://herdr.dev";
+ license = lib.licenses.agpl3Plus;
+ mainProgram = "herdr";
+ platforms = lib.platforms.linux ++ lib.platforms.darwin;
+ };
+}
diff --git a/src/update.rs b/src/update.rs
index f37a9265..2a2776fa 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -21,6 +21,7 @@ const UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
const HOMEBREW_FORMULA_API_URL: &str = "https://formulae.brew.sh/api/formula/herdr.json";
const HERDR_UPDATE_COMMAND: &str = "herdr update";
const HOMEBREW_UPDATE_COMMAND: &str = "brew update && brew upgrade herdr";
+const NIX_UPDATE_COMMAND: &str = "update through Nix";
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION";
const FAKE_UPDATE_NOTES_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_NOTES_VERSION";
@@ -678,6 +679,8 @@ fn wait_for_server_shutdown(timeout: Duration) -> Result<(), String> {
pub(crate) fn update_install_command() -> &'static str {
if is_homebrew_managed_install() {
HOMEBREW_UPDATE_COMMAND
+ } else if is_nix_managed_install() {
+ NIX_UPDATE_COMMAND
} else {
HERDR_UPDATE_COMMAND
}
@@ -697,6 +700,24 @@ fn is_homebrew_managed_install() -> bool {
.is_ok_and(|path| is_homebrew_managed_exe_path(&path))
}
+fn is_nix_managed_install() -> bool {
+ let Ok(current_exe) = env::current_exe() else {
+ return false;
+ };
+
+ if is_nix_store_exe_path(&current_exe) {
+ return true;
+ }
+
+ current_exe
+ .canonicalize()
+ .is_ok_and(|path| is_nix_store_exe_path(&path))
+}
+
+fn is_nix_store_exe_path(path: &Path) -> bool {
+ path.starts_with("/nix/store")
+}
+
fn is_homebrew_managed_exe_path(path: &Path) -> bool {
homebrew_cellar_keg_root(path).is_some()
}
@@ -991,6 +1012,12 @@ pub fn self_update() -> Result<Version, String> {
));
}
+ if is_nix_managed_install() {
+ return Err(
+ "self-update is disabled for Nix installs; update with `nix profile upgrade` or update the flake input that provides Herdr".into(),
+ );
+ }
+
if running_inside_herdr() {
return Err("run `herdr update` outside herdr after detaching from the session".into());
}
@@ -1075,6 +1102,8 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
return;
}
+ let nix_managed_install = is_nix_managed_install();
+
let release = match check_latest() {
Ok(Some(r)) => r,
Ok(None) => return,
@@ -1103,9 +1132,15 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
);
// Notify the TUI — blocking_send is safe from a std::thread
+ let install_command = if nix_managed_install {
+ NIX_UPDATE_COMMAND
+ } else {
+ HERDR_UPDATE_COMMAND
+ };
+
let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
version: release.version.to_string(),
- install_command: HERDR_UPDATE_COMMAND.to_string(),
+ install_command: install_command.to_string(),
});
}
@@ -1288,6 +1323,20 @@ mod tests {
}
#[test]
+ fn nix_store_path_is_detected() {
+ let path = Path::new("/nix/store/abc123-herdr-0.6.1/bin/herdr");
+
+ assert!(is_nix_store_exe_path(path));
+ }
+
+ #[test]
+ fn non_nix_store_path_is_not_detected() {
+ let path = Path::new("/usr/local/bin/herdr");
+
+ assert!(!is_nix_store_exe_path(path));
+ }
+
+ #[test]
fn parse_homebrew_formula_stable_version_reads_versions_stable() {
let version = parse_homebrew_formula_stable_version(
br#"{"versions":{"stable":"0.5.10","head":"HEAD","bottle":true}}"#,