Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/skills/agent-cli-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/agent-cli-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions agent_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -93,22 +93,23 @@ 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", {}))

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":
command_key = f"{ctx.parent.command.name}.{command_key}"

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
Expand Down
1 change: 1 addition & 0 deletions agent_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions agent_cli/dev/_config.py
Original file line number Diff line number Diff line change
@@ -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.<name>]` 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.<name>.<child>]` 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.<name>.<child>]` table."""
return get_dev_child_tables(name).get(child, {})
45 changes: 44 additions & 1 deletion agent_cli/dev/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"].
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions agent_cli/dev/coding_agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})>"
Expand Down
68 changes: 68 additions & 0 deletions agent_cli/dev/coding_agents/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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
Loading
Loading