diff --git a/.claude-plugin/skills/agent-cli-dev/SKILL.md b/.claude-plugin/skills/agent-cli-dev/SKILL.md index 2fe014980..85339ebab 100644 --- a/.claude-plugin/skills/agent-cli-dev/SKILL.md +++ b/.claude-plugin/skills/agent-cli-dev/SKILL.md @@ -174,7 +174,7 @@ agent-cli dev new validation-a --from HEAD --agent --with-agent codex -m tmux \ --prompt-file .claude/validation-a.md ``` -This works without an attached terminal. `agent-cli` creates or reuses a detached tmux session and returns a pane handle plus attach command. +This works without an attached terminal. `agent-cli` creates or reuses a detached tmux session and returns a pane handle plus attach command. Launches may also run pre-launch preparation by default; use `--no-hooks` only when you explicitly need to bypass that behavior. ## Example: Multi-feature implementation diff --git a/.claude/skills/agent-cli-dev/SKILL.md b/.claude/skills/agent-cli-dev/SKILL.md index 2fe014980..85339ebab 100644 --- a/.claude/skills/agent-cli-dev/SKILL.md +++ b/.claude/skills/agent-cli-dev/SKILL.md @@ -174,7 +174,7 @@ agent-cli dev new validation-a --from HEAD --agent --with-agent codex -m tmux \ --prompt-file .claude/validation-a.md ``` -This works without an attached terminal. `agent-cli` creates or reuses a detached tmux session and returns a pane handle plus attach command. +This works without an attached terminal. `agent-cli` creates or reuses a detached tmux session and returns a pane handle plus attach command. Launches may also run pre-launch preparation by default; use `--no-hooks` only when you explicitly need to bypass that behavior. ## Example: Multi-feature implementation diff --git a/agent_cli/cli.py b/agent_cli/cli.py index a40e8c730..981404159 100644 --- a/agent_cli/cli.py +++ b/agent_cli/cli.py @@ -4,7 +4,7 @@ import sys from pathlib import Path -from typing import Annotated +from typing import Annotated, Any import typer from rich.table import Table @@ -93,7 +93,7 @@ def main( set_process_title(ctx.invoked_subcommand) -def set_config_defaults(ctx: typer.Context, config_file: str | None) -> None: +def set_config_defaults(ctx: typer.Context, config_file: str | None) -> dict[str, Any]: """Set the default values for the CLI based on the config file.""" config = load_config(config_file) wildcard_config = normalize_provider_defaults(config.get("defaults", {})) @@ -101,7 +101,7 @@ def set_config_defaults(ctx: typer.Context, config_file: str | None) -> None: command_key = ctx.command.name or "" if not command_key: ctx.default_map = wildcard_config - return + return config # For nested subcommands (e.g., "memory proxy"), build "memory.proxy" if ctx.parent and ctx.parent.command.name and ctx.parent.command.name != "agent-cli": @@ -109,6 +109,7 @@ def set_config_defaults(ctx: typer.Context, config_file: str | None) -> None: command_config = normalize_provider_defaults(config.get(command_key, {})) ctx.default_map = {**wildcard_config, **command_config} + return config # Import commands from other modules to register them diff --git a/agent_cli/config.py b/agent_cli/config.py index 76d741bd6..02d9076cc 100644 --- a/agent_cli/config.py +++ b/agent_cli/config.py @@ -264,6 +264,7 @@ class Dev(BaseModel): editor: bool = False agent: bool = False + auto_trust: bool = True direnv: bool | None = None default_agent: str | None = None default_editor: str | None = None diff --git a/agent_cli/dev/_config.py b/agent_cli/dev/_config.py new file mode 100644 index 000000000..925c8da69 --- /dev/null +++ b/agent_cli/dev/_config.py @@ -0,0 +1,59 @@ +"""Helpers for reading runtime dev configuration.""" + +from __future__ import annotations + +from typing import Any + +import click + +from agent_cli.config import load_config + + +def get_runtime_config() -> dict[str, Any]: + """Return the config dict active for the current CLI invocation.""" + ctx = click.get_current_context(silent=True) + while ctx is not None: + if isinstance(ctx.obj, dict) and isinstance(ctx.obj.get("config"), dict): + return ctx.obj["config"] + ctx = ctx.parent + return load_config(None) + + +def get_dev_config() -> dict[str, Any]: + """Return the `[dev]` config table for the current CLI invocation.""" + dev_config = get_runtime_config().get("dev", {}) + return dev_config if isinstance(dev_config, dict) else {} + + +def get_dev_table(name: str) -> dict[str, Any]: + """Return a merged `[dev.]` table from nested or flattened config.""" + result: dict[str, Any] = {} + + nested = get_dev_config().get(name) + if isinstance(nested, dict): + result.update(nested) + + flat = get_runtime_config().get(f"dev.{name}") + if isinstance(flat, dict): + result.update(flat) + + return result + + +def get_dev_child_tables(name: str) -> dict[str, dict[str, Any]]: + """Return merged `[dev..]` tables keyed by child name.""" + result = { + child: value for child, value in get_dev_table(name).items() if isinstance(value, dict) + } + + prefix = f"dev.{name}." + for key, value in get_runtime_config().items(): + if key.startswith(prefix) and isinstance(value, dict): + result[key[len(prefix) :]] = value + + return result + + +def get_dev_child_table(name: str, child: str) -> dict[str, Any]: + """Return a single merged `[dev..]` table.""" + return get_dev_child_tables(name).get(child, {}) diff --git a/agent_cli/dev/cli.py b/agent_cli/dev/cli.py index 3ef52ebed..bf0288a8e 100644 --- a/agent_cli/dev/cli.py +++ b/agent_cli/dev/cli.py @@ -23,6 +23,7 @@ from ._branch_name import AGENTS as BRANCH_NAME_AGENTS from ._branch_name import generate_ai_branch_name, generate_random_branch_name from ._output import error, info, success, warn +from .hooks import LaunchContext, prepare_agent_launch from .launch import ( get_agent_env, launch_agent, @@ -83,7 +84,11 @@ def dev_callback( Creates isolated working directories for each feature branch. Each worktree has its own branch, allowing parallel development without stashing changes. """ - set_config_defaults(ctx, config_file) + config = set_config_defaults(ctx, config_file) + if isinstance(ctx.obj, dict): + ctx.obj["config"] = config + else: + ctx.obj = {"config": config} # The [dev] section config is intended for `dev new` options. # Click expects subcommand defaults under ctx.default_map["new"]. @@ -390,6 +395,13 @@ def new( help="Launch the agent in a specific multiplexer. Currently supported: tmux. When started outside tmux, creates or reuses a detached session and reports the pane handle", ), ] = None, + hooks: Annotated[ + bool, + typer.Option( + "--hooks/--no-hooks", + help="Run built-in agent preparation (like Codex auto-trust) and configured pre-launch hooks before starting the agent", + ), + ] = True, verbose: Annotated[ bool, typer.Option( @@ -491,6 +503,18 @@ def new( if resolved_agent and resolved_agent.is_available(): merged_args = merge_agent_args(resolved_agent, agent_args) agent_env = get_agent_env(resolved_agent) + prepare_agent_launch( + LaunchContext( + agent=resolved_agent, + worktree_path=result.path, + repo_root=repo_root, + branch=result.branch, + worktree_name=result.path.name, + task_file=task_file, + agent_env=agent_env, + ), + hooks_enabled=hooks, + ) agent_handle = launch_agent( result.path, resolved_agent, @@ -932,6 +956,13 @@ def start_agent( help="Launch the agent in a specific multiplexer instead of the current terminal. Currently supported: tmux", ), ] = None, + hooks: Annotated[ + bool, + typer.Option( + "--hooks/--no-hooks", + help="Run built-in agent preparation (like Codex auto-trust) and configured pre-launch hooks before starting the agent", + ), + ] = True, ) -> None: """Start an AI coding agent in an existing dev environment. @@ -970,6 +1001,18 @@ def start_agent( merged_args = merge_agent_args(agent, agent_args) agent_env = get_agent_env(agent) + prepare_agent_launch( + LaunchContext( + agent=agent, + worktree_path=wt.path, + repo_root=repo_root, + branch=wt.branch, + worktree_name=wt.name, + task_file=task_file, + agent_env=agent_env, + ), + hooks_enabled=hooks, + ) if multiplexer: handle = launch_agent( diff --git a/agent_cli/dev/coding_agents/base.py b/agent_cli/dev/coding_agents/base.py index c5fec4ce5..d329a86eb 100644 --- a/agent_cli/dev/coding_agents/base.py +++ b/agent_cli/dev/coding_agents/base.py @@ -117,6 +117,17 @@ def get_env(self) -> dict[str, str]: """Get any additional environment variables needed.""" return {} + def prepare_launch( + self, + worktree_path: Path, # noqa: ARG002 + repo_root: Path, # noqa: ARG002 + ) -> str | None: + """Perform any agent-specific preparation before launch. + + Returns an optional human-readable message describing a change that was made. + """ + return None + def __repr__(self) -> str: # noqa: D105 status = "available" if self.is_available() else "not installed" return f"<{self.__class__.__name__} {self.name!r} ({status})>" diff --git a/agent_cli/dev/coding_agents/codex.py b/agent_cli/dev/coding_agents/codex.py index 2ea51c20c..2862173f1 100644 --- a/agent_cli/dev/coding_agents/codex.py +++ b/agent_cli/dev/coding_agents/codex.py @@ -2,8 +2,70 @@ from __future__ import annotations +from pathlib import Path + from .base import CodingAgent +CODEX_CONFIG_PATH = Path.home() / ".codex" / "config.toml" + + +def _project_section_header(path: Path) -> str: + """Build the TOML section header for a trusted Codex project path.""" + escaped = str(path).replace("\\", "\\\\").replace('"', '\\"') + return f'[projects."{escaped}"]' + + +def _ensure_project_trusted(project_path: Path, config_path: Path | None = None) -> bool: + """Ensure Codex trusts the launched project path for headless launches. + + Returns True when the config file was modified. + """ + project_path = project_path.expanduser().resolve() + config_path = (config_path or CODEX_CONFIG_PATH).expanduser() + header = _project_section_header(project_path) + trust_line = 'trust_level = "trusted"' + + if not config_path.exists(): + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(f"{header}\n{trust_line}\n", encoding="utf-8") + return True + + text = config_path.read_text(encoding="utf-8") + lines = text.splitlines() + + for idx, line in enumerate(lines): + if line.strip() != header: + continue + + end = len(lines) + for j in range(idx + 1, len(lines)): + if lines[j].strip().startswith("[") and lines[j].strip().endswith("]"): + end = j + break + + for j in range(idx + 1, end): + stripped = lines[j].strip() + if not stripped.startswith("trust_level"): + continue + if stripped == trust_line: + return False + msg = ( + f"Codex trust for {project_path} is already configured in {config_path}. " + "Update that section or disable [dev].auto_trust." + ) + raise RuntimeError(msg) + + lines.insert(idx + 1, trust_line) + config_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return True + + new_text = text.rstrip("\n") + if new_text: + new_text += "\n\n" + new_text += f"{header}\n{trust_line}\n" + config_path.write_text(new_text, encoding="utf-8") + return True + class Codex(CodingAgent): """OpenAI Codex CLI coding agent.""" @@ -22,3 +84,9 @@ def prompt_args(self, prompt: str) -> list[str]: See: codex --help """ return [prompt] + + def prepare_launch(self, worktree_path: Path, repo_root: Path) -> str | None: # noqa: ARG002 + """Ensure Codex trusts the repository root before launch.""" + if _ensure_project_trusted(repo_root, CODEX_CONFIG_PATH): + return f"Trusted {repo_root.resolve()} in Codex config" + return None diff --git a/agent_cli/dev/hooks.py b/agent_cli/dev/hooks.py new file mode 100644 index 000000000..55101be35 --- /dev/null +++ b/agent_cli/dev/hooks.py @@ -0,0 +1,138 @@ +"""Launch preparation helpers for `agent-cli dev`.""" + +from __future__ import annotations + +import os +import shlex +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from ._config import get_dev_child_table, get_dev_config, get_dev_table +from ._output import info + +if TYPE_CHECKING: + from .coding_agents.base import CodingAgent + + +@dataclass(frozen=True) +class LaunchContext: + """Context provided to built-in preparation and user hooks.""" + + agent: CodingAgent + worktree_path: Path + repo_root: Path + branch: str | None + worktree_name: str + task_file: Path | None + agent_env: dict[str, str] + + +def _normalize_hook_commands(value: Any, *, config_key: str) -> list[str]: + """Normalize a hook config entry to a list of shell commands.""" + if value is None: + return [] + if isinstance(value, str): + value = [value] + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + msg = f"{config_key} must be a string or list of strings" + raise RuntimeError(msg) + commands = [item.strip() for item in value if item.strip()] + if len(commands) != len(value): + msg = f"{config_key} contains an empty command" + raise RuntimeError(msg) + return commands + + +def _load_dev_hook_settings(agent_name: str) -> tuple[bool, list[str]]: + """Load auto-trust and pre-launch hook settings for an agent.""" + auto_trust = bool(get_dev_config().get("auto_trust", True)) + global_hooks = get_dev_table("hooks") + agent_hooks = get_dev_child_table("hooks", agent_name) + + pre_launch_hooks = _normalize_hook_commands( + global_hooks.get("pre_launch"), + config_key="[dev.hooks].pre_launch", + ) + pre_launch_hooks.extend( + _normalize_hook_commands( + agent_hooks.get("pre_launch"), + config_key=f"[dev.hooks.{agent_name}].pre_launch", + ), + ) + return auto_trust, pre_launch_hooks + + +def _build_hook_env(context: LaunchContext) -> dict[str, str]: + """Build the environment passed to pre-launch hooks.""" + env = os.environ.copy() + env.update(context.agent_env) + env.update( + { + "AGENT_CLI_AGENT": context.agent.name, + "AGENT_CLI_WORKTREE": str(context.worktree_path), + "AGENT_CLI_REPO_ROOT": str(context.repo_root), + "AGENT_CLI_BRANCH": context.branch or "", + "AGENT_CLI_NAME": context.worktree_name, + "AGENT_CLI_TASK_FILE": str(context.task_file or ""), + # Keep the proposal name as an alias for compatibility. + "AGENT_CLI_PROMPT_FILE": str(context.task_file or ""), + }, + ) + return env + + +def _resolve_hook_command(command: str) -> list[str]: + """Parse a configured hook command into argv.""" + argv = shlex.split(command) + if not argv: + msg = "Hook command cannot be empty" + raise RuntimeError(msg) + + first = Path(argv[0]).expanduser() + if argv[0].startswith("~") or "/" in argv[0]: + argv[0] = str(first) + return argv + + +def _run_pre_launch_hook(command: str, context: LaunchContext) -> None: + """Run a single pre-launch hook.""" + argv = _resolve_hook_command(command) + info(f"Running pre-launch hook: {command}") + try: + result = subprocess.run( + argv, + cwd=context.worktree_path, + env=_build_hook_env(context), + check=False, + capture_output=True, + text=True, + ) + except FileNotFoundError as e: + msg = f"Pre-launch hook not found: {argv[0]}" + raise RuntimeError(msg) from e + + if result.returncode == 0: + return + + details = (result.stderr or result.stdout).strip() + msg = f"Pre-launch hook failed ({result.returncode}): {command}" + if details: + msg += f"\n{details}" + raise RuntimeError(msg) + + +def prepare_agent_launch(context: LaunchContext, *, hooks_enabled: bool = True) -> None: + """Run built-in preparation and configured pre-launch hooks.""" + if not hooks_enabled: + return + + auto_trust, pre_launch_hooks = _load_dev_hook_settings(context.agent.name) + if auto_trust and ( + message := context.agent.prepare_launch(context.worktree_path, context.repo_root) + ): + info(message) + + for command in pre_launch_hooks: + _run_pre_launch_hook(command, context) diff --git a/agent_cli/dev/launch.py b/agent_cli/dev/launch.py index 83ee9812b..6b97876e0 100644 --- a/agent_cli/dev/launch.py +++ b/agent_cli/dev/launch.py @@ -9,10 +9,10 @@ from pathlib import Path from typing import TYPE_CHECKING -from agent_cli.config import load_config from agent_cli.core.utils import console from . import coding_agents, editors, terminals, worktree +from ._config import get_dev_child_tables, get_dev_table from ._output import success, warn if TYPE_CHECKING: @@ -95,15 +95,8 @@ def get_config_agent_args() -> dict[str, list[str]] | None: Note: The config loader may flatten section names, so we check both nested structure and flattened 'dev.agent_args' key. """ - config = load_config(None) - - # First try the simple nested structure (for testing/mocks) - dev_config = config.get("dev", {}) - if isinstance(dev_config, dict) and "agent_args" in dev_config: - return dev_config["agent_args"] - - # Handle flattened key "dev.agent_args" - return config.get("dev.agent_args") + agent_args = get_dev_table("agent_args") + return agent_args or None def get_config_agent_env() -> dict[str, dict[str, str]] | None: @@ -117,22 +110,8 @@ def get_config_agent_env() -> dict[str, dict[str, str]] | None: 'dev.agent_env.claude' become top-level. We reconstruct the agent_env dict from these flattened keys. """ - config = load_config(None) - - # First try the simple nested structure (for testing/mocks) - dev_config = config.get("dev", {}) - if isinstance(dev_config, dict) and "agent_env" in dev_config: - return dev_config["agent_env"] - - # Handle flattened keys like "dev.agent_env.claude" - prefix = "dev.agent_env." - result: dict[str, dict[str, str]] = {} - for key, value in config.items(): - if key.startswith(prefix) and isinstance(value, dict): - agent_name = key[len(prefix) :] - result[agent_name] = value - - return result or None + agent_env = get_dev_child_tables("agent_env") + return agent_env or None def get_agent_env(agent: CodingAgent) -> dict[str, str]: diff --git a/agent_cli/dev/skill/SKILL.md b/agent_cli/dev/skill/SKILL.md index 2fe014980..85339ebab 100644 --- a/agent_cli/dev/skill/SKILL.md +++ b/agent_cli/dev/skill/SKILL.md @@ -174,7 +174,7 @@ agent-cli dev new validation-a --from HEAD --agent --with-agent codex -m tmux \ --prompt-file .claude/validation-a.md ``` -This works without an attached terminal. `agent-cli` creates or reuses a detached tmux session and returns a pane handle plus attach command. +This works without an attached terminal. `agent-cli` creates or reuses a detached tmux session and returns a pane handle plus attach command. Launches may also run pre-launch preparation by default; use `--no-hooks` only when you explicitly need to bypass that behavior. ## Example: Multi-feature implementation diff --git a/agent_cli/example-config.toml b/agent_cli/example-config.toml index c60214bfd..0ff60e659 100644 --- a/agent_cli/example-config.toml +++ b/agent_cli/example-config.toml @@ -191,6 +191,7 @@ enable_tts = true # Defaults for `agent-cli dev new` # editor = true # agent = true +# auto_trust = true # direnv = true # setup = true # copy_env = true @@ -208,3 +209,9 @@ codex = ["--dangerously-bypass-approvals-and-sandbox"] [dev.agent_env.claude] # CLAUDE_CODE_USE_VERTEX = "1" # ANTHROPIC_MODEL = "claude-opus-4-5" + +[dev.hooks] +# pre_launch = ["~/.config/agent-cli/hooks/pre-launch.sh"] + +[dev.hooks.codex] +# pre_launch = ["~/.config/agent-cli/hooks/codex-setup.sh"] diff --git a/docs/commands/dev.md b/docs/commands/dev.md index a8474466b..f6a88bc4a 100644 --- a/docs/commands/dev.md +++ b/docs/commands/dev.md @@ -87,6 +87,7 @@ agent-cli dev new [BRANCH] [OPTIONS] | `--prompt, -p` | - | Initial task for the AI agent. Saved to .claude/TASK.md. Implies --agent. Example: --prompt='Fix the login bug' | | `--prompt-file, -P` | - | Read the agent prompt from a file. Useful for long prompts to avoid shell quoting. Implies --agent | | `--multiplexer, -m` | - | Launch the agent in a specific multiplexer. Currently supported: tmux. When started outside tmux, creates or reuses a detached session and reports the pane handle | +| `--hooks/--no-hooks` | `true` | Run built-in agent preparation (like Codex auto-trust) and configured pre-launch hooks before starting the agent | | `--verbose, -v` | `false` | Stream output from setup commands instead of hiding it | @@ -295,6 +296,7 @@ agent-cli dev agent NAME [--agent/-a AGENT] [--agent-args ARGS] [--prompt/-p PRO | `--prompt, -p` | - | Initial task for the agent. Saved to .claude/TASK.md. Example: --prompt='Add unit tests for auth' | | `--prompt-file, -P` | - | Read the agent prompt from a file instead of command line | | `--multiplexer, -m` | - | Launch the agent in a specific multiplexer instead of the current terminal. Currently supported: tmux | +| `--hooks/--no-hooks` | `true` | Run built-in agent preparation (like Codex auto-trust) and configured pre-launch hooks before starting the agent | @@ -580,6 +582,7 @@ commented-out starter block for `[dev]`, `[dev.agent_args]`, and # Default flags for 'dev new' command editor = true # Always open editor (-e) agent = true # Always start agent (-a) +auto_trust = true # Auto-trust supported agents before launch direnv = true # Always generate .envrc (--direnv) # Worktree creation behavior @@ -605,6 +608,14 @@ codex = ["--dangerously-bypass-approvals-and-sandbox"] [dev.agent_env.claude] CLAUDE_CODE_USE_VERTEX = "1" ANTHROPIC_MODEL = "claude-opus-4-5" + +# Optional pre-launch hooks (run from the worktree directory) +[dev.hooks] +pre_launch = ["~/.config/agent-cli/hooks/pre-launch.sh"] + +# Optional per-agent pre-launch hooks +[dev.hooks.codex] +pre_launch = ["~/.config/agent-cli/hooks/codex-setup.sh"] ``` Or per-project in `agent-cli-config.toml`: @@ -622,6 +633,9 @@ claude = ["--dangerously-skip-permissions", "--model", "opus"] [dev.agent_env.claude] ANTHROPIC_MODEL = "claude-sonnet-4-20250514" + +[dev.hooks] +pre_launch = ["./scripts/prepare-worktree.sh"] ``` With this configuration, running `agent-cli dev new` will automatically open the editor, start the agent, and set up direnv. @@ -715,6 +729,22 @@ When launching an AI agent, the dev command automatically: 4. Falls back to supported terminals (kitty, iTerm2) 5. Prints instructions if no terminal is detected +Before launching an agent, `agent-cli dev` can also run launch preparation: + +- Built-in preparation for supported agents. Currently this means Codex auto-trust: when `[dev].auto_trust = true` (the default), `agent-cli` ensures the repository root is trusted in `~/.codex/config.toml` before launch. +- User-defined `pre_launch` hooks from `[dev.hooks]` and `[dev.hooks.]`. Global hooks run first, then agent-specific hooks. +- Hook config follows the same config source as the rest of `dev`, including `agent-cli dev --config path/to/config.toml ...`. +- Hooks run synchronously in the worktree directory and receive: + - `AGENT_CLI_AGENT` + - `AGENT_CLI_WORKTREE` + - `AGENT_CLI_REPO_ROOT` + - `AGENT_CLI_BRANCH` + - `AGENT_CLI_NAME` + - `AGENT_CLI_TASK_FILE` + - `AGENT_CLI_PROMPT_FILE` (alias of `AGENT_CLI_TASK_FILE`) +- Hook commands are executed directly, not through a shell. For pipelines or more complex setup, point `pre_launch` at a script. +- Use `--no-hooks` to bypass both built-in preparation and configured pre-launch hooks for a single launch. + ### Multi-agent Workflows Use `dev agent -m tmux` when you want multiple agents on the same worktree instead of multiple worktrees: diff --git a/tests/dev/test_cli.py b/tests/dev/test_cli.py index 894500682..aba705c9d 100644 --- a/tests/dev/test_cli.py +++ b/tests/dev/test_cli.py @@ -302,6 +302,7 @@ def test_new_uses_ai_branch_name_when_enabled(self, tmp_path: Path) -> None: ), patch("agent_cli.dev.cli.resolve_editor", return_value=None), patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent, + patch("agent_cli.dev.cli.prepare_agent_launch"), patch("agent_cli.dev.cli.merge_agent_args", return_value=None), patch("agent_cli.dev.cli.get_agent_env", return_value={}), patch("agent_cli.dev.cli.launch_agent", return_value=None), @@ -534,6 +535,7 @@ def test_new_shows_tmux_handle_when_requested(self, tmp_path: Path) -> None: ), patch("agent_cli.dev.cli.resolve_editor", return_value=None), patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent, + patch("agent_cli.dev.cli.prepare_agent_launch"), patch("agent_cli.dev.cli.merge_agent_args", return_value=None), patch("agent_cli.dev.cli.get_agent_env", return_value={}), patch( @@ -658,6 +660,103 @@ def test_new_rejects_empty_prompt_file(self, tmp_path: Path) -> None: assert f"Prompt file is empty: {prompt_file}" in result.output mock_ensure_repo.assert_not_called() + def test_new_skips_launch_preparation_when_hooks_are_disabled(self, tmp_path: Path) -> None: + """`--no-hooks` should bypass built-in preparation and configured hooks.""" + wt_path = tmp_path / "repo-worktrees" / "feature" + wt_path.mkdir(parents=True) + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch( + "agent_cli.dev.worktree.create_worktree", + return_value=CreateWorktreeResult( + success=True, + path=wt_path, + branch="feature", + ), + ), + patch("agent_cli.dev.cli.resolve_editor", return_value=None), + patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent, + patch("agent_cli.dev.cli.prepare_agent_launch") as mock_prepare, + patch("agent_cli.dev.cli.merge_agent_args", return_value=None), + patch("agent_cli.dev.cli.get_agent_env", return_value={}), + patch("agent_cli.dev.cli.launch_agent", return_value=None), + ): + mock_agent = mock_resolve_agent.return_value + mock_agent.is_available.return_value = True + result = runner.invoke( + app, + [ + "dev", + "new", + "feature", + "--agent", + "--no-hooks", + "--no-setup", + "--no-copy-env", + "--no-fetch", + "--no-direnv", + ], + ) + + assert result.exit_code == 0 + assert mock_prepare.call_count == 1 + assert mock_prepare.call_args.kwargs["hooks_enabled"] is False + + def test_new_uses_hooks_from_explicit_config_file(self, tmp_path: Path) -> None: + """`dev --config ... new` should apply hook config from that file.""" + wt_path = tmp_path / "repo-worktrees" / "feature" + wt_path.mkdir(parents=True) + config_path = tmp_path / "agent-cli-config.toml" + hook_path = tmp_path / "pre-launch.sh" + hook_path.write_text("#!/bin/sh\nexit 0\n") + config_path.write_text(f'[dev.hooks]\npre_launch = ["{hook_path.as_posix()}"]\n') + + with ( + patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), + patch( + "agent_cli.dev.worktree.create_worktree", + return_value=CreateWorktreeResult( + success=True, + path=wt_path, + branch="feature", + ), + ), + patch("agent_cli.dev.cli.resolve_editor", return_value=None), + patch("agent_cli.dev.cli.resolve_agent") as mock_resolve_agent, + patch("agent_cli.dev.cli.merge_agent_args", return_value=None), + patch("agent_cli.dev.cli.get_agent_env", return_value={}), + patch("agent_cli.dev.cli.launch_agent", return_value=None), + patch( + "agent_cli.dev.hooks.subprocess.run", + return_value=subprocess.CompletedProcess([], 0, "", ""), + ) as mock_run, + ): + mock_agent = mock_resolve_agent.return_value + mock_agent.name = "codex" + mock_agent.is_available.return_value = True + mock_agent.prepare_launch.return_value = None + result = runner.invoke( + app, + [ + "dev", + "--config", + str(config_path), + "new", + "feature", + "--agent", + "--no-setup", + "--no-copy-env", + "--no-fetch", + "--no-direnv", + ], + ) + + assert result.exit_code == 0 + assert mock_run.call_count == 1 + assert mock_run.call_args.args[0] == [str(hook_path)] + assert mock_run.call_args.kwargs["cwd"] == wt_path + class TestDevHelp: """Tests for dev command help.""" @@ -691,6 +790,7 @@ def test_agent_can_launch_in_requested_tmux(self) -> None: patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt), patch("agent_cli.dev.cli.coding_agents.detect_current_agent") as mock_detect_current, + patch("agent_cli.dev.cli.prepare_agent_launch"), patch("agent_cli.dev.cli.merge_agent_args", return_value=None), patch("agent_cli.dev.cli.get_agent_env", return_value={}), patch( @@ -723,6 +823,7 @@ def test_agent_quotes_tmux_attach_hint(self) -> None: patch("agent_cli.dev.cli._ensure_git_repo", return_value=Path("/repo")), patch("agent_cli.dev.worktree.find_worktree_by_name", return_value=wt), patch("agent_cli.dev.cli.coding_agents.detect_current_agent") as mock_detect_current, + patch("agent_cli.dev.cli.prepare_agent_launch"), patch("agent_cli.dev.cli.merge_agent_args", return_value=None), patch("agent_cli.dev.cli.get_agent_env", return_value={}), patch( @@ -1007,7 +1108,7 @@ class TestGetConfigAgentArgs: def test_returns_none_when_no_config(self) -> None: """Returns None when no agent_args in config.""" - with patch("agent_cli.dev.launch.load_config", return_value={}): + with patch("agent_cli.dev._config.get_runtime_config", return_value={}): result = get_config_agent_args() assert result is None @@ -1020,7 +1121,7 @@ def test_returns_agent_args_nested(self) -> None: }, }, } - with patch("agent_cli.dev.launch.load_config", return_value=config): + with patch("agent_cli.dev._config.get_runtime_config", return_value=config): result = get_config_agent_args() assert result == {"claude": ["--dangerously-skip-permissions"]} @@ -1032,7 +1133,7 @@ def test_returns_agent_args_from_flattened_key(self) -> None: "aider": ["--model", "gpt-4o"], }, } - with patch("agent_cli.dev.launch.load_config", return_value=config): + with patch("agent_cli.dev._config.get_runtime_config", return_value=config): result = get_config_agent_args() assert result == { "claude": ["--dangerously-skip-permissions"], @@ -1045,13 +1146,13 @@ class TestGetConfigAgentEnv: def test_returns_none_when_no_config(self) -> None: """Returns None when no agent_env in config.""" - with patch("agent_cli.dev.launch.load_config", return_value={}): + with patch("agent_cli.dev._config.get_runtime_config", return_value={}): result = get_config_agent_env() assert result is None def test_returns_none_when_no_dev_section(self) -> None: """Returns None when no dev section in config.""" - with patch("agent_cli.dev.launch.load_config", return_value={"other": {}}): + with patch("agent_cli.dev._config.get_runtime_config", return_value={"other": {}}): result = get_config_agent_env() assert result is None @@ -1064,7 +1165,7 @@ def test_returns_agent_env(self) -> None: }, }, } - with patch("agent_cli.dev.launch.load_config", return_value=config): + with patch("agent_cli.dev._config.get_runtime_config", return_value=config): result = get_config_agent_env() assert result == {"claude": {"CLAUDE_CODE_USE_VERTEX": "1", "ANTHROPIC_MODEL": "opus"}} @@ -1075,7 +1176,7 @@ def test_returns_agent_env_from_flattened_keys(self) -> None: "dev.agent_env.claude": {"CLAUDE_CODE_USE_VERTEX": "1", "ANTHROPIC_MODEL": "opus"}, "dev.agent_env.aider": {"OPENAI_API_KEY": "sk-xxx"}, } - with patch("agent_cli.dev.launch.load_config", return_value=config): + with patch("agent_cli.dev._config.get_runtime_config", return_value=config): result = get_config_agent_env() assert result == { "claude": {"CLAUDE_CODE_USE_VERTEX": "1", "ANTHROPIC_MODEL": "opus"}, diff --git a/tests/dev/test_coding_agents.py b/tests/dev/test_coding_agents.py index 7a5535de9..cdc63a175 100644 --- a/tests/dev/test_coding_agents.py +++ b/tests/dev/test_coding_agents.py @@ -16,6 +16,11 @@ ) from agent_cli.dev.coding_agents.aider import Aider from agent_cli.dev.coding_agents.claude import ClaudeCode +from agent_cli.dev.coding_agents.codex import ( + Codex, + _ensure_project_trusted, + _project_section_header, +) from agent_cli.dev.coding_agents.cursor_agent import CursorAgent @@ -160,6 +165,61 @@ def test_falls_back_to_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatc assert exe == "/usr/bin/claude" +class TestCodexTrustPreparation: + """Tests for Codex trust preparation.""" + + def test_ensure_project_trusted_appends_section_when_missing(self, tmp_path: Path) -> None: + """Missing project sections should be added to config.toml.""" + project_path = tmp_path / "repo-worktrees" / "feature" + config_path = tmp_path / ".codex" / "config.toml" + + changed = _ensure_project_trusted(project_path, config_path=config_path) + + assert changed is True + content = config_path.read_text() + assert _project_section_header(project_path.resolve()) in content + assert 'trust_level = "trusted"' in content + + def test_ensure_project_trusted_is_noop_when_already_trusted(self, tmp_path: Path) -> None: + """Existing trusted projects should not be rewritten.""" + project_path = tmp_path / "repo-worktrees" / "feature" + config_path = tmp_path / ".codex" / "config.toml" + config_path.parent.mkdir(parents=True) + original = f'{_project_section_header(project_path.resolve())}\ntrust_level = "trusted"\n' + config_path.write_text(original) + + changed = _ensure_project_trusted(project_path, config_path=config_path) + + assert changed is False + assert config_path.read_text() == original + + def test_ensure_project_trusted_rejects_conflicting_setting(self, tmp_path: Path) -> None: + """Existing non-trusted entries should fail loudly.""" + project_path = tmp_path / "repo-worktrees" / "feature" + config_path = tmp_path / ".codex" / "config.toml" + config_path.parent.mkdir(parents=True) + config_path.write_text( + f'{_project_section_header(project_path.resolve())}\ntrust_level = "untrusted"\n', + ) + + with pytest.raises(RuntimeError, match="disable \\[dev\\]\\.auto_trust"): + _ensure_project_trusted(project_path, config_path=config_path) + + def test_prepare_launch_returns_message_when_trust_added(self, tmp_path: Path) -> None: + """prepare_launch should report when it updated trust config.""" + repo_root = tmp_path / "repo" + worktree_path = tmp_path / "repo-worktrees" / "feature" + config_path = tmp_path / ".codex" / "config.toml" + config_path.parent.mkdir(parents=True) + + with patch("agent_cli.dev.coding_agents.codex.CODEX_CONFIG_PATH", config_path): + agent = Codex() + message = agent.prepare_launch(worktree_path, repo_root) + + assert message == f"Trusted {repo_root.resolve()} in Codex config" + assert _project_section_header(repo_root.resolve()) in config_path.read_text() + + class TestRegistry: """Tests for agent registry functions.""" diff --git a/tests/dev/test_hooks.py b/tests/dev/test_hooks.py new file mode 100644 index 000000000..d57b7848e --- /dev/null +++ b/tests/dev/test_hooks.py @@ -0,0 +1,154 @@ +"""Tests for dev launch preparation hooks.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Any, cast +from unittest.mock import MagicMock, patch + +import pytest + +from agent_cli.dev.hooks import LaunchContext, prepare_agent_launch + + +def _context(tmp_path: Path) -> LaunchContext: + worktree_path = tmp_path / "worktree" + worktree_path.mkdir(parents=True) + task_file = worktree_path / ".claude" / "TASK.md" + task_file.parent.mkdir(parents=True) + + agent = MagicMock() + agent.name = "codex" + agent.prepare_launch.return_value = None + return LaunchContext( + agent=agent, + worktree_path=worktree_path, + repo_root=tmp_path / "repo", + branch="feature", + worktree_name="feature", + task_file=task_file, + agent_env={"CLAUDE_CODE_USE_VERTEX": "1"}, + ) + + +class TestPrepareAgentLaunch: + """Tests for launch preparation.""" + + def test_skips_everything_when_disabled(self, tmp_path: Path) -> None: + """`hooks_enabled=False` should bypass trust prep and user hooks.""" + context = _context(tmp_path) + mock_prepare = cast("Any", context.agent.prepare_launch) + + with ( + patch("agent_cli.dev._config.get_runtime_config") as mock_get_runtime_config, + patch("agent_cli.dev.hooks.subprocess.run") as mock_run, + ): + prepare_agent_launch(context, hooks_enabled=False) + + mock_prepare.assert_not_called() + mock_get_runtime_config.assert_not_called() + mock_run.assert_not_called() + + def test_runs_auto_trust_and_global_then_agent_hooks(self, tmp_path: Path) -> None: + """Preparation should run built-in trust then global and per-agent hooks.""" + context = _context(tmp_path) + mock_prepare = cast("Any", context.agent.prepare_launch) + mock_prepare.return_value = "Trusted repo" + home = tmp_path / "home" + home.mkdir() + hook_path = home / "pre-launch.sh" + hook_path.write_text("#!/bin/sh\nexit 0\n") + expected_hook = hook_path + + with ( + patch("agent_cli.dev._config.get_runtime_config") as mock_get_runtime_config, + patch( + "agent_cli.dev.hooks.subprocess.run", + return_value=subprocess.CompletedProcess([], 0, "", ""), + ) as mock_run, + patch("agent_cli.dev.hooks.info"), + patch.dict( + "os.environ", + {"HOME": str(home), "USERPROFILE": str(home)}, + clear=False, + ), + ): + mock_get_runtime_config.return_value = { + "dev": {"auto_trust": True}, + "dev.hooks": {"pre_launch": ["~/pre-launch.sh"]}, + "dev.hooks.codex": {"pre_launch": ["codex-hook --flag"]}, + } + expected_hook = Path("~/pre-launch.sh").expanduser() + prepare_agent_launch(context) + + mock_prepare.assert_called_once_with( + context.worktree_path, + context.repo_root, + ) + assert mock_run.call_count == 2 + + first_call = mock_run.call_args_list[0] + assert first_call.args[0] == [str(expected_hook)] + assert first_call.kwargs["cwd"] == context.worktree_path + assert first_call.kwargs["env"]["AGENT_CLI_AGENT"] == "codex" + assert first_call.kwargs["env"]["AGENT_CLI_REPO_ROOT"] == str(context.repo_root) + assert first_call.kwargs["env"]["AGENT_CLI_WORKTREE"] == str(context.worktree_path) + assert first_call.kwargs["env"]["AGENT_CLI_TASK_FILE"] == str(context.task_file) + assert first_call.kwargs["env"]["CLAUDE_CODE_USE_VERTEX"] == "1" + + second_call = mock_run.call_args_list[1] + assert second_call.args[0] == ["codex-hook", "--flag"] + + def test_respects_auto_trust_false(self, tmp_path: Path) -> None: + """Hook commands can run even when auto-trust is disabled.""" + context = _context(tmp_path) + mock_prepare = cast("Any", context.agent.prepare_launch) + + with ( + patch( + "agent_cli.dev._config.get_runtime_config", + return_value={ + "dev": {"auto_trust": False}, + "dev.hooks": {"pre_launch": ["hook"]}, + }, + ), + patch( + "agent_cli.dev.hooks.subprocess.run", + return_value=subprocess.CompletedProcess([], 0, "", ""), + ) as mock_run, + ): + prepare_agent_launch(context) + + mock_prepare.assert_not_called() + mock_run.assert_called_once() + + def test_raises_on_hook_failure(self, tmp_path: Path) -> None: + """Non-zero hook exit should abort preparation.""" + context = _context(tmp_path) + + with ( + patch( + "agent_cli.dev._config.get_runtime_config", + return_value={"dev.hooks": {"pre_launch": ["broken-hook"]}}, + ), + patch( + "agent_cli.dev.hooks.subprocess.run", + return_value=subprocess.CompletedProcess([], 7, "", "boom"), + ), + pytest.raises(RuntimeError, match="Pre-launch hook failed"), + ): + prepare_agent_launch(context) + + def test_rejects_invalid_hook_config(self, tmp_path: Path) -> None: + """Hook config must be a string or list of strings.""" + context = _context(tmp_path) + + with ( + patch( + "agent_cli.dev._config.get_runtime_config", + return_value={"dev.hooks": {"pre_launch": {"not": "valid"}}}, + ), + pytest.raises(RuntimeError, match=r"\[dev\.hooks\]\.pre_launch"), + ): + prepare_agent_launch(context)