From 015fa8a070edad839526b1a9409b04dae1f11230 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Tue, 29 Sep 2026 20:07:06 +0100 Subject: [PATCH 01/22] herdr-setup: install the whole Herdr kit from devlaunch alone dl --herdr-setup now writes, besides the pane shell, the status segment and the agent-queue plugin (then links it and runs its startup hook), merges the packaged keymap/theme into Herdr's config (managed keys always set, default keys only when absent), installs herdr's Claude integration when it is missing or outdated, merges a tab-title Stop hook into settings.json (foreign hooks kept, only our entries and herdr's duplicates deduped, backed up once), writes herdr's skill (old one kept when --skill fails), writes kitty's F-key fix with an include line, and links ~/.local/bin/herdr to ~/.pixi/bin/herdr. Every asset is include_str!'d so the conda package and the wheel carry it. --dry-run, --no-claude and --no-kitty modify it. A chezmoi-managed file is skipped with the line to add; the other steps still run. The config merge moves from devlaunch-core (flows::herdr_environment::configure) into dl's herdr_kit, so the rest snapshot loses that one row. --- rust/Cargo.lock | 2 +- rust/devlaunch-core/Cargo.toml | 1 - rust/devlaunch-core/completions/dl.bash | 2 +- rust/devlaunch-core/public-api.rest.txt | 1 - .../src/flows/herdr_environment.rs | 192 +--- rust/dl/Cargo.toml | 7 +- rust/dl/src/cli.rs | 80 +- rust/dl/src/commands.rs | 2 +- rust/dl/src/herdr_environment.rs | 52 +- .../assets/agent-queue/herdr-plugin.toml | 37 + .../src/herdr_kit/assets/agent-queue/view.sh | 129 +++ rust/dl/src/herdr_kit/assets/config.toml | 144 +++ .../assets/devlaunch-herdr-tab-title.sh | 95 ++ rust/dl/src/herdr_kit/assets/kitty.conf | 19 + rust/dl/src/herdr_kit/assets/status.sh | 19 + rust/dl/src/herdr_kit/claude.rs | 298 ++++++ rust/dl/src/herdr_kit/config.rs | 645 ++++++++++++ rust/dl/src/herdr_kit/mod.rs | 974 ++++++++++++++++++ rust/dl/src/lib.rs | 11 + rust/dl/tests/completion_tables.rs | 14 +- rust/dl/tests/herdr_environment.rs | 384 ++++++- 21 files changed, 2847 insertions(+), 261 deletions(-) create mode 100644 rust/dl/src/herdr_kit/assets/agent-queue/herdr-plugin.toml create mode 100755 rust/dl/src/herdr_kit/assets/agent-queue/view.sh create mode 100644 rust/dl/src/herdr_kit/assets/config.toml create mode 100755 rust/dl/src/herdr_kit/assets/devlaunch-herdr-tab-title.sh create mode 100644 rust/dl/src/herdr_kit/assets/kitty.conf create mode 100644 rust/dl/src/herdr_kit/assets/status.sh create mode 100644 rust/dl/src/herdr_kit/claude.rs create mode 100644 rust/dl/src/herdr_kit/config.rs create mode 100644 rust/dl/src/herdr_kit/mod.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 65842b88..995dd44e 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 59faf293..41da2464 100644 --- a/rust/dl/src/commands.rs +++ b/rust/dl/src/commands.rs @@ -134,7 +134,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..2ef065a1 --- /dev/null +++ b/rust/dl/src/herdr_kit/assets/config.toml @@ -0,0 +1,144 @@ +# 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" + +# 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 100644 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..1e3e8178 --- /dev/null +++ b/rust/dl/src/herdr_kit/claude.rs @@ -0,0 +1,298 @@ +//! 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 +//! first copy stays; 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. +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, +} + +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) => { + if *hook != desired { + *hook = desired; + merged.registered = true; + } + } + None => { + stop.push(json!({"matcher": "", "hooks": [desired]})); + merged.registered = true; + } + } + } + + // herdr's hook: the first copy in SessionStart stays. + if let Some(groups) = hooks.get_mut("SessionStart").and_then(Value::as_array_mut) { + let mut seen = false; + merged.removed += remove_hooks(groups, |hook| { + if !command_of(hook).contains(HERDR_HOOK) { + return false; + } + std::mem::replace(&mut seen, 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"}]}] + }, + "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"] + ); + 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::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("*")); + assert_eq!(start[1]["hooks"][0]["command"], "resource-check.sh"); + assert_eq!(settings["hooks"]["Notification"], json!([])); + } + + #[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..40beac64 --- /dev/null +++ b/rust/dl/src/herdr_kit/config.rs @@ -0,0 +1,645 @@ +//! 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; + fill_defaults( + document.as_table_mut(), + packaged.as_table(), + "", + &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); + } +} + +fn fill_defaults( + user: &mut Table, + defaults: &Table, + prefix: &str, + next: &mut isize, + added: &mut Vec, +) { + for (key, default) in defaults.iter() { + let path = format!("{prefix}{key}"); + match (user.get_mut(key), default) { + (None, _) => { + let mut item = default.clone(); + renumber(&mut item, next); + user.insert(key, item); + added.push(path); + } + (Some(Item::Table(existing)), Item::Table(default)) => { + fill_defaults(existing, default, &format!("{path}."), next, added); + } + (Some(Item::ArrayOfTables(existing)), Item::ArrayOfTables(default)) => { + for entry in default.iter() { + if !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 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 commands + .iter() + .any(|entry| entry.get("key").and_then(Item::as_str) == Some(key.as_str())) + { + merged.kept.push(format!( + "{key} is bound to another command, 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 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_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..3295451b --- /dev/null +++ b/rust/dl/src/herdr_kit/mod.rs @@ -0,0 +1,974 @@ +//! `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. + fn tilde(&self, detail: &str) -> String { + let home = self.paths.home.to_string_lossy(); + if home.len() < 2 { + return detail.to_owned(); + } + detail.replace(&format!("{home}/"), "~/") + } + + /// "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. + fn install_file(&mut self, step: &str, path: &Path, bytes: &str, mode: u32) { + let outcome = match self.write(path, bytes.as_bytes(), Mode::Fixed(mode)) { + Ok(Written::Current) => Outcome::Current(path.display().to_string()), + Ok(Written::Changed) => Outcome::Changed(format!( + "{} {}", + self.did("wrote", "would write"), + path.display() + )), + Err(error) => Outcome::Failed(format!("{}: {error}", path.display())), + }; + self.report(step, outcome); + } + + 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 { + "" + } + ); + pane_shell_step(&mut setup); + kit_files(&mut setup); + plugin_link(&mut setup); + herdr_config(&mut setup); + 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 + } +} + +fn pane_shell_step(setup: &mut Setup) { + 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; + } + let outcome = match pane_shell::install(&path) { + pane_shell::Installed::AlreadyCurrent { path } => { + Outcome::Current(path.display().to_string()) + } + pane_shell::Installed::Written { path } | pane_shell::Installed::Refreshed { path } => { + Outcome::Changed(format!("wrote {}", path.display())) + } + pane_shell::Installed::Refused { path, reason } => { + Outcome::Failed(format!("{}: {reason}", path.display())) + } + }; + setup.report("pane shell", outcome); +} + +fn kit_files(setup: &mut Setup) { + let status = setup.paths.status_script(); + 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, + ); +} + +/// 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) { + let step = "herdr config"; + let path = setup.paths.herdr_config.clone(); + let targets = config::Targets { + shell: setup.paths.pane_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(&setup.paths.status_script().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); + let other_tab_title = settings(setup); + if !other_tab_title { + 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)); + } + 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); +} + +/// Merge the Stop hook into settings.json. True when another tab-title hook is +/// already registered, so devlaunch's script is not installed either. +fn settings(setup: &mut Setup) -> bool { + 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 other_tab_title = read + .as_ref() + .is_ok_and(|(_, merged)| merged.other_tab_title); + 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 instruction = format!("add a Stop hook with the command {command}"); + if let Some(outcome) = chezmoi_refusal(&path, &instruction) { + let outcome = match outcome { + Outcome::Skipped(_) if other_tab_title => { + Outcome::Skipped("chezmoi manages it, and it needs no change".to_owned()) + } + outcome => outcome, + }; + setup.report(step, outcome); + return other_tab_title; + } + let (original, merged) = match read { + Ok(read) => read, + Err(reason) => { + setup.report(step, Outcome::Failed(reason)); + return false; + } + }; + let Some(text) = merged.text else { + setup.report(step, Outcome::Current(path.display().to_string())); + return merged.other_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); + merged.other_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 dbb25174..1954cb61 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; @@ -655,6 +656,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..76692d0b 100644 --- a/rust/dl/tests/herdr_environment.rs +++ b/rust/dl/tests/herdr_environment.rs @@ -9,15 +9,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", @@ -229,7 +250,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 +269,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 +327,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 +504,346 @@ 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_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 + ); +} From e6d67426785c3ef8bcc03f096a90622da6cad356 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Tue, 29 Sep 2026 20:07:06 +0100 Subject: [PATCH 02/22] test: judge dl --herdr-setup from outside on a new machine Isolated HOME and XDG dirs, a fake herdr that records its argv, and the release binary: one run installs everything, a second writes nothing, and a dry run leaves the disk as it was. --- test/unit/test_herdr_setup_kit.py | 141 ++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 test/unit/test_herdr_setup_kit.py diff --git a/test/unit/test_herdr_setup_kit.py b/test/unit/test_herdr_setup_kit.py new file mode 100644 index 00000000..30369d8c --- /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 +def 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 From e23ce287ad0057a76fb05120e8e86015c48ec6fd Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Tue, 29 Sep 2026 20:07:06 +0100 Subject: [PATCH 03/22] docs: the Herdr kit dl --herdr-setup installs --- CHANGELOG.md | 24 ++++++++++ README.md | 10 +++-- docs/workspace-tools.md | 97 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b869ae88..53abd9c5 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.57.1] - 2026-09-29 ### Fixed diff --git a/README.md b/README.md index edcdf9a9..b0c8b283 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 ccebee93..e1fc943d 100644 --- a/docs/workspace-tools.md +++ b/docs/workspace-tools.md @@ -1461,6 +1461,94 @@ host-side half, and it is also why the mechanism is `pane report-agent` rather t already has an agent and does not establish one, so a container sending only those stays invisible. +## 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`, and desktop toasts. + +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 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 @@ -1557,8 +1645,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. @@ -1573,8 +1662,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 From 0e50c58c28d34bebb5de40722879d99dd05e25a1 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Tue, 29 Sep 2026 20:14:30 +0100 Subject: [PATCH 04/22] Make status.sh executable and name the pytest fixture for pylint --- rust/dl/src/herdr_kit/assets/status.sh | 0 test/unit/test_herdr_setup_kit.py | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) mode change 100644 => 100755 rust/dl/src/herdr_kit/assets/status.sh diff --git a/rust/dl/src/herdr_kit/assets/status.sh b/rust/dl/src/herdr_kit/assets/status.sh old mode 100644 new mode 100755 diff --git a/test/unit/test_herdr_setup_kit.py b/test/unit/test_herdr_setup_kit.py index 30369d8c..52da506b 100644 --- a/test/unit/test_herdr_setup_kit.py +++ b/test/unit/test_herdr_setup_kit.py @@ -46,8 +46,8 @@ BOOKKEEPING = {"herdr.log", "linked", "integrated"} -@pytest.fixture -def new_machine(tmp_path: Path) -> dict: +@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) From 154470011749cd3c63373a3e29d0c48c62b52ff1 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:09:43 +0100 Subject: [PATCH 05/22] fix: herdr hook dedupe kept the stale entry and dropped the current one herdr appends its current SessionStart entry, so the last copy is the one to keep. --- rust/dl/src/herdr_kit/claude.rs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/rust/dl/src/herdr_kit/claude.rs b/rust/dl/src/herdr_kit/claude.rs index 1e3e8178..83de3550 100644 --- a/rust/dl/src/herdr_kit/claude.rs +++ b/rust/dl/src/herdr_kit/claude.rs @@ -7,7 +7,7 @@ //! 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 -//! first copy stays; the rest go. +//! 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 @@ -145,14 +145,19 @@ pub(crate) fn merge(original: Option<&str>, command: &str) -> Result Date: Wed, 30 Sep 2026 19:11:14 +0100 Subject: [PATCH 06/22] fix: config could name a pane shell the same run failed to install herdr_config built terminal.default_shell and the status.sh segment from the paths it meant to install, not from what this run installed. When the pane shell (or status.sh) could not be written, the config still pointed every new pane at a missing file. The install steps now hand back the installed path, and the config step is skipped when either is absent. --- rust/dl/src/herdr_kit/mod.rs | 81 +++++++++++++++++++----------- rust/dl/tests/herdr_environment.rs | 32 ++++++++++++ 2 files changed, 85 insertions(+), 28 deletions(-) diff --git a/rust/dl/src/herdr_kit/mod.rs b/rust/dl/src/herdr_kit/mod.rs index 3295451b..becba552 100644 --- a/rust/dl/src/herdr_kit/mod.rs +++ b/rust/dl/src/herdr_kit/mod.rs @@ -175,18 +175,26 @@ impl Setup { write_if_changed(path, bytes, mode, self.options.dry_run) } - /// Report a whole-file install of one of devlaunch's own files. - fn install_file(&mut self, step: &str, path: &Path, bytes: &str, mode: u32) { - let outcome = match self.write(path, bytes.as_bytes(), Mode::Fixed(mode)) { - Ok(Written::Current) => Outcome::Current(path.display().to_string()), - Ok(Written::Changed) => Outcome::Changed(format!( - "{} {}", - self.did("wrote", "would write"), - path.display() - )), - Err(error) => Outcome::Failed(format!("{}: {error}", path.display())), + /// 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 { @@ -298,10 +306,10 @@ pub(crate) fn setup(options: HerdrSetupOptions) -> Ending { "" } ); - pane_shell_step(&mut setup); - kit_files(&mut setup); + let shell = pane_shell_step(&mut setup); + let status = kit_files(&mut setup); plugin_link(&mut setup); - herdr_config(&mut setup); + herdr_config(&mut setup, shell, status); local_bin_link(&mut setup); claude_steps(&mut setup); kitty_steps(&mut setup); @@ -324,7 +332,8 @@ pub(crate) fn setup(options: HerdrSetupOptions) -> Ending { } } -fn pane_shell_step(setup: &mut Setup) { +/// 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); @@ -334,25 +343,29 @@ fn pane_shell_step(setup: &mut Setup) { Outcome::Changed(format!("would write {}", path.display())) }; setup.report("pane shell", outcome); - return; + return Some(path); } - let outcome = match pane_shell::install(&path) { + let (outcome, installed) = match pane_shell::install(&path) { pane_shell::Installed::AlreadyCurrent { path } => { - Outcome::Current(path.display().to_string()) - } - pane_shell::Installed::Written { path } | pane_shell::Installed::Refreshed { path } => { - Outcome::Changed(format!("wrote {}", path.display())) - } - pane_shell::Installed::Refused { path, reason } => { - Outcome::Failed(format!("{}: {reason}", path.display())) + (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 } -fn kit_files(setup: &mut Setup) { +/// 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(); - setup.install_file("status segment", &status, STATUS_SCRIPT, 0o755); + let status = setup.install_file("status segment", &status, STATUS_SCRIPT, 0o755); let plugin = setup.paths.plugin(); setup.install_file( "agent-queue plugin", @@ -366,6 +379,7 @@ fn kit_files(setup: &mut Setup) { PLUGIN_VIEW, 0o755, ); + status } /// The root a plugin id is linked from, out of `herdr plugin list --json`. @@ -477,18 +491,29 @@ fn link_plugin(setup: &Setup, plugin: &Path) -> Outcome { Outcome::Changed(format!("linked {}; startup hook: {said}", plugin.display())) } -fn herdr_config(setup: &mut Setup) { +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: setup.paths.pane_shell.to_string_lossy().into_owned(), + 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(&setup.paths.status_script().to_string_lossy()), + status: shell_quote(&status.to_string_lossy()), }; if let Some(outcome) = chezmoi_refusal( &path, diff --git a/rust/dl/tests/herdr_environment.rs b/rust/dl/tests/herdr_environment.rs index 76692d0b..0f44f315 100644 --- a/rust/dl/tests/herdr_environment.rs +++ b/rust/dl/tests/herdr_environment.rs @@ -847,3 +847,35 @@ fn a_dotfiles_tab_title_hook_keeps_devlaunchs_script_out_even_under_chezmoi() { 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()); +} From 597740506a674c4ce263d6e1a103cf3d9a18e467 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:08:25 +0100 Subject: [PATCH 07/22] herdr setup: do not run integration install on a chezmoi-managed settings.json `herdr integration install claude` rewrote ~/.claude/settings.json even when chezmoi managed it and the settings step had skipped it, so the next `chezmoi apply` reverted the change. The integration step now asks chezmoi first and reports skipped, with the hooks to add to the dotfiles source. --- rust/dl/src/herdr_kit/claude.rs | 2 +- rust/dl/src/herdr_kit/mod.rs | 8 ++++++++ rust/dl/tests/herdr_environment.rs | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/rust/dl/src/herdr_kit/claude.rs b/rust/dl/src/herdr_kit/claude.rs index 83de3550..d0524fdb 100644 --- a/rust/dl/src/herdr_kit/claude.rs +++ b/rust/dl/src/herdr_kit/claude.rs @@ -22,7 +22,7 @@ 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. -const HERDR_HOOK: &str = "herdr-agent-state.sh"; +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; diff --git a/rust/dl/src/herdr_kit/mod.rs b/rust/dl/src/herdr_kit/mod.rs index becba552..19ba83dd 100644 --- a/rust/dl/src/herdr_kit/mod.rs +++ b/rust/dl/src/herdr_kit/mod.rs @@ -666,6 +666,14 @@ fn integration(setup: &mut Setup) { 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, diff --git a/rust/dl/tests/herdr_environment.rs b/rust/dl/tests/herdr_environment.rs index 0f44f315..0852b15c 100644 --- a/rust/dl/tests/herdr_environment.rs +++ b/rust/dl/tests/herdr_environment.rs @@ -798,6 +798,25 @@ fn chezmoi_managed_kitty_and_settings_get_the_line_to_add_instead() { 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(); From 236994044982697b9eb0a1bcb33ce6104f5f93e7 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:08:57 +0100 Subject: [PATCH 08/22] Stop herdr setup giving a default action a key the user already bound With [keys] zoom = "f6", setup added rename_tab = ["prefix+shift+t", "f6"]; with a [[keys.command]] on f7, it added f7 to next_agent. A filled default action or command now drops every key string the user's [keys] already binds, and an action left with no key is not added. --- rust/dl/src/herdr_kit/config.rs | 116 +++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 3 deletions(-) diff --git a/rust/dl/src/herdr_kit/config.rs b/rust/dl/src/herdr_kit/config.rs index 40beac64..95984ad2 100644 --- a/rust/dl/src/herdr_kit/config.rs +++ b/rust/dl/src/herdr_kit/config.rs @@ -70,10 +70,12 @@ pub(crate) fn merge(original: &str, targets: &Targets) -> Result .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, ); @@ -137,28 +139,89 @@ fn renumber_table(table: &mut Table, next: &mut isize) { } } +/// 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 mut item = default.clone(); + 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}."), next, added); + fill_defaults(existing, default, &format!("{path}."), bound, next, added); } (Some(Item::ArrayOfTables(existing)), Item::ArrayOfTables(default)) => { for entry in default.iter() { - if !existing.iter().any(|mine| same_binding(mine, entry)) { + 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))); } @@ -515,6 +578,53 @@ mod tests { assert!(!merged.added.iter().any(|path| path == "keys.prefix")); } + 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"; From fa257db18d354c3b38078a9d7a91a5ae01511177 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:11:04 +0100 Subject: [PATCH 09/22] Keep the agent-queue toggle off a prefix+a the user bound to a plain action With [keys] zoom = "prefix+a", setup still added a [[keys.command]] on prefix+a, because it checked the key only against other [[keys.command]] entries. It now checks every key the user's [keys] binds, and reports the toggle skipped as it does for a command. --- docs/workspace-tools.md | 2 +- rust/dl/src/herdr_kit/config.rs | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/workspace-tools.md b/docs/workspace-tools.md index e1fc943d..c22a761a 100644 --- a/docs/workspace-tools.md +++ b/docs/workspace-tools.md @@ -1510,7 +1510,7 @@ An existing config is merged, with comments and order kept. Two kinds of key: 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 command stays bound, and the toggle gets no key. + `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. diff --git a/rust/dl/src/herdr_kit/config.rs b/rust/dl/src/herdr_kit/config.rs index 95984ad2..3ebbe994 100644 --- a/rust/dl/src/herdr_kit/config.rs +++ b/rust/dl/src/herdr_kit/config.rs @@ -395,6 +395,7 @@ fn set_agent_queue_binding( }) .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())) @@ -432,12 +433,9 @@ fn set_agent_queue_binding( .and_then(Item::as_str) .unwrap_or_default() .to_owned(); - if commands - .iter() - .any(|entry| entry.get("key").and_then(Item::as_str) == Some(key.as_str())) - { + if bound.contains(&key) { merged.kept.push(format!( - "{key} is bound to another command, so the agent-queue toggle ({AGENT_QUEUE_ACTION}) has no key" + "{key} is bound to something else, so the agent-queue toggle ({AGENT_QUEUE_ACTION}) has no key" )); return; } @@ -677,6 +675,19 @@ mod tests { ); } + #[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 = From 90d27b57280c6687caaedae1cd98b34724c4fb9f Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:09:24 +0100 Subject: [PATCH 10/22] test: pin that a foreign hook group with no hooks survives the merge --- rust/dl/src/herdr_kit/claude.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rust/dl/src/herdr_kit/claude.rs b/rust/dl/src/herdr_kit/claude.rs index d0524fdb..69e73cba 100644 --- a/rust/dl/src/herdr_kit/claude.rs +++ b/rust/dl/src/herdr_kit/claude.rs @@ -216,7 +216,8 @@ mod tests { "model": "opus", "hooks": { "Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "telemetry.sh"}]}], - "Notification": [{"matcher": "", "hooks": [{"type": "command", "command": "notify"}]}] + "Notification": [{"matcher": "", "hooks": [{"type": "command", "command": "notify"}]}], + "PreToolUse": [{"matcher": "Bash", "hooks": []}] }, "theme": "dark" }); @@ -229,6 +230,10 @@ mod tests { 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); From 559fb894b631ea812a60d8a30b1eb684fb087a63 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:12:05 +0100 Subject: [PATCH 11/22] test: pin that a dry run writes no settings backup --- rust/dl/tests/herdr_environment.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/rust/dl/tests/herdr_environment.rs b/rust/dl/tests/herdr_environment.rs index 0852b15c..2e2f52d5 100644 --- a/rust/dl/tests/herdr_environment.rs +++ b/rust/dl/tests/herdr_environment.rs @@ -898,3 +898,18 @@ fn a_status_segment_that_failed_to_install_is_not_written_into_the_config() { ); 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()); +} From 4146370b86efa94f35d77883284ffd3793e8f47f Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:12:05 +0100 Subject: [PATCH 12/22] test: pin that a herdr file already on ~/.local/bin is left alone --- rust/dl/tests/herdr_environment.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/rust/dl/tests/herdr_environment.rs b/rust/dl/tests/herdr_environment.rs index 2e2f52d5..0674f6a8 100644 --- a/rust/dl/tests/herdr_environment.rs +++ b/rust/dl/tests/herdr_environment.rs @@ -913,3 +913,19 @@ fn a_dry_run_over_existing_files_writes_no_settings_backup() { 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}" + ); +} From ae19975d33e223e56fd321c2eb2bbc34c1960b08 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:12:05 +0100 Subject: [PATCH 13/22] test: pin that duplicate herdr SessionStart hooks are removed once, with a backup --- rust/dl/tests/herdr_environment.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/rust/dl/tests/herdr_environment.rs b/rust/dl/tests/herdr_environment.rs index 0674f6a8..e5a20dc4 100644 --- a/rust/dl/tests/herdr_environment.rs +++ b/rust/dl/tests/herdr_environment.rs @@ -929,3 +929,26 @@ fn a_herdr_file_already_on_local_bin_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}"); +} From 62341b988ca51b5e8e81b4334b06287c64681d9a Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:12:05 +0100 Subject: [PATCH 14/22] test: pin that a second run over a user's own files changes nothing --- rust/dl/tests/herdr_environment.rs | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/rust/dl/tests/herdr_environment.rs b/rust/dl/tests/herdr_environment.rs index e5a20dc4..510f7e92 100644 --- a/rust/dl/tests/herdr_environment.rs +++ b/rust/dl/tests/herdr_environment.rs @@ -952,3 +952,34 @@ fn duplicate_herdr_session_hooks_are_removed_once_with_a_backup() { 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}"); + } +} From 25f586234142048d7ec7f1ac96eae38e88bb274b Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:12:18 +0100 Subject: [PATCH 15/22] ci: say what herdr_environment spawns and why it takes the plain form --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5d5f65c..38c40e1c 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: | From 753984315e50661cd798026e02bad080fc5e34d2 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:12:05 +0100 Subject: [PATCH 16/22] test: pin the kitty include appended to a kitty.conf with no final newline --- rust/dl/tests/herdr_environment.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/rust/dl/tests/herdr_environment.rs b/rust/dl/tests/herdr_environment.rs index 510f7e92..a7f4ffbd 100644 --- a/rust/dl/tests/herdr_environment.rs +++ b/rust/dl/tests/herdr_environment.rs @@ -983,3 +983,20 @@ fn a_second_run_over_a_users_own_files_changes_nothing() { 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}" + ); +} From 3cafe6514f3a6b12f6a2d956a29e53aefcbcce91 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:09:31 +0100 Subject: [PATCH 17/22] herdr-setup: an unreadable settings.json no longer installs an unregistered tab-title hook settings() returned false both for "no other tab-title hook" and for "could not read settings.json", so a settings.json with invalid JSON failed the settings step and still got ~/.claude/hooks/devlaunch-herdr-tab-title.sh, which nothing runs. settings() now says Ours, Other or Unknown, and the script is installed only on Ours. --- rust/dl/src/herdr_kit/mod.rs | 36 ++++++++++++++++++++---------- rust/dl/tests/herdr_environment.rs | 16 +++++++++++++ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/rust/dl/src/herdr_kit/mod.rs b/rust/dl/src/herdr_kit/mod.rs index 19ba83dd..efaa62ae 100644 --- a/rust/dl/src/herdr_kit/mod.rs +++ b/rust/dl/src/herdr_kit/mod.rs @@ -632,8 +632,7 @@ fn claude_steps(setup: &mut Setup) { ); } integration(setup); - let other_tab_title = settings(setup); - if !other_tab_title { + if settings(setup) == TabTitle::Ours { let hook = setup.paths.hook_script(); setup.install_file("claude tab-title hook", &hook, claude::HOOK_SCRIPT, 0o755); } @@ -695,9 +694,19 @@ fn integration(setup: &mut Setup) { setup.report(step, outcome); } -/// Merge the Stop hook into settings.json. True when another tab-title hook is -/// already registered, so devlaunch's script is not installed either. -fn settings(setup: &mut Setup) -> bool { +/// 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(); @@ -712,9 +721,12 @@ fn settings(setup: &mut Setup) -> bool { .map(|merged| (original, merged)) .map_err(|reason| format!("{}: {reason}", path.display())) }); - let other_tab_title = read - .as_ref() - .is_ok_and(|(_, merged)| merged.other_tab_title); + 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", @@ -733,18 +745,18 @@ fn settings(setup: &mut Setup) -> bool { outcome => outcome, }; setup.report(step, outcome); - return other_tab_title; + return tab_title; } let (original, merged) = match read { Ok(read) => read, Err(reason) => { setup.report(step, Outcome::Failed(reason)); - return false; + return tab_title; } }; let Some(text) = merged.text else { setup.report(step, Outcome::Current(path.display().to_string())); - return merged.other_tab_title; + return tab_title; }; let mut changes = Vec::new(); if merged.registered { @@ -777,7 +789,7 @@ fn settings(setup: &mut Setup) -> bool { Err(error) => Outcome::Failed(format!("{}: {error}", path.display())), }; setup.report(step, outcome); - merged.other_tab_title + tab_title } /// Copy settings.json aside the first time devlaunch changes it, and never again. diff --git a/rust/dl/tests/herdr_environment.rs b/rust/dl/tests/herdr_environment.rs index a7f4ffbd..3ffe9d9b 100644 --- a/rust/dl/tests/herdr_environment.rs +++ b/rust/dl/tests/herdr_environment.rs @@ -1000,3 +1000,19 @@ fn the_kitty_include_is_appended_to_a_kitty_conf_without_a_final_newline() { "{second}" ); } + +#[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}" + ); +} From 8580956701aa2f904c5d4a1d29f0256eb7ac09f2 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:11:49 +0100 Subject: [PATCH 18/22] herdr-setup: a chezmoi-managed settings.json that needs hooks removed is no longer reported as needing no change The chezmoi branch said "it needs no change" whenever another herdr-tab-title.sh hook was registered, using that as a stand-in for the merge changing nothing. A file with that hook beside devlaunch's own, or with two herdr SessionStart copies, still needs entries removed. The report now follows the merge: no change only when it would write nothing, otherwise the steps it would take. --- rust/dl/src/herdr_kit/mod.rs | 26 ++++++++++++++++++++------ rust/dl/tests/herdr_environment.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/rust/dl/src/herdr_kit/mod.rs b/rust/dl/src/herdr_kit/mod.rs index efaa62ae..ab164c3b 100644 --- a/rust/dl/src/herdr_kit/mod.rs +++ b/rust/dl/src/herdr_kit/mod.rs @@ -736,13 +736,27 @@ fn settings(setup: &mut Setup) -> TabTitle { ), ); } - let instruction = format!("add a Stop hook with the command {command}"); - if let Some(outcome) = chezmoi_refusal(&path, &instruction) { - let outcome = match outcome { - Outcome::Skipped(_) if other_tab_title => { - Outcome::Skipped("chezmoi manages it, and it needs no change".to_owned()) + let add = format!("add a Stop hook with the command {command}"); + let instruction = match &read { + Ok((_, merged)) if merged.text.is_none() => None, + Ok((_, merged)) => { + let mut steps = Vec::new(); + if merged.registered { + steps.push(add); } - outcome => outcome, + if merged.removed > 0 { + steps.push(format!("remove {} duplicate hook(s)", merged.removed)); + } + Some(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; diff --git a/rust/dl/tests/herdr_environment.rs b/rust/dl/tests/herdr_environment.rs index 3ffe9d9b..7b3d9065 100644 --- a/rust/dl/tests/herdr_environment.rs +++ b/rust/dl/tests/herdr_environment.rs @@ -1001,6 +1001,34 @@ fn the_kitty_include_is_appended_to_a_kitty_conf_without_a_final_newline() { ); } +#[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 unreadable_settings_fail_the_step_and_install_no_orphan_hook_script() { let host = Host::new_machine(); From ce8de6c661c731d8fd29b181a4801715485c73b5 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:14:04 +0100 Subject: [PATCH 19/22] herdr-setup: chezmoi instructions no longer print a ~ path that a later run refuses, or re-ask for a hook already added report() spelled the home directory ~ across the whole line, so the herdr config instruction told the user to paste default_shell = ~/.local/bin/dl-herdr-shell, which the next run's set_default_shell refuses as a custom shell; the same ~ in the Stop hook command would sit in single quotes and never expand. Text to paste is now in backticks and kept verbatim. The chezmoi settings report also asked to add the Stop hook on every run, even after the user had added it with devlaunch's exact command, because the merge counts a hook without the timeout as needing a correction. It now asks only when no Stop hook runs that command. --- rust/dl/src/herdr_kit/claude.rs | 11 +++++- rust/dl/src/herdr_kit/config.rs | 2 +- rust/dl/src/herdr_kit/mod.rs | 25 +++++++++---- rust/dl/tests/herdr_environment.rs | 58 ++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 8 deletions(-) diff --git a/rust/dl/src/herdr_kit/claude.rs b/rust/dl/src/herdr_kit/claude.rs index 69e73cba..23bfc5ec 100644 --- a/rust/dl/src/herdr_kit/claude.rs +++ b/rust/dl/src/herdr_kit/claude.rs @@ -44,6 +44,8 @@ pub(crate) struct Merged { 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 { @@ -133,6 +135,7 @@ pub(crate) fn merge(original: Option<&str>, command: &str) -> Result { + merged.runs_ours = command_of(hook) == command; if *hook != desired { *hook = desired; merged.registered = true; @@ -245,7 +248,13 @@ mod tests { .unwrap() .text .unwrap(); - assert_eq!(merge(Some(&once), COMMAND).unwrap(), Merged::default()); + assert_eq!( + merge(Some(&once), COMMAND).unwrap(), + Merged { + runs_ours: true, + ..Merged::default() + } + ); } #[test] diff --git a/rust/dl/src/herdr_kit/config.rs b/rust/dl/src/herdr_kit/config.rs index 3ebbe994..154818a8 100644 --- a/rust/dl/src/herdr_kit/config.rs +++ b/rust/dl/src/herdr_kit/config.rs @@ -304,7 +304,7 @@ fn set_default_shell( && current != targets.prototype_shell { return Err(format!( - "Herdr uses a custom default_shell ({current}); set terminal.default_shell to {} manually to replace it", + "Herdr uses a custom default_shell ({current}); set terminal.default_shell to `{}` manually to replace it", targets.shell )); } diff --git a/rust/dl/src/herdr_kit/mod.rs b/rust/dl/src/herdr_kit/mod.rs index ab164c3b..faabbd1e 100644 --- a/rust/dl/src/herdr_kit/mod.rs +++ b/rust/dl/src/herdr_kit/mod.rs @@ -154,12 +154,26 @@ impl Setup { } /// `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(); } - detail.replace(&format!("{home}/"), "~/") + 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. @@ -518,7 +532,7 @@ fn herdr_config(setup: &mut Setup, shell: Option, status: Option TabTitle { ), ); } - let add = format!("add a Stop hook with the command {command}"); + let add = format!("add a Stop hook with the command `{command}`"); let instruction = match &read { - Ok((_, merged)) if merged.text.is_none() => None, Ok((_, merged)) => { let mut steps = Vec::new(); - if merged.registered { + if merged.registered && !merged.runs_ours { steps.push(add); } if merged.removed > 0 { steps.push(format!("remove {} duplicate hook(s)", merged.removed)); } - Some(steps.join(" and ")) + (!steps.is_empty()).then(|| steps.join(" and ")) } Err(_) => Some(add), }; diff --git a/rust/dl/tests/herdr_environment.rs b/rust/dl/tests/herdr_environment.rs index 7b3d9065..9d091db5 100644 --- a/rust/dl/tests/herdr_environment.rs +++ b/rust/dl/tests/herdr_environment.rs @@ -1029,6 +1029,64 @@ fn chezmoi_managed_settings_that_the_merge_would_change_are_not_reported_as_need ); } +#[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(); From 2a602bdaf6019a68644b7868e2880e6de0cbcd0b Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:26:02 +0100 Subject: [PATCH 20/22] fix(herdr-setup): a new-machine config did not resume agents on restore The packaged herdr config predates [session] resume_agents_on_restore and startup_per_agent_delay_ms, so dl --herdr-setup never turned auto-resume on. Ship both as default keys: a new config gets them, and a user's value wins. --- docs/workspace-tools.md | 3 ++- rust/dl/src/herdr_kit/assets/config.toml | 9 ++++++++ rust/dl/src/herdr_kit/config.rs | 29 ++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docs/workspace-tools.md b/docs/workspace-tools.md index 392d7c5a..a701d87a 100644 --- a/docs/workspace-tools.md +++ b/docs/workspace-tools.md @@ -1555,7 +1555,8 @@ or with `--no-claude`. The kitty steps use `$KITTY_CONFIG_DIRECTORY`, otherwise 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`, and desktop toasts. +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: diff --git a/rust/dl/src/herdr_kit/assets/config.toml b/rust/dl/src/herdr_kit/assets/config.toml index 2ef065a1..b65f03e0 100644 --- a/rust/dl/src/herdr_kit/assets/config.toml +++ b/rust/dl/src/herdr_kit/assets/config.toml @@ -13,6 +13,15 @@ onboarding = false [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". diff --git a/rust/dl/src/herdr_kit/config.rs b/rust/dl/src/herdr_kit/config.rs index 154818a8..a3765ecd 100644 --- a/rust/dl/src/herdr_kit/config.rs +++ b/rust/dl/src/herdr_kit/config.rs @@ -576,6 +576,35 @@ mod tests { 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(), From b2acc856f12df566aee245c9a7cab96a90b8838f Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:38:19 +0100 Subject: [PATCH 21/22] test(herdr_environment): the spawned dl keeps its coverage counters The suite clears the child's environment, which dropped LLVM_PROFILE_FILE, so every counter the 37 tests produced went to a stray default_*.profraw that cargo llvm-cov never reads. Forward it through KeepingCoverage, as the other binary-boundary suites do. --- rust/dl/tests/herdr_environment.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/dl/tests/herdr_environment.rs b/rust/dl/tests/herdr_environment.rs index 9d091db5..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; @@ -54,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")) From c7c2c6c2fc9e0c49964e30b0aa9035391fc67c87 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Wed, 30 Sep 2026 19:38:19 +0100 Subject: [PATCH 22/22] ci(rust-coverage): collect the herdr_environment suite It runs in the rust job but was missing from the coverage suite list, so herdr_kit and herdr_environment.rs reported only what the unit tests reach. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38c40e1c..7b65fb23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -446,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