From ce0bf7ae5bbe4b5d61484795362ba08aea65e72c Mon Sep 17 00:00:00 2001 From: Illia Grybkov Date: Tue, 24 Feb 2026 15:07:25 -0600 Subject: [PATCH] feat(hive): add extra_dirs support for passing additional directories to agents Add a top-level `extra_dirs` config list and per-agent `extra_dirs_flag` field so `hive run` automatically injects directory arguments into the agent command. Relative paths resolve against the main repo root, ensuring correct behavior when running from worktrees. Supported flags: Claude/Copilot/Codex `--add-dir`, Gemini `--include-directories`. Cursor agent CLI has no multi-dir support. Co-authored-by: Cursor --- .../hive_cli/src/hive_cli/commands/run.py | 40 ++++- .../hive_cli/src/hive_cli/config/__init__.py | 35 ++++ .../hive_cli/src/hive_cli/config/default.yml | 16 +- .../hive_cli/src/hive_cli/config/schema.py | 6 + .../hive_cli/src/hive_cli/config/settings.py | 1 + .../hive_cli/src/hive_cli/git/__init__.py | 2 + .../hive_cli/src/hive_cli/git/worktree.py | 8 +- .../packages/hive_cli/tests/test_config.py | 156 ++++++++++++++++++ .../packages/hive_cli/tests/test_run.py | 108 ++++++++++++ .../packages/hive_cli/tests/test_worktree.py | 34 ++++ 10 files changed, 388 insertions(+), 18 deletions(-) diff --git a/profiles/agents/packages/hive_cli/src/hive_cli/commands/run.py b/profiles/agents/packages/hive_cli/src/hive_cli/commands/run.py index 1583173..3ad29ba 100644 --- a/profiles/agents/packages/hive_cli/src/hive_cli/commands/run.py +++ b/profiles/agents/packages/hive_cli/src/hive_cli/commands/run.py @@ -9,7 +9,13 @@ from cyclopts import App, Parameter from ..agents import detect_agent -from ..config import KNOWN_AGENTS, get_agent_config, get_runtime_settings, get_settings +from ..config import ( + KNOWN_AGENTS, + get_agent_config, + get_extra_dirs_args, + get_runtime_settings, + get_settings, +) from ..utils import error, format_yellow from .exec_runner import run_in_worktree @@ -235,6 +241,8 @@ def run_with_dynamic_agent(command: list[str]) -> int: if current_agent_config: skip_perm_args = current_agent_config.skip_permissions_args + extra_dir_args = get_extra_dirs_args(current_agent_name) + # Handle resume logic if enabled and agent has resume args if resume: current_agent_config = get_agent_config(current_agent_name) @@ -244,6 +252,7 @@ def run_with_dynamic_agent(command: list[str]) -> int: current_cmd[0], *current_agent_config.resume_args, *skip_perm_args, + *extra_dir_args, *args, ] child_env = get_runtime_settings().build_child_env() @@ -256,9 +265,14 @@ def run_with_dynamic_agent(command: list[str]) -> int: return 0 # Resume failed, fall back to base command - # Build final command with skip-permissions args - if skip_perm_args: - final_cmd = [current_cmd[0], *skip_perm_args, *current_cmd[1:]] + # Build final command with skip-permissions and extra-dirs args + if skip_perm_args or extra_dir_args: + final_cmd = [ + current_cmd[0], + *skip_perm_args, + *extra_dir_args, + *current_cmd[1:], + ] else: final_cmd = current_cmd @@ -267,12 +281,21 @@ def run_with_dynamic_agent(command: list[str]) -> int: result = subprocess.run(final_cmd, env=child_env) return result.returncode + # Compute extra-dirs args for initial agent + initial_extra_dirs = get_extra_dirs_args(detected.name) + has_extra_dirs = bool(initial_extra_dirs) + # Use dynamic runner when: # - restart/restart_confirmation mode (needs restart loop) # - resume is enabled AND agent has resume_args (needs retry logic) # - skip-permissions is enabled (needs arg injection) + # - extra_dirs configured (needs arg injection per agent) use_dynamic_runner = ( - restart or restart_confirmation or has_resume_args or has_skip_permissions + restart + or restart_confirmation + or has_resume_args + or has_skip_permissions + or has_extra_dirs ) # Determine auto_select settings: CLI overrides config @@ -281,14 +304,15 @@ def run_with_dynamic_agent(command: list[str]) -> int: if auto_select_branch is None and config.worktrees.auto_select.enabled: auto_select_branch = config.worktrees.auto_select.branch - # Build initial command with skip-permissions args if applicable + # Build initial command with skip-permissions and extra-dirs args if applicable initial_skip_args: list[str] = [] if rt.skip_permissions: init_agent_config = get_agent_config(detected.name) if init_agent_config: initial_skip_args = init_agent_config.skip_permissions_args - if initial_skip_args: - initial_cmd = [detected.command, *initial_skip_args, *args] + injected_args = [*initial_skip_args, *initial_extra_dirs] + if injected_args: + initial_cmd = [detected.command, *injected_args, *args] else: initial_cmd = [detected.command, *args] diff --git a/profiles/agents/packages/hive_cli/src/hive_cli/config/__init__.py b/profiles/agents/packages/hive_cli/src/hive_cli/config/__init__.py index 0285ad7..344958e 100644 --- a/profiles/agents/packages/hive_cli/src/hive_cli/config/__init__.py +++ b/profiles/agents/packages/hive_cli/src/hive_cli/config/__init__.py @@ -123,6 +123,40 @@ def get_agent_order() -> list[str]: return settings.agents.order +def get_extra_dirs_args(agent_name: str) -> list[str]: + """Build CLI arguments for extra directories. + + Reads extra_dirs from settings, resolves each path relative to the main + repo root (so relative paths work identically from worktrees), then + pairs each resolved path with the agent's extra_dirs_flag. + + Args: + agent_name: Name of the agent (to look up extra_dirs_flag). + + Returns: + List like [flag, path1, flag, path2, ...], or [] if no dirs or + the agent has no extra_dirs_flag configured. + """ + from ..git import expand_path, get_main_repo + + settings = get_settings() + dirs = settings.extra_dirs + if not dirs: + return [] + + agent_cfg = settings.agents.configs.get(agent_name, AgentConfig()) + flag = agent_cfg.extra_dirs_flag + if not flag: + return [] + + main_repo = get_main_repo() + result: list[str] = [] + for d in dirs: + resolved = expand_path(d, main_repo) + result.extend([flag, str(resolved)]) + return result + + __all__ = [ # Base "HiveBaseSettings", @@ -165,4 +199,5 @@ def get_agent_order() -> list[str]: # Helpers "get_agent_config", "get_agent_order", + "get_extra_dirs_args", ] diff --git a/profiles/agents/packages/hive_cli/src/hive_cli/config/default.yml b/profiles/agents/packages/hive_cli/src/hive_cli/config/default.yml index 3249631..8ff79e0 100644 --- a/profiles/agents/packages/hive_cli/src/hive_cli/config/default.yml +++ b/profiles/agents/packages/hive_cli/src/hive_cli/config/default.yml @@ -12,32 +12,32 @@ agents: - copilot configs: - # Claude uses --continue flag claude: resume_args: ["--continue"] skip_permissions_args: ["--dangerously-skip-permissions"] + extra_dirs_flag: "--add-dir" - # Copilot uses --continue flag copilot: resume_args: ["--continue"] skip_permissions_args: ["--allow-all"] + extra_dirs_flag: "--add-dir" - # Codex uses resume subcommand with --last codex: resume_args: ["resume", "--last"] skip_permissions_args: ["--full-auto"] + extra_dirs_flag: "--add-dir" - # Gemini uses --resume with latest argument gemini: resume_args: ["--resume", "latest"] skip_permissions_args: ["-y"] + extra_dirs_flag: "--include-directories" - # Cursor agent CLI uses resume subcommand + # Cursor agent CLI - no extra dirs support agent: resume_args: ["resume"] skip_permissions_args: ["-f"] - # Cursor agent alternative name + # Cursor agent alternative name - no extra dirs support cursor-agent: resume_args: ["resume"] skip_permissions_args: ["-f"] @@ -84,3 +84,7 @@ zellij: github: fetch_issues: true issue_limit: 20 + +# Additional directories to pass to the agent via its extra_dirs_flag. +# Relative paths resolve against the main repo root. +extra_dirs: [] diff --git a/profiles/agents/packages/hive_cli/src/hive_cli/config/schema.py b/profiles/agents/packages/hive_cli/src/hive_cli/config/schema.py index 08c920f..a1a4e32 100644 --- a/profiles/agents/packages/hive_cli/src/hive_cli/config/schema.py +++ b/profiles/agents/packages/hive_cli/src/hive_cli/config/schema.py @@ -20,10 +20,13 @@ class AgentConfig(BaseModel): Attributes: resume_args: Arguments to add for resume functionality. skip_permissions_args: Arguments to add for skip-permissions mode. + extra_dirs_flag: CLI flag the agent uses for additional directories + (e.g., "--add-dir" for Claude, "--directory" for Cursor). """ resume_args: Annotated[list[str], Field(default_factory=list)] skip_permissions_args: Annotated[list[str], Field(default_factory=list)] + extra_dirs_flag: str | None = None class AgentsConfig(HiveBaseSettings): @@ -166,6 +169,8 @@ class HiveConfig(BaseModel): worktrees: Git worktree configuration. zellij: Zellij configuration. github: GitHub integration configuration. + extra_dirs: Additional directories to pass to the agent. + Relative paths are resolved against the main repo root. """ agents: Annotated[AgentsConfig, Field(default_factory=AgentsConfig)] @@ -173,3 +178,4 @@ class HiveConfig(BaseModel): worktrees: Annotated[WorktreesConfig, Field(default_factory=WorktreesConfig)] zellij: Annotated[ZellijConfig, Field(default_factory=ZellijConfig)] github: Annotated[GitHubConfig, Field(default_factory=GitHubConfig)] + extra_dirs: Annotated[list[str], Field(default_factory=list)] diff --git a/profiles/agents/packages/hive_cli/src/hive_cli/config/settings.py b/profiles/agents/packages/hive_cli/src/hive_cli/config/settings.py index 0d43650..5583df5 100644 --- a/profiles/agents/packages/hive_cli/src/hive_cli/config/settings.py +++ b/profiles/agents/packages/hive_cli/src/hive_cli/config/settings.py @@ -82,6 +82,7 @@ class HiveSettings(HiveBaseSettings): worktrees: Annotated[WorktreesConfig, Field(default_factory=WorktreesConfig)] zellij: Annotated[ZellijConfig, Field(default_factory=ZellijConfig)] github: Annotated[GitHubConfig, Field(default_factory=GitHubConfig)] + extra_dirs: Annotated[list[str], Field(default_factory=list)] @classmethod def settings_customise_sources( diff --git a/profiles/agents/packages/hive_cli/src/hive_cli/git/__init__.py b/profiles/agents/packages/hive_cli/src/hive_cli/git/__init__.py index 64f6dca..869ba79 100644 --- a/profiles/agents/packages/hive_cli/src/hive_cli/git/__init__.py +++ b/profiles/agents/packages/hive_cli/src/hive_cli/git/__init__.py @@ -5,6 +5,7 @@ WorktreeInfo, create_worktree, delete_worktree, + expand_path, fetch_origin, get_all_branches, get_current_branch, @@ -21,6 +22,7 @@ "WorktreeInfo", "create_worktree", "delete_worktree", + "expand_path", "fetch_origin", "get_all_branches", "get_current_branch", diff --git a/profiles/agents/packages/hive_cli/src/hive_cli/git/worktree.py b/profiles/agents/packages/hive_cli/src/hive_cli/git/worktree.py index b8d1ae3..44dc129 100644 --- a/profiles/agents/packages/hive_cli/src/hive_cli/git/worktree.py +++ b/profiles/agents/packages/hive_cli/src/hive_cli/git/worktree.py @@ -49,7 +49,7 @@ def sanitize_branch_name(branch: str) -> str: return sanitized.strip("-") -def _expand_path(path_str: str, main_repo: Path) -> Path: +def expand_path(path_str: str, main_repo: Path) -> Path: """Expand a path string, handling ~, env vars, and relative paths. Args: @@ -65,7 +65,7 @@ def _expand_path(path_str: str, main_repo: Path) -> Path: # If relative, resolve against main_repo if not path.is_absolute(): - path = main_repo / path + path = (main_repo / path).resolve() return path @@ -118,7 +118,7 @@ def get_worktrees_base(main_repo: Path | None = None) -> Path: # collide, so the base already groups by repo name implicitly pass - return _expand_path(template, main_repo) + return expand_path(template, main_repo) def _find_existing_worktree(branch: str, main_repo: Path) -> Path | None: @@ -160,7 +160,7 @@ def _compute_worktree_path(branch: str, main_repo: Path) -> Path: expanded = template.replace("{repo}", repo_name).replace( "{branch}", safe_branch ) - return _expand_path(expanded, main_repo) + return expand_path(expanded, main_repo) base = get_worktrees_base(main_repo) diff --git a/profiles/agents/packages/hive_cli/tests/test_config.py b/profiles/agents/packages/hive_cli/tests/test_config.py index 56bb434..26bbdff 100644 --- a/profiles/agents/packages/hive_cli/tests/test_config.py +++ b/profiles/agents/packages/hive_cli/tests/test_config.py @@ -11,6 +11,7 @@ deep_merge, find_config_files, find_global_config, + get_extra_dirs_args, get_xdg_config_home, load_config, reload_config, @@ -444,3 +445,158 @@ def test_reload_clears_cache(self, tmp_path, monkeypatch): # After reload, returns new value config3 = reload_config() assert config3.agents.order == ["gemini"] + + +class TestExtraDirsConfig: + """Tests for extra_dirs configuration.""" + + def test_default_extra_dirs_empty(self, tmp_path, monkeypatch): + """Default config has empty extra_dirs.""" + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + load_config.cache_clear() + with patch("hive_cli.config.loader.find_config_files", return_value=[]): + config = load_config() + + assert config.extra_dirs == [] + + def test_extra_dirs_from_file(self, tmp_path, monkeypatch): + """Loads extra_dirs from config file.""" + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + config_file = tmp_path / ".hive.yml" + config_file.write_text(""" +extra_dirs: + - ../sibling-repo + - ~/Projects/shared-lib + - /absolute/path +""") + + load_config.cache_clear() + with patch( + "hive_cli.config.loader.find_config_files", return_value=[config_file] + ): + config = load_config() + + assert config.extra_dirs == [ + "../sibling-repo", + "~/Projects/shared-lib", + "/absolute/path", + ] + + def test_extra_dirs_flag_in_agent_config(self, tmp_path, monkeypatch): + """Agent configs can specify extra_dirs_flag.""" + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + config_file = tmp_path / ".hive.yml" + config_file.write_text(""" +agents: + configs: + claude: + extra_dirs_flag: "--add-dir" + agent: + extra_dirs_flag: "--directory" +""") + + load_config.cache_clear() + with patch( + "hive_cli.config.loader.find_config_files", return_value=[config_file] + ): + config = load_config() + + assert config.agents.configs["claude"].extra_dirs_flag == "--add-dir" + assert config.agents.configs["agent"].extra_dirs_flag == "--directory" + + def test_extra_dirs_flag_default_none(self): + """AgentConfig extra_dirs_flag defaults to None.""" + config = AgentConfig() + assert config.extra_dirs_flag is None + + def test_claude_extra_dirs_flag_from_defaults(self, tmp_path, monkeypatch): + """Claude gets --add-dir from default.yml config.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.delenv("HIVE_AGENTS_ORDER", raising=False) + + load_config.cache_clear() + with patch("hive_cli.config.loader.find_config_files", return_value=[]): + config = load_config() + + assert config.agents.configs["claude"].extra_dirs_flag == "--add-dir" + + +class TestGetExtraDirsArgs: + """Tests for get_extra_dirs_args helper.""" + + def test_empty_when_no_dirs(self, tmp_path, monkeypatch): + """Returns empty list when no extra_dirs configured.""" + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + load_config.cache_clear() + with patch("hive_cli.config.loader.find_config_files", return_value=[]): + result = get_extra_dirs_args("claude") + + assert result == [] + + def test_empty_when_agent_has_no_flag(self, tmp_path, monkeypatch): + """Returns empty list when agent has no extra_dirs_flag.""" + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + config_file = tmp_path / ".hive.yml" + config_file.write_text(""" +extra_dirs: + - /some/dir +""") + + load_config.cache_clear() + with patch( + "hive_cli.config.loader.find_config_files", return_value=[config_file] + ): + result = get_extra_dirs_args("unknown-agent") + + assert result == [] + + def test_builds_flag_path_pairs(self, tmp_path, monkeypatch): + """Builds [flag, path, flag, path] pairs for absolute dirs.""" + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + config_file = tmp_path / ".hive.yml" + config_file.write_text(""" +extra_dirs: + - /abs/dir1 + - /abs/dir2 +""") + + load_config.cache_clear() + with ( + patch( + "hive_cli.config.loader.find_config_files", return_value=[config_file] + ), + patch("hive_cli.git.get_main_repo", return_value=tmp_path), + ): + result = get_extra_dirs_args("claude") + + assert result == ["--add-dir", "/abs/dir1", "--add-dir", "/abs/dir2"] + + def test_relative_paths_resolve_against_main_repo(self, tmp_path, monkeypatch): + """Relative paths are resolved against main repo root.""" + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + main_repo = tmp_path / "main-repo" + main_repo.mkdir() + + config_file = tmp_path / ".hive.yml" + config_file.write_text(""" +extra_dirs: + - ../sibling +""") + + load_config.cache_clear() + with ( + patch( + "hive_cli.config.loader.find_config_files", return_value=[config_file] + ), + patch("hive_cli.git.get_main_repo", return_value=main_repo), + ): + result = get_extra_dirs_args("claude") + + assert result == ["--add-dir", str(tmp_path / "sibling")] diff --git a/profiles/agents/packages/hive_cli/tests/test_run.py b/profiles/agents/packages/hive_cli/tests/test_run.py index 65fcfa4..8901731 100644 --- a/profiles/agents/packages/hive_cli/tests/test_run.py +++ b/profiles/agents/packages/hive_cli/tests/test_run.py @@ -532,6 +532,114 @@ def test_run_help_shows_restart(self, cli_runner: CycloptsTestRunner): assert "--restart" in result.output +class TestRunExtraDirs: + """Tests for run command extra_dirs injection.""" + + def test_run_injects_extra_dirs( + self, cli_runner: CycloptsTestRunner, temp_git_repo, monkeypatch + ): + """Test that extra_dirs args are injected into agent command.""" + config_file = temp_git_repo / ".hive.yml" + config_file.write_text(""" +extra_dirs: + - /abs/shared-lib +""") + + import subprocess as real_subprocess + + with ( + patch("shutil.which", return_value="/usr/bin/claude"), + patch( + "hive_cli.commands.exec_runner.get_git_root", return_value=temp_git_repo + ), + patch("hive_cli.git.get_main_repo", return_value=temp_git_repo), + patch( + "hive_cli.config.loader.find_config_files", + return_value=[config_file], + ), + patch.object(real_subprocess, "run") as mock_run, + patch("hive_cli.commands.exec_runner.os.execvpe"), + ): + reload_config() + mock_run.return_value.returncode = 0 + cli_runner.invoke(app, ["run", "-a", "claude"]) + agent_calls = [c for c in mock_run.call_args_list if c[0][0][0] == "claude"] + assert len(agent_calls) >= 1 + call_args = agent_calls[0][0][0] + assert "--add-dir" in call_args + assert "/abs/shared-lib" in call_args + + def test_run_extra_dirs_relative_resolves_to_main_repo( + self, cli_runner: CycloptsTestRunner, temp_git_repo, monkeypatch + ): + """Test that relative extra_dirs resolve against main repo root.""" + sibling = temp_git_repo.parent / "sibling" + sibling.mkdir() + + config_file = temp_git_repo / ".hive.yml" + config_file.write_text(""" +extra_dirs: + - ../sibling +""") + + import subprocess as real_subprocess + + with ( + patch("shutil.which", return_value="/usr/bin/claude"), + patch( + "hive_cli.commands.exec_runner.get_git_root", return_value=temp_git_repo + ), + patch("hive_cli.git.get_main_repo", return_value=temp_git_repo), + patch( + "hive_cli.config.loader.find_config_files", + return_value=[config_file], + ), + patch.object(real_subprocess, "run") as mock_run, + patch("hive_cli.commands.exec_runner.os.execvpe"), + ): + reload_config() + mock_run.return_value.returncode = 0 + cli_runner.invoke(app, ["run", "-a", "claude"]) + agent_calls = [c for c in mock_run.call_args_list if c[0][0][0] == "claude"] + assert len(agent_calls) >= 1 + call_args = agent_calls[0][0][0] + assert "--add-dir" in call_args + assert str(sibling) in call_args + + def test_run_no_extra_dirs_when_agent_has_no_flag( + self, cli_runner: CycloptsTestRunner, temp_git_repo, monkeypatch + ): + """Test that agents without extra_dirs_flag don't get extra dirs.""" + config_file = temp_git_repo / ".hive.yml" + config_file.write_text(""" +agents: + configs: + gemini: + extra_dirs_flag: null +extra_dirs: + - /some/dir +""") + + with ( + patch("shutil.which", return_value="/usr/bin/gemini"), + patch( + "hive_cli.commands.exec_runner.get_git_root", return_value=temp_git_repo + ), + patch("hive_cli.git.get_main_repo", return_value=temp_git_repo), + patch( + "hive_cli.config.loader.find_config_files", + return_value=[config_file], + ), + patch("hive_cli.commands.exec_runner.os.execvpe") as mock_execvpe, + ): + reload_config() + cli_runner.invoke(app, ["run", "-a", "gemini"]) + # No extra_dirs_flag → execvp (no dynamic runner needed) + mock_execvpe.assert_called_once() + call_args = mock_execvpe.call_args[0][1] + assert "/some/dir" not in call_args + + class TestRunHelp: """Tests for run command help.""" diff --git a/profiles/agents/packages/hive_cli/tests/test_worktree.py b/profiles/agents/packages/hive_cli/tests/test_worktree.py index fc9e4e7..a684476 100644 --- a/profiles/agents/packages/hive_cli/tests/test_worktree.py +++ b/profiles/agents/packages/hive_cli/tests/test_worktree.py @@ -9,6 +9,7 @@ from hive_cli.git.worktree import ( WorktreeInfo, _path_to_name, + expand_path, get_worktree_path, get_worktrees_base, list_worktrees, @@ -343,3 +344,36 @@ def test_default_is_main(self, tmp_path): """Test default is_main is False.""" info = WorktreeInfo(branch="feature", path=tmp_path) assert info.is_main is False + + +class TestExpandPath: + """Tests for expand_path function.""" + + def test_absolute_path_unchanged(self, tmp_path): + """Absolute paths are returned as-is.""" + result = expand_path("/absolute/path", tmp_path) + assert result == Path("/absolute/path") + + def test_tilde_expansion(self, tmp_path): + """Tilde is expanded to home directory.""" + result = expand_path("~/some/dir", tmp_path) + assert result == Path.home() / "some" / "dir" + + def test_relative_path_resolves_against_main_repo(self, tmp_path): + """Relative paths resolve against the provided main_repo.""" + main_repo = tmp_path / "main-repo" + main_repo.mkdir() + result = expand_path("../sibling", main_repo) + assert result == (main_repo / "../sibling").resolve() + assert result == tmp_path / "sibling" + + def test_env_var_expansion(self, tmp_path, monkeypatch): + """Environment variables in paths are expanded.""" + monkeypatch.setenv("MY_DIR", "expanded-dir") + result = expand_path("$MY_DIR/sub", tmp_path) + assert result == (tmp_path / "expanded-dir" / "sub").resolve() + + def test_dot_relative_resolves_against_main_repo(self, tmp_path): + """Dot-relative paths resolve against main_repo.""" + result = expand_path("./subdir", tmp_path) + assert result == (tmp_path / "subdir").resolve()