diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5d5f65c..7b65fb23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,8 +302,9 @@ jobs: # fence as the two above. `|| ec=$?` rather than `; ec=$?` because the # `run:` block is `bash -e` and a bare `;` exits at the failing line, so # the `cat` never runs and a timeout reports with an empty log -- see the - # backstop's comment below. `herdr_environment` spawns nothing and takes - # the plain form. + # backstop's comment below. `herdr_environment` runs a stub herdr and the + # agent-queue view.sh, but each exits before the test ends and nothing + # detaches, so it takes the plain form. - name: Test dl herdr_editor working-directory: rust run: | @@ -445,6 +446,7 @@ jobs: "-p dl --test lifecycle" \ "-p dl --test launch" \ "-p dl --test migration" \ + "-p dl --test herdr_environment" \ ; do echo "::group::cargo llvm-cov $suite" timeout 900 cargo llvm-cov --no-report --locked $suite -- --test-threads=1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc72ee9..a3ddacc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`dl --herdr-setup` sets up a whole machine for Herdr, with no dotfiles.** Besides the + pane shell, it now installs a CPU, memory and disk segment for the tab bar and the + agent-queue plugin (linked, with its startup hook run once), merges devlaunch's keymap, + theme and sidebar into Herdr's config, installs herdr's Claude Code integration when it + is missing or outdated, adds a Stop hook that names a one-pane tab after Claude's + session title, writes herdr's skill, writes kitty's F-key fix, and links + `~/.local/bin/herdr` to `~/.pixi/bin/herdr`. Every file is compiled into `dl`, so the + conda package and the wheel carry it. Each step prints `changed`, `current`, `skipped` + or `failed`, and a second run writes nothing. `--dry-run` prints the plan and writes + nothing; `--no-claude` and `--no-kitty` leave those two alone. + +### Changed + +- **The Herdr config is merged, not only pointed at the pane shell.** A key you set is + never changed. Four keys that point at files setup installs are kept current: + `terminal.default_shell`, the `prefix+a` agent-queue binding, the `status.sh` tab-bar + segment, and the ` · herdr` end of `ui.window_title`. A missing or empty config gets + devlaunch's packaged config. +- **A Herdr config that chezmoi manages no longer stops `dl --herdr-setup`.** The config + step is skipped with the source file named, and the other steps run. A chezmoi that + cannot answer still fails the command. + ## [0.58.0] - 2026-09-30 ### Added diff --git a/README.md b/README.md index 9a13088e..bd38f685 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,8 @@ instead. [docs/cli.md](docs/cli.md) has the full `--rm` contract, including whic | `dl --reconcile` | Re-point workspaces whose recorded source folder went missing. Deletes nothing | | `dl --purge` | Remove devlaunch's own workspaces and caches | | `dl --install` | Install shell completions, and the `dl-herdr-shell` name a herdr pane opens through | -| `dl --herdr-setup` | Install and configure the Herdr pane shell | +| `dl --herdr-setup` | Set up Herdr for devlaunch on this machine: the pane shell, a merged Herdr config, the agent-queue plugin, the status segment, Claude Code's tab-title hook and herdr skill, and kitty's F-key fix. Safe to re-run | +| `dl --herdr-setup --dry-run` | Print every change setup would make, and write nothing. `--no-claude` and `--no-kitty` leave those two alone | | `dl --herdr-env set KEY=VALUE` | Save a variable for new host shells in this Herdr workspace | | `dl --herdr-env profile NAME` | Choose an existing Claude login for new panes | | `dl --herdr-env show` | Show saved overrides; `unset KEY` removes an inherited variable and `clear` resets overrides | @@ -329,9 +330,10 @@ instead. [docs/cli.md](docs/cli.md) has the full `--rm` contract, including whic `--herdr-shell` is not a command to type. It is what herdr's `[terminal] default_shell` points at, so that splitting a pane in an `aid` tab opens a terminal in that workspace's container instead of -on this host. `dl --herdr-setup` writes the script and updates Herdr config; -`dl --install` writes the script and prints the config line for a manual setup. -[docs/workspace-tools.md](docs/workspace-tools.md) has the rest. +on this host. `dl --herdr-setup` writes the script and points Herdr's config at it, along with the +rest of the Herdr kit. `dl --install` writes the script and prints the config line for a manual +setup. [docs/workspace-tools.md](docs/workspace-tools.md) has the rest, including what setup +changes and what it never touches. `--prune`, `--reconcile` and `--purge` print their plan and ask first. `-y` skips the question, and for `--prune` and `rm`, `--force` goes ahead despite work that is nowhere else. diff --git a/docs/workspace-tools.md b/docs/workspace-tools.md index f356d09f..a701d87a 100644 --- a/docs/workspace-tools.md +++ b/docs/workspace-tools.md @@ -1515,6 +1515,95 @@ as some reboot orders do, leaves nothing to restore, and `aid resume` is still t back. A `dl -- claude ...` typed by hand tells herdr nothing, because only `aid` knows which session its line opens. +## Setting up Herdr on a new machine + +```bash +dl --herdr-setup --dry-run # see every change first +dl --herdr-setup +herdr server reload-config # only if a Herdr session is already running +``` + +One command gives a machine the whole Herdr setup devlaunch is built around, with +no dotfiles. devlaunch ships as a conda package and a wheel that hold its binaries +and nothing else, so every file below is compiled into `dl` and written out by +setup. Each step reports `changed`, `current`, `skipped` or `failed`, with the +reason. A step writes a file only when its content differs, so a second run +reports every step `current` and writes nothing. A failed step makes the command +exit non-zero; the other steps still run. + +| Step | What it writes | +|---|---| +| pane shell | `~/.local/bin/dl-herdr-shell`, [below](#opening-a-new-pane-in-the-workspace-its-tab-already-holds) | +| status segment | `$XDG_DATA_HOME/devlaunch/herdr/status.sh`: CPU, memory and disk for the tab bar | +| agent-queue plugin | `$XDG_DATA_HOME/devlaunch/herdr/plugins/agent-queue/`, then `herdr plugin link` and the plugin's startup hook, which linking does not fire | +| herdr config | a merge into Herdr's config, described next | +| herdr on `~/.local/bin` | a link to `~/.pixi/bin/herdr`, only when that exists and `~/.local/bin/herdr` does not, so `herdr machine add` does not push a stale copy | +| claude integration | `herdr integration install claude`, only when `herdr integration status` says it is missing or outdated | +| claude settings | a Stop hook entry in Claude Code's `settings.json` | +| claude tab-title hook | `hooks/devlaunch-herdr-tab-title.sh`, which names a one-pane tab after Claude's session title and stops once a person renames the tab | +| claude herdr skill | `skills/herdr/SKILL.md`, from `herdr --skill` | +| kitty F-key fix | `devlaunch-herdr.conf` in kitty's config directory, and an `include` line for it in `kitty.conf` | + +`$XDG_DATA_HOME` is `~/.local/share` when it is unset. The Claude steps use +`$CLAUDE_CONFIG_DIR` when it is set, otherwise `~/.claude`, which is the directory +herdr's own installer uses. They are skipped when that directory does not exist, +or with `--no-claude`. The kitty steps use `$KITTY_CONFIG_DIRECTORY`, otherwise +`$XDG_CONFIG_HOME/kitty`, and are skipped when it does not exist, or with +`--no-kitty`. Setup never starts, stops or reloads a Herdr server. + +### What the config merge changes, and what it never does + +A missing or empty config gets devlaunch's packaged config: a keymap with bare +F-keys and Ctrl chords beside Herdr's prefix layer, `ctrl+space` as the prefix, a +white text theme, the agent queue on F7 and `prefix+a`, desktop toasts, and +[agents resumed on restore](#coming-back-after-herdr-restarts). + +An existing config is merged, with comments and order kept. Two kinds of key: + +- **Managed keys** point at files setup installs, so they are set on every run: + `terminal.default_shell`, the `prefix+a` binding to `local.agent-queue.toggle`, + the `status.sh` segment in `ui.tab_bar_right`, and the ` · herdr` end of + `ui.window_title`. Each stops at a value that is plainly yours. A custom + `default_shell` fails the step. A `status.sh` of your own keeps its segment. A + `prefix+a` bound to another action or command stays bound, and the toggle gets no key. +- **Default keys** are everything else in the packaged config. Each is added only + when the key is absent. A value you set is never changed, even one that matches + Herdr's own default. + +Tables that setup adds go after your own. The file is written by a rename from a +temporary file in the same directory, and keeps its mode. + +### Claude Code's settings.json + +Claude Code rewrites `settings.json` itself, and other tools add hooks to it, so +the merge owns two things. It keeps exactly one Stop hook that runs +`devlaunch-herdr-tab-title.sh`. It also keeps one copy of herdr's SessionStart hook +(`herdr-agent-state.sh`), because older herdr releases appended another copy on +every install. Every other key, event and hook is left as it is, in its order. A +file that needs no change is not rewritten. Before its first change, setup copies +the file to `settings.json.devlaunch-backup`, and it never replaces that copy. + +When a `herdr-tab-title.sh` Stop hook is already registered, for example from +dotfiles, setup adds neither its entry nor its script, so two hooks do not rename +the same tab. + +The skill is written only when `herdr --skill` succeeds and prints something, so a +herdr that dropped the flag cannot replace a working skill with an error message. +A `skills/herdr` that is a symlink stays one, and the file is written through it. + +### Files chezmoi manages + +Before it edits Herdr's config, `settings.json` or `kitty.conf`, setup asks chezmoi +whether it manages the file. A managed file is left alone, and the step prints the +line to add to its chezmoi source instead. Files that only devlaunch writes, such as +the plugin and `devlaunch-herdr.conf`, are written either way. + +### DEVLAUNCH_HERDR stays off + +Setup ends by naming [`DEVLAUNCH_HERDR`](#reporting-an-agent-started-inside-the-workspace), +and does not set it. It lends herdr into every container and opens a second ssh +connection per launch, so it stays a choice you make. + ## Opening a new pane in the workspace its tab already holds The two sections above are about *watching* a pane. This one is about working in @@ -1611,8 +1700,9 @@ existence rather than its container. `dl --install` writes the script and leaves the config alone. `dl --herdr-setup` uses `$HERDR_CONFIG_PATH` when set, otherwise `$XDG_CONFIG_HOME/herdr/config.toml`, or `~/.config/herdr/config.toml` when XDG_CONFIG_HOME is unset. If chezmoi manages a -regular config file, setup refuses the edit and names the source file. Change -`terminal.default_shell` in that source, apply it, and run `dl --install` to install +regular config file, setup leaves it alone, reports the config step as skipped and +names the source file, and goes on with the other steps. Change +`terminal.default_shell` in that source and apply it; setup has already installed the launcher. A symlink is different: setup preserves the link and updates its target. @@ -1627,8 +1717,8 @@ itself report every file as not managed, and nothing downstream can tell that ap from the truth. Re-running setup preserves an already current file. A custom `default_shell` -is refused with the manual replacement instruction, rather than silently replacing -the user's launcher. +fails the config step with the manual replacement instruction, rather than silently +replacing the user's launcher, and the config is left as it was. ### Workspace environments and Claude logins diff --git a/rust/Cargo.lock b/rust/Cargo.lock index c9436736..bcf63124 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -452,7 +452,6 @@ dependencies = [ "shlex 2.0.1", "tempfile", "toml", - "toml_edit", ] [[package]] @@ -521,6 +520,7 @@ dependencies = [ "skim", "tempfile", "term", + "toml_edit", ] [[package]] diff --git a/rust/devlaunch-core/Cargo.toml b/rust/devlaunch-core/Cargo.toml index fd2e9603..2d6fb6f4 100644 --- a/rust/devlaunch-core/Cargo.toml +++ b/rust/devlaunch-core/Cargo.toml @@ -7,7 +7,6 @@ license.workspace = true repository.workspace = true [dependencies] -toml_edit = { workspace = true } devlaunch-runner = { workspace = true } libc = { workspace = true } serde = { workspace = true } diff --git a/rust/devlaunch-core/completions/dl.bash b/rust/devlaunch-core/completions/dl.bash index 5344e781..2e4d1741 100644 --- a/rust/devlaunch-core/completions/dl.bash +++ b/rust/devlaunch-core/completions/dl.bash @@ -69,7 +69,7 @@ _dl_completion() { # The retired spellings (--stop, --autorm) are absent by rule rather than by # hand: the grammar marks them `hide = true`, and the test drops every hidden # flag, so a spelling this build only still answers for is never offered. - local global_opts="--ls --install --refresh --prune --reconcile --purge --herdr-shell --herdr-setup --herdr-env --herdr-workspace --rm --devcontainer --claude-profile --claude-profiles --help -h --version" + local global_opts="--ls --install --refresh --prune --reconcile --purge --herdr-shell --herdr-setup --dry-run --no-claude --no-kitty --herdr-env --herdr-workspace --rm --devcontainer --claude-profile --claude-profiles --help -h --version" if [[ "$cmd" == aid ]]; then global_opts="--claude --codex --gemini --devcontainer --claude-profile --help -h --version" fi diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index 99d4e061..c79652de 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -1487,7 +1487,6 @@ pub fn devlaunch_core::flows::herdr_environment::Store::clear(&self) -> core::io pub fn devlaunch_core::flows::herdr_environment::Store::new(&std::path::Path, &std::path::Path, &str) -> core::io::error::Result pub fn devlaunch_core::flows::herdr_environment::Store::read(&self) -> core::io::error::Result pub fn devlaunch_core::flows::herdr_environment::Store::update(&self, impl core::ops::function::FnOnce(&mut devlaunch_core::flows::herdr_environment::Environment) -> core::io::error::Result<()>) -> core::io::error::Result<()> -pub fn devlaunch_core::flows::herdr_environment::configure(&std::path::Path, &std::path::Path, &std::path::Path) -> core::io::error::Result pub fn devlaunch_core::flows::herdr_environment::profile_directory(&devlaunch_core::clients::ProfileName, &std::path::Path) -> core::io::error::Result pub mod devlaunch_core::flows::kept_copies pub mod devlaunch_core::flows::kill diff --git a/rust/devlaunch-core/src/flows/herdr_environment.rs b/rust/devlaunch-core/src/flows/herdr_environment.rs index 7a39676c..8aff9fbd 100644 --- a/rust/devlaunch-core/src/flows/herdr_environment.rs +++ b/rust/devlaunch-core/src/flows/herdr_environment.rs @@ -298,22 +298,9 @@ impl Store { } } -// The two writers below take opposite mode policies on purpose, and which one a caller wants -// follows from who owns the file: a config belongs to the user, so its mode is theirs to choose, -// while the state file is ours and holds whatever secrets were set as overrides. -fn replace_preserving_mode(path: &Path, bytes: &[u8]) -> io::Result<()> { - let temporary = staged(path, bytes)?; - match fs::metadata(path) { - Ok(existing) => temporary - .as_file() - .set_permissions(existing.permissions())?, - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(error), - } - temporary.persist(path).map_err(|e| e.error)?; - Ok(()) -} - +// The state file is ours and holds whatever secrets were set as overrides, so it is +// always private. (The Herdr config writer that kept the user's mode moved to +// `dl --herdr-setup`, in `rust/dl/src/herdr_kit/mod.rs`.) fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { let temporary = staged(path, bytes)?; temporary @@ -329,61 +316,6 @@ fn staged(path: &Path, bytes: &[u8]) -> io::Result { Ok(temporary) } -/// Change only Herdr's pane launcher. Unknown launchers require an explicit manual edit. -pub fn configure(config: &Path, script: &Path, home: &Path) -> io::Result { - let resolved; - let config = match fs::symlink_metadata(config) { - Ok(metadata) if metadata.file_type().is_symlink() => { - resolved = fs::canonicalize(config)?; - resolved.as_path() - } - Ok(_) => config, - Err(error) if error.kind() == io::ErrorKind::NotFound => config, - Err(error) => return Err(error), - }; - let original = match fs::read_to_string(config) { - Ok(text) => text, - Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(), - Err(error) => return Err(error), - }; - let mut document = original - .parse::() - .map_err(|e| invalid(format!("invalid Herdr config: {e}")))?; - let script = script - .to_str() - .ok_or_else(|| invalid("pane shell path is not UTF-8"))?; - let terminal = document - .entry("terminal") - .or_insert(toml_edit::Item::Table(toml_edit::Table::new())); - let table = terminal - .as_table_like_mut() - .ok_or_else(|| invalid("Herdr terminal config is not a table"))?; - if let Some(current) = table.get("default_shell") { - let current = current - .as_str() - .ok_or_else(|| invalid("Herdr default_shell is not a string"))?; - if current == script { - return Ok(false); - } - let prototype = home.join(".local/bin/herdr-workspace-shell"); - if !current.is_empty() - && current != "dl-herdr-shell" - && current != prototype.to_string_lossy() - { - return Err(invalid(format!( - "Herdr uses a custom default_shell ({current}); set terminal.default_shell to {script} manually to replace it" - ))); - } - } - table.insert("default_shell", toml_edit::value(script)); - let directory = config - .parent() - .ok_or_else(|| invalid("config path has no parent"))?; - fs::create_dir_all(directory)?; - replace_preserving_mode(config, document.to_string().as_bytes())?; - Ok(true) -} - #[cfg(test)] mod tests { use super::*; @@ -551,124 +483,6 @@ mod tests { assert_eq!(*store.read().unwrap().claude(), ClaudeConfig::Inherited); } - #[test] - fn installer_preserves_comments_settings_and_is_idempotent() { - let root = tempfile::tempdir().unwrap(); - let config = root.path().join("config.toml"); - let original = - "# My config\n[terminal]\nfont_size = 14 # keep me\n[theme]\nname = 'dark'\n"; - fs::write(&config, original).unwrap(); - let script = root.path().join("bin/dl-herdr-shell"); - assert!(configure(&config, &script, root.path()).unwrap()); - let once = fs::read_to_string(&config).unwrap(); - assert!(once.contains("# My config")); - assert!(once.contains("font_size = 14 # keep me")); - assert!(once.contains("name = 'dark'")); - assert!(!configure(&config, &script, root.path()).unwrap()); - assert_eq!(fs::read_to_string(&config).unwrap(), once); - } - - #[test] - fn installer_keeps_the_mode_the_config_already_had() { - let root = tempfile::tempdir().unwrap(); - let script = root.path().join("bin/dl-herdr-shell"); - for mode in [0o644, 0o600] { - let config = root.path().join(format!("config-{mode:o}.toml")); - fs::write(&config, "[terminal]\nfont_size = 14\n").unwrap(); - fs::set_permissions(&config, fs::Permissions::from_mode(mode)).unwrap(); - assert!(configure(&config, &script, root.path()).unwrap()); - assert_eq!( - fs::metadata(&config).unwrap().permissions().mode() & 0o777, - mode, - "{mode:o}" - ); - } - let fresh = root.path().join("fresh.toml"); - assert!(configure(&fresh, &script, root.path()).unwrap()); - assert_eq!( - fs::metadata(&fresh).unwrap().permissions().mode() & 0o777, - 0o600 - ); - } - - #[test] - fn installer_keeps_the_mode_of_a_symlink_target() { - let root = tempfile::tempdir().unwrap(); - let config = root.path().join("linked.toml"); - let source = root.path().join("linked-source.toml"); - fs::write(&source, "[terminal]\nfont_size = 14\n").unwrap(); - fs::set_permissions(&source, fs::Permissions::from_mode(0o644)).unwrap(); - std::os::unix::fs::symlink("linked-source.toml", &config).unwrap(); - let script = root.path().join("bin/dl-herdr-shell"); - - assert!(configure(&config, &script, root.path()).unwrap()); - assert_eq!( - fs::metadata(&source).unwrap().permissions().mode() & 0o777, - 0o644 - ); - } - - #[test] - fn installer_updates_symlink_target_and_preserves_link() { - let root = tempfile::tempdir().unwrap(); - let config = root.path().join("config.toml"); - let source = root.path().join("source.toml"); - fs::write(&source, "# My config\n[terminal]\nfont_size = 14\n").unwrap(); - std::os::unix::fs::symlink("source.toml", &config).unwrap(); - let script = root.path().join("bin/dl-herdr-shell"); - - assert!(configure(&config, &script, root.path()).unwrap()); - assert_eq!(fs::read_link(&config).unwrap(), Path::new("source.toml")); - let updated = fs::read_to_string(&source).unwrap(); - let document = updated.parse::().unwrap(); - assert_eq!( - document["terminal"]["default_shell"].as_str(), - script.to_str() - ); - assert_eq!(document["terminal"]["font_size"].as_integer(), Some(14)); - assert!(updated.contains("# My config")); - assert!(!configure(&config, &script, root.path()).unwrap()); - } - - #[test] - fn installer_refuses_dangling_symlink_without_replacing_it() { - let root = tempfile::tempdir().unwrap(); - let config = root.path().join("config.toml"); - std::os::unix::fs::symlink("missing.toml", &config).unwrap(); - let script = root.path().join("bin/dl-herdr-shell"); - - assert_eq!( - configure(&config, &script, root.path()).unwrap_err().kind(), - io::ErrorKind::NotFound - ); - assert_eq!(fs::read_link(&config).unwrap(), Path::new("missing.toml")); - assert!(!root.path().join("missing.toml").exists()); - } - - #[test] - fn installer_refuses_custom_launchers_and_migrates_only_the_known_prototype() { - let root = tempfile::tempdir().unwrap(); - let config = root.path().join("config.toml"); - let script = root.path().join("bin/dl-herdr-shell"); - for original in [ - "[terminal]\ndefault_shell = '/custom/launcher'\n", - "invalid toml = [", - ] { - fs::write(&config, original).unwrap(); - assert!(configure(&config, &script, root.path()).is_err()); - assert_eq!(fs::read_to_string(&config).unwrap(), original); - } - fs::write( - &config, - format!( - "[terminal]\ndefault_shell = {:?}\n", - root.path().join(".local/bin/herdr-workspace-shell") - ), - ) - .unwrap(); - assert!(configure(&config, &script, root.path()).unwrap()); - } - #[test] fn concurrent_writers_keep_each_others_keys() { let root = tempfile::tempdir().unwrap(); diff --git a/rust/dl/Cargo.toml b/rust/dl/Cargo.toml index c7cc31b4..a2da4bd3 100644 --- a/rust/dl/Cargo.toml +++ b/rust/dl/Cargo.toml @@ -19,6 +19,12 @@ serde = { workspace = true } serde_json = { workspace = true } skim = { workspace = true } term = { workspace = true } +# `dl --herdr-setup` merges into a Herdr config somebody else wrote, so comments +# and key order have to survive it. Already core's, for the same edit. +toml_edit = { workspace = true } +# Same-directory temporary files, so every file `dl --herdr-setup` writes is +# replaced by a rename and a reader never sees half of one. +tempfile = { workspace = true } # The released package ships two binaries, and a maturin bin-wheel ships the bin # targets of exactly *one* cargo package (maturin 1.14 asks cargo metadata for @@ -57,7 +63,6 @@ required-features = ["wheel"] [dev-dependencies] devlaunch-test-support = { workspace = true } -tempfile = { workspace = true } # `tests/picker.rs` opens a terminal and reads back what the picker drew on it. # Already a workspace dependency -- `aid` runs its session on one of these -- so # this adds a name to a dev-dependency list and nothing to the dependency tree. diff --git a/rust/dl/src/cli.rs b/rust/dl/src/cli.rs index a667f07b..72790acc 100644 --- a/rust/dl/src/cli.rs +++ b/rust/dl/src/cli.rs @@ -423,7 +423,8 @@ pub(crate) enum Command { profile: Option, }, HerdrEditorReady, - HerdrSetup, + /// `dl --herdr-setup [--dry-run] [--no-claude] [--no-kitty]` + HerdrSetup(HerdrSetupOptions), HerdrEnv { action: HerdrEnvAction, workspace: Option, @@ -444,6 +445,18 @@ pub(crate) enum Command { }, } +/// What a `--herdr-setup` line asks for: which parts of the kit to install, and +/// whether to write anything at all. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct HerdrSetupOptions { + /// Report every planned change and write nothing. + pub(crate) dry_run: bool, + /// Install the Claude Code pieces (`--no-claude` turns them off). + pub(crate) claude: bool, + /// Install the kitty F-key fix (`--no-kitty` turns it off). + pub(crate) kitty: bool, +} + /// What a `--herdr-env` line asks for, resolved from its words here so that the /// arity and the spelling are settled before anything looks for a Herdr session. #[derive(Clone, Debug, PartialEq, Eq)] @@ -638,9 +651,19 @@ pub(crate) struct Cli { /// workspace its tab already holds, or on this host when the tab holds none. #[arg(long = "herdr-shell", group = "what")] herdr_shell: bool, - /// Install the Herdr pane shell and configure new tabs and splits. + /// Install the Herdr kit: the pane shell, config, agent-queue plugin, status + /// segment, Claude Code hooks and skill, and the kitty F-key fix. #[arg(long, group = "what")] herdr_setup: bool, + /// With `--herdr-setup`: print every planned change and write nothing. + #[arg(long, requires = "herdr_setup")] + dry_run: bool, + /// With `--herdr-setup`: leave Claude Code's hooks, settings and skill alone. + #[arg(long, requires = "herdr_setup")] + no_claude: bool, + /// With `--herdr-setup`: leave kitty's config alone. + #[arg(long, requires = "herdr_setup")] + no_kitty: bool, /// Manage saved workspace variables: set KEY=VALUE, unset KEY, profile NAME, show, clear. #[arg(long, group = "what")] herdr_env: bool, @@ -894,6 +917,25 @@ impl Cli { .find_map(|(given, chosen)| given.then_some(chosen)) } + /// A `--herdr-setup` modifier given to some other command, if one was. + /// + /// clap's `requires = "herdr_setup"` does not catch this: a requirement on a + /// member of the `what` group is met by *any* member being present, so + /// `dl --prune --dry-run` parses. A dry run the command then ignores is the + /// one misreading that deletes something, so it is refused by name. + pub(crate) fn misplaced_setup_modifier(&self) -> Option<&'static str> { + if self.herdr_setup { + return None; + } + [ + (self.dry_run, "--dry-run"), + (self.no_claude, "--no-claude"), + (self.no_kitty, "--no-kitty"), + ] + .into_iter() + .find_map(|(given, flag)| given.then_some(flag)) + } + /// The retired spelling this line used, if it used one. fn retired_flag(&self) -> Option { [ @@ -1063,7 +1105,11 @@ fn global_command(cli: &Cli, chosen: Chosen) -> Result { profile: cli.claude_profile.clone(), }, Chosen::HerdrEditorReady => Command::HerdrEditorReady, - Chosen::HerdrSetup => Command::HerdrSetup, + Chosen::HerdrSetup => Command::HerdrSetup(HerdrSetupOptions { + dry_run: cli.dry_run, + claude: !cli.no_claude, + kitty: !cli.no_kitty, + }), Chosen::HerdrEnv => Command::HerdrEnv { action: HerdrEnvAction::of(&cli.words).ok_or(GrammarError::HerdrEnvUsage)?, workspace: cli.herdr_workspace.clone(), @@ -1333,6 +1379,34 @@ mod tests { resolved(argv).unwrap_or_else(|| panic!("clap refused {argv:?}")) } + #[test] + fn setup_modifiers_belong_to_herdr_setup_alone() { + let cli = |argv: &[&str]| { + Cli::try_parse_from(std::iter::once("dl").chain(argv.iter().copied())).unwrap() + }; + assert_eq!( + cli(&["--prune", "--dry-run"]).misplaced_setup_modifier(), + Some("--dry-run") + ); + assert_eq!( + cli(&["--ls", "--no-kitty"]).misplaced_setup_modifier(), + Some("--no-kitty") + ); + assert_eq!( + parse(&["--herdr-setup", "--dry-run", "--no-claude"]), + Ok(Command::HerdrSetup(HerdrSetupOptions { + dry_run: true, + claude: false, + kitty: true, + })) + ); + assert_eq!( + cli(&["--herdr-setup", "--dry-run", "--no-claude", "--no-kitty"]) + .misplaced_setup_modifier(), + None + ); + } + /// The same, or `None` for a line clap itself refuses. /// /// [`parse`] is the one every other test wants, because a line written out in a diff --git a/rust/dl/src/commands.rs b/rust/dl/src/commands.rs index c2db1b8a..c7fcf021 100644 --- a/rust/dl/src/commands.rs +++ b/rust/dl/src/commands.rs @@ -136,7 +136,7 @@ pub(crate) fn dispatch( // workspace is a `dl `, and the whole point is that it is // indistinguishable from one typed by hand -- same launch, same terminal // title, same agent reporting, same everything a manager reads. - Command::HerdrSetup => crate::herdr_environment::setup(), + Command::HerdrSetup(options) => crate::herdr_kit::setup(options), Command::HerdrEnv { action, workspace } => { crate::herdr_environment::manage(&action, workspace.as_deref()) } diff --git a/rust/dl/src/herdr_environment.rs b/rust/dl/src/herdr_environment.rs index 4146faed..2e91b2fc 100644 --- a/rust/dl/src/herdr_environment.rs +++ b/rust/dl/src/herdr_environment.rs @@ -10,7 +10,6 @@ use devlaunch_core::osext; use crate::cli::HerdrEnvAction; use crate::commands::Ending; -use crate::pane_shell; fn invalid(message: &str) -> io::Error { io::Error::new(io::ErrorKind::InvalidInput, message) @@ -156,7 +155,8 @@ pub(crate) fn open_pane() -> Ending { report(result) } -enum ConfigOwner { +/// Who owns a config file `dl --herdr-setup` would edit. +pub(crate) enum ConfigOwner { Chezmoi(PathBuf), Unmanaged, ProbeFailed(String), @@ -169,7 +169,7 @@ enum ConfigOwner { /// case the refusal exists for. A bare `chezmoi source-path` separates them /// without reading English out of stderr: it prints the source directory when /// chezmoi is healthy, and fails with the same complaint when it is not. -fn chezmoi_source(config: &Path) -> io::Result { +pub(crate) fn chezmoi_source(config: &Path) -> io::Result { if fs::symlink_metadata(config).is_ok_and(|metadata| metadata.file_type().is_symlink()) { return Ok(ConfigOwner::Unmanaged); } @@ -213,49 +213,3 @@ fn chezmoi_source(config: &Path) -> io::Result { } Ok(ConfigOwner::Chezmoi(PathBuf::from(source))) } - -pub(crate) fn setup() -> Ending { - report((|| { - let home = home()?; - let script = pane_shell::install_path(Some(&home)).expect("home supplied"); - let fallback_config = std::env::var_os("XDG_CONFIG_HOME") - .filter(|s| !s.is_empty()) - .map(PathBuf::from) - .unwrap_or_else(|| home.join(".config")) - .join("herdr/config.toml"); - let config = std::env::var_os("HERDR_CONFIG_PATH") - .filter(|s| !s.is_empty()) - .map(PathBuf::from) - .unwrap_or(fallback_config); - match chezmoi_source(&config)? { - ConfigOwner::Chezmoi(source) => { - return Err(invalid(&format!( - "Herdr config is managed by chezmoi from {}; set terminal.default_shell to {} there, apply it, then run `dl --install`", - source.display(), - script.display() - ))); - } - ConfigOwner::ProbeFailed(complaint) => { - return Err(invalid(&format!( - "chezmoi could not say whether it manages {}, so it was left alone -- fix chezmoi, or unset it from PATH, and run setup again. chezmoi said: {complaint}", - config.display() - ))); - } - ConfigOwner::Unmanaged => {} - } - // Validate and write the config only after the executable is available. - if let pane_shell::Installed::Refused { reason, .. } = pane_shell::install(&script) { - return Err(io::Error::other(reason)); - } - let changed = herdr_environment::configure(&config, &script, &home)?; - eprintln!( - "Herdr pane shell configured in {}{}", - config.display(), - if changed { "" } else { " (already current)" } - ); - eprintln!( - "Run `herdr server reload-config` for the session to use it. Select a login with `dl --herdr-env profile NAME` in each workspace." - ); - Ok(()) - })()) -} diff --git a/rust/dl/src/herdr_kit/assets/agent-queue/herdr-plugin.toml b/rust/dl/src/herdr_kit/assets/agent-queue/herdr-plugin.toml new file mode 100644 index 00000000..3fb13591 --- /dev/null +++ b/rust/dl/src/herdr_kit/assets/agent-queue/herdr-plugin.toml @@ -0,0 +1,37 @@ +id = "local.agent-queue" +name = "Agent queue" +version = "0.1.0" +min_herdr_version = "0.8.0" +description = "Filter the agent panel down to agents that need attention, so F7 walks a real queue" +platforms = ["linux", "macos"] + +# Runs once after the server restores the session and the API socket is ready -- +# and again after a live-handoff takeover, which is the case that matters: an +# `agent.view.set` projection is transient and dies with the server, so without +# this hook the queue silently reverts to the full panel after every restart. +# +# Not an event hook. The projection is reevaluated by herdr itself whenever agent +# facts change, so it needs installing once, not maintaining. +[[startup]] +command = ["./view.sh", "set"] + +# Bound to prefix+a in config.toml as type = "plugin_action". herdr has one +# Agents panel and one projection on it, so "two sidebars" is not on the menu; +# a flip between the two views is the nearest thing. +[[actions]] +id = "toggle" +title = "Agents panel: needs me / all" +contexts = ["workspace"] +command = ["./view.sh", "toggle"] + +[[actions]] +id = "needs-me" +title = "Agents panel: needs me" +contexts = ["workspace"] +command = ["./view.sh", "set"] + +[[actions]] +id = "all" +title = "Agents panel: all" +contexts = ["workspace"] +command = ["./view.sh", "clear"] diff --git a/rust/dl/src/herdr_kit/assets/agent-queue/view.sh b/rust/dl/src/herdr_kit/assets/agent-queue/view.sh new file mode 100755 index 00000000..46dc601d --- /dev/null +++ b/rust/dl/src/herdr_kit/assets/agent-queue/view.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# Make F7 walk a queue of things that need you, rather than a list of everything. +# +# view.sh set blocked, done, then idle -- never working ("needs me") +# view.sh clear the Agents panel shows every agent ("all") +# view.sh toggle flip between the two +# +# `keys.next_agent` is documented as "focus the next agent shown in the agent +# panel". It is *positional* -- one row from wherever you already are -- not +# priority-seeking. `ui.agent_panel_sort = "priority"` fixes the panel's ORDER, +# so row 1 is the most blocked, but F7 from row 5 still goes to row 6. That gap +# between "the queue is sorted" and "the key walks the queue" is the whole +# reason this plugin exists. +# +# It is made worse by `done` being defined as "idle and not yet seen": focusing +# the ticked agent marks it seen, it collapses to `idle`, and the panel re-sorts +# underneath you, so the *next* F7 lands somewhere unrelated. +# +# `agent.view.set` is the lever, because it "controls the expanded and collapsed +# sidebar, mobile Agents list, mouse targets, indexed focus, and next/previous +# Agent navigation" -- navigation included. Filter the panel to blocked+done and +# F7 cannot physically land on something that does not need you. +# +# There is no CLI for it, only the socket, which is why this is raw JSON rather +# than a `herdr` invocation like the rest of the plugin API. +# +# `idle` is in the filter too, sorted last. It was blocked+done only at first, +# which made the panel an inbox: `done` means "idle and not yet seen", so an +# agent that finished while its tab was focused went straight to `idle`, never +# entered the panel, and could not be found again by F7 once forgotten. With +# idle included and `attention desc` first, blocked and done still head the +# list -- prefix+alt+1 is still "most urgent", F7 from the top still walks the +# real queue -- and F7 then continues into idle agents, most recently changed +# first, instead of wrapping. The trade is that F7 can once again land on an +# agent that needs nothing; what it buys is never losing one. +# +# The cost, stated plainly: `working` agents leave the Agents panel while they +# work, and reappear the moment they finish or block. herdr has exactly one +# Agents panel and one projection on it, so there is no second sidebar showing +# everything -- the Spaces panel is that view at workspace granularity, prefix+w +# is the picker over all of them, and `toggle` (prefix+a) is the way to look at +# the full list for a moment and come back. +# +# The projection is transient and dies with the server, so `set` is also what +# the manifest's [[startup]] hook runs. State lives in a file because there is +# an `agent.view.set` and an `agent.view.clear` but no `agent.view.get`. + +set -euo pipefail + +sock="${HERDR_SOCKET_PATH:-${XDG_CONFIG_HOME:-$HOME/.config}/herdr/herdr.sock}" +# Deliberately not HERDR_PLUGIN_STATE_DIR: that is only set when herdr invokes +# the script, and `dl --herdr-setup` calls it directly after linking, so +# honouring it would leave two state files that disagree about which view is on. +state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/herdr-agent-queue" +state="$state_dir/view" + +[ -S "$sock" ] || { + echo "agent-queue: no herdr socket at $sock" >&2 + exit 0 +} +mkdir -p "$state_dir" + +# One line of JSON each: the socket frames requests by newline, so an embedded +# newline would be a second request. +set_request='{"id":"agent-queue-set","method":"agent.view.set","params":{"source":"local.agent-queue","label":"needs me","filter":{"op":"in","field":"status","values":["blocked","done","idle"]},"sort":[{"field":"attention","order":"desc"},{"field":"state_change_seq","order":"desc"}]}}' +clear_request='{"id":"agent-queue-clear","method":"agent.view.clear","params":{"source":"local.agent-queue"}}' + +# An alternative filter, the docs' own worked example: everything in the +# workspace you are looking at, plus anything needing attention elsewhere. +# Keeps working agents visible at the cost of F7 walking your current +# workspace's idle agents again. +# {"op":"any","filters":[ +# {"op":"eq","field":"workspace_id","value":{"context":"current_workspace_id"}}, +# {"op":"in","field":"status","values":["blocked","done"]}]} + +send() { + if command -v socat >/dev/null 2>&1; then + printf '%s\n' "$1" | socat -T5 - "UNIX-CONNECT:$sock" + else + printf '%s\n' "$1" | SOCK="$sock" python3 -c ' +import os, socket, sys +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.settimeout(5) +s.connect(os.environ["SOCK"]) +s.sendall(sys.stdin.buffer.read()) +s.shutdown(socket.SHUT_WR) +sys.stdout.write(s.recv(65536).decode()) +' + fi +} + +mode="${1:-set}" +if [ "$mode" = toggle ]; then + if [ "$(cat "$state" 2>/dev/null || true)" = needs-me ]; then + mode=clear + else + mode=set + fi +fi + +case "$mode" in + set) + response=$(send "$set_request") + # A `set` that did not take is worth one line on stderr and nothing + # more -- a startup hook that fails does not stop the server, and a + # full agent panel is a degraded queue, not a broken session. + case "$response" in + *'"active":true'*) + echo needs-me >"$state" + echo "agent-queue: needs me -- F7 walks blocked, done, then idle" + ;; + *) echo "agent-queue: agent.view.set did not take: $response" >&2 ;; + esac + ;; + clear) + response=$(send "$clear_request") + case "$response" in + *'"active":false'*) + echo all >"$state" + echo "agent-queue: all -- F7 walks every agent" + ;; + *) echo "agent-queue: agent.view.clear did not take: $response" >&2 ;; + esac + ;; + *) + echo "usage: view.sh [set|clear|toggle]" >&2 + exit 2 + ;; +esac diff --git a/rust/dl/src/herdr_kit/assets/config.toml b/rust/dl/src/herdr_kit/assets/config.toml new file mode 100644 index 00000000..b65f03e0 --- /dev/null +++ b/rust/dl/src/herdr_kit/assets/config.toml @@ -0,0 +1,153 @@ +# Herdr config written by `dl --herdr-setup`. +# +# Yours to edit. A later `dl --herdr-setup` adds keys that are missing and never +# changes a value you set, except these four, which point at files devlaunch +# installs: terminal.default_shell, the prefix+a agent-queue binding, the +# status.sh segment in ui.tab_bar_right, and the " · herdr" end of +# ui.window_title. Run `herdr server reload-config` after an edit. + +onboarding = false + +# New tabs and splits open through devlaunch's pane shell: inside the workspace +# the tab holds, or as your ordinary shell when it holds none. +[terminal] +default_shell = "dl-herdr-shell" + +# On restore, herdr types each agent pane's saved `dl -- claude --resume` +# line into a fresh host shell, one agent every startup_per_agent_delay_ms. At +# 1000 or more, a second pane in the same tab has time to enter its workspace +# through dl-herdr-shell first, so `dl` runs inside the container and fails. +# Keep the delay at 500 or below. +[session] +resume_agents_on_restore = true +startup_per_agent_delay_ms = 100 + +# Catppuccin Mocha with the text ramp lifted to white, so every label passes +# WCAG AA on the dark base. Herdr accepts only a subset of the Catppuccin token +# names and drops the rest with "unknown config key". +[theme.custom] +text = "#ffffff" +subtext0 = "#ffffff" +overlay1 = "#ffffff" +overlay0 = "#ffffff" +# Borders stay a step below the text, so frames do not compete with words. +surface1 = "#9aa0b6" +surface0 = "#5f6379" +# The current row in the sidebar: 3.6:1 against sidebar_bg, 4.9:1 under white. +active_row_bg = "#6c7086" + +# Herdr's prefix layer is kept. Bare F-keys and a few Ctrl chords are added on +# the same actions, so almost every value is an array. In kitty, F2 and F4 need +# the fix `dl --herdr-setup` writes to devlaunch-herdr.conf. +[keys] +# `prefix` takes one string, not an array. +prefix = "ctrl+space" + +new_tab = ["prefix+c", "f2", "ctrl+alt+c"] +previous_tab = ["prefix+p", "f3", "ctrl+alt+p"] +next_tab = ["prefix+n", "f4", "ctrl+alt+n"] +switch_tab = "prefix+1..9" +close_tab = "prefix+shift+x" +rename_tab = ["prefix+shift+t", "f6"] +move_tab_previous = "prefix+shift+comma" +move_tab_next = "prefix+shift+period" +goto = ["prefix+g", "shift+f1"] + +split_vertical = "prefix+v" +split_horizontal = "prefix+minus" +close_pane = ["prefix+x", "f10"] +zoom = "prefix+z" +focus_pane_left = "prefix+h" +focus_pane_down = "prefix+j" +focus_pane_up = "prefix+k" +focus_pane_right = "prefix+l" +swap_pane_left = "prefix+shift+h" +swap_pane_down = "prefix+shift+j" +swap_pane_up = "prefix+shift+k" +swap_pane_right = "prefix+shift+l" +rename_pane = "prefix+shift+p" +resize_mode = "prefix+r" +# Keyboard selection with vim motions, `/` and `?` search, `v` and `y`. +copy_mode = "prefix+[" + +new_workspace = "prefix+shift+n" +rename_workspace = "prefix+shift+w" +close_workspace = "prefix+shift+d" +workspace_picker = ["prefix+w", "shift+f5"] +new_worktree = "prefix+shift+g" +toggle_sidebar = "prefix+b" + +edit_scrollback = ["prefix+e", "f5"] +detach = "prefix+q" +settings = "prefix+s" +help = "prefix+?" +reload_config = "prefix+shift+r" + +# The agent queue. With ui.agent_panel_sort = "priority" and the agent-queue +# plugin's filter on, next_agent walks only the agents that need you. +next_agent = ["prefix+period", "ctrl+period", "f7"] +previous_agent = ["prefix+comma", "ctrl+comma", "shift+f7"] +focus_agent = "prefix+alt+1..9" +open_notification_target = ["prefix+o", "ctrl+o"] + +# A popup does not receive HERDR_PANE_ID; HERDR_ACTIVE_PANE_ID names the tiled +# pane under it. +[[keys.command]] +key = "prefix+t" +type = "popup" +command = "exec \"${SHELL:-sh}\"" +description = "scratch terminal" +width = "80%" +height = "80%" + +[[keys.command]] +key = "prefix+shift+b" +type = "shell" +command = "\"$HERDR_BIN_PATH\" pane move \"$HERDR_ACTIVE_PANE_ID\" --new-tab" +description = "break pane into a new tab" + +# Agents panel: "needs me" (blocked, done, idle) or "all". The action is the +# agent-queue plugin `dl --herdr-setup` links. +[[keys.command]] +key = "prefix+a" +type = "plugin_action" +command = "local.agent-queue.toggle" +description = "agents panel: needs me / all" + +[ui] +prompt_new_tab_name = false + +# CPU, memory and root disk at the right end of the tab row. A `command` +# segment runs on the Herdr server, so on a remote machine the figures are that +# machine's, and `hostname` says which one. +tab_bar_right = [ + { type = "command", command = "~/.local/share/devlaunch/herdr/status.sh", interval_seconds = 5, timeout_seconds = 3 }, + { type = "hostname" }, +] + +agent_panel_sort = "priority" +# A glyph per state, so the state does not rest on hue alone. +status_indicators = "symbols" +window_title = "{hostname}: {workspace} · herdr" +show_agent_labels_on_pane_borders = true + +# Herdr's default sidebar rows, restated so each token can carry `dim = false`. +# Herdr dims secondary tokens with an attribute that no palette entry reaches. +[ui.sidebar.agents] +rows = [ + [ + { token = "state_icon" }, + { token = "machine", dim = false }, + { token = "workspace", dim = false }, + { token = "tab", dim = false }, + ], + [ + { token = "agent", dim = false, fg = "#ffffff" }, + ], +] + +[ui.toast] +# Desktop notifications, for work in a tab you are not looking at. +delivery = "system" +# Notify only if the pane is still in the same state after the delay. +delay_seconds = 2 diff --git a/rust/dl/src/herdr_kit/assets/devlaunch-herdr-tab-title.sh b/rust/dl/src/herdr_kit/assets/devlaunch-herdr-tab-title.sh new file mode 100755 index 00000000..df3bcb87 --- /dev/null +++ b/rust/dl/src/herdr_kit/assets/devlaunch-herdr-tab-title.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Installed by `dl --herdr-setup`, which rewrites this file when devlaunch ships +# a new version of it. Copy it under another name before editing it. +# +# Name the Herdr tab after Claude Code's own session title, and stop the moment +# a human disagrees. +# +# Claude Code already writes a one-line summary of the session to the terminal +# title; Herdr captures it per pane as terminal_title_stripped (the raw one +# carries a spinner glyph). So this is a copy, not a generation step -- no model +# call, no token cost. All it adds is `herdr tab rename`. +# +# Three things it must never fight: +# +# - A name you typed. Herdr's auto label is either "" or a bare integer (the +# tab's position in the row), and nothing else ever is, so the integer test +# alone is enough to claim an unnamed tab. Keeping it after that is what the +# state file is for: we only overwrite a name we ourselves last wrote. Rename +# a tab by hand and this goes quiet on it permanently -- and clearing the +# name in the UI hands it back, because the label reads as auto again. +# +# - devlaunch. `dl` and `aid` name a tab after its workspace when they launch +# into it. That name is neither a bare integer nor one this hook wrote, so the +# hook leaves it alone like any other name a person chose. Inside a +# devcontainer there is no herdr binary unless DEVLAUNCH_HERDR=1 lends one, and +# this hook is installed in the host's Claude settings, not the container's. +# The guards below keep it quiet outside Herdr. +# +# - Another agent in the same tab. Two Claudes in a split would each write +# their own title every turn and the tab would flicker between them, so a +# multi-pane tab keeps its number. +# +# Installed as a Stop hook, so the name tracks the conversation as it drifts +# rather than freezing on whatever the first turn happened to be about. Set +# ONCE_ONLY=1 below for the freeze-on-first-turn behaviour instead. + +set -uo pipefail + +ONCE_ONLY=0 +MAX_LEN=40 + +# Drain the hook JSON even when we are about to bail, so Claude Code never +# writes into a closed pipe. +hook_input="$(cat 2>/dev/null || true)" + +[ "${HERDR_ENV:-}" = "1" ] || exit 0 +[ -n "${HERDR_TAB_ID:-}" ] || exit 0 +[ -n "${HERDR_PANE_ID:-}" ] || exit 0 +command -v herdr >/dev/null 2>&1 || exit 0 +command -v jq >/dev/null 2>&1 || exit 0 + +# A subagent finishing is not the session changing topic. +[ -n "$hook_input" ] && + [ "$(printf '%s' "$hook_input" | jq -r '.agent_id // empty' 2>/dev/null)" != "" ] && + exit 0 + +hd() { timeout 2 herdr "$@" 2>/dev/null; } + +tab=$(hd tab get "$HERDR_TAB_ID") || exit 0 +[ -n "$tab" ] || exit 0 +label=$(printf '%s' "$tab" | jq -r '.result.tab.label // ""') +panes=$(printf '%s' "$tab" | jq -r '.result.tab.pane_count // 0') + +[ "$panes" = "1" ] || exit 0 + +state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/herdr-claude-tab-title" +state="$state_dir/${HERDR_TAB_ID//[^A-Za-z0-9]/_}" +last=$(cat "$state" 2>/dev/null || true) + +if [[ "$label" =~ ^[0-9]*$ ]]; then + : # unnamed: ours to claim +elif [ "$label" = "$last" ]; then + [ "$ONCE_ONLY" = "1" ] && exit 0 # ours already, and we only write once +else + exit 0 # somebody named it: hands off +fi + +title=$(hd pane get "$HERDR_PANE_ID" | + jq -r '.result.pane.terminal_title_stripped // empty' | + tr -d '\r\n' | + sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + +[ -n "$title" ] || exit 0 +[ "$title" = "$label" ] && exit 0 +[ "${#title}" -gt "$MAX_LEN" ] && title="${title:0:MAX_LEN-1}…" + +hd tab rename "$HERDR_TAB_ID" "$title" >/dev/null || exit 0 + +mkdir -p "$state_dir" 2>/dev/null || exit 0 +printf '%s' "$title" >"$state" + +# Tab ids are never reused, so abandoned state files only ever accumulate. +find "$state_dir" -maxdepth 1 -type f -mtime +30 -delete 2>/dev/null + +exit 0 diff --git a/rust/dl/src/herdr_kit/assets/kitty.conf b/rust/dl/src/herdr_kit/assets/kitty.conf new file mode 100644 index 00000000..1ee4baee --- /dev/null +++ b/rust/dl/src/herdr_kit/assets/kitty.conf @@ -0,0 +1,19 @@ +# Written by `dl --herdr-setup`, which rewrites this file when devlaunch ships a +# new version of it. kitty.conf includes it with `include devlaunch-herdr.conf`. +# +# herdr turns on the kitty keyboard protocol, and under it kitty sends F2 and F4 +# as bare `CSI Q` and `CSI S`. herdr's parser drops that form, so those keys +# never reach it. These lines send the SS3 form (`ESC O Q`, `ESC O S`) instead, +# which herdr parses. SS3 is also what kitty's own terminfo advertises, so +# programs in other kitty windows still see the keys. +# +# F3 is `CSI 13~` in both modes and needs no fix, but herdr also parses its SS3 +# form, so it is sent the same way. F1 has the same problem and no line here, +# because the keymap `dl --herdr-setup` ships binds only shift+f1, which kitty +# sends as `CSI 1;2P` and herdr parses. +# +# Remove this file once herdr parses the bare form. `ctrl+space c/p/n` reach the +# same tab actions with no terminal help. +map f2 send_text all \x1bOQ +map f3 send_text all \x1bOR +map f4 send_text all \x1bOS diff --git a/rust/dl/src/herdr_kit/assets/status.sh b/rust/dl/src/herdr_kit/assets/status.sh new file mode 100755 index 00000000..21f76995 --- /dev/null +++ b/rust/dl/src/herdr_kit/assets/status.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# One line for herdr's tab_bar_right: CPU %, RAM used/total, root disk used/total. +# herdr strips escape codes from this output, so colour comes from emoji instead: +# a dot before each figure, green below 75%, yellow from 75%, red from 90%. +dot() { if [ "$1" -ge 90 ]; then echo 🔴; elif [ "$1" -ge 75 ]; then echo 🟡; else echo 🟢; fi; } + +set -- $(head -1 /proc/stat); t1=$(($2+$3+$4+$5+$6+$7+$8)); i1=$(($5+$6)) +sleep 1 +set -- $(head -1 /proc/stat); t2=$(($2+$3+$4+$5+$6+$7+$8)); i2=$(($5+$6)) +cpu=$(( 100 * ( (t2-t1) - (i2-i1) ) / (t2-t1) )) + +set -- $(awk '/^MemTotal/{t=$2} /^MemAvailable/{a=$2} END{printf "%d %.1f/%.0fG", 100*(t-a)/t, (t-a)/1048576, t/1048576}' /proc/meminfo) +mem_pct=$1 mem=$2 + +# df's own Use%: used / (used + available), so root's reserved blocks count as full. +set -- $(df -Pk / | awk 'NR==2{printf "%d %.0f/%.0fG", 100*$3/($3+$4)+0.999, $3/1048576, $2/1048576}') +disk_pct=$1 disk=$2 + +echo "$(dot "$cpu") cpu ${cpu}% $(dot "$mem_pct") mem ${mem} $(dot "$disk_pct") disk ${disk}" diff --git a/rust/dl/src/herdr_kit/claude.rs b/rust/dl/src/herdr_kit/claude.rs new file mode 100644 index 00000000..23bfc5ec --- /dev/null +++ b/rust/dl/src/herdr_kit/claude.rs @@ -0,0 +1,328 @@ +//! The `~/.claude/settings.json` half of `dl --herdr-setup`. +//! +//! Claude Code rewrites this file itself, and other tools add hooks to it, so the +//! merge owns two things and nothing else: +//! +//! - **Its own Stop hook**, the one that runs `devlaunch-herdr-tab-title.sh`. +//! Added when absent, corrected when it drifted, and kept to one copy. +//! - **Duplicates of herdr's SessionStart hook** (`herdr-agent-state.sh`), which +//! older herdr releases appended once per `herdr integration install`. The +//! last copy stays, because herdr appends its current form; the rest go. +//! +//! Every other key, event and hook passes through untouched, in its order. A +//! file the merge does not change is not rewritten, so its formatting is kept +//! exactly; a file it does change is written as Claude Code writes it, two-space +//! JSON with key order preserved. + +use serde_json::{Map, Value, json}; + +/// The hook script's file name. Distinct from any name a user or herdr would +/// pick, because it is how the merge recognises its own entry. +pub(crate) const HOOK_NAME: &str = "devlaunch-herdr-tab-title.sh"; +/// The script itself. +pub(crate) const HOOK_SCRIPT: &str = include_str!("assets/devlaunch-herdr-tab-title.sh"); +/// herdr's own Claude hook, as `herdr integration install claude` writes it. +pub(crate) const HERDR_HOOK: &str = "herdr-agent-state.sh"; +/// A tab-title hook installed some other way, such as from dotfiles. +const OTHER_TAB_TITLE_HOOK: &str = "herdr-tab-title.sh"; +const TIMEOUT_SECONDS: u64 = 10; + +/// The command the Stop hook runs: silent when the script is gone. +pub(crate) fn hook_command(script: &str) -> String { + let quoted = super::shell_quote(script); + format!("[ -x {quoted} ] && {quoted} || true") +} + +/// What the merge found and would write. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Merged { + /// The new file, or `None` when nothing changes. + pub(crate) text: Option, + /// A tab-title hook from somewhere else is registered, so devlaunch's is not. + pub(crate) other_tab_title: bool, + /// Duplicate hook entries removed. + pub(crate) removed: usize, + /// devlaunch's Stop hook was added or corrected. + pub(crate) registered: bool, + /// A Stop hook already runs devlaunch's command, whatever else it says. + pub(crate) runs_ours: bool, +} + +fn command_of(hook: &Value) -> &str { + hook.get("command") + .and_then(Value::as_str) + .unwrap_or_default() +} + +fn is_ours(hook: &Value) -> bool { + command_of(hook).contains(HOOK_NAME) +} + +fn is_other_tab_title(hook: &Value) -> bool { + let command = command_of(hook); + command.contains(OTHER_TAB_TITLE_HOOK) && !command.contains(HOOK_NAME) +} + +/// Remove every hook in `groups` that `drop` selects, then any group it emptied. +fn remove_hooks(groups: &mut Vec, mut drop: impl FnMut(&Value) -> bool) -> usize { + let mut removed = 0; + groups.retain_mut(|group| { + let Some(hooks) = group.get_mut("hooks").and_then(Value::as_array_mut) else { + return true; + }; + let before = hooks.len(); + hooks.retain(|hook| !drop(hook)); + removed += before - hooks.len(); + !(before > 0 && hooks.is_empty()) + }); + removed +} + +/// Merge devlaunch's Stop hook into `original` (`None`: no file yet). +pub(crate) fn merge(original: Option<&str>, command: &str) -> Result { + let before: Value = match original { + Some(text) if !text.trim().is_empty() => serde_json::from_str(text) + .map_err(|e| format!("settings.json is not valid JSON: {e}"))?, + _ => Value::Object(Map::new()), + }; + let mut after = before.clone(); + let root = after + .as_object_mut() + .ok_or("settings.json is not a JSON object")?; + let hooks = root + .entry("hooks") + .or_insert_with(|| Value::Object(Map::new())) + .as_object_mut() + .ok_or("settings.json's hooks is not an object")?; + for (event, groups) in hooks.iter() { + if !groups.is_array() { + return Err(format!("settings.json's hooks.{event} is not an array")); + } + } + let mut merged = Merged { + other_tab_title: hooks.values().flat_map(hooks_in).any(is_other_tab_title), + ..Merged::default() + }; + + // Our hook: at most one, in Stop, and none at all beside somebody else's. + let keep_ours = !merged.other_tab_title; + let mut kept = false; + for (event, groups) in hooks.iter_mut() { + let groups = groups.as_array_mut().expect("checked above"); + let in_stop = event == "Stop"; + merged.removed += remove_hooks(groups, |hook| { + if !is_ours(hook) { + return false; + } + if keep_ours && in_stop && !kept { + kept = true; + return false; + } + true + }); + } + if keep_ours { + let desired = json!({"type": "command", "command": command, "timeout": TIMEOUT_SECONDS}); + let stop = hooks + .entry("Stop") + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("checked above"); + let existing = stop + .iter_mut() + .filter_map(|group| group.get_mut("hooks").and_then(Value::as_array_mut)) + .flatten() + .find(|hook| is_ours(hook)); + match existing { + Some(hook) => { + merged.runs_ours = command_of(hook) == command; + if *hook != desired { + *hook = desired; + merged.registered = true; + } + } + None => { + stop.push(json!({"matcher": "", "hooks": [desired]})); + merged.registered = true; + } + } + } + + // herdr's hook: the last copy in SessionStart, the one herdr wrote most recently, stays. + if let Some(groups) = hooks.get_mut("SessionStart").and_then(Value::as_array_mut) { + let is_herdrs = |hook: &Value| command_of(hook).contains(HERDR_HOOK); + let mut stale = hooks_in(&Value::Array(groups.clone())) + .filter(|hook| is_herdrs(hook)) + .count() + .saturating_sub(1); + merged.removed += remove_hooks(groups, |hook| { + if !is_herdrs(hook) || stale == 0 { + return false; + } + stale -= 1; + true + }); + } + + // A `hooks` object this merge created and left empty was never there. + if before.get("hooks").is_none() && hooks.is_empty() { + root.remove("hooks"); + } + if after != before { + let mut text = serde_json::to_string_pretty(&after).expect("a JSON value serialises"); + if original.is_none_or(|text| text.ends_with('\n')) { + text.push('\n'); + } + merged.text = Some(text); + } + Ok(merged) +} + +fn hooks_in(groups: &Value) -> impl Iterator { + groups + .as_array() + .into_iter() + .flatten() + .filter_map(|group| group.get("hooks").and_then(Value::as_array)) + .flatten() +} + +#[cfg(test)] +mod tests { + use super::*; + + const COMMAND: &str = "[ -x /h/.claude/hooks/devlaunch-herdr-tab-title.sh ] && /h/.claude/hooks/devlaunch-herdr-tab-title.sh || true"; + + fn herdr_hook(matcher: &str) -> Value { + json!({"matcher": matcher, "hooks": [{"type": "command", "command": "bash '/h/.claude/hooks/herdr-agent-state.sh' session", "timeout": 10}]}) + } + + fn parse(text: &str) -> Value { + serde_json::from_str(text).unwrap() + } + + #[test] + fn a_missing_file_gets_only_the_stop_hook() { + let merged = merge(None, COMMAND).unwrap(); + let settings = parse(merged.text.as_deref().unwrap()); + assert_eq!( + settings, + json!({"hooks": {"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": COMMAND, "timeout": 10}]}]}}) + ); + assert!(merged.registered); + assert!(merged.text.unwrap().ends_with('\n')); + } + + #[test] + fn foreign_keys_and_hooks_survive_in_their_order() { + let original = json!({ + "model": "opus", + "hooks": { + "Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "telemetry.sh"}]}], + "Notification": [{"matcher": "", "hooks": [{"type": "command", "command": "notify"}]}], + "PreToolUse": [{"matcher": "Bash", "hooks": []}] + }, + "theme": "dark" + }); + let text = serde_json::to_string_pretty(&original).unwrap(); + let merged = merge(Some(&text), COMMAND).unwrap(); + let settings = parse(merged.text.as_deref().unwrap()); + let keys: Vec<_> = settings.as_object().unwrap().keys().cloned().collect(); + assert_eq!(keys, ["model", "hooks", "theme"]); + assert_eq!( + settings["hooks"]["Notification"], + original["hooks"]["Notification"] + ); + assert_eq!( + settings["hooks"]["PreToolUse"], + original["hooks"]["PreToolUse"] + ); + let stop = settings["hooks"]["Stop"].as_array().unwrap(); + assert_eq!(stop[0], original["hooks"]["Stop"][0]); + assert_eq!(stop[1]["hooks"][0]["command"], COMMAND); + } + + #[test] + fn a_second_merge_changes_nothing() { + let once = merge(Some("{\"model\": \"opus\"}"), COMMAND) + .unwrap() + .text + .unwrap(); + assert_eq!( + merge(Some(&once), COMMAND).unwrap(), + Merged { + runs_ours: true, + ..Merged::default() + } + ); + } + + #[test] + fn duplicates_of_our_hook_and_of_herdrs_are_removed() { + let ours = json!({"matcher": "", "hooks": [{"type": "command", "command": COMMAND, "timeout": 10}]}); + let original = json!({"hooks": { + "Stop": [ours.clone(), ours.clone()], + "SessionStart": [herdr_hook("*"), herdr_hook("^(startup|resume)$"), {"matcher": "", "hooks": [{"type": "command", "command": "resource-check.sh"}]}], + "Notification": [ours] + }}); + let merged = merge(Some(&original.to_string()), COMMAND).unwrap(); + assert_eq!(merged.removed, 3); + let settings = parse(merged.text.as_deref().unwrap()); + assert_eq!(settings["hooks"]["Stop"].as_array().unwrap().len(), 1); + let start = settings["hooks"]["SessionStart"].as_array().unwrap(); + assert_eq!(start.len(), 2); + assert_eq!(start[0], herdr_hook("^(startup|resume)$")); + assert_eq!(start[1]["hooks"][0]["command"], "resource-check.sh"); + assert_eq!(settings["hooks"]["Notification"], json!([])); + } + + #[test] + fn the_herdr_hook_written_last_survives_the_dedupe() { + let stale = json!({"matcher": "*", "hooks": [{"type": "command", "command": "bash /h/.claude/hooks/herdr-agent-state.sh session", "timeout": 10}]}); + let current = herdr_hook("^(startup|resume|clear|compact|fork)$"); + let original = json!({"hooks": {"SessionStart": [stale, current.clone()]}}); + let merged = merge(Some(&original.to_string()), COMMAND).unwrap(); + assert_eq!(merged.removed, 1); + let settings = parse(merged.text.as_deref().unwrap()); + assert_eq!(settings["hooks"]["SessionStart"], json!([current])); + } + + #[test] + fn a_tab_title_hook_from_dotfiles_is_left_to_run_alone() { + let original = json!({"hooks": {"Stop": [{"matcher": "", "hooks": [ + {"type": "command", "command": "[ -x \"$HOME/.claude/hooks/herdr-tab-title.sh\" ] && \"$HOME/.claude/hooks/herdr-tab-title.sh\" || true"}, + {"type": "command", "command": COMMAND} + ]}]}}); + let merged = merge(Some(&original.to_string()), COMMAND).unwrap(); + assert!(merged.other_tab_title); + let settings = parse(merged.text.as_deref().unwrap()); + let hooks = settings["hooks"]["Stop"][0]["hooks"].as_array().unwrap(); + assert_eq!(hooks.len(), 1); + assert!(command_of(&hooks[0]).contains("/herdr-tab-title.sh")); + } + + #[test] + fn a_drifted_entry_is_corrected_in_place() { + let original = json!({"hooks": {"Stop": [{"matcher": "", "hooks": [ + {"type": "command", "command": "/old/devlaunch-herdr-tab-title.sh"}, + {"type": "command", "command": "telemetry.sh"} + ]}]}}); + let merged = merge(Some(&original.to_string()), COMMAND).unwrap(); + let settings = parse(merged.text.as_deref().unwrap()); + let hooks = settings["hooks"]["Stop"][0]["hooks"].as_array().unwrap(); + assert_eq!(hooks[0]["command"], COMMAND); + assert_eq!(hooks[1]["command"], "telemetry.sh"); + } + + #[test] + fn unusable_settings_are_refused() { + for original in [ + "[]", + "{\"hooks\": []}", + "{\"hooks\": {\"Stop\": {}}}", + "{not json", + ] { + assert!(merge(Some(original), COMMAND).is_err(), "{original}"); + } + } +} diff --git a/rust/dl/src/herdr_kit/config.rs b/rust/dl/src/herdr_kit/config.rs new file mode 100644 index 00000000..a3765ecd --- /dev/null +++ b/rust/dl/src/herdr_kit/config.rs @@ -0,0 +1,795 @@ +//! The Herdr config half of `dl --herdr-setup`: a merge, never a replacement. +//! +//! Two kinds of key, and the difference is who a value belongs to. +//! +//! **Managed** keys point at files this setup installs, so a path that has moved +//! is a broken config rather than a preference: `terminal.default_shell`, the +//! `prefix+a` binding for the agent-queue plugin, the `status.sh` segment in +//! `ui.tab_bar_right`, and the ` · herdr` end of `ui.window_title`. They are set +//! on every run. Even so, each one stops short of a value that is plainly the +//! user's: a custom `default_shell` is refused rather than replaced, a +//! `status.sh` of the user's own keeps its segment, and a `prefix+a` bound to +//! something else keeps its binding. +//! +//! **Default** keys are everything else in `assets/config.toml`, the keymap, +//! theme, sidebar and toasts devlaunch ships. Each is added only where the key +//! is absent, so a value the user set, including one that matches herdr's own +//! default, is never overwritten. +//! +//! An empty or missing config is the one case that is not a merge: it gets the +//! packaged file verbatim, comments and all, with the managed paths filled in. + +use toml_edit::{ArrayOfTables, DocumentMut, InlineTable, Item, Table, TableLike, Value}; + +/// The config devlaunch ships, and the source of every default key. +pub(crate) const PACKAGED: &str = include_str!("assets/config.toml"); +/// The plugin action `prefix+a` runs. +pub(crate) const AGENT_QUEUE_ACTION: &str = "local.agent-queue.toggle"; +/// The end of `ui.window_title` a window manager rule can match on. +pub(crate) const TITLE_SUFFIX: &str = " · herdr"; +/// The status command `assets/config.toml` spells, before the real path replaces it. +const STATUS_PLACEHOLDER: &str = "~/.local/share/devlaunch/herdr/status.sh"; + +/// The paths the managed keys point at, resolved for this machine. +pub(crate) struct Targets { + /// The pane shell, for `terminal.default_shell`. + pub(crate) shell: String, + /// The older launcher a `default_shell` may still name, which is replaced. + pub(crate) prototype_shell: String, + /// The installed `status.sh`, for the tab-bar segment. + pub(crate) status: String, +} + +/// What a merge would change. +#[derive(Debug, Default)] +pub(crate) struct Merged { + /// The whole new file. + pub(crate) text: String, + /// The config was empty, and is now the packaged file. + pub(crate) fresh: bool, + /// Default keys that were absent and are now set, as dotted paths. + pub(crate) added: Vec, + /// Managed keys that were set or corrected. + pub(crate) managed: Vec, + /// Managed keys left alone, and why. + pub(crate) kept: Vec, +} + +/// Merge the packaged config into `original`. +/// +/// Refuses, changing nothing, for a config that does not parse and for a custom +/// `default_shell`: both need a person, and a guess costs every pane. +pub(crate) fn merge(original: &str, targets: &Targets) -> Result { + let packaged = packaged(); + let mut merged = Merged::default(); + let mut document = if original.trim().is_empty() { + merged.fresh = true; + packaged.clone() + } else { + let mut document = original + .parse::() + .map_err(|e| format!("invalid Herdr config: {e}"))?; + let mut next = last_position(document.as_table()) + 1; + let bound = bound_keys(document.as_table()); + fill_defaults( + document.as_table_mut(), + packaged.as_table(), + "", + &bound, + &mut next, + &mut merged.added, + ); + document + }; + let mut next = last_position(document.as_table()) + 1; + set_default_shell(&mut document, targets, &mut merged)?; + set_status_segment(&mut document, targets, &mut merged); + set_agent_queue_binding(&mut document, &packaged, &mut next, &mut merged); + set_window_title(&mut document, &mut merged); + merged.text = document.to_string(); + Ok(merged) +} + +fn packaged() -> DocumentMut { + PACKAGED + .parse() + .expect("assets/config.toml is valid TOML; a unit test parses it") +} + +/// The largest position any table in `table` holds, so appended tables go last. +fn last_position(table: &Table) -> isize { + let mut last = table.position().unwrap_or(0); + for (_, item) in table.iter() { + match item { + Item::Table(child) => last = last.max(last_position(child)), + Item::ArrayOfTables(array) => { + for child in array.iter() { + last = last.max(last_position(child)); + } + } + _ => {} + } + } + last +} + +/// Give `item`'s tables positions from `next` onward, in document order. +/// +/// A cloned table keeps the position it had in the packaged file, and toml_edit +/// sorts every table in a document by position when it writes one out. So an +/// inserted table left alone would land among the user's tables wherever its old +/// number happened to fall. +fn renumber(item: &mut Item, next: &mut isize) { + match item { + Item::Table(table) => renumber_table(table, next), + Item::ArrayOfTables(array) => { + for table in array.iter_mut() { + renumber_table(table, next); + } + } + _ => {} + } +} + +fn renumber_table(table: &mut Table, next: &mut isize) { + table.set_position(*next); + *next += 1; + for (_, child) in table.iter_mut() { + renumber(child, next); + } +} + +/// Every key string the user's `[keys]` binds, to an action or a `[[keys.command]]`. +fn bound_keys(root: &Table) -> Vec { + let Some(keys) = root.get("keys").and_then(Item::as_table_like) else { + return Vec::new(); + }; + let mut bound = Vec::new(); + for (_, item) in keys.iter() { + match item { + Item::Value(Value::String(key)) => bound.push(key.value().to_owned()), + Item::Value(Value::Array(array)) => { + bound.extend(array.iter().filter_map(Value::as_str).map(str::to_owned)); + } + Item::ArrayOfTables(commands) => bound.extend( + commands + .iter() + .filter_map(|entry| entry.get("key").and_then(Item::as_str)) + .map(str::to_owned), + ), + _ => {} + } + } + bound +} + +/// `default` without the keys in `bound`, or `None` when it keeps no key at all. +fn unclaimed(default: &Item, bound: &[String]) -> Option { + let free = |key: &str| !bound.iter().any(|mine| mine == key); + match default { + Item::Value(Value::String(key)) => free(key.value()).then(|| default.clone()), + Item::Value(Value::Array(array)) => { + let mut array = array.clone(); + array.retain(|key| key.as_str().is_none_or(free)); + (!array.is_empty()).then(|| Item::Value(Value::Array(array))) + } + Item::ArrayOfTables(commands) => { + let mut kept = ArrayOfTables::new(); + for entry in commands.iter() { + if entry.get("key").and_then(Item::as_str).is_none_or(free) { + kept.push(entry.clone()); + } + } + (!kept.is_empty()).then_some(Item::ArrayOfTables(kept)) + } + _ => Some(default.clone()), + } +} + +fn fill_defaults( + user: &mut Table, + defaults: &Table, + prefix: &str, + bound: &[String], + next: &mut isize, + added: &mut Vec, +) { + let keymap = prefix == "keys."; + for (key, default) in defaults.iter() { + let path = format!("{prefix}{key}"); + match (user.get_mut(key), default) { + (None, _) => { + let item = if keymap { + unclaimed(default, bound) + } else { + Some(default.clone()) + }; + let Some(mut item) = item else { + continue; + }; + renumber(&mut item, next); + user.insert(key, item); + added.push(path); + } + (Some(Item::Table(existing)), Item::Table(default)) => { + fill_defaults(existing, default, &format!("{path}."), bound, next, added); + } + (Some(Item::ArrayOfTables(existing)), Item::ArrayOfTables(default)) => { + for entry in default.iter() { + let claimed = keymap + && entry + .get("key") + .and_then(Item::as_str) + .is_some_and(|key| bound.iter().any(|mine| mine == key)); + if !claimed && !existing.iter().any(|mine| same_binding(mine, entry)) { + push_entry(existing, entry.clone(), next); + added.push(format!("{path} {}", describe_binding(entry))); + } + } + } + // Present in any other shape: the user's value, whatever it is. + (Some(_), _) => {} + } + } +} + +/// Two `[[keys.command]]` entries are one binding when they share a key or a command. +fn same_binding(one: &Table, other: &Table) -> bool { + let field = + |table: &Table, name: &str| table.get(name).and_then(Item::as_str).map(str::to_owned); + let key = field(one, "key"); + let command = field(one, "command"); + (key.is_some() && key == field(other, "key")) + || (command.is_some() && command == field(other, "command")) +} + +fn describe_binding(entry: &Table) -> String { + entry + .get("key") + .and_then(Item::as_str) + .unwrap_or("?") + .to_owned() +} + +/// Append `entry` to `array` just after its current last entry. +fn push_entry(array: &mut ArrayOfTables, mut entry: Table, next: &mut isize) { + match array.iter().last().and_then(Table::position) { + // The same position as the last entry: the sort is stable, so the new one + // is written straight after it rather than at the end of the file. + Some(position) => entry.set_position(position), + None => { + entry.set_position(*next); + *next += 1; + } + } + array.push(entry); +} + +/// Replace a string value, keeping the whitespace and comment around it. +fn set_string(value: &mut Value, text: &str) { + let decor = value.decor().clone(); + *value = Value::from(text); + *value.decor_mut() = decor; +} + +fn table_like<'d>(document: &'d mut DocumentMut, key: &str) -> Option<&'d mut dyn TableLike> { + document + .entry(key) + .or_insert(Item::Table(Table::new())) + .as_table_like_mut() +} + +fn set_default_shell( + document: &mut DocumentMut, + targets: &Targets, + merged: &mut Merged, +) -> Result<(), String> { + let terminal = + table_like(document, "terminal").ok_or("Herdr terminal config is not a table")?; + match terminal.get_mut("default_shell") { + None => { + terminal.insert("default_shell", toml_edit::value(targets.shell.as_str())); + } + Some(item) => { + let value = item + .as_value_mut() + .filter(|value| value.is_str()) + .ok_or("Herdr default_shell is not a string")?; + let current = value.as_str().unwrap_or_default(); + if current == targets.shell { + return Ok(()); + } + if !current.is_empty() + && current != crate::pane_shell::NAME + && current != targets.prototype_shell + { + return Err(format!( + "Herdr uses a custom default_shell ({current}); set terminal.default_shell to `{}` manually to replace it", + targets.shell + )); + } + set_string(value, &targets.shell); + } + } + merged.managed.push("terminal.default_shell".to_owned()); + Ok(()) +} + +fn status_segment(status: &str) -> Value { + let mut segment = InlineTable::new(); + segment.insert("type", "command".into()); + segment.insert("command", status.into()); + segment.insert("interval_seconds", 5.into()); + segment.insert("timeout_seconds", 3.into()); + Value::InlineTable(segment) +} + +fn set_status_segment(document: &mut DocumentMut, targets: &Targets, merged: &mut Merged) { + let Some(ui) = table_like(document, "ui") else { + merged + .kept + .push("ui is not a table, so the status segment was not added".to_owned()); + return; + }; + let Some(item) = ui.get_mut("tab_bar_right") else { + let mut bar = toml_edit::Array::new(); + bar.push(status_segment(&targets.status)); + ui.insert("tab_bar_right", toml_edit::value(bar)); + merged.managed.push("ui.tab_bar_right".to_owned()); + return; + }; + let Some(bar) = item.as_array_mut() else { + merged.kept.push( + "ui.tab_bar_right is not an array, so the status segment was not added".to_owned(), + ); + return; + }; + let mut theirs = false; + for segment in bar.iter_mut() { + let Some(command) = segment + .as_inline_table_mut() + .and_then(|table| table.get_mut("command")) + else { + continue; + }; + let Some(current) = command.as_str() else { + continue; + }; + if current == targets.status { + return; + } + if current == STATUS_PLACEHOLDER { + set_string(command, &targets.status); + merged + .managed + .push("ui.tab_bar_right status segment".to_owned()); + return; + } + theirs |= current.ends_with("status.sh"); + } + if theirs { + merged.kept.push( + "ui.tab_bar_right already runs a status.sh of your own, so devlaunch's was not added" + .to_owned(), + ); + return; + } + bar.push(status_segment(&targets.status)); + merged + .managed + .push("ui.tab_bar_right status segment".to_owned()); +} + +fn set_agent_queue_binding( + document: &mut DocumentMut, + packaged: &DocumentMut, + next: &mut isize, + merged: &mut Merged, +) { + let ours = packaged["keys"]["command"] + .as_array_of_tables() + .and_then(|array| { + array.iter().find(|entry| { + entry.get("command").and_then(Item::as_str) == Some(AGENT_QUEUE_ACTION) + }) + }) + .expect("assets/config.toml binds the agent-queue toggle") + .clone(); + let bound = bound_keys(document.as_table()); + let Some(keys) = document + .entry("keys") + .or_insert(Item::Table(Table::new())) + .as_table_mut() + else { + merged + .kept + .push("keys is not a table, so prefix+a was not bound".to_owned()); + return; + }; + let commands = keys + .entry("command") + .or_insert(Item::ArrayOfTables(ArrayOfTables::new())); + let Some(commands) = commands.as_array_of_tables_mut() else { + merged.kept.push( + "keys.command is not written as [[keys.command]] tables, so prefix+a was not bound" + .to_owned(), + ); + return; + }; + let existing = commands + .iter_mut() + .find(|entry| entry.get("command").and_then(Item::as_str) == Some(AGENT_QUEUE_ACTION)); + if let Some(entry) = existing { + if entry.get("type").and_then(Item::as_str) != Some("plugin_action") { + entry.insert("type", toml_edit::value("plugin_action")); + merged + .managed + .push("keys.command agent-queue toggle".to_owned()); + } + return; + } + let key = ours + .get("key") + .and_then(Item::as_str) + .unwrap_or_default() + .to_owned(); + if bound.contains(&key) { + merged.kept.push(format!( + "{key} is bound to something else, so the agent-queue toggle ({AGENT_QUEUE_ACTION}) has no key" + )); + return; + } + push_entry(commands, ours, next); + merged + .managed + .push(format!("keys.command {key} agent-queue toggle")); +} + +fn set_window_title(document: &mut DocumentMut, merged: &mut Merged) { + let Some(ui) = table_like(document, "ui") else { + return; + }; + match ui.get_mut("window_title") { + None => { + ui.insert( + "window_title", + toml_edit::value(format!("{{hostname}}: {{workspace}}{TITLE_SUFFIX}")), + ); + } + Some(item) => { + let Some(value) = item.as_value_mut().filter(|value| value.is_str()) else { + merged + .kept + .push("ui.window_title is not a string, so it was left alone".to_owned()); + return; + }; + let current = value.as_str().unwrap_or_default().to_owned(); + if current.ends_with(TITLE_SUFFIX) { + return; + } + set_string(value, &format!("{current}{TITLE_SUFFIX}")); + } + } + merged.managed.push("ui.window_title".to_owned()); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn targets() -> Targets { + Targets { + shell: "/home/u/.local/bin/dl-herdr-shell".to_owned(), + prototype_shell: "/home/u/.local/bin/herdr-workspace-shell".to_owned(), + status: "/home/u/.local/share/devlaunch/herdr/status.sh".to_owned(), + } + } + + fn parse(text: &str) -> DocumentMut { + text.parse().unwrap() + } + + fn commands(document: &DocumentMut) -> Vec<(String, String)> { + document["keys"]["command"] + .as_array_of_tables() + .unwrap() + .iter() + .map(|entry| { + ( + entry["key"].as_str().unwrap().to_owned(), + entry["command"].as_str().unwrap().to_owned(), + ) + }) + .collect() + } + + #[test] + fn the_packaged_config_parses_and_binds_nothing_personal() { + let document = packaged(); + let bound = commands(&document); + assert!( + bound + .iter() + .any(|(key, command)| key == "prefix+a" && command == AGENT_QUEUE_ACTION) + ); + assert!( + bound + .iter() + .all(|(_, command)| !command.contains("/.local/bin/")), + "a binding points at a script devlaunch does not ship: {bound:?}" + ); + assert!(!PACKAGED.contains("zja")); + assert!(PACKAGED.contains(STATUS_PLACEHOLDER)); + } + + #[test] + fn an_empty_config_gets_the_packaged_file_with_real_paths() { + for original in ["", "\n \n"] { + let merged = merge(original, &targets()).unwrap(); + let document = parse(&merged.text); + assert_eq!( + document["terminal"]["default_shell"].as_str(), + Some(targets().shell.as_str()) + ); + assert!( + merged + .text + .contains("# Herdr config written by `dl --herdr-setup`") + ); + assert!(merged.text.contains(&targets().status)); + assert!(!merged.text.contains(STATUS_PLACEHOLDER)); + assert_eq!(document["keys"]["prefix"].as_str(), Some("ctrl+space")); + assert!( + document["ui"]["window_title"] + .as_str() + .unwrap() + .ends_with(TITLE_SUFFIX) + ); + } + } + + #[test] + fn default_keys_fill_gaps_and_never_replace_a_user_value() { + let original = "# mine\n[keys]\nprefix = \"ctrl+b\" # muscle memory\nnew_tab = \"prefix+c\"\n\n[theme.custom]\ntext = \"#cccccc\"\n"; + let merged = merge(original, &targets()).unwrap(); + let document = parse(&merged.text); + // A root key has to precede every table, so `onboarding` lands above the + // comment that belongs to `[keys]`, and the comment stays with its table. + assert!(merged.text.contains("# mine\n[keys]\n"), "{}", merged.text); + assert!(merged.text.contains("prefix = \"ctrl+b\" # muscle memory")); + assert_eq!(document["keys"]["new_tab"].as_str(), Some("prefix+c")); + assert_eq!( + document["theme"]["custom"]["text"].as_str(), + Some("#cccccc") + ); + // Absent keys come from the package. + assert_eq!( + document["theme"]["custom"]["surface1"].as_str(), + Some("#9aa0b6") + ); + assert!(document["keys"]["next_agent"].is_array()); + assert_eq!( + document["ui"]["toast"]["delay_seconds"].as_integer(), + Some(2) + ); + assert!(merged.added.contains(&"theme.custom.surface1".to_owned())); + assert!(!merged.added.iter().any(|path| path == "keys.prefix")); + } + + #[test] + fn a_new_config_resumes_agents_and_a_users_choice_is_kept() { + let fresh = parse(&merge("", &targets()).unwrap().text); + assert_eq!( + fresh["session"]["resume_agents_on_restore"].as_bool(), + Some(true) + ); + assert!(fresh["session"]["startup_per_agent_delay_ms"].as_integer() <= Some(500)); + + let original = "[session]\nresume_agents_on_restore = false\n"; + let merged = merge(original, &targets()).unwrap(); + let document = parse(&merged.text); + assert_eq!( + document["session"]["resume_agents_on_restore"].as_bool(), + Some(false) + ); + assert!( + !merged + .managed + .iter() + .any(|path| path.starts_with("session.")) + ); + assert!( + merged + .added + .contains(&"session.startup_per_agent_delay_ms".to_owned()) + ); + } + + fn keys_of(document: &DocumentMut, action: &str) -> Vec { + match document["keys"].get(action) { + None => Vec::new(), + Some(item) => match item.as_str() { + Some(key) => vec![key.to_owned()], + None => item + .as_array() + .unwrap() + .iter() + .map(|key| key.as_str().unwrap().to_owned()) + .collect(), + }, + } + } + + #[test] + fn a_default_action_never_takes_a_key_the_user_already_bound() { + let merged = merge("[keys]\nzoom = \"f6\"\n", &targets()).unwrap(); + let document = parse(&merged.text); + assert_eq!(keys_of(&document, "zoom"), ["f6"]); + assert_eq!(keys_of(&document, "rename_tab"), ["prefix+shift+t"]); + + let original = + "[[keys.command]]\nkey = \"f7\"\ntype = \"shell\"\ncommand = \"echo mine\"\n"; + let merged = merge(original, &targets()).unwrap(); + let document = parse(&merged.text); + assert_eq!( + keys_of(&document, "next_agent"), + ["prefix+period", "ctrl+period"] + ); + assert!( + commands(&document) + .iter() + .all(|(key, command)| key != "f7" || command == "echo mine") + ); + + let merged = merge( + "[keys]\nsplit_vertical = [\"prefix+g\", \"shift+f1\"]\nhelp = \"prefix+t\"\n", + &targets(), + ) + .unwrap(); + let document = parse(&merged.text); + assert!(keys_of(&document, "goto").is_empty(), "{}", merged.text); + assert!(!merged.added.iter().any(|path| path == "keys.goto")); + assert!(!commands(&document).iter().any(|(key, _)| key == "prefix+t")); + } + + #[test] + fn inserted_tables_go_after_the_users_tables() { + let original = "[keys]\nprefix = \"ctrl+b\"\n\n[terminal]\nfont_size = 14\n"; + let merged = merge(original, &targets()).unwrap(); + let terminal = merged.text.find("[terminal]").unwrap(); + let keys = merged.text.find("[keys]").unwrap(); + let theme = merged.text.find("[theme.custom]").unwrap(); + let ui = merged.text.find("[ui]").unwrap(); + assert!( + keys < terminal && terminal < theme && theme < ui, + "{}", + merged.text + ); + } + + #[test] + fn managed_keys_are_set_even_when_the_user_had_a_value() { + let original = + "[ui]\nwindow_title = \"{workspace}\"\ntab_bar_right = [{ type = \"hostname\" }]\n"; + let merged = merge(original, &targets()).unwrap(); + let document = parse(&merged.text); + assert_eq!( + document["ui"]["window_title"].as_str(), + Some("{workspace} · herdr") + ); + let bar = document["ui"]["tab_bar_right"].as_array().unwrap(); + assert_eq!(bar.len(), 2, "{bar:?}"); + assert_eq!( + bar.get(1).unwrap().as_inline_table().unwrap()["command"].as_str(), + Some(targets().status.as_str()) + ); + assert!( + commands(&document) + .iter() + .any(|(_, command)| command == AGENT_QUEUE_ACTION) + ); + } + + #[test] + fn a_status_script_of_the_users_own_keeps_its_segment() { + let original = "[ui]\ntab_bar_right = [{ type = \"command\", command = \"~/.config/herdr/status.sh\" }]\n"; + let merged = merge(original, &targets()).unwrap(); + let document = parse(&merged.text); + assert_eq!(document["ui"]["tab_bar_right"].as_array().unwrap().len(), 1); + assert!( + merged + .kept + .iter() + .any(|note| note.contains("status.sh of your own")) + ); + } + + #[test] + fn prefix_a_bound_to_a_plain_action_gets_no_toggle() { + let merged = merge("[keys]\nzoom = \"prefix+a\"\n", &targets()).unwrap(); + let document = parse(&merged.text); + assert_eq!(keys_of(&document, "zoom"), ["prefix+a"]); + assert!( + !commands(&document).iter().any(|(key, _)| key == "prefix+a"), + "{}", + merged.text + ); + assert!(merged.kept.iter().any(|note| note.contains("prefix+a"))); + } + + #[test] + fn prefix_a_bound_elsewhere_is_left_bound_and_reported() { + let original = + "[[keys.command]]\nkey = \"prefix+a\"\ntype = \"shell\"\ncommand = \"echo mine\"\n"; + let merged = merge(original, &targets()).unwrap(); + let document = parse(&merged.text); + let bound = commands(&document); + assert!(bound.contains(&("prefix+a".to_owned(), "echo mine".to_owned()))); + assert!( + !bound + .iter() + .any(|(_, command)| command == AGENT_QUEUE_ACTION) + ); + assert!(merged.kept.iter().any(|note| note.contains("prefix+a"))); + // The other packaged bindings still arrive, straight after the user's. + assert!(bound.iter().any(|(key, _)| key == "prefix+t")); + } + + #[test] + fn the_toggle_on_another_key_is_kept_and_not_duplicated() { + let original = "[[keys.command]]\nkey = \"f8\"\ntype = \"shell\"\ncommand = \"local.agent-queue.toggle\"\n"; + let merged = merge(original, &targets()).unwrap(); + let document = parse(&merged.text); + let toggles: Vec<_> = document["keys"]["command"] + .as_array_of_tables() + .unwrap() + .iter() + .filter(|entry| entry["command"].as_str() == Some(AGENT_QUEUE_ACTION)) + .collect(); + assert_eq!(toggles.len(), 1); + assert_eq!(toggles[0]["key"].as_str(), Some("f8")); + assert_eq!(toggles[0]["type"].as_str(), Some("plugin_action")); + } + + #[test] + fn a_second_merge_changes_nothing() { + for original in [ + "", + "# mine\n[terminal]\nfont_size = 14 # keep\n", + "[keys]\nprefix = \"ctrl+b\"\n[[keys.command]]\nkey = \"prefix+a\"\ntype = \"shell\"\ncommand = \"echo mine\"\n", + ] { + let once = merge(original, &targets()).unwrap(); + let twice = merge(&once.text, &targets()).unwrap(); + assert_eq!(twice.text, once.text, "{original:?}"); + assert!( + !twice.fresh && twice.added.is_empty() && twice.managed.is_empty(), + "{twice:?}" + ); + } + } + + #[test] + fn a_custom_shell_and_invalid_toml_are_refused_and_the_prototype_is_replaced() { + for original in [ + "[terminal]\ndefault_shell = '/custom/launcher'\n", + "invalid toml = [", + ] { + assert!(merge(original, &targets()).is_err(), "{original:?}"); + } + for replaceable in [ + "dl-herdr-shell", + "/home/u/.local/bin/herdr-workspace-shell", + "", + ] { + let original = format!("[terminal]\ndefault_shell = {replaceable:?} # launcher\n"); + let merged = merge(&original, &targets()).unwrap(); + assert!( + merged + .text + .contains(&format!("default_shell = {:?} # launcher", targets().shell)), + "{}", + merged.text + ); + } + } +} diff --git a/rust/dl/src/herdr_kit/mod.rs b/rust/dl/src/herdr_kit/mod.rs new file mode 100644 index 00000000..faabbd1e --- /dev/null +++ b/rust/dl/src/herdr_kit/mod.rs @@ -0,0 +1,1046 @@ +//! `dl --herdr-setup`: everything a new machine needs for Herdr and devlaunch to +//! work together, from devlaunch alone. +//! +//! devlaunch ships as a conda package and a wheel that carry the binaries and +//! nothing else, so every file this installs is compiled in (`assets/`) and +//! written out here. Each step is idempotent: it writes only when the content on +//! disk differs, and a second run reports every step `current`. +//! +//! The steps, in order: +//! +//! 1. the pane shell `dl-herdr-shell` (what `default_shell` names); +//! 2. `status.sh` and the agent-queue plugin, under +//! `$XDG_DATA_HOME/devlaunch/herdr/`, then `herdr plugin link` and the +//! plugin's startup hook, which linking does not fire; +//! 3. the Herdr config, merged ([`config`]), and refused when chezmoi owns it; +//! 4. `~/.local/bin/herdr` pointing at `~/.pixi/bin/herdr`, so `herdr machine +//! add` does not push a stale copy; +//! 5. Claude Code: herdr's integration, the tab-title Stop hook, its +//! `settings.json` entry ([`claude`]), and herdr's skill; +//! 6. kitty's F-key fix, in a file of its own that `kitty.conf` includes. +//! +//! It never starts, stops or reloads a Herdr server. It says when a reload is +//! needed. + +mod claude; +mod config; + +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use crate::cli::HerdrSetupOptions; +use crate::commands::Ending; +use crate::herdr_environment::{ConfigOwner, chezmoi_source}; +use crate::pane_shell; + +const STATUS_SCRIPT: &str = include_str!("assets/status.sh"); +const PLUGIN_MANIFEST: &str = include_str!("assets/agent-queue/herdr-plugin.toml"); +const PLUGIN_VIEW: &str = include_str!("assets/agent-queue/view.sh"); +const PLUGIN_ID: &str = "local.agent-queue"; +const KITTY_FIX: &str = include_str!("assets/kitty.conf"); +const KITTY_FILE: &str = "devlaunch-herdr.conf"; + +/// Quote `word` for a POSIX shell, leaving a plain path readable. +pub(crate) fn shell_quote(word: &str) -> String { + let plain = !word.is_empty() + && word + .chars() + .all(|c| c.is_ascii_alphanumeric() || "/._-+=:,@%".contains(c)); + if plain { + word.to_owned() + } else { + format!("'{}'", word.replace('\'', r"'\''")) + } +} + +/// How one step ended. +#[derive(Debug, PartialEq, Eq)] +enum Outcome { + /// Something was written (or, in a dry run, would be). + Changed(String), + /// Already as it should be. + Current(String), + /// Deliberately not done, and why. + Skipped(String), + /// Tried and failed, and why. The run exits non-zero. + Failed(String), +} + +/// Whether a file on disk already holds what this build writes. +#[derive(Debug, PartialEq, Eq)] +enum Written { + Current, + Changed, +} + +/// The mode a written file ends with. +#[derive(Clone, Copy)] +enum Mode { + /// Always this mode: the file is devlaunch's. + Fixed(u32), + /// The mode it already had, else this one: the file is the user's. + Keep(u32), +} + +struct Paths { + home: PathBuf, + pane_shell: PathBuf, + kit: PathBuf, + herdr_config: PathBuf, + claude: PathBuf, + kitty: PathBuf, +} + +fn env_path(name: &str) -> Option { + std::env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +impl Paths { + fn from_process(home: PathBuf) -> Self { + let config_home = env_path("XDG_CONFIG_HOME").unwrap_or_else(|| home.join(".config")); + let data_home = env_path("XDG_DATA_HOME").unwrap_or_else(|| home.join(".local/share")); + Self { + pane_shell: pane_shell::install_path(Some(&home)).expect("home supplied"), + kit: data_home.join("devlaunch/herdr"), + herdr_config: env_path("HERDR_CONFIG_PATH") + .unwrap_or_else(|| config_home.join("herdr/config.toml")), + // The same two places `herdr integration install claude` resolves, so + // the hook, the settings and herdr's own hook land side by side. + claude: env_path("CLAUDE_CONFIG_DIR").unwrap_or_else(|| home.join(".claude")), + kitty: env_path("KITTY_CONFIG_DIRECTORY").unwrap_or_else(|| config_home.join("kitty")), + home, + } + } + + fn status_script(&self) -> PathBuf { + self.kit.join("status.sh") + } + + fn plugin(&self) -> PathBuf { + self.kit.join("plugins/agent-queue") + } + + fn hook_script(&self) -> PathBuf { + self.claude.join("hooks").join(claude::HOOK_NAME) + } +} + +struct Setup { + options: HerdrSetupOptions, + paths: Paths, + herdr: String, + failed: bool, + config_changed: bool, +} + +impl Setup { + fn report(&mut self, step: &str, outcome: Outcome) { + let (status, detail) = match outcome { + Outcome::Changed(detail) if self.options.dry_run => ("planned", detail), + Outcome::Changed(detail) => ("changed", detail), + Outcome::Current(detail) => ("current", detail), + Outcome::Skipped(detail) => ("skipped", detail), + Outcome::Failed(detail) => { + self.failed = true; + ("failed", detail) + } + }; + eprintln!(" {status:<8} {step}: {}", self.tilde(&detail)); + } + + /// `detail` with the home directory spelled `~`, so a line fits a terminal. + /// Text in backticks is left as it is: it is what the user pastes, and a + /// `~` there is not what a later run expects. + fn tilde(&self, detail: &str) -> String { + let home = self.paths.home.to_string_lossy(); + if home.len() < 2 { + return detail.to_owned(); + } + let home = format!("{home}/"); + detail + .split('`') + .enumerate() + .map(|(index, part)| { + if index % 2 == 0 { + part.replace(&home, "~/") + } else { + part.to_owned() + } + }) + .collect::>() + .join("`") + } + + /// "wrote" or "would write", for the message of a change. + fn did(&self, done: &str, planned: &str) -> String { + if self.options.dry_run { + planned.to_owned() + } else { + done.to_owned() + } + } + + fn write(&self, path: &Path, bytes: &[u8], mode: Mode) -> io::Result { + write_if_changed(path, bytes, mode, self.options.dry_run) + } + + /// Report a whole-file install of one of devlaunch's own files, and give back + /// its path when it is in place (or, in a dry run, would be). + fn install_file(&mut self, step: &str, path: &Path, bytes: &str, mode: u32) -> Option { + let (outcome, installed) = match self.write(path, bytes.as_bytes(), Mode::Fixed(mode)) { + Ok(Written::Current) => (Outcome::Current(path.display().to_string()), true), + Ok(Written::Changed) => ( + Outcome::Changed(format!( + "{} {}", + self.did("wrote", "would write"), + path.display() + )), + true, + ), + Err(error) => ( + Outcome::Failed(format!("{}: {error}", path.display())), + false, + ), + }; + self.report(step, outcome); + installed.then(|| path.to_owned()) + } + + fn herdr(&self, args: &[&str]) -> io::Result { + Command::new(&self.herdr) + .args(args) + .stdin(Stdio::null()) + .output() + } +} + +/// Write `bytes` to `path` when they differ from what is there, by a rename from +/// a temporary file in the same directory. A symlink is written through: its +/// target is replaced and the link is kept. A dangling one is an error. +fn write_if_changed(path: &Path, bytes: &[u8], mode: Mode, dry_run: bool) -> io::Result { + let target = match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => fs::canonicalize(path)?, + _ => path.to_owned(), + }; + let existing = match fs::read(&target) { + Ok(existing) => Some(existing), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + let current_mode = fs::metadata(&target) + .ok() + .map(|metadata| metadata.permissions().mode() & 0o7777); + let wanted_mode = match mode { + Mode::Fixed(mode) => mode, + Mode::Keep(fallback) => current_mode.unwrap_or(fallback), + }; + if existing.as_deref() == Some(bytes) && current_mode == Some(wanted_mode) { + return Ok(Written::Current); + } + if dry_run { + return Ok(Written::Changed); + } + let directory = target + .parent() + .ok_or_else(|| io::Error::other("a file to write has no directory"))?; + fs::create_dir_all(directory)?; + let mut temporary = tempfile::NamedTempFile::new_in(directory)?; + io::Write::write_all(&mut temporary, bytes)?; + temporary + .as_file() + .set_permissions(fs::Permissions::from_mode(wanted_mode))?; + temporary.persist(&target).map_err(|e| e.error)?; + Ok(Written::Changed) +} + +/// Read a file the user owns, following a symlink; `None` when it does not exist. +fn read_optional(path: &Path) -> io::Result> { + match fs::read_to_string(path) { + Ok(text) => Ok(Some(text)), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + if fs::symlink_metadata(path).is_ok() { + // A link to nothing: writing through it would create a file + // somewhere the user did not choose. + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!( + "{} is a symlink to a file that does not exist", + path.display() + ), + )); + } + Ok(None) + } + Err(error) => Err(error), + } +} + +/// `Some(outcome)` when chezmoi owns `path` or cannot say, so it must not be edited. +fn chezmoi_refusal(path: &Path, instruction: &str) -> Option { + match chezmoi_source(path) { + Ok(ConfigOwner::Unmanaged) => None, + Ok(ConfigOwner::Chezmoi(source)) => Some(Outcome::Skipped(format!( + "chezmoi manages it from {}; {instruction} there and apply it", + source.display() + ))), + Ok(ConfigOwner::ProbeFailed(complaint)) => Some(Outcome::Failed(format!( + "chezmoi could not say whether it manages {}, so it was left alone. Fix chezmoi, or take it off PATH, and run setup again. chezmoi said: {complaint}", + path.display() + ))), + Err(error) => Some(Outcome::Failed(format!( + "could not ask chezmoi about {}: {error}", + path.display() + ))), + } +} + +pub(crate) fn setup(options: HerdrSetupOptions) -> Ending { + let Some(home) = devlaunch_core::osext::home_dir() else { + eprintln!("dl: HOME is required"); + return Ending::Refused; + }; + let mut setup = Setup { + options, + paths: Paths::from_process(home), + herdr: devlaunch_core::clients::herdr_binary_from_process() + .unwrap_or_else(|| "herdr".to_owned()), + failed: false, + config_changed: false, + }; + eprintln!( + "Installing the Herdr kit{}", + if options.dry_run { + " (dry run: nothing is written)" + } else { + "" + } + ); + let shell = pane_shell_step(&mut setup); + let status = kit_files(&mut setup); + plugin_link(&mut setup); + herdr_config(&mut setup, shell, status); + local_bin_link(&mut setup); + claude_steps(&mut setup); + kitty_steps(&mut setup); + + if options.dry_run { + eprintln!("Dry run: nothing was written. Run `dl --herdr-setup` to apply it."); + } else if setup.config_changed { + eprintln!( + "Run `herdr server reload-config` for a running Herdr session to use the new config." + ); + } + eprintln!("Select a Claude login for a Herdr workspace with `dl --herdr-env profile NAME`."); + eprintln!( + "Optional: set DEVLAUNCH_HERDR=1 so that an agent started inside a workspace reports to its Herdr pane. It is off by default." + ); + if setup.failed { + Ending::Refused + } else { + Ending::Done + } +} + +/// The installed pane shell, or `None` when this run could not put it in place. +fn pane_shell_step(setup: &mut Setup) -> Option { + let path = setup.paths.pane_shell.clone(); + if setup.options.dry_run { + let current = fs::read_to_string(&path).ok().as_deref() == Some(pane_shell::SCRIPT); + let outcome = if current { + Outcome::Current(path.display().to_string()) + } else { + Outcome::Changed(format!("would write {}", path.display())) + }; + setup.report("pane shell", outcome); + return Some(path); + } + let (outcome, installed) = match pane_shell::install(&path) { + pane_shell::Installed::AlreadyCurrent { path } => { + (Outcome::Current(path.display().to_string()), Some(path)) + } + pane_shell::Installed::Written { path } | pane_shell::Installed::Refreshed { path } => ( + Outcome::Changed(format!("wrote {}", path.display())), + Some(path), + ), + pane_shell::Installed::Refused { path, reason } => ( + Outcome::Failed(format!("{}: {reason}", path.display())), + None, + ), + }; + setup.report("pane shell", outcome); + installed +} + +/// The installed `status.sh`, or `None` when this run could not put it in place. +fn kit_files(setup: &mut Setup) -> Option { + let status = setup.paths.status_script(); + let status = setup.install_file("status segment", &status, STATUS_SCRIPT, 0o755); + let plugin = setup.paths.plugin(); + setup.install_file( + "agent-queue plugin", + &plugin.join("herdr-plugin.toml"), + PLUGIN_MANIFEST, + 0o644, + ); + setup.install_file( + "agent-queue plugin", + &plugin.join("view.sh"), + PLUGIN_VIEW, + 0o755, + ); + status +} + +/// The root a plugin id is linked from, out of `herdr plugin list --json`. +fn linked_root(listing: &str, id: &str) -> Result, String> { + let listing: serde_json::Value = + serde_json::from_str(listing).map_err(|e| format!("unreadable plugin list: {e}"))?; + let plugins = listing + .pointer("/result/plugins") + .and_then(serde_json::Value::as_array) + .ok_or("the plugin list has no result.plugins")?; + Ok(plugins + .iter() + .find(|plugin| plugin.get("plugin_id").and_then(serde_json::Value::as_str) == Some(id)) + .map(|plugin| { + plugin + .get("plugin_root") + .and_then(serde_json::Value::as_str) + .unwrap_or("an unknown path") + .to_owned() + })) +} + +fn same_path(one: &Path, other: &Path) -> bool { + one == other + || matches!( + (fs::canonicalize(one), fs::canonicalize(other)), + (Ok(one), Ok(other)) if one == other + ) +} + +fn herdr_missing(setup: &Setup, error: &io::Error) -> Outcome { + if error.kind() == io::ErrorKind::NotFound { + Outcome::Skipped(format!("{} is not installed or not on PATH", setup.herdr)) + } else { + Outcome::Failed(format!("could not run {}: {error}", setup.herdr)) + } +} + +fn stderr_of(output: &Output) -> String { + let text = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + if text.is_empty() { + format!("exit status {}", output.status) + } else { + text + } +} + +fn plugin_link(setup: &mut Setup) { + let step = "plugin link"; + let plugin = setup.paths.plugin(); + let listing = match setup.herdr(&["plugin", "list", "--json"]) { + Ok(output) if output.status.success() => output, + Ok(output) => { + let outcome = Outcome::Failed(format!( + "`herdr plugin list` failed: {}", + stderr_of(&output) + )); + return setup.report(step, outcome); + } + Err(error) => { + let outcome = herdr_missing(setup, &error); + return setup.report(step, outcome); + } + }; + let outcome = match linked_root(&String::from_utf8_lossy(&listing.stdout), PLUGIN_ID) { + Err(reason) => Outcome::Failed(reason), + Ok(Some(root)) if same_path(Path::new(&root), &plugin) => { + Outcome::Current(format!("{PLUGIN_ID} is linked from {root}")) + } + Ok(Some(root)) => Outcome::Skipped(format!( + "{PLUGIN_ID} is already linked from {root}, which is left as it is" + )), + Ok(None) if setup.options.dry_run => Outcome::Changed(format!( + "would link {} and run its startup hook", + plugin.display() + )), + Ok(None) => link_plugin(setup, &plugin), + }; + setup.report(step, outcome); +} + +fn link_plugin(setup: &Setup, plugin: &Path) -> Outcome { + let directory = plugin.to_string_lossy(); + match setup.herdr(&["plugin", "link", &directory]) { + Ok(output) if output.status.success() => {} + Ok(output) => { + return Outcome::Failed(format!( + "`herdr plugin link` failed: {}", + stderr_of(&output) + )); + } + Err(error) => return Outcome::Failed(format!("could not run {}: {error}", setup.herdr)), + } + // `plugin link` does not fire the [[startup]] hook; only a server start does. + // The hook exits 0 with a note when no server is running. + let hook = Command::new(plugin.join("view.sh")) + .arg("set") + .current_dir(plugin) + .stdin(Stdio::null()) + .output(); + let said = match hook { + Ok(output) => { + let mut said = String::from_utf8_lossy(&output.stdout).into_owned(); + said.push_str(&String::from_utf8_lossy(&output.stderr)); + said.lines().last().unwrap_or_default().trim().to_owned() + } + Err(error) => format!("the startup hook did not run: {error}"), + }; + Outcome::Changed(format!("linked {}; startup hook: {said}", plugin.display())) +} + +fn herdr_config(setup: &mut Setup, shell: Option, status: Option) { + let step = "herdr config"; + let (shell, status) = match (shell, status) { + (Some(shell), Some(status)) => (shell, status), + (None, _) => { + let outcome = Outcome::Skipped("the pane shell is not installed".to_owned()); + return setup.report(step, outcome); + } + (_, None) => { + let outcome = Outcome::Skipped("the status segment is not installed".to_owned()); + return setup.report(step, outcome); + } + }; + let path = setup.paths.herdr_config.clone(); + let targets = config::Targets { + shell: shell.to_string_lossy().into_owned(), + prototype_shell: setup + .paths + .home + .join(".local/bin/herdr-workspace-shell") + .to_string_lossy() + .into_owned(), + status: shell_quote(&status.to_string_lossy()), + }; + if let Some(outcome) = chezmoi_refusal( + &path, + &format!( + "set terminal.default_shell to `{}` (and take what you want from `dl --herdr-setup`'s packaged config)", + targets.shell + ), + ) { + return setup.report(step, outcome); + } + let original = match read_optional(&path) { + Ok(original) => original, + Err(error) => return setup.report(step, Outcome::Failed(error.to_string())), + }; + let merged = match config::merge(original.as_deref().unwrap_or_default(), &targets) { + Ok(merged) => merged, + Err(reason) => return setup.report(step, Outcome::Failed(reason)), + }; + for note in &merged.kept { + eprintln!(" kept: {note}"); + } + if original.as_deref() == Some(merged.text.as_str()) { + return setup.report(step, Outcome::Current(path.display().to_string())); + } + match setup.write(&path, merged.text.as_bytes(), Mode::Keep(0o644)) { + Ok(_) => { + let mut detail = format!( + "{} {}", + setup.did("updated", "would update"), + path.display() + ); + if merged.fresh { + detail.push_str(" with devlaunch's packaged config"); + } else if !merged.managed.is_empty() { + detail.push_str(&format!("; set {}", merged.managed.join(", "))); + } + if !merged.added.is_empty() { + detail.push_str(&format!("; added {} default key(s)", merged.added.len())); + } + setup.config_changed = true; + setup.report(step, Outcome::Changed(detail)); + if setup.options.dry_run { + if merged.fresh { + eprintln!(" add: every key in the packaged config"); + } + for added in &merged.added { + eprintln!(" add: {added}"); + } + } + } + Err(error) => setup.report( + step, + Outcome::Failed(format!("{}: {error}", path.display())), + ), + } +} + +fn local_bin_link(setup: &mut Setup) { + let step = "herdr on ~/.local/bin"; + let pixi = setup.paths.home.join(".pixi/bin/herdr"); + let local = setup.paths.home.join(".local/bin/herdr"); + let outcome = if fs::metadata(&pixi).is_err() { + Outcome::Skipped(format!("no {}", pixi.display())) + } else { + match fs::read_link(&local) { + Ok(target) if target == pixi => { + Outcome::Current(format!("{} -> {}", local.display(), pixi.display())) + } + Ok(_) => Outcome::Skipped(format!( + "{} already exists and is left alone", + local.display() + )), + Err(_) if fs::symlink_metadata(&local).is_ok() => Outcome::Skipped(format!( + "{} already exists and is left alone", + local.display() + )), + Err(_) if setup.options.dry_run => Outcome::Changed(format!( + "would link {} -> {}", + local.display(), + pixi.display() + )), + Err(_) => match local + .parent() + .map_or(Ok(()), fs::create_dir_all) + .and_then(|()| std::os::unix::fs::symlink(&pixi, &local)) + { + Ok(()) => { + Outcome::Changed(format!("linked {} -> {}", local.display(), pixi.display())) + } + Err(error) => Outcome::Failed(format!("{}: {error}", local.display())), + }, + } + }; + setup.report(step, outcome); +} + +/// What `herdr integration status` says about Claude, as the word that decides. +fn claude_integration(status: &str) -> Option<&str> { + let line = status.lines().find(|line| line.starts_with("claude:"))?; + let state = line.trim_start_matches("claude:").trim(); + Some(state) +} + +fn claude_steps(setup: &mut Setup) { + if !setup.options.claude { + return setup.report("claude", Outcome::Skipped("--no-claude".to_owned())); + } + let claude = setup.paths.claude.clone(); + if !claude.is_dir() { + return setup.report( + "claude", + Outcome::Skipped(format!( + "no Claude Code config directory at {}", + claude.display() + )), + ); + } + integration(setup); + if settings(setup) == TabTitle::Ours { + let hook = setup.paths.hook_script(); + setup.install_file("claude tab-title hook", &hook, claude::HOOK_SCRIPT, 0o755); + } + skill(setup); +} + +fn integration(setup: &mut Setup) { + let step = "claude integration"; + let status = match setup.herdr(&["integration", "status"]) { + Ok(output) if output.status.success() => { + String::from_utf8_lossy(&output.stdout).into_owned() + } + Ok(output) => { + let outcome = Outcome::Failed(format!( + "`herdr integration status` failed: {}", + stderr_of(&output) + )); + return setup.report(step, outcome); + } + Err(error) => { + let outcome = herdr_missing(setup, &error); + return setup.report(step, outcome); + } + }; + let state = claude_integration(&status) + .unwrap_or("not reported") + .to_owned(); + // Only when it is not current: each install used to append another copy of its + // SessionStart entry to settings.json. + if state.starts_with("current") { + return setup.report(step, Outcome::Current(state)); + } + let settings = setup.paths.claude.join("settings.json"); + let instruction = format!( + "add the {} hooks that `herdr integration install claude` writes", + claude::HERDR_HOOK + ); + if let Some(outcome) = chezmoi_refusal(&settings, &instruction) { + return setup.report(step, outcome); + } + if setup.options.dry_run { + return setup.report( + step, + Outcome::Changed(format!( + "would run `herdr integration install claude` ({state})" + )), + ); + } + let outcome = match setup.herdr(&["integration", "install", "claude"]) { + Ok(output) if output.status.success() => Outcome::Changed(format!( + "ran `herdr integration install claude` (was {state})" + )), + Ok(output) => Outcome::Failed(format!( + "`herdr integration install claude` failed: {}", + stderr_of(&output) + )), + Err(error) => Outcome::Failed(format!("could not run {}: {error}", setup.herdr)), + }; + setup.report(step, outcome); +} + +/// Whose Stop hook sets the tab title, as settings.json says. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TabTitle { + /// devlaunch's, so its script is installed. + Ours, + /// A herdr-tab-title.sh from elsewhere, so devlaunch's script is not installed. + Other, + /// settings.json could not be read, so no script is installed that nothing runs. + Unknown, +} + +/// Merge the Stop hook into settings.json, and say whose tab-title hook it holds. +fn settings(setup: &mut Setup) -> TabTitle { + let step = "claude settings"; + let path = setup.paths.claude.join("settings.json"); + let hook = setup.paths.hook_script(); + let command = claude::hook_command(&hook.to_string_lossy()); + // Read and merge first, writing nothing: whether a tab-title hook from + // elsewhere is registered decides the script step even when chezmoi owns + // this file and the merge cannot be written. + let read = read_optional(&path) + .map_err(|error| error.to_string()) + .and_then(|original| { + claude::merge(original.as_deref(), &command) + .map(|merged| (original, merged)) + .map_err(|reason| format!("{}: {reason}", path.display())) + }); + let tab_title = match &read { + Ok((_, merged)) if merged.other_tab_title => TabTitle::Other, + Ok(_) => TabTitle::Ours, + Err(_) => TabTitle::Unknown, + }; + let other_tab_title = tab_title == TabTitle::Other; + if other_tab_title { + setup.report( + "claude tab-title hook", + Outcome::Skipped( + "a herdr-tab-title.sh Stop hook is already registered, so devlaunch's is not added" + .to_owned(), + ), + ); + } + let add = format!("add a Stop hook with the command `{command}`"); + let instruction = match &read { + Ok((_, merged)) => { + let mut steps = Vec::new(); + if merged.registered && !merged.runs_ours { + steps.push(add); + } + if merged.removed > 0 { + steps.push(format!("remove {} duplicate hook(s)", merged.removed)); + } + (!steps.is_empty()).then(|| steps.join(" and ")) + } + Err(_) => Some(add), + }; + if let Some(outcome) = chezmoi_refusal(&path, instruction.as_deref().unwrap_or_default()) { + let outcome = match (outcome, instruction) { + (Outcome::Skipped(_), None) => { + Outcome::Current("chezmoi manages it, and it needs no change".to_owned()) + } + (outcome, _) => outcome, + }; + setup.report(step, outcome); + return tab_title; + } + let (original, merged) = match read { + Ok(read) => read, + Err(reason) => { + setup.report(step, Outcome::Failed(reason)); + return tab_title; + } + }; + let Some(text) = merged.text else { + setup.report(step, Outcome::Current(path.display().to_string())); + return tab_title; + }; + let mut changes = Vec::new(); + if merged.registered { + changes.push("registered the tab-title Stop hook".to_owned()); + } + if merged.removed > 0 { + changes.push(format!("removed {} duplicate hook(s)", merged.removed)); + } + let outcome = match backup_once(setup, &path, original.as_deref()).and_then(|backup| { + setup + .write(&path, text.as_bytes(), Mode::Keep(0o644)) + .map(|_| backup) + }) { + Ok(backup) => { + let mut detail = format!( + "{} {}: {}", + setup.did("updated", "would update"), + path.display(), + changes.join(", ") + ); + if let Some(backup) = backup { + detail.push_str(&format!( + "; {} {}", + setup.did("backed up the original to", "would back up the original to"), + backup.display() + )); + } + Outcome::Changed(detail) + } + Err(error) => Outcome::Failed(format!("{}: {error}", path.display())), + }; + setup.report(step, outcome); + tab_title +} + +/// Copy settings.json aside the first time devlaunch changes it, and never again. +fn backup_once(setup: &Setup, path: &Path, original: Option<&str>) -> io::Result> { + let Some(original) = original else { + return Ok(None); + }; + let backup = path.with_file_name("settings.json.devlaunch-backup"); + if fs::symlink_metadata(&backup).is_ok() { + return Ok(None); + } + if !setup.options.dry_run { + write_if_changed(&backup, original.as_bytes(), Mode::Keep(0o600), false)?; + } + Ok(Some(backup)) +} + +fn skill(setup: &mut Setup) { + let step = "claude herdr skill"; + let path = setup.paths.claude.join("skills/herdr/SKILL.md"); + // A herdr that dropped or broke `--skill` prints a usage error or nothing; + // writing either over a working skill is worse than keeping the old one. + let skill = match setup.herdr(&["--skill"]) { + Ok(output) + if output.status.success() && !output.stdout.iter().all(u8::is_ascii_whitespace) => + { + output.stdout + } + Ok(output) if output.status.success() => { + let outcome = Outcome::Skipped( + "`herdr --skill` printed nothing; the existing skill is kept".to_owned(), + ); + return setup.report(step, outcome); + } + Ok(output) => { + let outcome = Outcome::Skipped(format!( + "`herdr --skill` failed ({}); the existing skill is kept", + stderr_of(&output) + )); + return setup.report(step, outcome); + } + Err(error) => { + let outcome = herdr_missing(setup, &error); + return setup.report(step, outcome); + } + }; + let outcome = match setup.write(&path, &skill, Mode::Keep(0o644)) { + Ok(Written::Current) => Outcome::Current(path.display().to_string()), + Ok(Written::Changed) => Outcome::Changed(format!( + "{} {}", + setup.did("wrote", "would write"), + path.display() + )), + Err(error) => Outcome::Failed(format!("{}: {error}", path.display())), + }; + setup.report(step, outcome); +} + +fn has_include(kitty_conf: &str) -> bool { + kitty_conf + .lines() + .any(|line| line.split_whitespace().collect::>() == ["include", KITTY_FILE]) +} + +fn kitty_steps(setup: &mut Setup) { + if !setup.options.kitty { + return setup.report("kitty", Outcome::Skipped("--no-kitty".to_owned())); + } + let directory = setup.paths.kitty.clone(); + if !directory.is_dir() { + return setup.report( + "kitty", + Outcome::Skipped(format!( + "no kitty config directory at {}", + directory.display() + )), + ); + } + setup.install_file( + "kitty F-key fix", + &directory.join(KITTY_FILE), + KITTY_FIX, + 0o644, + ); + + let step = "kitty.conf include"; + let line = format!("include {KITTY_FILE}"); + let conf = directory.join("kitty.conf"); + let original = match read_optional(&conf) { + Ok(original) => original, + Err(error) => return setup.report(step, Outcome::Failed(error.to_string())), + }; + if original.as_deref().is_some_and(has_include) { + return setup.report( + step, + Outcome::Current(format!("{} has `{line}`", conf.display())), + ); + } + if original.is_some() + && let Some(outcome) = chezmoi_refusal(&conf, &format!("add the line `{line}`")) + { + return setup.report(step, outcome); + } + let mut text = original.unwrap_or_default(); + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + if !text.is_empty() { + text.push('\n'); + } + text.push_str("# The F-key fix for herdr, written by `dl --herdr-setup`.\n"); + text.push_str(&line); + text.push('\n'); + let outcome = match setup.write(&conf, text.as_bytes(), Mode::Keep(0o644)) { + Ok(_) => Outcome::Changed(format!( + "{} `{line}` to {}", + setup.did("added", "would add"), + conf.display() + )), + Err(error) => Outcome::Failed(format!("{}: {error}", conf.display())), + }; + setup.report(step, outcome); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_paths_stay_readable_and_others_are_quoted() { + assert_eq!( + shell_quote("/home/u/.local/share/x.sh"), + "/home/u/.local/share/x.sh" + ); + assert_eq!(shell_quote("/home/my user/x.sh"), "'/home/my user/x.sh'"); + assert_eq!(shell_quote("/it's"), r"'/it'\''s'"); + } + + #[test] + fn the_plugin_list_names_where_an_id_is_linked_from() { + let listing = r#"{"id":"cli:plugin","result":{"plugins":[{"plugin_id":"local.agent-queue","plugin_root":"/p/agent-queue"},{"plugin_id":"local.telemetry","plugin_root":"/p/t"}],"type":"plugin_list"}}"#; + assert_eq!( + linked_root(listing, PLUGIN_ID).unwrap().as_deref(), + Some("/p/agent-queue") + ); + assert_eq!(linked_root(listing, "local.other").unwrap(), None); + assert!(linked_root("{}", PLUGIN_ID).is_err()); + } + + #[test] + fn the_claude_line_of_integration_status_is_read() { + let status = "pi: outdated (v8 < v9) (/h/.pi/x.ts)\nclaude: current (v10) (/h/.claude/hooks/herdr-agent-state.sh)\n"; + assert_eq!( + claude_integration(status), + Some("current (v10) (/h/.claude/hooks/herdr-agent-state.sh)") + ); + assert_eq!(claude_integration("pi: current\n"), None); + } + + #[test] + fn the_include_line_is_found_however_it_is_spaced() { + assert!(has_include( + "font_size 12\n include devlaunch-herdr.conf \n" + )); + assert!(!has_include("# include devlaunch-herdr.conf\n")); + assert!(!has_include("include other.conf\n")); + } + + #[test] + fn a_file_is_written_once_through_a_symlink_and_keeps_the_link() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("real.toml"); + let link = root.path().join("link.toml"); + fs::write(&target, "old").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o640)).unwrap(); + std::os::unix::fs::symlink("real.toml", &link).unwrap(); + assert_eq!( + write_if_changed(&link, b"new", Mode::Keep(0o644), true).unwrap(), + Written::Changed + ); + assert_eq!( + fs::read_to_string(&target).unwrap(), + "old", + "a dry run writes nothing" + ); + assert_eq!( + write_if_changed(&link, b"new", Mode::Keep(0o644), false).unwrap(), + Written::Changed + ); + assert_eq!(fs::read_link(&link).unwrap(), Path::new("real.toml")); + assert_eq!(fs::read_to_string(&target).unwrap(), "new"); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o640 + ); + assert_eq!( + write_if_changed(&link, b"new", Mode::Keep(0o644), false).unwrap(), + Written::Current + ); + } + + #[test] + fn a_script_with_the_right_bytes_and_the_wrong_mode_is_fixed() { + let root = tempfile::tempdir().unwrap(); + let script = root.path().join("status.sh"); + fs::write(&script, STATUS_SCRIPT).unwrap(); + fs::set_permissions(&script, fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!( + write_if_changed(&script, STATUS_SCRIPT.as_bytes(), Mode::Fixed(0o755), false).unwrap(), + Written::Changed + ); + assert_eq!( + fs::metadata(&script).unwrap().permissions().mode() & 0o777, + 0o755 + ); + } + + #[test] + fn a_dangling_symlink_is_refused_and_left() { + let root = tempfile::tempdir().unwrap(); + let link = root.path().join("config.toml"); + std::os::unix::fs::symlink("missing.toml", &link).unwrap(); + assert!(read_optional(&link).is_err()); + assert!(write_if_changed(&link, b"x", Mode::Keep(0o644), false).is_err()); + assert!(!root.path().join("missing.toml").exists()); + } +} diff --git a/rust/dl/src/lib.rs b/rust/dl/src/lib.rs index 2b0feb22..aef8def4 100644 --- a/rust/dl/src/lib.rs +++ b/rust/dl/src/lib.rs @@ -28,6 +28,7 @@ mod commands; mod hangup; mod herdr_editor; mod herdr_environment; +mod herdr_kit; mod launch; mod pane_shell; mod render; @@ -674,6 +675,16 @@ fn command_line(argv: &[String]) -> Result { return Err(usage.exit_code()); } }; + if let Some(flag) = parsed.misplaced_setup_modifier() { + // A usage error in clap's own words and with clap's exit 2, because it is + // the refusal `requires` was meant to make (see the method). + let usage = ::command().error( + clap::error::ErrorKind::MissingRequiredArgument, + format!("{flag} can only be used with --herdr-setup"), + ); + let _ = usage.print(); + return Err(usage.exit_code()); + } cli::resolve(parsed, argv).map_err(|grammar| { eprintln!("{}", grammar_refusal(&grammar)); // Python's `logging.error(...); return 1` for every shape it refused after diff --git a/rust/dl/tests/completion_tables.rs b/rust/dl/tests/completion_tables.rs index e51811b5..396732e6 100644 --- a/rust/dl/tests/completion_tables.rs +++ b/rust/dl/tests/completion_tables.rs @@ -130,7 +130,7 @@ struct Flag { /// The group exists because its members are mutually exclusive for exactly /// that reason, which is what makes it the original this fact is copied from. names_a_command: bool, - requires_herdr_env: bool, + requires_a_herdr_command: bool, } /// Every flag the `Cli` struct declares, in declaration order. @@ -178,7 +178,11 @@ fn grammar_flags(grammar: &str) -> Vec { hidden: parts.contains(&"hide = true"), takes_value: ty.trim().trim_end_matches(',') != "bool", names_a_command: parts.contains(&"group = \"what\""), - requires_herdr_env: parts.contains(&"requires = \"herdr_env\""), + // `--herdr-workspace` modifies `--herdr-env`, and `--dry-run`, + // `--no-claude` and `--no-kitty` modify `--herdr-setup`: none is a + // launch modifier, so no workspace spec follows any of them. + requires_a_herdr_command: parts.contains(&"requires = \"herdr_env\"") + || parts.contains(&"requires = \"herdr_setup\""), }); } assert!( @@ -460,7 +464,7 @@ fn the_flags_a_value_follows_are_the_grammars_value_taking_flags() { assert_eq!(assigned(&script, "local value_opts="), expected); let launch_values: BTreeSet = grammar_flags(&grammar) .iter() - .filter(|flag| flag.takes_value && !flag.hidden && !flag.requires_herdr_env) + .filter(|flag| flag.takes_value && !flag.hidden && !flag.requires_a_herdr_command) .map(|flag| flag.long.clone()) .collect(); assert_eq!(aid_flag_list(&rewrite, "DL_VALUE_OPTIONS"), launch_values); @@ -503,7 +507,7 @@ fn the_flags_a_spec_may_follow_are_the_launch_modifiers_the_grammar_leaves_over( .filter(|flag| { !flag.names_a_command && !flag.hidden - && !flag.requires_herdr_env + && !flag.requires_a_herdr_command && !withheld.contains(flag.long.as_str()) }) .map(|flag| flag.long.clone()) @@ -535,7 +539,7 @@ fn every_flag_whose_value_is_completed_is_one_a_spec_may_follow() { let management_values: BTreeSet = grammar_flags(&argument_grammar()) .iter() - .filter(|flag| flag.takes_value && flag.requires_herdr_env) + .filter(|flag| flag.takes_value && flag.requires_a_herdr_command) .map(|flag| flag.long.clone()) .collect(); let values: BTreeSet = assigned(&script, "local value_opts=") diff --git a/rust/dl/tests/herdr_environment.rs b/rust/dl/tests/herdr_environment.rs index 913e7c2d..e37ba576 100644 --- a/rust/dl/tests/herdr_environment.rs +++ b/rust/dl/tests/herdr_environment.rs @@ -1,3 +1,4 @@ +use devlaunch_test_support::KeepingCoverage; use std::fs; use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::symlink; @@ -9,15 +10,36 @@ fn executable(path: &Path, body: &str) { fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); } +/// A herdr that records every call in `$HOME/herdr.log` and keeps what +/// `plugin link` and `integration install` did in files beside it, so a second +/// run sees the first one's effect. Anything else answers `{}`, which is what +/// `workspace get` needs. +const HERDR_STUB: &str = r#"#!/bin/sh +printf '%s\n' "$*" >> "$HOME/herdr.log" +case "$1 $2" in + "plugin list") + if [ -f "$HOME/linked" ]; then + printf '{"result":{"plugins":[{"plugin_id":"local.agent-queue","plugin_root":"%s"}]}}\n' "$(cat "$HOME/linked")" + else + printf '{"result":{"plugins":[]}}\n' + fi ;; + "plugin link") printf '%s' "$3" > "$HOME/linked" ;; + "integration status") + if [ -f "$HOME/integrated" ]; then echo 'claude: current (v10) (x)'; else echo 'claude: not installed (x)'; fi ;; + "integration install") touch "$HOME/integrated" ;; + "--skill ") + if [ -f "$HOME/skill-fails" ]; then echo 'usage: herdr' ; exit 2; fi + echo '# herdr skill' ;; + *) printf '%s\n' '{}' ;; +esac +"#; + struct Host(tempfile::TempDir); impl Host { fn new() -> Self { let host = Self(tempfile::tempdir().unwrap()); - executable( - &host.0.path().join("herdr"), - "#!/bin/sh\nprintf '%s\\n' '{}'\n", - ); + executable(&host.0.path().join("herdr"), HERDR_STUB); executable( &host.0.path().join("shell"), "#!/bin/sh\nprintf '%s\\n' \"${MESSAGE-unset}\" \"${CLAUDE_CONFIG_DIR-unset}\" \"${CLAUDE_CODE_OAUTH_TOKEN-unset}\" \"${ANTHROPIC_API_KEY-unset}\"\n", @@ -33,6 +55,7 @@ impl Host { let mut command = Command::new(env!("CARGO_BIN_EXE_dl")); command .env_clear() + .keeping_coverage() .env("HOME", self.0.path()) .env("PATH", format!("{}:/usr/bin:/bin", self.0.path().display())) .env("SHELL", self.0.path().join("shell")) @@ -229,7 +252,7 @@ fn setup_honors_the_herdr_config_override() { } #[test] -fn setup_refuses_to_drift_a_chezmoi_managed_regular_config() { +fn setup_leaves_a_chezmoi_managed_config_alone_and_installs_the_rest() { let host = Host::new(); let config = host.0.path().join(".config/herdr/config.toml"); let source = host.0.path().join("dotfiles/config.toml.tmpl"); @@ -248,14 +271,16 @@ fn setup_refuses_to_drift_a_chezmoi_managed_regular_config() { .output() .unwrap(); - assert!(!output.status.success(), "{output:?}"); + assert!(output.status.success(), "{output:?}"); assert_eq!( fs::read_to_string(&config).unwrap(), "[terminal]\nfont_size = 17\n" ); - assert!(!host.0.path().join(".local/bin/dl-herdr-shell").exists()); + assert!(host.0.path().join(".local/bin/dl-herdr-shell").exists()); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains(source.to_str().unwrap()), "{stderr}"); + assert!(stderr.contains("skipped herdr config"), "{stderr}"); + // Paths under HOME are reported as `~/...`. + assert!(stderr.contains("~/dotfiles/config.toml.tmpl"), "{stderr}"); } #[test] @@ -304,8 +329,8 @@ fn setup_refuses_when_the_chezmoi_probe_cannot_answer() { fs::read_to_string(&config).unwrap(), "[terminal]\nfont_size = 17\n" ); - assert!(!host.0.path().join(".local/bin/dl-herdr-shell").exists()); let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("failed herdr config"), "{stderr}"); assert!(stderr.contains("invalid config"), "{stderr}"); } @@ -481,3 +506,601 @@ fn a_bad_workspace_id_is_reported_on_the_flag_and_swallowed_on_the_variable() { .starts_with("inherited\n") ); } + +/// Every file and link under `root`, with its content (or target) and mode, minus +/// the herdr stub's own bookkeeping. Two equal snapshots are two runs that wrote +/// nothing between them. +fn snapshot(root: &Path) -> std::collections::BTreeMap { + fn walk(root: &Path, directory: &Path, into: &mut std::collections::BTreeMap) { + for entry in fs::read_dir(directory).unwrap() { + let path = entry.unwrap().path(); + let name = path.strip_prefix(root).unwrap().display().to_string(); + if ["herdr.log", "linked", "integrated", "skill-fails"].contains(&name.as_str()) { + continue; + } + let metadata = fs::symlink_metadata(&path).unwrap(); + if metadata.file_type().is_symlink() { + into.insert( + name, + format!("-> {}", fs::read_link(&path).unwrap().display()), + ); + } else if metadata.is_dir() { + walk(root, &path, into); + } else { + let mode = metadata.permissions().mode() & 0o777; + let content = String::from_utf8_lossy(&fs::read(&path).unwrap()).into_owned(); + into.insert(name, format!("{mode:o} {content}")); + } + } + } + let mut files = std::collections::BTreeMap::new(); + walk(root, root, &mut files); + files +} + +impl Host { + /// A home a new machine would have: Claude Code and kitty configured, herdr + /// from pixi, and nothing of devlaunch's. + fn new_machine() -> Self { + let host = Self::new(); + fs::create_dir_all(host.0.path().join(".claude")).unwrap(); + fs::create_dir_all(host.0.path().join(".config/kitty")).unwrap(); + fs::create_dir_all(host.0.path().join(".pixi/bin")).unwrap(); + executable(&host.0.path().join(".pixi/bin/herdr"), HERDR_STUB); + host + } + + fn setup(&self, extra: &[&str]) -> Output { + let mut args = vec!["--herdr-setup"]; + args.extend_from_slice(extra); + self.command(&args) + .env_remove("HERDR_ENV") + .output() + .unwrap() + } + + fn path(&self, relative: &str) -> std::path::PathBuf { + self.0.path().join(relative) + } + + fn herdr_calls(&self) -> String { + fs::read_to_string(self.path("herdr.log")).unwrap_or_default() + } +} + +fn stderr_of(output: &Output) -> String { + assert!(output.status.success(), "{output:?}"); + String::from_utf8(output.stderr.clone()).unwrap() +} + +#[test] +fn setup_installs_the_whole_kit_on_a_new_machine_and_a_second_run_changes_nothing() { + let host = Host::new_machine(); + let first = stderr_of(&host.setup(&[])); + assert!(!first.contains("failed"), "{first}"); + + let kit = host.path(".local/share/devlaunch/herdr"); + for (file, mode) in [ + ("status.sh", 0o755), + ("plugins/agent-queue/view.sh", 0o755), + ("plugins/agent-queue/herdr-plugin.toml", 0o644), + ] { + let metadata = fs::metadata(kit.join(file)).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, mode, "{file}"); + } + let calls = host.herdr_calls(); + assert!( + calls.contains(&format!( + "plugin link {}", + kit.join("plugins/agent-queue").display() + )), + "{calls}" + ); + assert!(calls.contains("integration install claude"), "{calls}"); + // The startup hook ran by hand: with no server it says so and exits 0. + assert!(first.contains("agent-queue: no herdr socket"), "{first}"); + + let config = fs::read_to_string(host.path(".config/herdr/config.toml")).unwrap(); + let shell = host.path(".local/bin/dl-herdr-shell"); + assert!( + config.contains(&format!("default_shell = \"{}\"", shell.display())), + "{config}" + ); + assert!( + config.contains(&kit.join("status.sh").display().to_string()), + "{config}" + ); + assert!(config.contains("local.agent-queue.toggle"), "{config}"); + assert!(first.contains("herdr server reload-config"), "{first}"); + assert!(first.contains("DEVLAUNCH_HERDR=1"), "{first}"); + + let hook = host.path(".claude/hooks/devlaunch-herdr-tab-title.sh"); + assert_eq!( + fs::metadata(&hook).unwrap().permissions().mode() & 0o777, + 0o755 + ); + let settings: serde_json::Value = + serde_json::from_str(&fs::read_to_string(host.path(".claude/settings.json")).unwrap()) + .unwrap(); + assert!( + settings["hooks"]["Stop"][0]["hooks"][0]["command"] + .as_str() + .unwrap() + .contains(hook.to_str().unwrap()) + ); + assert_eq!( + fs::read_to_string(host.path(".claude/skills/herdr/SKILL.md")).unwrap(), + "# herdr skill\n" + ); + assert!( + fs::read_to_string(host.path(".config/kitty/devlaunch-herdr.conf")) + .unwrap() + .contains("map f2 send_text all \\x1bOQ") + ); + assert!( + fs::read_to_string(host.path(".config/kitty/kitty.conf")) + .unwrap() + .contains("include devlaunch-herdr.conf") + ); + assert_eq!( + fs::read_link(host.path(".local/bin/herdr")).unwrap(), + host.path(".pixi/bin/herdr") + ); + + let before = snapshot(host.0.path()); + fs::remove_file(host.path("herdr.log")).unwrap(); + let second = stderr_of(&host.setup(&[])); + assert_eq!(snapshot(host.0.path()), before); + assert!(!second.contains("changed"), "{second}"); + assert!(!second.contains("reload-config"), "{second}"); + let calls = host.herdr_calls(); + assert!( + !calls.contains("plugin link") && !calls.contains("integration install"), + "{calls}" + ); +} + +#[test] +fn a_dry_run_reports_the_plan_and_writes_nothing() { + let host = Host::new_machine(); + let before = snapshot(host.0.path()); + let plan = stderr_of(&host.setup(&["--dry-run"])); + assert_eq!(snapshot(host.0.path()), before); + for step in [ + "planned pane shell", + "planned status segment", + "planned plugin link", + "planned herdr config", + "planned claude integration", + "planned claude settings", + "planned claude herdr skill", + "planned kitty.conf include", + "planned herdr on ~/.local/bin", + ] { + assert!(plan.contains(step), "{step}: {plan}"); + } + assert!( + plan.contains("add: every key in the packaged config"), + "{plan}" + ); + let calls = host.herdr_calls(); + assert!( + !calls.contains("plugin link") && !calls.contains("integration install"), + "{calls}" + ); +} + +#[test] +fn no_claude_and_no_kitty_leave_those_homes_untouched() { + let host = Host::new_machine(); + let claude = snapshot(&host.path(".claude")); + let kitty = snapshot(&host.path(".config/kitty")); + let output = stderr_of(&host.setup(&["--no-claude", "--no-kitty"])); + assert_eq!(snapshot(&host.path(".claude")), claude); + assert_eq!(snapshot(&host.path(".config/kitty")), kitty); + assert!(output.contains("skipped claude: --no-claude"), "{output}"); + assert!(output.contains("skipped kitty: --no-kitty"), "{output}"); + assert!(!host.herdr_calls().contains("integration")); +} + +#[test] +fn settings_keep_every_foreign_key_and_hook_and_are_backed_up_once() { + let host = Host::new_machine(); + let settings = host.path(".claude/settings.json"); + let original = "{\n \"model\": \"opus\",\n \"hooks\": {\n \"Stop\": [\n {\n \"matcher\": \"\",\n \"hooks\": [\n {\n \"type\": \"command\",\n \"command\": \"telemetry.sh\"\n }\n ]\n }\n ]\n },\n \"theme\": \"dark\"\n}\n"; + fs::write(&settings, original).unwrap(); + stderr_of(&host.setup(&[])); + let merged: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!(merged["model"], "opus"); + assert_eq!(merged["theme"], "dark"); + assert_eq!( + merged["hooks"]["Stop"][0]["hooks"][0]["command"], + "telemetry.sh" + ); + assert_eq!(merged["hooks"]["Stop"].as_array().unwrap().len(), 2); + let backup = host.path(".claude/settings.json.devlaunch-backup"); + assert_eq!(fs::read_to_string(&backup).unwrap(), original); + + // A later change is not backed up over the first backup. + let mut changed = merged.clone(); + changed["hooks"]["Stop"].as_array_mut().unwrap().pop(); + fs::write(&settings, serde_json::to_string_pretty(&changed).unwrap()).unwrap(); + stderr_of(&host.setup(&[])); + assert_eq!(fs::read_to_string(&backup).unwrap(), original); +} + +#[test] +fn a_failing_skill_dump_keeps_the_old_skill_and_a_linked_skill_dir_stays_linked() { + let host = Host::new_machine(); + let shared = host.path(".claude/shared-skills/herdr"); + fs::create_dir_all(&shared).unwrap(); + fs::create_dir_all(host.path(".claude/skills")).unwrap(); + symlink("../shared-skills/herdr", host.path(".claude/skills/herdr")).unwrap(); + fs::write(shared.join("SKILL.md"), "old skill\n").unwrap(); + + fs::write(host.path("skill-fails"), "").unwrap(); + let output = stderr_of(&host.setup(&[])); + assert!(output.contains("the existing skill is kept"), "{output}"); + assert_eq!( + fs::read_to_string(shared.join("SKILL.md")).unwrap(), + "old skill\n" + ); + + fs::remove_file(host.path("skill-fails")).unwrap(); + stderr_of(&host.setup(&[])); + assert_eq!( + fs::read_to_string(shared.join("SKILL.md")).unwrap(), + "# herdr skill\n" + ); + assert_eq!( + fs::read_link(host.path(".claude/skills/herdr")).unwrap(), + Path::new("../shared-skills/herdr") + ); +} + +#[test] +fn a_plugin_linked_from_elsewhere_is_not_linked_again() { + let host = Host::new_machine(); + fs::write(host.path("linked"), "/somewhere/else/agent-queue").unwrap(); + let output = stderr_of(&host.setup(&[])); + assert!( + output.contains("already linked from /somewhere/else/agent-queue"), + "{output}" + ); + assert!(!host.herdr_calls().contains("plugin link")); +} + +#[test] +fn chezmoi_managed_kitty_and_settings_get_the_line_to_add_instead() { + let host = Host::new_machine(); + let source = host.path("dotfiles/source"); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(host.path(".config/kitty/kitty.conf"), "font_size 12\n").unwrap(); + fs::write(host.path(".claude/settings.json"), "{}\n").unwrap(); + executable( + &host.path("chezmoi"), + &format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", source.display()), + ); + let output = stderr_of(&host.setup(&[])); + assert_eq!( + fs::read_to_string(host.path(".config/kitty/kitty.conf")).unwrap(), + "font_size 12\n" + ); + assert_eq!( + fs::read_to_string(host.path(".claude/settings.json")).unwrap(), + "{}\n" + ); + assert!( + output.contains("add the line `include devlaunch-herdr.conf`"), + "{output}" + ); + assert!(output.contains("skipped claude settings"), "{output}"); + // The file kitty.conf would include is devlaunch's own, so it is still written. + assert!(host.path(".config/kitty/devlaunch-herdr.conf").exists()); +} + +#[test] +fn a_chezmoi_managed_settings_file_is_not_rewritten_by_herdrs_integration_install() { + let host = Host::new_machine(); + let source = host.path("dotfiles/source"); + fs::write(host.path(".claude/settings.json"), "{}\n").unwrap(); + executable( + &host.path("chezmoi"), + &format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", source.display()), + ); + let output = stderr_of(&host.setup(&[])); + let calls = host.herdr_calls(); + assert!(!calls.contains("integration install"), "{calls}"); + assert!(output.contains("skipped claude integration"), "{output}"); + assert!( + output.contains("herdr integration install claude"), + "{output}" + ); +} + +#[test] +fn a_missing_herdr_skips_what_needs_it_and_installs_the_rest() { + let host = Host::new_machine(); + let output = host + .command(&["--herdr-setup"]) + .env_remove("HERDR_ENV") + .env_remove("HERDR_BIN_PATH") + .env("PATH", "/usr/bin:/bin") + .output() + .unwrap(); + let output = stderr_of(&output); + assert!( + output.contains("skipped plugin link: herdr is not installed"), + "{output}" + ); + assert!(output.contains("skipped claude integration"), "{output}"); + assert!(output.contains("skipped claude herdr skill"), "{output}"); + assert!(host.path(".config/herdr/config.toml").exists()); + assert!( + host.path(".claude/hooks/devlaunch-herdr-tab-title.sh") + .exists() + ); +} + +#[test] +fn a_dotfiles_tab_title_hook_keeps_devlaunchs_script_out_even_under_chezmoi() { + let host = Host::new_machine(); + let settings = r#"{"hooks":{"Stop":[{"matcher":"","hooks":[{"type":"command","command":"$HOME/.claude/hooks/herdr-tab-title.sh"}]}]}}"#; + fs::write(host.path(".claude/settings.json"), settings).unwrap(); + let source = host.path("dotfiles/modify_settings.json"); + executable( + &host.path("chezmoi"), + &format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", source.display()), + ); + let output = stderr_of(&host.setup(&[])); + assert!( + output.contains("herdr-tab-title.sh Stop hook is already registered"), + "{output}" + ); + assert!( + !host + .path(".claude/hooks/devlaunch-herdr-tab-title.sh") + .exists() + ); + assert_eq!( + fs::read_to_string(host.path(".claude/settings.json")).unwrap(), + settings + ); +} + +#[test] +fn a_pane_shell_that_failed_to_install_is_not_written_into_the_config() { + let host = Host::new_machine(); + fs::create_dir_all(host.path(".local")).unwrap(); + fs::write(host.path(".local/bin"), "not a directory").unwrap(); + let output = host.setup(&[]); + assert!(!output.status.success(), "{output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("failed pane shell"), "{stderr}"); + assert!( + stderr.contains("skipped herdr config: the pane shell is not installed"), + "{stderr}" + ); + assert!(!host.path(".config/herdr/config.toml").exists()); +} + +#[test] +fn a_status_segment_that_failed_to_install_is_not_written_into_the_config() { + let host = Host::new_machine(); + fs::create_dir_all(host.path(".local/share/devlaunch")).unwrap(); + fs::write(host.path(".local/share/devlaunch/herdr"), "not a directory").unwrap(); + let output = host.setup(&[]); + assert!(!output.status.success(), "{output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("failed status segment"), "{stderr}"); + assert!( + stderr.contains("skipped herdr config: the status segment is not installed"), + "{stderr}" + ); + assert!(!host.path(".config/herdr/config.toml").exists()); +} + +#[test] +fn a_dry_run_over_existing_files_writes_no_settings_backup() { + let host = Host::new_machine(); + fs::write(host.path(".claude/settings.json"), "{}").unwrap(); + let config = host.path(".config/herdr/config.toml"); + fs::create_dir_all(config.parent().unwrap()).unwrap(); + fs::write(&config, "[terminal]\nfont_size=17").unwrap(); + fs::write(host.path(".config/kitty/kitty.conf"), "font_size 12\n").unwrap(); + let before = snapshot(host.0.path()); + let plan = stderr_of(&host.setup(&["--dry-run"])); + assert!(plan.contains("would back up the original to"), "{plan}"); + assert_eq!(snapshot(host.0.path()), before); + assert!(!host.path(".claude/settings.json.devlaunch-backup").exists()); +} + +#[test] +fn a_herdr_file_already_on_local_bin_is_left_alone() { + let host = Host::new_machine(); + let local = host.path(".local/bin/herdr"); + fs::create_dir_all(local.parent().unwrap()).unwrap(); + fs::write(&local, "mine").unwrap(); + let output = stderr_of(&host.setup(&[])); + assert_eq!(fs::read_to_string(&local).unwrap(), "mine"); + assert!( + output.contains( + "skipped herdr on ~/.local/bin: ~/.local/bin/herdr already exists and is left alone" + ), + "{output}" + ); +} + +#[test] +fn duplicate_herdr_session_hooks_are_removed_once_with_a_backup() { + let host = Host::new_machine(); + let settings = host.path(".claude/settings.json"); + let herdr = r#"{"matcher":"","hooks":[{"type":"command","command":"bash '/h/.claude/hooks/herdr-agent-state.sh' session","timeout":10}]}"#; + let original = format!(r#"{{"hooks":{{"SessionStart":[{herdr},{herdr}]}}}}"#); + fs::write(&settings, &original).unwrap(); + let first = stderr_of(&host.setup(&[])); + assert!(first.contains("removed 1 duplicate hook(s)"), "{first}"); + assert_eq!( + fs::read_to_string(host.path(".claude/settings.json.devlaunch-backup")).unwrap(), + original + ); + let merged: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!(merged["hooks"]["SessionStart"].as_array().unwrap().len(), 1); + + let before = snapshot(host.0.path()); + let second = stderr_of(&host.setup(&[])); + assert_eq!(snapshot(host.0.path()), before); + assert!(second.contains("current claude settings"), "{second}"); +} + +#[test] +fn a_second_run_over_a_users_own_files_changes_nothing() { + let host = Host::new_machine(); + fs::write( + host.path(".claude/settings.json"), + r#"{"model":"opus","hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"guard.sh"}]}]}}"#, + ) + .unwrap(); + let config = host.path(".config/herdr/config.toml"); + fs::create_dir_all(config.parent().unwrap()).unwrap(); + fs::write( + &config, + "[terminal]\nfont_size = 17\n\n[ui]\ntheme = \"mine\"\n", + ) + .unwrap(); + fs::write(host.path(".config/kitty/kitty.conf"), "font_size 12\n").unwrap(); + stderr_of(&host.setup(&[])); + + let before = snapshot(host.0.path()); + let second = stderr_of(&host.setup(&[])); + assert_eq!(snapshot(host.0.path()), before); + assert!(!second.contains("changed"), "{second}"); + for step in [ + "current herdr config", + "current claude settings", + "current kitty.conf include", + ] { + assert!(second.contains(step), "{step}: {second}"); + } +} + +#[test] +fn the_kitty_include_is_appended_to_a_kitty_conf_without_a_final_newline() { + let host = Host::new_machine(); + let conf = host.path(".config/kitty/kitty.conf"); + fs::write(&conf, "font_size 12").unwrap(); + stderr_of(&host.setup(&[])); + assert_eq!( + fs::read_to_string(&conf).unwrap(), + "font_size 12\n\n# The F-key fix for herdr, written by `dl --herdr-setup`.\ninclude devlaunch-herdr.conf\n" + ); + let second = stderr_of(&host.setup(&[])); + assert!( + second.contains("current kitty.conf include: ~/.config/kitty/kitty.conf has `include devlaunch-herdr.conf`"), + "{second}" + ); +} + +#[test] +fn chezmoi_managed_settings_that_the_merge_would_change_are_not_reported_as_needing_none() { + let host = Host::new_machine(); + let ours = host.path(".claude/hooks/devlaunch-herdr-tab-title.sh"); + let settings = format!( + r#"{{"hooks":{{"Stop":[{{"matcher":"","hooks":[{{"type":"command","command":"$HOME/.claude/hooks/herdr-tab-title.sh"}},{{"type":"command","command":"{}"}}]}}]}}}}"#, + ours.display() + ); + fs::write(host.path(".claude/settings.json"), &settings).unwrap(); + let source = host.path("dotfiles/modify_settings.json"); + executable( + &host.path("chezmoi"), + &format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", source.display()), + ); + let output = stderr_of(&host.setup(&[])); + let line = output + .lines() + .find(|line| line.contains("claude settings:")) + .unwrap(); + assert!(!line.contains("needs no change"), "{output}"); + assert!(line.contains("remove 1 duplicate hook"), "{output}"); + assert!(!line.contains("add a Stop hook"), "{output}"); + assert_eq!( + fs::read_to_string(host.path(".claude/settings.json")).unwrap(), + settings + ); +} + +#[test] +fn chezmoi_instructions_name_the_absolute_paths_to_paste() { + let host = Host::new_machine(); + fs::create_dir_all(host.path(".config/herdr")).unwrap(); + fs::write(host.path(".config/herdr/config.toml"), "").unwrap(); + fs::write(host.path(".claude/settings.json"), "{}\n").unwrap(); + let source = host.path("dotfiles/source"); + executable( + &host.path("chezmoi"), + &format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", source.display()), + ); + let output = stderr_of(&host.setup(&[])); + let shell = host.path(".local/bin/dl-herdr-shell"); + assert!( + output.contains(&format!( + "set terminal.default_shell to `{}`", + shell.display() + )), + "{output}" + ); + let hook = host.path(".claude/hooks/devlaunch-herdr-tab-title.sh"); + assert!( + output.contains(&format!( + "add a Stop hook with the command `[ -x {0} ] && {0} || true`", + hook.display() + )), + "{output}" + ); +} + +#[test] +fn a_chezmoi_managed_stop_hook_the_user_added_is_not_asked_for_again() { + let host = Host::new_machine(); + let hook = host.path(".claude/hooks/devlaunch-herdr-tab-title.sh"); + let settings = format!( + r#"{{"hooks":{{"Stop":[{{"matcher":"","hooks":[{{"type":"command","command":"[ -x {0} ] && {0} || true"}}]}}]}}}}"#, + hook.display() + ); + fs::write(host.path(".claude/settings.json"), &settings).unwrap(); + let source = host.path("dotfiles/modify_settings.json"); + executable( + &host.path("chezmoi"), + &format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", source.display()), + ); + let output = stderr_of(&host.setup(&[])); + let line = output + .lines() + .find(|line| line.contains("claude settings:")) + .unwrap(); + assert!(!line.contains("add a Stop hook"), "{output}"); + assert!(line.contains("current"), "{output}"); + assert!(hook.exists(), "{output}"); + assert_eq!( + fs::read_to_string(host.path(".claude/settings.json")).unwrap(), + settings + ); +} + +#[test] +fn unreadable_settings_fail_the_step_and_install_no_orphan_hook_script() { + let host = Host::new_machine(); + fs::write(host.path(".claude/settings.json"), "{not json").unwrap(); + let output = host.setup(&[]); + assert!(!output.status.success(), "{output:?}"); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("failed claude settings"), "{stderr}"); + assert!( + !host + .path(".claude/hooks/devlaunch-herdr-tab-title.sh") + .exists(), + "{stderr}" + ); +} diff --git a/test/unit/test_herdr_setup_kit.py b/test/unit/test_herdr_setup_kit.py new file mode 100644 index 00000000..52da506b --- /dev/null +++ b/test/unit/test_herdr_setup_kit.py @@ -0,0 +1,141 @@ +"""`dl --herdr-setup` on a new machine, judged through the released binary. + +The Rust suites pin the merge rules (`rust/dl/src/herdr_kit/`) and each step +(`rust/dl/tests/herdr_environment.rs`). This is the one check from outside: +a home with Claude Code and kitty configured and nothing of devlaunch's, a +fake herdr on PATH that writes down every call, and the binary that ships. + +Everything the run could touch is scoped to `tmp_path`: HOME, all four XDG +directories, and a PATH of the fake herdr plus the system directories, so no +real herdr, chezmoi or kitty is reachable. `CLAUDE_CONFIG_DIR` and the +`HERDR_*` variables are left out on purpose, because both point a real session +at the files a test must never write. +""" + +import json +import os +import subprocess +import tomllib +from pathlib import Path + +import pytest + +from fixtures.e2e_helpers import dl_command + +# The same contract as the stub in rust/dl/tests/herdr_environment.rs: record +# every call, remember a link and an install so a second run sees them, and +# print a skill. +FAKE_HERDR = r"""#!/bin/sh +printf '%s\n' "$*" >> "$HOME/herdr.log" +case "$1 $2" in + "plugin list") + if [ -f "$HOME/linked" ]; then + printf '{"result":{"plugins":[{"plugin_id":"local.agent-queue","plugin_root":"%s"}]}}\n' "$(cat "$HOME/linked")" + else + printf '{"result":{"plugins":[]}}\n' + fi ;; + "plugin link") printf '%s' "$3" > "$HOME/linked" ;; + "integration status") + if [ -f "$HOME/integrated" ]; then echo 'claude: current (v10) (x)'; else echo 'claude: not installed (x)'; fi ;; + "integration install") touch "$HOME/integrated" ;; + "--skill ") echo '# herdr skill' ;; + *) echo '{}' ;; +esac +""" + +BOOKKEEPING = {"herdr.log", "linked", "integrated"} + + +@pytest.fixture(name="new_machine") +def fixture_new_machine(tmp_path: Path) -> dict: + home = tmp_path / "home" + (home / ".claude").mkdir(parents=True) + (home / ".config" / "kitty").mkdir(parents=True) + tools = tmp_path / "tools" + tools.mkdir() + herdr = tools / "herdr" + herdr.write_text(FAKE_HERDR) + herdr.chmod(0o755) + return { + "HOME": str(home), + "PATH": f"{tools}:/usr/bin:/bin", + "XDG_CONFIG_HOME": str(home / ".config"), + "XDG_DATA_HOME": str(home / ".local" / "share"), + "XDG_CACHE_HOME": str(home / ".cache"), + "XDG_STATE_HOME": str(home / ".local" / "state"), + "LANG": os.environ.get("LANG", "C.UTF-8"), + } + + +def setup(env: dict, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [*dl_command(), "--herdr-setup", *args], + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def snapshot(home: Path) -> dict: + files = {} + for path in sorted(home.rglob("*")): + name = str(path.relative_to(home)) + if name in BOOKKEEPING: + continue + if path.is_symlink(): + files[name] = f"-> {os.readlink(path)}" + elif path.is_file(): + files[name] = (path.stat().st_mode & 0o777, path.read_bytes()) + return files + + +def test_one_command_configures_a_new_machine_and_a_second_changes_nothing(new_machine): + home = Path(new_machine["HOME"]) + + first = setup(new_machine) + + assert first.returncode == 0, first.stderr + assert "failed" not in first.stderr, first.stderr + kit = home / ".local/share/devlaunch/herdr" + config = tomllib.loads((home / ".config/herdr/config.toml").read_text()) + assert config["terminal"]["default_shell"] == str(home / ".local/bin/dl-herdr-shell") + assert config["ui"]["tab_bar_right"][0]["command"] == str(kit / "status.sh") + assert config["ui"]["window_title"].endswith(" · herdr") + assert any( + entry["key"] == "prefix+a" and entry["command"] == "local.agent-queue.toggle" + for entry in config["keys"]["command"] + ) + assert os.access(kit / "plugins/agent-queue/view.sh", os.X_OK) + calls = (home / "herdr.log").read_text() + assert f"plugin link {kit / 'plugins/agent-queue'}" in calls, calls + assert "integration install claude" in calls, calls + settings = json.loads((home / ".claude/settings.json").read_text()) + [stop] = settings["hooks"]["Stop"] + assert "devlaunch-herdr-tab-title.sh" in stop["hooks"][0]["command"] + assert (home / ".claude/skills/herdr/SKILL.md").read_text() == "# herdr skill\n" + assert "include devlaunch-herdr.conf" in (home / ".config/kitty/kitty.conf").read_text() + assert "herdr server reload-config" in first.stderr + assert "DEVLAUNCH_HERDR=1" in first.stderr + + before = snapshot(home) + (home / "herdr.log").unlink() + second = setup(new_machine) + + assert second.returncode == 0, second.stderr + assert snapshot(home) == before + assert "changed" not in second.stderr, second.stderr + calls = (home / "herdr.log").read_text() + assert "plugin link" not in calls and "integration install" not in calls, calls + + +def test_a_dry_run_changes_nothing_on_disk(new_machine): + home = Path(new_machine["HOME"]) + before = snapshot(home) + + result = setup(new_machine, "--dry-run") + + assert result.returncode == 0, result.stderr + assert snapshot(home) == before + assert "planned herdr config" in result.stderr, result.stderr + assert "nothing was written" in result.stderr