agent-cli 0.70.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_cli/__init__.py +5 -0
- agent_cli/__main__.py +6 -0
- agent_cli/_extras.json +14 -0
- agent_cli/_requirements/.gitkeep +0 -0
- agent_cli/_requirements/audio.txt +79 -0
- agent_cli/_requirements/faster-whisper.txt +215 -0
- agent_cli/_requirements/kokoro.txt +425 -0
- agent_cli/_requirements/llm.txt +183 -0
- agent_cli/_requirements/memory.txt +355 -0
- agent_cli/_requirements/mlx-whisper.txt +222 -0
- agent_cli/_requirements/piper.txt +176 -0
- agent_cli/_requirements/rag.txt +402 -0
- agent_cli/_requirements/server.txt +154 -0
- agent_cli/_requirements/speed.txt +77 -0
- agent_cli/_requirements/vad.txt +155 -0
- agent_cli/_requirements/wyoming.txt +71 -0
- agent_cli/_tools.py +368 -0
- agent_cli/agents/__init__.py +23 -0
- agent_cli/agents/_voice_agent_common.py +136 -0
- agent_cli/agents/assistant.py +383 -0
- agent_cli/agents/autocorrect.py +284 -0
- agent_cli/agents/chat.py +496 -0
- agent_cli/agents/memory/__init__.py +31 -0
- agent_cli/agents/memory/add.py +190 -0
- agent_cli/agents/memory/proxy.py +160 -0
- agent_cli/agents/rag_proxy.py +128 -0
- agent_cli/agents/speak.py +209 -0
- agent_cli/agents/transcribe.py +671 -0
- agent_cli/agents/transcribe_daemon.py +499 -0
- agent_cli/agents/voice_edit.py +291 -0
- agent_cli/api.py +22 -0
- agent_cli/cli.py +106 -0
- agent_cli/config.py +503 -0
- agent_cli/config_cmd.py +307 -0
- agent_cli/constants.py +27 -0
- agent_cli/core/__init__.py +1 -0
- agent_cli/core/audio.py +461 -0
- agent_cli/core/audio_format.py +299 -0
- agent_cli/core/chroma.py +88 -0
- agent_cli/core/deps.py +191 -0
- agent_cli/core/openai_proxy.py +139 -0
- agent_cli/core/process.py +195 -0
- agent_cli/core/reranker.py +120 -0
- agent_cli/core/sse.py +87 -0
- agent_cli/core/transcription_logger.py +70 -0
- agent_cli/core/utils.py +526 -0
- agent_cli/core/vad.py +175 -0
- agent_cli/core/watch.py +65 -0
- agent_cli/dev/__init__.py +14 -0
- agent_cli/dev/cli.py +1588 -0
- agent_cli/dev/coding_agents/__init__.py +19 -0
- agent_cli/dev/coding_agents/aider.py +24 -0
- agent_cli/dev/coding_agents/base.py +167 -0
- agent_cli/dev/coding_agents/claude.py +39 -0
- agent_cli/dev/coding_agents/codex.py +24 -0
- agent_cli/dev/coding_agents/continue_dev.py +15 -0
- agent_cli/dev/coding_agents/copilot.py +24 -0
- agent_cli/dev/coding_agents/cursor_agent.py +48 -0
- agent_cli/dev/coding_agents/gemini.py +28 -0
- agent_cli/dev/coding_agents/opencode.py +15 -0
- agent_cli/dev/coding_agents/registry.py +49 -0
- agent_cli/dev/editors/__init__.py +19 -0
- agent_cli/dev/editors/base.py +89 -0
- agent_cli/dev/editors/cursor.py +15 -0
- agent_cli/dev/editors/emacs.py +46 -0
- agent_cli/dev/editors/jetbrains.py +56 -0
- agent_cli/dev/editors/nano.py +31 -0
- agent_cli/dev/editors/neovim.py +33 -0
- agent_cli/dev/editors/registry.py +59 -0
- agent_cli/dev/editors/sublime.py +20 -0
- agent_cli/dev/editors/vim.py +42 -0
- agent_cli/dev/editors/vscode.py +15 -0
- agent_cli/dev/editors/zed.py +20 -0
- agent_cli/dev/project.py +568 -0
- agent_cli/dev/registry.py +52 -0
- agent_cli/dev/skill/SKILL.md +141 -0
- agent_cli/dev/skill/examples.md +571 -0
- agent_cli/dev/terminals/__init__.py +19 -0
- agent_cli/dev/terminals/apple_terminal.py +82 -0
- agent_cli/dev/terminals/base.py +56 -0
- agent_cli/dev/terminals/gnome.py +51 -0
- agent_cli/dev/terminals/iterm2.py +84 -0
- agent_cli/dev/terminals/kitty.py +77 -0
- agent_cli/dev/terminals/registry.py +48 -0
- agent_cli/dev/terminals/tmux.py +58 -0
- agent_cli/dev/terminals/warp.py +132 -0
- agent_cli/dev/terminals/zellij.py +78 -0
- agent_cli/dev/worktree.py +856 -0
- agent_cli/docs_gen.py +417 -0
- agent_cli/example-config.toml +185 -0
- agent_cli/install/__init__.py +5 -0
- agent_cli/install/common.py +89 -0
- agent_cli/install/extras.py +174 -0
- agent_cli/install/hotkeys.py +48 -0
- agent_cli/install/services.py +87 -0
- agent_cli/memory/__init__.py +7 -0
- agent_cli/memory/_files.py +250 -0
- agent_cli/memory/_filters.py +63 -0
- agent_cli/memory/_git.py +157 -0
- agent_cli/memory/_indexer.py +142 -0
- agent_cli/memory/_ingest.py +408 -0
- agent_cli/memory/_persistence.py +182 -0
- agent_cli/memory/_prompt.py +91 -0
- agent_cli/memory/_retrieval.py +294 -0
- agent_cli/memory/_store.py +169 -0
- agent_cli/memory/_streaming.py +44 -0
- agent_cli/memory/_tasks.py +48 -0
- agent_cli/memory/api.py +113 -0
- agent_cli/memory/client.py +272 -0
- agent_cli/memory/engine.py +361 -0
- agent_cli/memory/entities.py +43 -0
- agent_cli/memory/models.py +112 -0
- agent_cli/opts.py +433 -0
- agent_cli/py.typed +0 -0
- agent_cli/rag/__init__.py +3 -0
- agent_cli/rag/_indexer.py +67 -0
- agent_cli/rag/_indexing.py +226 -0
- agent_cli/rag/_prompt.py +30 -0
- agent_cli/rag/_retriever.py +156 -0
- agent_cli/rag/_store.py +48 -0
- agent_cli/rag/_utils.py +218 -0
- agent_cli/rag/api.py +175 -0
- agent_cli/rag/client.py +299 -0
- agent_cli/rag/engine.py +302 -0
- agent_cli/rag/models.py +55 -0
- agent_cli/scripts/.runtime/.gitkeep +0 -0
- agent_cli/scripts/__init__.py +1 -0
- agent_cli/scripts/check_plugin_skill_sync.py +50 -0
- agent_cli/scripts/linux-hotkeys/README.md +63 -0
- agent_cli/scripts/linux-hotkeys/toggle-autocorrect.sh +45 -0
- agent_cli/scripts/linux-hotkeys/toggle-transcription.sh +58 -0
- agent_cli/scripts/linux-hotkeys/toggle-voice-edit.sh +58 -0
- agent_cli/scripts/macos-hotkeys/README.md +45 -0
- agent_cli/scripts/macos-hotkeys/skhd-config-example +5 -0
- agent_cli/scripts/macos-hotkeys/toggle-autocorrect.sh +12 -0
- agent_cli/scripts/macos-hotkeys/toggle-transcription.sh +37 -0
- agent_cli/scripts/macos-hotkeys/toggle-voice-edit.sh +37 -0
- agent_cli/scripts/nvidia-asr-server/README.md +99 -0
- agent_cli/scripts/nvidia-asr-server/pyproject.toml +27 -0
- agent_cli/scripts/nvidia-asr-server/server.py +255 -0
- agent_cli/scripts/nvidia-asr-server/shell.nix +32 -0
- agent_cli/scripts/nvidia-asr-server/uv.lock +4654 -0
- agent_cli/scripts/run-openwakeword.sh +11 -0
- agent_cli/scripts/run-piper-windows.ps1 +30 -0
- agent_cli/scripts/run-piper.sh +24 -0
- agent_cli/scripts/run-whisper-linux.sh +40 -0
- agent_cli/scripts/run-whisper-macos.sh +6 -0
- agent_cli/scripts/run-whisper-windows.ps1 +51 -0
- agent_cli/scripts/run-whisper.sh +9 -0
- agent_cli/scripts/run_faster_whisper_server.py +136 -0
- agent_cli/scripts/setup-linux-hotkeys.sh +72 -0
- agent_cli/scripts/setup-linux.sh +108 -0
- agent_cli/scripts/setup-macos-hotkeys.sh +61 -0
- agent_cli/scripts/setup-macos.sh +76 -0
- agent_cli/scripts/setup-windows.ps1 +63 -0
- agent_cli/scripts/start-all-services-windows.ps1 +53 -0
- agent_cli/scripts/start-all-services.sh +178 -0
- agent_cli/scripts/sync_extras.py +138 -0
- agent_cli/server/__init__.py +3 -0
- agent_cli/server/cli.py +721 -0
- agent_cli/server/common.py +222 -0
- agent_cli/server/model_manager.py +288 -0
- agent_cli/server/model_registry.py +225 -0
- agent_cli/server/proxy/__init__.py +3 -0
- agent_cli/server/proxy/api.py +444 -0
- agent_cli/server/streaming.py +67 -0
- agent_cli/server/tts/__init__.py +3 -0
- agent_cli/server/tts/api.py +335 -0
- agent_cli/server/tts/backends/__init__.py +82 -0
- agent_cli/server/tts/backends/base.py +139 -0
- agent_cli/server/tts/backends/kokoro.py +403 -0
- agent_cli/server/tts/backends/piper.py +253 -0
- agent_cli/server/tts/model_manager.py +201 -0
- agent_cli/server/tts/model_registry.py +28 -0
- agent_cli/server/tts/wyoming_handler.py +249 -0
- agent_cli/server/whisper/__init__.py +3 -0
- agent_cli/server/whisper/api.py +413 -0
- agent_cli/server/whisper/backends/__init__.py +89 -0
- agent_cli/server/whisper/backends/base.py +97 -0
- agent_cli/server/whisper/backends/faster_whisper.py +225 -0
- agent_cli/server/whisper/backends/mlx.py +270 -0
- agent_cli/server/whisper/languages.py +116 -0
- agent_cli/server/whisper/model_manager.py +157 -0
- agent_cli/server/whisper/model_registry.py +28 -0
- agent_cli/server/whisper/wyoming_handler.py +203 -0
- agent_cli/services/__init__.py +343 -0
- agent_cli/services/_wyoming_utils.py +64 -0
- agent_cli/services/asr.py +506 -0
- agent_cli/services/llm.py +228 -0
- agent_cli/services/tts.py +450 -0
- agent_cli/services/wake_word.py +142 -0
- agent_cli-0.70.5.dist-info/METADATA +2118 -0
- agent_cli-0.70.5.dist-info/RECORD +196 -0
- agent_cli-0.70.5.dist-info/WHEEL +4 -0
- agent_cli-0.70.5.dist-info/entry_points.txt +4 -0
- agent_cli-0.70.5.dist-info/licenses/LICENSE +21 -0
agent_cli/dev/cli.py
ADDED
|
@@ -0,0 +1,1588 @@
|
|
|
1
|
+
"""CLI commands for the dev module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Annotated, NoReturn
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from agent_cli.cli import app as main_app
|
|
20
|
+
from agent_cli.cli import set_config_defaults
|
|
21
|
+
from agent_cli.config import load_config
|
|
22
|
+
from agent_cli.core.process import set_process_title
|
|
23
|
+
from agent_cli.core.utils import console, err_console
|
|
24
|
+
|
|
25
|
+
# Word lists for generating random branch names (like Docker container names)
|
|
26
|
+
_ADJECTIVES = [
|
|
27
|
+
"happy",
|
|
28
|
+
"clever",
|
|
29
|
+
"swift",
|
|
30
|
+
"bright",
|
|
31
|
+
"calm",
|
|
32
|
+
"eager",
|
|
33
|
+
"fancy",
|
|
34
|
+
"gentle",
|
|
35
|
+
"jolly",
|
|
36
|
+
"keen",
|
|
37
|
+
"lively",
|
|
38
|
+
"merry",
|
|
39
|
+
"nice",
|
|
40
|
+
"proud",
|
|
41
|
+
"quick",
|
|
42
|
+
"sharp",
|
|
43
|
+
"smart",
|
|
44
|
+
"sunny",
|
|
45
|
+
"witty",
|
|
46
|
+
"zesty",
|
|
47
|
+
"bold",
|
|
48
|
+
"cool",
|
|
49
|
+
"fresh",
|
|
50
|
+
"grand",
|
|
51
|
+
]
|
|
52
|
+
_NOUNS = [
|
|
53
|
+
"fox",
|
|
54
|
+
"owl",
|
|
55
|
+
"bear",
|
|
56
|
+
"wolf",
|
|
57
|
+
"hawk",
|
|
58
|
+
"lion",
|
|
59
|
+
"tiger",
|
|
60
|
+
"eagle",
|
|
61
|
+
"falcon",
|
|
62
|
+
"otter",
|
|
63
|
+
"panda",
|
|
64
|
+
"raven",
|
|
65
|
+
"shark",
|
|
66
|
+
"whale",
|
|
67
|
+
"zebra",
|
|
68
|
+
"bison",
|
|
69
|
+
"crane",
|
|
70
|
+
"dolphin",
|
|
71
|
+
"gecko",
|
|
72
|
+
"heron",
|
|
73
|
+
"koala",
|
|
74
|
+
"lemur",
|
|
75
|
+
"moose",
|
|
76
|
+
"newt",
|
|
77
|
+
"oriole",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _generate_branch_name(existing_branches: set[str] | None = None) -> str:
|
|
82
|
+
"""Generate a unique random branch name like 'clever-fox'.
|
|
83
|
+
|
|
84
|
+
If the name already exists, adds a numeric suffix (clever-fox-2).
|
|
85
|
+
"""
|
|
86
|
+
existing = existing_branches or set()
|
|
87
|
+
base = f"{random.choice(_ADJECTIVES)}-{random.choice(_NOUNS)}" # noqa: S311
|
|
88
|
+
|
|
89
|
+
if base not in existing:
|
|
90
|
+
return base
|
|
91
|
+
|
|
92
|
+
# Add numeric suffix to avoid collision
|
|
93
|
+
for i in range(2, 100):
|
|
94
|
+
candidate = f"{base}-{i}"
|
|
95
|
+
if candidate not in existing:
|
|
96
|
+
return candidate
|
|
97
|
+
|
|
98
|
+
# Fallback: add random digits
|
|
99
|
+
return f"{base}-{random.randint(100, 999)}" # noqa: S311
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
from . import coding_agents, editors, terminals, worktree # noqa: E402
|
|
103
|
+
from .project import ( # noqa: E402
|
|
104
|
+
copy_env_files,
|
|
105
|
+
detect_project_type,
|
|
106
|
+
is_direnv_available,
|
|
107
|
+
run_setup,
|
|
108
|
+
setup_direnv,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if TYPE_CHECKING:
|
|
112
|
+
from .coding_agents.base import CodingAgent
|
|
113
|
+
from .editors.base import Editor
|
|
114
|
+
|
|
115
|
+
app = typer.Typer(
|
|
116
|
+
name="dev",
|
|
117
|
+
help="Parallel development environment manager using git worktrees.",
|
|
118
|
+
add_completion=True,
|
|
119
|
+
rich_markup_mode="markdown",
|
|
120
|
+
no_args_is_help=True,
|
|
121
|
+
)
|
|
122
|
+
main_app.add_typer(app, name="dev", rich_help_panel="Development")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.callback()
|
|
126
|
+
def dev_callback(
|
|
127
|
+
ctx: typer.Context,
|
|
128
|
+
config_file: Annotated[
|
|
129
|
+
str | None,
|
|
130
|
+
typer.Option("--config", "-c", help="Path to config file"),
|
|
131
|
+
] = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Parallel development environment manager using git worktrees."""
|
|
134
|
+
set_config_defaults(ctx, config_file)
|
|
135
|
+
if ctx.invoked_subcommand is not None:
|
|
136
|
+
set_process_title(f"dev-{ctx.invoked_subcommand}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _error(msg: str) -> NoReturn:
|
|
140
|
+
"""Print an error message and exit."""
|
|
141
|
+
err_console.print(f"[bold red]Error:[/bold red] {msg}")
|
|
142
|
+
raise typer.Exit(1)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _success(msg: str) -> None:
|
|
146
|
+
"""Print a success message."""
|
|
147
|
+
console.print(f"[bold green]✓[/bold green] {msg}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _info(msg: str) -> None:
|
|
151
|
+
"""Print an info message, with special styling for commands."""
|
|
152
|
+
# Style commands (messages starting with "Running: ")
|
|
153
|
+
if msg.startswith("Running: "):
|
|
154
|
+
cmd = msg[9:] # Remove "Running: " prefix
|
|
155
|
+
console.print(f"[dim]→[/dim] Running: [bold cyan]{cmd}[/bold cyan]")
|
|
156
|
+
else:
|
|
157
|
+
console.print(f"[dim]→[/dim] {msg}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _warn(msg: str) -> None:
|
|
161
|
+
"""Print a warning message."""
|
|
162
|
+
console.print(f"[yellow]Warning:[/yellow] {msg}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _ensure_git_repo() -> Path:
|
|
166
|
+
"""Ensure we're in a git repository and return the repo root."""
|
|
167
|
+
if not worktree.git_available():
|
|
168
|
+
_error("Git is not installed or not in PATH")
|
|
169
|
+
|
|
170
|
+
repo_root = worktree.get_main_repo_root()
|
|
171
|
+
if repo_root is None:
|
|
172
|
+
_error("Not in a git repository")
|
|
173
|
+
|
|
174
|
+
return repo_root
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _resolve_editor(
|
|
178
|
+
use_editor: bool,
|
|
179
|
+
editor_name: str | None,
|
|
180
|
+
default_editor: str | None = None,
|
|
181
|
+
) -> Editor | None:
|
|
182
|
+
"""Resolve which editor to use based on flags and config defaults."""
|
|
183
|
+
# Use explicit name if provided
|
|
184
|
+
if editor_name:
|
|
185
|
+
editor = editors.get_editor(editor_name)
|
|
186
|
+
if editor is None:
|
|
187
|
+
_warn(f"Editor '{editor_name}' not found")
|
|
188
|
+
return editor
|
|
189
|
+
|
|
190
|
+
# If no flag and no default, don't use an editor
|
|
191
|
+
if not use_editor and not default_editor:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
# If default is set in config, use it
|
|
195
|
+
if default_editor:
|
|
196
|
+
editor = editors.get_editor(default_editor)
|
|
197
|
+
if editor is not None:
|
|
198
|
+
return editor
|
|
199
|
+
_warn(f"Default editor '{default_editor}' from config not found")
|
|
200
|
+
|
|
201
|
+
# Auto-detect current or first available
|
|
202
|
+
editor = editors.detect_current_editor()
|
|
203
|
+
if editor is None:
|
|
204
|
+
available = editors.get_available_editors()
|
|
205
|
+
return available[0] if available else None
|
|
206
|
+
return editor
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _resolve_agent(
|
|
210
|
+
use_agent: bool,
|
|
211
|
+
agent_name: str | None,
|
|
212
|
+
default_agent: str | None = None,
|
|
213
|
+
) -> CodingAgent | None:
|
|
214
|
+
"""Resolve which coding agent to use based on flags and config defaults."""
|
|
215
|
+
# Use explicit name if provided
|
|
216
|
+
if agent_name:
|
|
217
|
+
agent = coding_agents.get_agent(agent_name)
|
|
218
|
+
if agent is None:
|
|
219
|
+
_warn(f"Agent '{agent_name}' not found")
|
|
220
|
+
return agent
|
|
221
|
+
|
|
222
|
+
# If no flag and no default, don't use an agent
|
|
223
|
+
if not use_agent and not default_agent:
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
# If default is set in config, use it
|
|
227
|
+
if default_agent:
|
|
228
|
+
agent = coding_agents.get_agent(default_agent)
|
|
229
|
+
if agent is not None:
|
|
230
|
+
return agent
|
|
231
|
+
_warn(f"Default agent '{default_agent}' from config not found")
|
|
232
|
+
|
|
233
|
+
# Auto-detect current or first available
|
|
234
|
+
agent = coding_agents.detect_current_agent()
|
|
235
|
+
if agent is None:
|
|
236
|
+
available = coding_agents.get_available_agents()
|
|
237
|
+
return available[0] if available else None
|
|
238
|
+
return agent
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _get_config_agent_args() -> dict[str, list[str]] | None:
|
|
242
|
+
"""Load agent_args from config file.
|
|
243
|
+
|
|
244
|
+
Config format:
|
|
245
|
+
[dev.agent_args]
|
|
246
|
+
claude = ["--dangerously-skip-permissions"]
|
|
247
|
+
|
|
248
|
+
Note: The config loader may flatten section names, so we check both
|
|
249
|
+
nested structure and flattened 'dev.agent_args' key.
|
|
250
|
+
"""
|
|
251
|
+
config = load_config(None)
|
|
252
|
+
|
|
253
|
+
# First try the simple nested structure (for testing/mocks)
|
|
254
|
+
dev_config = config.get("dev", {})
|
|
255
|
+
if isinstance(dev_config, dict) and "agent_args" in dev_config:
|
|
256
|
+
return dev_config["agent_args"]
|
|
257
|
+
|
|
258
|
+
# Handle flattened key "dev.agent_args"
|
|
259
|
+
return config.get("dev.agent_args")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _get_config_agent_env() -> dict[str, dict[str, str]] | None:
|
|
263
|
+
"""Load agent_env from config file.
|
|
264
|
+
|
|
265
|
+
Config format:
|
|
266
|
+
[dev.agent_env]
|
|
267
|
+
claude = { CLAUDE_CODE_USE_VERTEX = "1", ANTHROPIC_MODEL = "opus" }
|
|
268
|
+
|
|
269
|
+
Note: The config loader flattens nested dicts, so keys like
|
|
270
|
+
'dev.agent_env.claude' become top-level. We reconstruct the
|
|
271
|
+
agent_env dict from these flattened keys.
|
|
272
|
+
"""
|
|
273
|
+
config = load_config(None)
|
|
274
|
+
|
|
275
|
+
# First try the simple nested structure (for testing/mocks)
|
|
276
|
+
dev_config = config.get("dev", {})
|
|
277
|
+
if isinstance(dev_config, dict) and "agent_env" in dev_config:
|
|
278
|
+
return dev_config["agent_env"]
|
|
279
|
+
|
|
280
|
+
# Handle flattened keys like "dev.agent_env.claude"
|
|
281
|
+
prefix = "dev.agent_env."
|
|
282
|
+
result: dict[str, dict[str, str]] = {}
|
|
283
|
+
for key, value in config.items():
|
|
284
|
+
if key.startswith(prefix) and isinstance(value, dict):
|
|
285
|
+
agent_name = key[len(prefix) :]
|
|
286
|
+
result[agent_name] = value
|
|
287
|
+
|
|
288
|
+
return result if result else None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _get_agent_env(agent: CodingAgent) -> dict[str, str]:
|
|
292
|
+
"""Get environment variables for an agent.
|
|
293
|
+
|
|
294
|
+
Merges config env vars with agent's built-in env vars.
|
|
295
|
+
Config env vars take precedence.
|
|
296
|
+
"""
|
|
297
|
+
# Start with agent's built-in env vars
|
|
298
|
+
env = agent.get_env().copy()
|
|
299
|
+
|
|
300
|
+
# Add config env vars (these override built-in ones)
|
|
301
|
+
config_env = _get_config_agent_env()
|
|
302
|
+
if config_env and agent.name in config_env:
|
|
303
|
+
env.update(config_env[agent.name])
|
|
304
|
+
|
|
305
|
+
return env
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _merge_agent_args(
|
|
309
|
+
agent: CodingAgent,
|
|
310
|
+
cli_args: list[str] | None,
|
|
311
|
+
) -> list[str] | None:
|
|
312
|
+
"""Merge CLI args with config args for an agent.
|
|
313
|
+
|
|
314
|
+
Config args are applied first, CLI args are appended (and can override).
|
|
315
|
+
"""
|
|
316
|
+
config_args = _get_config_agent_args()
|
|
317
|
+
result: list[str] = []
|
|
318
|
+
|
|
319
|
+
# Add config args for this agent
|
|
320
|
+
if config_args and agent.name in config_args:
|
|
321
|
+
result.extend(config_args[agent.name])
|
|
322
|
+
|
|
323
|
+
# Add CLI args (these override/extend config args)
|
|
324
|
+
if cli_args:
|
|
325
|
+
result.extend(cli_args)
|
|
326
|
+
|
|
327
|
+
return result if result else None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _is_ssh_session() -> bool:
|
|
331
|
+
"""Check if we're in an SSH session."""
|
|
332
|
+
return bool(os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_CLIENT"))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _launch_editor(path: Path, editor: Editor) -> None:
|
|
336
|
+
"""Launch editor via subprocess (editors are GUI apps that detach)."""
|
|
337
|
+
try:
|
|
338
|
+
subprocess.Popen(editor.open_command(path))
|
|
339
|
+
_success(f"Opened {editor.name}")
|
|
340
|
+
except Exception as e:
|
|
341
|
+
_warn(f"Could not open editor: {e}")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _write_prompt_to_worktree(worktree_path: Path, prompt: str) -> Path:
|
|
345
|
+
"""Write the prompt to .claude/TASK.md in the worktree.
|
|
346
|
+
|
|
347
|
+
This makes the task description available to the spawned agent
|
|
348
|
+
and provides a record of what was requested.
|
|
349
|
+
"""
|
|
350
|
+
claude_dir = worktree_path / ".claude"
|
|
351
|
+
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
352
|
+
task_file = claude_dir / "TASK.md"
|
|
353
|
+
task_file.write_text(prompt + "\n")
|
|
354
|
+
return task_file
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _format_env_prefix(env: dict[str, str]) -> str:
|
|
358
|
+
"""Format environment variables as shell prefix.
|
|
359
|
+
|
|
360
|
+
Returns a string like 'VAR1=value1 VAR2=value2 ' that can be
|
|
361
|
+
prepended to a command.
|
|
362
|
+
"""
|
|
363
|
+
if not env:
|
|
364
|
+
return ""
|
|
365
|
+
# Quote values that contain spaces or special characters
|
|
366
|
+
parts = [f"{k}={shlex.quote(v)}" for k, v in sorted(env.items())]
|
|
367
|
+
return " ".join(parts) + " "
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _generate_heredoc_delimiter() -> str:
|
|
371
|
+
"""Generate a unique heredoc delimiter using UUID."""
|
|
372
|
+
import uuid # noqa: PLC0415
|
|
373
|
+
|
|
374
|
+
return f"PROMPT_{uuid.uuid4().hex[:12]}"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _create_prompt_wrapper_script(
|
|
378
|
+
worktree_path: Path,
|
|
379
|
+
agent: CodingAgent,
|
|
380
|
+
prompt: str,
|
|
381
|
+
extra_args: list[str] | None = None,
|
|
382
|
+
env: dict[str, str] | None = None,
|
|
383
|
+
) -> Path:
|
|
384
|
+
"""Create a wrapper script that launches the agent with the prompt.
|
|
385
|
+
|
|
386
|
+
Uses a heredoc with quoted delimiter to avoid ALL shell interpretation
|
|
387
|
+
of special characters ($, !, `, etc.) in the prompt content.
|
|
388
|
+
|
|
389
|
+
Script is written to a temp directory to avoid polluting the worktree.
|
|
390
|
+
"""
|
|
391
|
+
script_path = Path(tempfile.gettempdir()) / f"agent-cli-{worktree_path.name}.sh"
|
|
392
|
+
delimiter = _generate_heredoc_delimiter()
|
|
393
|
+
|
|
394
|
+
# Build the agent command without the prompt
|
|
395
|
+
exe = agent.get_executable()
|
|
396
|
+
if exe is None:
|
|
397
|
+
msg = f"{agent.name} is not installed"
|
|
398
|
+
raise RuntimeError(msg)
|
|
399
|
+
|
|
400
|
+
cmd_parts = [shlex.quote(exe)]
|
|
401
|
+
if extra_args:
|
|
402
|
+
cmd_parts.extend(shlex.quote(arg) for arg in extra_args)
|
|
403
|
+
|
|
404
|
+
agent_cmd = " ".join(cmd_parts)
|
|
405
|
+
env_prefix = _format_env_prefix(env or {})
|
|
406
|
+
|
|
407
|
+
# Create script with heredoc - quoted delimiter prevents all shell expansion
|
|
408
|
+
script_content = f"""#!/usr/bin/env bash
|
|
409
|
+
# Auto-generated script to launch agent with prompt
|
|
410
|
+
# The heredoc with quoted delimiter (<<'{delimiter}') prevents shell interpretation
|
|
411
|
+
{env_prefix}exec {agent_cmd} "$(cat <<'{delimiter}'
|
|
412
|
+
{prompt}
|
|
413
|
+
{delimiter}
|
|
414
|
+
)"
|
|
415
|
+
"""
|
|
416
|
+
script_path.write_text(script_content)
|
|
417
|
+
script_path.chmod(0o755)
|
|
418
|
+
return script_path
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _launch_agent(
|
|
422
|
+
path: Path,
|
|
423
|
+
agent: CodingAgent,
|
|
424
|
+
extra_args: list[str] | None = None,
|
|
425
|
+
prompt: str | None = None,
|
|
426
|
+
env: dict[str, str] | None = None,
|
|
427
|
+
) -> None:
|
|
428
|
+
"""Launch agent in a new terminal tab.
|
|
429
|
+
|
|
430
|
+
Agents are interactive TUIs that need a proper terminal.
|
|
431
|
+
Priority: tmux/zellij tab > terminal tab > print instructions.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
path: Directory to launch the agent in
|
|
435
|
+
agent: The coding agent to launch
|
|
436
|
+
extra_args: Additional CLI arguments for the agent
|
|
437
|
+
prompt: Optional initial prompt
|
|
438
|
+
env: Environment variables to set for the agent
|
|
439
|
+
|
|
440
|
+
"""
|
|
441
|
+
terminal = terminals.detect_current_terminal()
|
|
442
|
+
|
|
443
|
+
# Use wrapper script for prompts when opening in a terminal tab.
|
|
444
|
+
# All terminals pass commands through a shell (zellij write-chars, tmux new-window,
|
|
445
|
+
# bash -c, AppleScript, etc.), so special characters ($, !, `, etc.) get interpreted.
|
|
446
|
+
# The wrapper script uses a heredoc with quoted delimiter to prevent this.
|
|
447
|
+
if prompt and terminal is not None:
|
|
448
|
+
script_path = _create_prompt_wrapper_script(path, agent, prompt, extra_args, env)
|
|
449
|
+
full_cmd = f"bash {shlex.quote(str(script_path))}"
|
|
450
|
+
else:
|
|
451
|
+
agent_cmd = shlex.join(agent.launch_command(path, extra_args, prompt))
|
|
452
|
+
env_prefix = _format_env_prefix(env or {})
|
|
453
|
+
full_cmd = env_prefix + agent_cmd
|
|
454
|
+
|
|
455
|
+
if terminal:
|
|
456
|
+
# We're in a multiplexer (tmux/zellij) or supported terminal (kitty/iTerm2)
|
|
457
|
+
# Tab name format: repo@branch
|
|
458
|
+
repo_root = worktree.get_main_repo_root(path)
|
|
459
|
+
branch = worktree.get_current_branch(path)
|
|
460
|
+
repo_name = repo_root.name if repo_root else path.name
|
|
461
|
+
tab_name = f"{repo_name}@{branch}" if branch else repo_name
|
|
462
|
+
if terminal.open_new_tab(path, full_cmd, tab_name=tab_name):
|
|
463
|
+
_success(f"Started {agent.name} in new {terminal.name} tab")
|
|
464
|
+
return
|
|
465
|
+
_warn(f"Could not open new tab in {terminal.name}")
|
|
466
|
+
|
|
467
|
+
# No terminal detected or failed - print instructions
|
|
468
|
+
if _is_ssh_session():
|
|
469
|
+
console.print("\n[yellow]SSH session without terminal multiplexer.[/yellow]")
|
|
470
|
+
console.print("[bold]Start a multiplexer first, then run:[/bold]")
|
|
471
|
+
else:
|
|
472
|
+
console.print(f"\n[bold]To start {agent.name}:[/bold]")
|
|
473
|
+
console.print(f" cd {path}")
|
|
474
|
+
console.print(f" {full_cmd}")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@app.command("new")
|
|
478
|
+
def new( # noqa: C901, PLR0912, PLR0915
|
|
479
|
+
branch: Annotated[
|
|
480
|
+
str | None,
|
|
481
|
+
typer.Argument(help="Branch name (auto-generated if not provided)"),
|
|
482
|
+
] = None,
|
|
483
|
+
from_ref: Annotated[
|
|
484
|
+
str | None,
|
|
485
|
+
typer.Option("--from", "-f", help="Create branch from this ref (default: main/master)"),
|
|
486
|
+
] = None,
|
|
487
|
+
editor: Annotated[
|
|
488
|
+
bool,
|
|
489
|
+
typer.Option("--editor", "-e", help="Open in editor after creation"),
|
|
490
|
+
] = False,
|
|
491
|
+
agent: Annotated[
|
|
492
|
+
bool,
|
|
493
|
+
typer.Option("--agent", "-a", help="Start AI coding agent after creation"),
|
|
494
|
+
] = False,
|
|
495
|
+
agent_name: Annotated[
|
|
496
|
+
str | None,
|
|
497
|
+
typer.Option("--with-agent", help="Specific agent to use (claude, codex, gemini, aider)"),
|
|
498
|
+
] = None,
|
|
499
|
+
editor_name: Annotated[
|
|
500
|
+
str | None,
|
|
501
|
+
typer.Option("--with-editor", help="Specific editor to use (cursor, vscode, zed)"),
|
|
502
|
+
] = None,
|
|
503
|
+
default_agent: Annotated[
|
|
504
|
+
str | None,
|
|
505
|
+
typer.Option(hidden=True, help="Default agent from config"),
|
|
506
|
+
] = None,
|
|
507
|
+
default_editor: Annotated[
|
|
508
|
+
str | None,
|
|
509
|
+
typer.Option(hidden=True, help="Default editor from config"),
|
|
510
|
+
] = None,
|
|
511
|
+
setup: Annotated[
|
|
512
|
+
bool,
|
|
513
|
+
typer.Option("--setup/--no-setup", help="Run automatic project setup"),
|
|
514
|
+
] = True,
|
|
515
|
+
copy_env: Annotated[
|
|
516
|
+
bool,
|
|
517
|
+
typer.Option("--copy-env/--no-copy-env", help="Copy .env files from main repo"),
|
|
518
|
+
] = True,
|
|
519
|
+
fetch: Annotated[
|
|
520
|
+
bool,
|
|
521
|
+
typer.Option("--fetch/--no-fetch", help="Git fetch before creating"),
|
|
522
|
+
] = True,
|
|
523
|
+
direnv: Annotated[
|
|
524
|
+
bool | None,
|
|
525
|
+
typer.Option(
|
|
526
|
+
"--direnv/--no-direnv",
|
|
527
|
+
help="Set up direnv (generate .envrc, run direnv allow). Default: enabled if direnv is installed.",
|
|
528
|
+
),
|
|
529
|
+
] = None,
|
|
530
|
+
agent_args: Annotated[
|
|
531
|
+
list[str] | None,
|
|
532
|
+
typer.Option(
|
|
533
|
+
"--agent-args",
|
|
534
|
+
help="Extra arguments to pass to the agent (e.g., --agent-args='--dangerously-skip-permissions')",
|
|
535
|
+
),
|
|
536
|
+
] = None,
|
|
537
|
+
prompt: Annotated[
|
|
538
|
+
str | None,
|
|
539
|
+
typer.Option(
|
|
540
|
+
"--prompt",
|
|
541
|
+
"-p",
|
|
542
|
+
help="Initial prompt to pass to the AI agent (e.g., --prompt='Fix the login bug')",
|
|
543
|
+
),
|
|
544
|
+
] = None,
|
|
545
|
+
prompt_file: Annotated[
|
|
546
|
+
Path | None,
|
|
547
|
+
typer.Option(
|
|
548
|
+
"--prompt-file",
|
|
549
|
+
"-P",
|
|
550
|
+
help="Read initial prompt from a file (avoids shell quoting issues with long prompts)",
|
|
551
|
+
exists=True,
|
|
552
|
+
readable=True,
|
|
553
|
+
),
|
|
554
|
+
] = None,
|
|
555
|
+
verbose: Annotated[
|
|
556
|
+
bool,
|
|
557
|
+
typer.Option("--verbose", "-v", help="Show detailed output and stream command output"),
|
|
558
|
+
] = False,
|
|
559
|
+
) -> None:
|
|
560
|
+
"""Create a new parallel development environment (git worktree)."""
|
|
561
|
+
# Handle prompt-file option (takes precedence over --prompt)
|
|
562
|
+
if prompt_file is not None:
|
|
563
|
+
prompt = prompt_file.read_text().strip()
|
|
564
|
+
|
|
565
|
+
# If a prompt is provided, automatically enable agent mode
|
|
566
|
+
if prompt:
|
|
567
|
+
agent = True
|
|
568
|
+
|
|
569
|
+
repo_root = _ensure_git_repo()
|
|
570
|
+
|
|
571
|
+
# Generate branch name if not provided
|
|
572
|
+
if branch is None:
|
|
573
|
+
# Get existing branches to avoid collisions
|
|
574
|
+
existing = {wt.branch for wt in worktree.list_worktrees() if wt.branch}
|
|
575
|
+
branch = _generate_branch_name(existing)
|
|
576
|
+
_info(f"Generated branch name: {branch}")
|
|
577
|
+
|
|
578
|
+
# Create the worktree
|
|
579
|
+
_info(f"Creating worktree for branch '{branch}'...")
|
|
580
|
+
result = worktree.create_worktree(
|
|
581
|
+
branch,
|
|
582
|
+
repo_path=repo_root,
|
|
583
|
+
from_ref=from_ref,
|
|
584
|
+
fetch=fetch,
|
|
585
|
+
on_log=_info,
|
|
586
|
+
capture_output=not verbose,
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
if not result.success:
|
|
590
|
+
_error(result.error or "Failed to create worktree")
|
|
591
|
+
|
|
592
|
+
assert result.path is not None
|
|
593
|
+
_success(f"Created worktree at {result.path}")
|
|
594
|
+
|
|
595
|
+
# Show warning if --from was ignored
|
|
596
|
+
if result.warning:
|
|
597
|
+
_warn(result.warning)
|
|
598
|
+
|
|
599
|
+
# Copy env files
|
|
600
|
+
if copy_env:
|
|
601
|
+
copied = copy_env_files(repo_root, result.path)
|
|
602
|
+
if copied:
|
|
603
|
+
names = ", ".join(f.name for f in copied)
|
|
604
|
+
_success(f"Copied env file(s): {names}")
|
|
605
|
+
|
|
606
|
+
# Detect and run project setup
|
|
607
|
+
project = None
|
|
608
|
+
if setup:
|
|
609
|
+
project = detect_project_type(result.path)
|
|
610
|
+
if project:
|
|
611
|
+
_info(f"Detected {project.description}")
|
|
612
|
+
success, output = run_setup(
|
|
613
|
+
result.path,
|
|
614
|
+
project,
|
|
615
|
+
on_log=_info,
|
|
616
|
+
capture_output=not verbose,
|
|
617
|
+
)
|
|
618
|
+
if success:
|
|
619
|
+
_success("Project setup complete")
|
|
620
|
+
else:
|
|
621
|
+
_warn(f"Setup failed: {output}")
|
|
622
|
+
|
|
623
|
+
# Set up direnv (default: enabled if direnv is installed)
|
|
624
|
+
use_direnv = direnv if direnv is not None else is_direnv_available()
|
|
625
|
+
if use_direnv:
|
|
626
|
+
if is_direnv_available():
|
|
627
|
+
success, msg = setup_direnv(
|
|
628
|
+
result.path,
|
|
629
|
+
project,
|
|
630
|
+
on_log=_info,
|
|
631
|
+
capture_output=not verbose,
|
|
632
|
+
)
|
|
633
|
+
# Show success for meaningful actions (created or allowed)
|
|
634
|
+
if success and ("created" in msg or "allowed" in msg):
|
|
635
|
+
_success(msg)
|
|
636
|
+
elif success:
|
|
637
|
+
_info(msg)
|
|
638
|
+
else:
|
|
639
|
+
_warn(msg)
|
|
640
|
+
elif direnv is True:
|
|
641
|
+
# Only warn if user explicitly requested direnv
|
|
642
|
+
_warn("direnv not installed, skipping .envrc setup")
|
|
643
|
+
|
|
644
|
+
# Write prompt to worktree (makes task available to the spawned agent)
|
|
645
|
+
if prompt:
|
|
646
|
+
task_file = _write_prompt_to_worktree(result.path, prompt)
|
|
647
|
+
_success(f"Wrote task to {task_file.relative_to(result.path)}")
|
|
648
|
+
|
|
649
|
+
# Resolve editor and agent
|
|
650
|
+
resolved_editor = _resolve_editor(editor, editor_name, default_editor)
|
|
651
|
+
resolved_agent = _resolve_agent(agent, agent_name, default_agent)
|
|
652
|
+
|
|
653
|
+
# Launch editor (GUI app - subprocess works)
|
|
654
|
+
if resolved_editor and resolved_editor.is_available():
|
|
655
|
+
_launch_editor(result.path, resolved_editor)
|
|
656
|
+
|
|
657
|
+
# Launch agent (interactive TUI - needs terminal tab)
|
|
658
|
+
if resolved_agent and resolved_agent.is_available():
|
|
659
|
+
merged_args = _merge_agent_args(resolved_agent, agent_args)
|
|
660
|
+
agent_env = _get_agent_env(resolved_agent)
|
|
661
|
+
_launch_agent(result.path, resolved_agent, merged_args, prompt, agent_env)
|
|
662
|
+
|
|
663
|
+
# Print summary
|
|
664
|
+
console.print()
|
|
665
|
+
console.print(
|
|
666
|
+
Panel(
|
|
667
|
+
f"[bold]Dev environment created:[/bold] {result.path}\n[bold]Branch:[/bold] {result.branch}",
|
|
668
|
+
title="[green]Success[/green]",
|
|
669
|
+
border_style="green",
|
|
670
|
+
),
|
|
671
|
+
)
|
|
672
|
+
console.print(f'[dim]To enter the worktree:[/dim] cd "$(ag dev path {branch})"')
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
@app.command("list")
|
|
676
|
+
def list_envs(
|
|
677
|
+
json_output: Annotated[
|
|
678
|
+
bool,
|
|
679
|
+
typer.Option("--json", help="Output as JSON for automation"),
|
|
680
|
+
] = False,
|
|
681
|
+
) -> None:
|
|
682
|
+
"""List all dev environments (worktrees) for the current repository."""
|
|
683
|
+
_ensure_git_repo()
|
|
684
|
+
|
|
685
|
+
worktrees = worktree.list_worktrees()
|
|
686
|
+
|
|
687
|
+
if not worktrees:
|
|
688
|
+
if json_output:
|
|
689
|
+
print(json.dumps({"worktrees": []}))
|
|
690
|
+
else:
|
|
691
|
+
console.print("[dim]No worktrees found[/dim]")
|
|
692
|
+
return
|
|
693
|
+
|
|
694
|
+
if json_output:
|
|
695
|
+
data = [
|
|
696
|
+
{
|
|
697
|
+
"name": wt.name,
|
|
698
|
+
"path": wt.path.as_posix(),
|
|
699
|
+
"branch": wt.branch,
|
|
700
|
+
"is_main": wt.is_main,
|
|
701
|
+
"is_detached": wt.is_detached,
|
|
702
|
+
"is_locked": wt.is_locked,
|
|
703
|
+
"is_prunable": wt.is_prunable,
|
|
704
|
+
}
|
|
705
|
+
for wt in worktrees
|
|
706
|
+
]
|
|
707
|
+
print(json.dumps({"worktrees": data}))
|
|
708
|
+
return
|
|
709
|
+
|
|
710
|
+
table = Table(title="Dev Environments (Git Worktrees)")
|
|
711
|
+
table.add_column("Name", style="cyan")
|
|
712
|
+
table.add_column("Branch", style="green")
|
|
713
|
+
table.add_column("Path", style="dim", overflow="fold")
|
|
714
|
+
table.add_column("Status", style="yellow")
|
|
715
|
+
|
|
716
|
+
home = Path.home()
|
|
717
|
+
|
|
718
|
+
for wt in worktrees:
|
|
719
|
+
name = "[bold]main[/bold]" if wt.is_main else wt.name
|
|
720
|
+
branch_name = wt.branch or "(detached)"
|
|
721
|
+
|
|
722
|
+
status_parts = []
|
|
723
|
+
if wt.is_main:
|
|
724
|
+
status_parts.append("main")
|
|
725
|
+
if wt.is_detached:
|
|
726
|
+
status_parts.append("detached")
|
|
727
|
+
if wt.is_locked:
|
|
728
|
+
status_parts.append("locked")
|
|
729
|
+
if wt.is_prunable:
|
|
730
|
+
status_parts.append("prunable")
|
|
731
|
+
status = ", ".join(status_parts) if status_parts else "ok"
|
|
732
|
+
|
|
733
|
+
# Use ~ for home directory to shorten paths
|
|
734
|
+
try:
|
|
735
|
+
display_path = "~/" + str(wt.path.relative_to(home))
|
|
736
|
+
except ValueError:
|
|
737
|
+
display_path = str(wt.path)
|
|
738
|
+
|
|
739
|
+
table.add_row(name, branch_name, display_path, status)
|
|
740
|
+
|
|
741
|
+
console.print(table)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _format_file_changes(status: worktree.WorktreeStatus) -> str:
|
|
745
|
+
"""Format file changes for display (e.g., '2M 1S 3?')."""
|
|
746
|
+
parts: list[str] = []
|
|
747
|
+
if status.modified:
|
|
748
|
+
parts.append(f"{status.modified}M")
|
|
749
|
+
if status.staged:
|
|
750
|
+
parts.append(f"{status.staged}S")
|
|
751
|
+
if status.untracked:
|
|
752
|
+
parts.append(f"{status.untracked}?")
|
|
753
|
+
return " ".join(parts) if parts else "[dim]clean[/dim]"
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _format_ahead_behind(status: worktree.WorktreeStatus) -> str:
|
|
757
|
+
"""Format ahead/behind for display (e.g., '+3/-2')."""
|
|
758
|
+
if status.ahead == 0 and status.behind == 0:
|
|
759
|
+
return "[dim]—[/dim]"
|
|
760
|
+
parts: list[str] = []
|
|
761
|
+
if status.ahead:
|
|
762
|
+
parts.append(f"[green]+{status.ahead}[/green]")
|
|
763
|
+
if status.behind:
|
|
764
|
+
parts.append(f"[red]-{status.behind}[/red]")
|
|
765
|
+
return "/".join(parts)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _is_stale(status: worktree.WorktreeStatus, stale_days: int) -> bool:
|
|
769
|
+
"""Check if worktree is stale based on last commit time."""
|
|
770
|
+
import time # noqa: PLC0415
|
|
771
|
+
|
|
772
|
+
if status.last_commit_timestamp is None:
|
|
773
|
+
return False
|
|
774
|
+
days_since = (time.time() - status.last_commit_timestamp) / (60 * 60 * 24)
|
|
775
|
+
return days_since >= stale_days
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
@app.command("status")
|
|
779
|
+
def status_cmd( # noqa: PLR0915
|
|
780
|
+
stale_days: Annotated[
|
|
781
|
+
int,
|
|
782
|
+
typer.Option("--stale-days", "-s", help="Highlight worktrees inactive for N+ days"),
|
|
783
|
+
] = 7,
|
|
784
|
+
json_output: Annotated[
|
|
785
|
+
bool,
|
|
786
|
+
typer.Option("--json", help="Output as JSON for automation"),
|
|
787
|
+
] = False,
|
|
788
|
+
) -> None:
|
|
789
|
+
"""Show status of all dev environments (worktrees) with git status.
|
|
790
|
+
|
|
791
|
+
Displays file changes (Modified, Staged, Untracked), commits ahead/behind
|
|
792
|
+
upstream, and last commit time for each worktree.
|
|
793
|
+
"""
|
|
794
|
+
_ensure_git_repo()
|
|
795
|
+
|
|
796
|
+
worktrees = worktree.list_worktrees()
|
|
797
|
+
|
|
798
|
+
if not worktrees:
|
|
799
|
+
if json_output:
|
|
800
|
+
print(json.dumps({"worktrees": []}))
|
|
801
|
+
else:
|
|
802
|
+
console.print("[dim]No worktrees found[/dim]")
|
|
803
|
+
return
|
|
804
|
+
|
|
805
|
+
if json_output:
|
|
806
|
+
data = []
|
|
807
|
+
for wt in worktrees:
|
|
808
|
+
status = worktree.get_worktree_status(wt.path)
|
|
809
|
+
entry: dict[str, str | int | bool | float | None] = {
|
|
810
|
+
"name": wt.name,
|
|
811
|
+
"branch": wt.branch,
|
|
812
|
+
"is_main": wt.is_main,
|
|
813
|
+
}
|
|
814
|
+
if status:
|
|
815
|
+
entry["modified"] = status.modified
|
|
816
|
+
entry["staged"] = status.staged
|
|
817
|
+
entry["untracked"] = status.untracked
|
|
818
|
+
entry["ahead"] = status.ahead
|
|
819
|
+
entry["behind"] = status.behind
|
|
820
|
+
entry["last_commit_timestamp"] = status.last_commit_timestamp
|
|
821
|
+
entry["last_commit_time"] = status.last_commit_time
|
|
822
|
+
entry["is_stale"] = _is_stale(status, stale_days)
|
|
823
|
+
data.append(entry)
|
|
824
|
+
print(json.dumps({"worktrees": data, "stale_days": stale_days}))
|
|
825
|
+
return
|
|
826
|
+
|
|
827
|
+
table = Table(title="Dev Environment Status")
|
|
828
|
+
table.add_column("Name", style="cyan")
|
|
829
|
+
table.add_column("Branch", style="green")
|
|
830
|
+
table.add_column("Changes", justify="right")
|
|
831
|
+
table.add_column("↑/↓", justify="center")
|
|
832
|
+
table.add_column("Last Commit")
|
|
833
|
+
|
|
834
|
+
for wt in worktrees:
|
|
835
|
+
name = "[bold]main[/bold]" if wt.is_main else wt.name
|
|
836
|
+
branch_name = wt.branch or "(detached)"
|
|
837
|
+
|
|
838
|
+
status = worktree.get_worktree_status(wt.path)
|
|
839
|
+
if status is None:
|
|
840
|
+
table.add_row(name, branch_name, "[red]?[/red]", "", "")
|
|
841
|
+
continue
|
|
842
|
+
|
|
843
|
+
changes = _format_file_changes(status)
|
|
844
|
+
ahead_behind = _format_ahead_behind(status)
|
|
845
|
+
|
|
846
|
+
# Format last commit time with stale warning
|
|
847
|
+
last_commit = status.last_commit_time or "[dim]unknown[/dim]"
|
|
848
|
+
if _is_stale(status, stale_days):
|
|
849
|
+
last_commit = f"[yellow]{last_commit} ⚠️[/yellow]"
|
|
850
|
+
|
|
851
|
+
table.add_row(name, branch_name, changes, ahead_behind, last_commit)
|
|
852
|
+
|
|
853
|
+
console.print(table)
|
|
854
|
+
|
|
855
|
+
# Summary
|
|
856
|
+
total = len(worktrees)
|
|
857
|
+
stale_count = sum(
|
|
858
|
+
1
|
|
859
|
+
for wt in worktrees
|
|
860
|
+
if (s := worktree.get_worktree_status(wt.path)) and _is_stale(s, stale_days)
|
|
861
|
+
)
|
|
862
|
+
dirty_count = sum(
|
|
863
|
+
1
|
|
864
|
+
for wt in worktrees
|
|
865
|
+
if (s := worktree.get_worktree_status(wt.path))
|
|
866
|
+
and (s.modified > 0 or s.staged > 0 or s.untracked > 0)
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
summary_parts = [f"[bold]{total}[/bold] worktree{'s' if total != 1 else ''}"]
|
|
870
|
+
if dirty_count:
|
|
871
|
+
summary_parts.append(f"[yellow]{dirty_count} with uncommitted changes[/yellow]")
|
|
872
|
+
if stale_count:
|
|
873
|
+
summary_parts.append(f"[yellow]{stale_count} stale (>{stale_days} days)[/yellow]")
|
|
874
|
+
|
|
875
|
+
console.print("\n" + " · ".join(summary_parts))
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
@app.command("rm")
|
|
879
|
+
def remove(
|
|
880
|
+
name: Annotated[str, typer.Argument(help="Branch or directory name of the worktree to remove")],
|
|
881
|
+
force: Annotated[
|
|
882
|
+
bool,
|
|
883
|
+
typer.Option("--force", "-f", help="Force removal even with uncommitted changes"),
|
|
884
|
+
] = False,
|
|
885
|
+
delete_branch: Annotated[
|
|
886
|
+
bool,
|
|
887
|
+
typer.Option("--delete-branch", "-d", help="Also delete the branch"),
|
|
888
|
+
] = False,
|
|
889
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
|
|
890
|
+
) -> None:
|
|
891
|
+
"""Remove a dev environment (worktree)."""
|
|
892
|
+
repo_root = _ensure_git_repo()
|
|
893
|
+
|
|
894
|
+
wt = worktree.find_worktree_by_name(name, repo_root)
|
|
895
|
+
if wt is None:
|
|
896
|
+
_error(f"Worktree not found: {name}")
|
|
897
|
+
|
|
898
|
+
if wt.is_main:
|
|
899
|
+
_error("Cannot remove the main worktree")
|
|
900
|
+
|
|
901
|
+
if not yes and not force:
|
|
902
|
+
console.print(f"[bold]Will remove:[/bold] {wt.path}")
|
|
903
|
+
if delete_branch:
|
|
904
|
+
console.print(f"[bold]Will delete branch:[/bold] {wt.branch}")
|
|
905
|
+
if not typer.confirm("Continue?"):
|
|
906
|
+
raise typer.Abort
|
|
907
|
+
|
|
908
|
+
success, error = worktree.remove_worktree(
|
|
909
|
+
wt.path,
|
|
910
|
+
force=force,
|
|
911
|
+
delete_branch=delete_branch,
|
|
912
|
+
repo_path=repo_root,
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
if success:
|
|
916
|
+
_success(f"Removed worktree: {wt.path}")
|
|
917
|
+
else:
|
|
918
|
+
_error(error or "Failed to remove worktree")
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
@app.command("path")
|
|
922
|
+
def path_cmd(
|
|
923
|
+
name: Annotated[str, typer.Argument(help="Branch name or directory name of the worktree")],
|
|
924
|
+
) -> None:
|
|
925
|
+
"""Print the path to a dev environment (for shell integration).
|
|
926
|
+
|
|
927
|
+
Usage: cd "$(agent-cli dev path my-feature)"
|
|
928
|
+
"""
|
|
929
|
+
repo_root = _ensure_git_repo()
|
|
930
|
+
|
|
931
|
+
wt = worktree.find_worktree_by_name(name, repo_root)
|
|
932
|
+
if wt is None:
|
|
933
|
+
_error(f"Worktree not found: {name}")
|
|
934
|
+
|
|
935
|
+
print(wt.path.as_posix())
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
@app.command("editor")
|
|
939
|
+
def open_editor(
|
|
940
|
+
name: Annotated[str, typer.Argument(help="Branch name or directory name of the worktree")],
|
|
941
|
+
editor_name: Annotated[
|
|
942
|
+
str | None,
|
|
943
|
+
typer.Option("--editor", "-e", help="Specific editor to use"),
|
|
944
|
+
] = None,
|
|
945
|
+
) -> None:
|
|
946
|
+
"""Open a dev environment in an editor."""
|
|
947
|
+
repo_root = _ensure_git_repo()
|
|
948
|
+
|
|
949
|
+
wt = worktree.find_worktree_by_name(name, repo_root)
|
|
950
|
+
if wt is None:
|
|
951
|
+
_error(f"Worktree not found: {name}")
|
|
952
|
+
|
|
953
|
+
if editor_name:
|
|
954
|
+
editor = editors.get_editor(editor_name)
|
|
955
|
+
if editor is None:
|
|
956
|
+
_error(f"Editor not found: {editor_name}")
|
|
957
|
+
else:
|
|
958
|
+
editor = editors.detect_current_editor()
|
|
959
|
+
if editor is None:
|
|
960
|
+
available = editors.get_available_editors()
|
|
961
|
+
if not available:
|
|
962
|
+
_error("No editors available")
|
|
963
|
+
editor = available[0]
|
|
964
|
+
|
|
965
|
+
if not editor.is_available():
|
|
966
|
+
_error(f"{editor.name} is not installed")
|
|
967
|
+
|
|
968
|
+
try:
|
|
969
|
+
subprocess.Popen(editor.open_command(wt.path))
|
|
970
|
+
_success(f"Opened {wt.path} in {editor.name}")
|
|
971
|
+
except Exception as e:
|
|
972
|
+
_error(f"Failed to open editor: {e}")
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
@app.command("agent")
|
|
976
|
+
def start_agent(
|
|
977
|
+
name: Annotated[str, typer.Argument(help="Branch name or directory name of the worktree")],
|
|
978
|
+
agent_name: Annotated[
|
|
979
|
+
str | None,
|
|
980
|
+
typer.Option("--agent", "-a", help="Specific agent (claude, codex, gemini, aider)"),
|
|
981
|
+
] = None,
|
|
982
|
+
agent_args: Annotated[
|
|
983
|
+
list[str] | None,
|
|
984
|
+
typer.Option(
|
|
985
|
+
"--agent-args",
|
|
986
|
+
help="Extra arguments to pass to the agent (e.g., --agent-args='--dangerously-skip-permissions')",
|
|
987
|
+
),
|
|
988
|
+
] = None,
|
|
989
|
+
prompt: Annotated[
|
|
990
|
+
str | None,
|
|
991
|
+
typer.Option(
|
|
992
|
+
"--prompt",
|
|
993
|
+
"-p",
|
|
994
|
+
help="Initial prompt to pass to the AI agent (e.g., --prompt='Fix the login bug')",
|
|
995
|
+
),
|
|
996
|
+
] = None,
|
|
997
|
+
prompt_file: Annotated[
|
|
998
|
+
Path | None,
|
|
999
|
+
typer.Option(
|
|
1000
|
+
"--prompt-file",
|
|
1001
|
+
"-P",
|
|
1002
|
+
help="Read initial prompt from a file (avoids shell quoting issues with long prompts)",
|
|
1003
|
+
exists=True,
|
|
1004
|
+
readable=True,
|
|
1005
|
+
),
|
|
1006
|
+
] = None,
|
|
1007
|
+
) -> None:
|
|
1008
|
+
"""Start an AI coding agent in a dev environment."""
|
|
1009
|
+
# Handle prompt-file option (takes precedence over --prompt)
|
|
1010
|
+
if prompt_file is not None:
|
|
1011
|
+
prompt = prompt_file.read_text().strip()
|
|
1012
|
+
|
|
1013
|
+
repo_root = _ensure_git_repo()
|
|
1014
|
+
|
|
1015
|
+
wt = worktree.find_worktree_by_name(name, repo_root)
|
|
1016
|
+
if wt is None:
|
|
1017
|
+
_error(f"Worktree not found: {name}")
|
|
1018
|
+
|
|
1019
|
+
if agent_name:
|
|
1020
|
+
agent = coding_agents.get_agent(agent_name)
|
|
1021
|
+
if agent is None:
|
|
1022
|
+
_error(f"Agent not found: {agent_name}")
|
|
1023
|
+
else:
|
|
1024
|
+
agent = coding_agents.detect_current_agent()
|
|
1025
|
+
if agent is None:
|
|
1026
|
+
available = coding_agents.get_available_agents()
|
|
1027
|
+
if not available:
|
|
1028
|
+
_error("No AI coding agents available")
|
|
1029
|
+
agent = available[0]
|
|
1030
|
+
|
|
1031
|
+
if not agent.is_available():
|
|
1032
|
+
_error(f"{agent.name} is not installed. Install from: {agent.install_url}")
|
|
1033
|
+
|
|
1034
|
+
# Write prompt to worktree (makes task available to the agent)
|
|
1035
|
+
if prompt:
|
|
1036
|
+
task_file = _write_prompt_to_worktree(wt.path, prompt)
|
|
1037
|
+
_success(f"Wrote task to {task_file.relative_to(wt.path)}")
|
|
1038
|
+
|
|
1039
|
+
merged_args = _merge_agent_args(agent, agent_args)
|
|
1040
|
+
agent_env = _get_agent_env(agent)
|
|
1041
|
+
_info(f"Starting {agent.name} in {wt.path}...")
|
|
1042
|
+
try:
|
|
1043
|
+
os.chdir(wt.path)
|
|
1044
|
+
# Merge agent env with current environment
|
|
1045
|
+
run_env = os.environ.copy()
|
|
1046
|
+
run_env.update(agent_env)
|
|
1047
|
+
subprocess.run(
|
|
1048
|
+
agent.launch_command(wt.path, merged_args, prompt),
|
|
1049
|
+
check=False,
|
|
1050
|
+
env=run_env,
|
|
1051
|
+
)
|
|
1052
|
+
except Exception as e:
|
|
1053
|
+
_error(f"Failed to start agent: {e}")
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
@app.command("agents")
|
|
1057
|
+
def list_agents(
|
|
1058
|
+
json_output: Annotated[
|
|
1059
|
+
bool,
|
|
1060
|
+
typer.Option("--json", help="Output as JSON for automation"),
|
|
1061
|
+
] = False,
|
|
1062
|
+
) -> None:
|
|
1063
|
+
"""List available AI coding agents."""
|
|
1064
|
+
current = coding_agents.detect_current_agent()
|
|
1065
|
+
|
|
1066
|
+
if json_output:
|
|
1067
|
+
data = [
|
|
1068
|
+
{
|
|
1069
|
+
"name": agent.name,
|
|
1070
|
+
"command": agent.command,
|
|
1071
|
+
"is_available": agent.is_available(),
|
|
1072
|
+
"is_current": current is not None and agent.name == current.name,
|
|
1073
|
+
"install_url": agent.install_url,
|
|
1074
|
+
}
|
|
1075
|
+
for agent in coding_agents.get_all_agents()
|
|
1076
|
+
]
|
|
1077
|
+
print(json.dumps({"agents": data}))
|
|
1078
|
+
return
|
|
1079
|
+
|
|
1080
|
+
table = Table(title="AI Coding Agents")
|
|
1081
|
+
table.add_column("Status", width=3)
|
|
1082
|
+
table.add_column("Name", style="cyan")
|
|
1083
|
+
table.add_column("Command", style="dim")
|
|
1084
|
+
table.add_column("Notes")
|
|
1085
|
+
|
|
1086
|
+
for agent in coding_agents.get_all_agents():
|
|
1087
|
+
status = "[green]✓[/green]" if agent.is_available() else "[red]✗[/red]"
|
|
1088
|
+
notes = ""
|
|
1089
|
+
if current and agent.name == current.name:
|
|
1090
|
+
notes = "[bold yellow]← current[/bold yellow]"
|
|
1091
|
+
elif not agent.is_available():
|
|
1092
|
+
notes = f"[dim]{agent.install_url}[/dim]"
|
|
1093
|
+
table.add_row(status, agent.name, agent.command, notes)
|
|
1094
|
+
|
|
1095
|
+
console.print(table)
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
@app.command("editors")
|
|
1099
|
+
def list_editors_cmd(
|
|
1100
|
+
json_output: Annotated[
|
|
1101
|
+
bool,
|
|
1102
|
+
typer.Option("--json", help="Output as JSON for automation"),
|
|
1103
|
+
] = False,
|
|
1104
|
+
) -> None:
|
|
1105
|
+
"""List available editors."""
|
|
1106
|
+
current = editors.detect_current_editor()
|
|
1107
|
+
|
|
1108
|
+
if json_output:
|
|
1109
|
+
data = [
|
|
1110
|
+
{
|
|
1111
|
+
"name": editor.name,
|
|
1112
|
+
"command": editor.command,
|
|
1113
|
+
"is_available": editor.is_available(),
|
|
1114
|
+
"is_current": current is not None and editor.name == current.name,
|
|
1115
|
+
"install_url": editor.install_url,
|
|
1116
|
+
}
|
|
1117
|
+
for editor in editors.get_all_editors()
|
|
1118
|
+
]
|
|
1119
|
+
print(json.dumps({"editors": data}))
|
|
1120
|
+
return
|
|
1121
|
+
|
|
1122
|
+
table = Table(title="Editors")
|
|
1123
|
+
table.add_column("Status", width=3)
|
|
1124
|
+
table.add_column("Name", style="cyan")
|
|
1125
|
+
table.add_column("Command", style="dim")
|
|
1126
|
+
table.add_column("Notes")
|
|
1127
|
+
|
|
1128
|
+
for editor in editors.get_all_editors():
|
|
1129
|
+
status = "[green]✓[/green]" if editor.is_available() else "[red]✗[/red]"
|
|
1130
|
+
notes = ""
|
|
1131
|
+
if current and editor.name == current.name:
|
|
1132
|
+
notes = "[bold yellow]← current[/bold yellow]"
|
|
1133
|
+
elif not editor.is_available():
|
|
1134
|
+
notes = f"[dim]{editor.install_url}[/dim]"
|
|
1135
|
+
table.add_row(status, editor.name, editor.command, notes)
|
|
1136
|
+
|
|
1137
|
+
console.print(table)
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
@app.command("terminals")
|
|
1141
|
+
def list_terminals_cmd(
|
|
1142
|
+
json_output: Annotated[
|
|
1143
|
+
bool,
|
|
1144
|
+
typer.Option("--json", help="Output as JSON for automation"),
|
|
1145
|
+
] = False,
|
|
1146
|
+
) -> None:
|
|
1147
|
+
"""List available terminals."""
|
|
1148
|
+
current = terminals.detect_current_terminal()
|
|
1149
|
+
|
|
1150
|
+
if json_output:
|
|
1151
|
+
data = [
|
|
1152
|
+
{
|
|
1153
|
+
"name": terminal.name,
|
|
1154
|
+
"is_available": terminal.is_available(),
|
|
1155
|
+
"is_current": current is not None and terminal.name == current.name,
|
|
1156
|
+
}
|
|
1157
|
+
for terminal in terminals.get_all_terminals()
|
|
1158
|
+
]
|
|
1159
|
+
print(json.dumps({"terminals": data}))
|
|
1160
|
+
return
|
|
1161
|
+
|
|
1162
|
+
table = Table(title="Terminals")
|
|
1163
|
+
table.add_column("Status", width=3)
|
|
1164
|
+
table.add_column("Name", style="cyan")
|
|
1165
|
+
table.add_column("Notes")
|
|
1166
|
+
|
|
1167
|
+
for terminal in terminals.get_all_terminals():
|
|
1168
|
+
status = "[green]✓[/green]" if terminal.is_available() else "[red]✗[/red]"
|
|
1169
|
+
notes = (
|
|
1170
|
+
"[bold yellow]← current[/bold yellow]"
|
|
1171
|
+
if current and terminal.name == current.name
|
|
1172
|
+
else ""
|
|
1173
|
+
)
|
|
1174
|
+
table.add_row(status, terminal.name, notes)
|
|
1175
|
+
|
|
1176
|
+
console.print(table)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _print_item_status(
|
|
1180
|
+
name: str,
|
|
1181
|
+
available: bool,
|
|
1182
|
+
is_current: bool,
|
|
1183
|
+
not_available_msg: str = "not installed",
|
|
1184
|
+
) -> None:
|
|
1185
|
+
"""Print status of an item (editor, agent, terminal)."""
|
|
1186
|
+
if available:
|
|
1187
|
+
note = " [yellow](current)[/yellow]" if is_current else ""
|
|
1188
|
+
_success(f"{name}{note}")
|
|
1189
|
+
else:
|
|
1190
|
+
console.print(f" [dim]○[/dim] {name} ({not_available_msg})")
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _doctor_check_git() -> None:
|
|
1194
|
+
"""Check git status for doctor command."""
|
|
1195
|
+
console.print("[bold]Git:[/bold]")
|
|
1196
|
+
if worktree.git_available():
|
|
1197
|
+
_success("Git is available")
|
|
1198
|
+
else:
|
|
1199
|
+
console.print(" [red]✗[/red] Git is not installed")
|
|
1200
|
+
|
|
1201
|
+
repo_root = worktree.get_main_repo_root()
|
|
1202
|
+
if repo_root:
|
|
1203
|
+
_success(f"In git repository: {repo_root}")
|
|
1204
|
+
else:
|
|
1205
|
+
console.print(" [yellow]○[/yellow] Not in a git repository")
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
@app.command("run")
|
|
1209
|
+
def run_cmd(
|
|
1210
|
+
name: Annotated[str, typer.Argument(help="Branch name or directory name of the worktree")],
|
|
1211
|
+
command: Annotated[list[str], typer.Argument(help="Command to run in the worktree")],
|
|
1212
|
+
) -> None:
|
|
1213
|
+
"""Run a command in a dev environment.
|
|
1214
|
+
|
|
1215
|
+
Example: agent-cli dev run my-feature npm test
|
|
1216
|
+
"""
|
|
1217
|
+
repo_root = _ensure_git_repo()
|
|
1218
|
+
|
|
1219
|
+
wt = worktree.find_worktree_by_name(name, repo_root)
|
|
1220
|
+
if wt is None:
|
|
1221
|
+
_error(f"Worktree not found: {name}")
|
|
1222
|
+
|
|
1223
|
+
if not command:
|
|
1224
|
+
_error("No command specified")
|
|
1225
|
+
|
|
1226
|
+
_info(f"Running in {wt.path}: {' '.join(command)}")
|
|
1227
|
+
try:
|
|
1228
|
+
result = subprocess.run(command, cwd=wt.path, check=False)
|
|
1229
|
+
raise typer.Exit(result.returncode)
|
|
1230
|
+
except FileNotFoundError:
|
|
1231
|
+
_error(f"Command not found: {command[0]}")
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _find_worktrees_with_no_commits(repo_root: Path) -> list[worktree.WorktreeInfo]:
|
|
1235
|
+
"""Find worktrees whose branches have no commits ahead of the default branch."""
|
|
1236
|
+
worktrees_list = worktree.list_worktrees()
|
|
1237
|
+
default_branch = worktree.get_default_branch(repo_root)
|
|
1238
|
+
to_remove: list[worktree.WorktreeInfo] = []
|
|
1239
|
+
|
|
1240
|
+
for wt in worktrees_list:
|
|
1241
|
+
if wt.is_main or not wt.branch:
|
|
1242
|
+
continue
|
|
1243
|
+
|
|
1244
|
+
# Check if branch has any commits ahead of default branch
|
|
1245
|
+
result = subprocess.run(
|
|
1246
|
+
["git", "rev-list", f"{default_branch}..{wt.branch}", "--count"], # noqa: S607
|
|
1247
|
+
capture_output=True,
|
|
1248
|
+
text=True,
|
|
1249
|
+
cwd=repo_root,
|
|
1250
|
+
check=False,
|
|
1251
|
+
)
|
|
1252
|
+
if result.returncode == 0 and result.stdout.strip() == "0":
|
|
1253
|
+
to_remove.append(wt)
|
|
1254
|
+
|
|
1255
|
+
return to_remove
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _find_worktrees_with_merged_prs(
|
|
1259
|
+
repo_root: Path,
|
|
1260
|
+
) -> list[tuple[worktree.WorktreeInfo, str]]:
|
|
1261
|
+
"""Find worktrees whose PRs have been merged on GitHub.
|
|
1262
|
+
|
|
1263
|
+
Returns a list of tuples containing (worktree_info, pr_url).
|
|
1264
|
+
"""
|
|
1265
|
+
worktrees_list = worktree.list_worktrees()
|
|
1266
|
+
to_remove: list[tuple[worktree.WorktreeInfo, str]] = []
|
|
1267
|
+
|
|
1268
|
+
for wt in worktrees_list:
|
|
1269
|
+
if wt.is_main or not wt.branch:
|
|
1270
|
+
continue
|
|
1271
|
+
|
|
1272
|
+
# Check if PR for this branch is merged
|
|
1273
|
+
result = subprocess.run(
|
|
1274
|
+
["gh", "pr", "list", "--head", wt.branch, "--state", "merged", "--json", "number,url"], # noqa: S607
|
|
1275
|
+
capture_output=True,
|
|
1276
|
+
text=True,
|
|
1277
|
+
cwd=repo_root,
|
|
1278
|
+
check=False,
|
|
1279
|
+
)
|
|
1280
|
+
if result.returncode == 0 and result.stdout.strip() not in ("", "[]"):
|
|
1281
|
+
prs = json.loads(result.stdout)
|
|
1282
|
+
pr_url = prs[0]["url"] if prs else ""
|
|
1283
|
+
to_remove.append((wt, pr_url))
|
|
1284
|
+
|
|
1285
|
+
return to_remove
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def _clean_merged_worktrees(
|
|
1289
|
+
repo_root: Path,
|
|
1290
|
+
dry_run: bool,
|
|
1291
|
+
yes: bool,
|
|
1292
|
+
) -> None:
|
|
1293
|
+
"""Remove worktrees with merged PRs (requires gh CLI)."""
|
|
1294
|
+
_info("Checking for worktrees with merged PRs...")
|
|
1295
|
+
|
|
1296
|
+
# Check if gh CLI is available
|
|
1297
|
+
gh_version = subprocess.run(
|
|
1298
|
+
["gh", "--version"], # noqa: S607
|
|
1299
|
+
capture_output=True,
|
|
1300
|
+
check=False,
|
|
1301
|
+
)
|
|
1302
|
+
if gh_version.returncode != 0:
|
|
1303
|
+
_error("GitHub CLI (gh) not found. Install from: https://cli.github.com/")
|
|
1304
|
+
|
|
1305
|
+
# Check if gh is authenticated
|
|
1306
|
+
gh_auth = subprocess.run(
|
|
1307
|
+
["gh", "auth", "status"], # noqa: S607
|
|
1308
|
+
capture_output=True,
|
|
1309
|
+
check=False,
|
|
1310
|
+
)
|
|
1311
|
+
if gh_auth.returncode != 0:
|
|
1312
|
+
_error("Not authenticated with GitHub. Run: gh auth login")
|
|
1313
|
+
|
|
1314
|
+
to_remove = _find_worktrees_with_merged_prs(repo_root)
|
|
1315
|
+
|
|
1316
|
+
if not to_remove:
|
|
1317
|
+
_info("No worktrees with merged PRs found")
|
|
1318
|
+
return
|
|
1319
|
+
|
|
1320
|
+
console.print(f"\n[bold]Found {len(to_remove)} worktree(s) with merged PRs:[/bold]")
|
|
1321
|
+
for wt, pr_url in to_remove:
|
|
1322
|
+
console.print(f" • {wt.branch} ({wt.path})")
|
|
1323
|
+
if pr_url:
|
|
1324
|
+
console.print(f" PR: [link={pr_url}]{pr_url}[/link]")
|
|
1325
|
+
|
|
1326
|
+
if dry_run:
|
|
1327
|
+
_info("[dry-run] Would remove the above worktrees")
|
|
1328
|
+
elif yes or typer.confirm("\nRemove these worktrees?"):
|
|
1329
|
+
for wt, _pr_url in to_remove:
|
|
1330
|
+
success, error = worktree.remove_worktree(
|
|
1331
|
+
wt.path,
|
|
1332
|
+
force=False,
|
|
1333
|
+
delete_branch=True,
|
|
1334
|
+
repo_path=repo_root,
|
|
1335
|
+
)
|
|
1336
|
+
if success:
|
|
1337
|
+
_success(f"Removed {wt.branch}")
|
|
1338
|
+
else:
|
|
1339
|
+
_warn(f"Failed to remove {wt.branch}: {error}")
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _clean_no_commits_worktrees(
|
|
1343
|
+
repo_root: Path,
|
|
1344
|
+
dry_run: bool,
|
|
1345
|
+
yes: bool,
|
|
1346
|
+
) -> None:
|
|
1347
|
+
"""Remove worktrees with no commits ahead of the default branch."""
|
|
1348
|
+
_info("Checking for worktrees with no commits...")
|
|
1349
|
+
|
|
1350
|
+
to_remove = _find_worktrees_with_no_commits(repo_root)
|
|
1351
|
+
|
|
1352
|
+
if not to_remove:
|
|
1353
|
+
_info("No worktrees with zero commits found")
|
|
1354
|
+
return
|
|
1355
|
+
|
|
1356
|
+
default_branch = worktree.get_default_branch(repo_root)
|
|
1357
|
+
console.print(
|
|
1358
|
+
f"\n[bold]Found {len(to_remove)} worktree(s) with no commits ahead of {default_branch}:[/bold]",
|
|
1359
|
+
)
|
|
1360
|
+
for wt in to_remove:
|
|
1361
|
+
console.print(f" • {wt.branch} ({wt.path})")
|
|
1362
|
+
|
|
1363
|
+
if dry_run:
|
|
1364
|
+
_info("[dry-run] Would remove the above worktrees")
|
|
1365
|
+
elif yes or typer.confirm("\nRemove these worktrees?"):
|
|
1366
|
+
for wt in to_remove:
|
|
1367
|
+
success, error = worktree.remove_worktree(
|
|
1368
|
+
wt.path,
|
|
1369
|
+
force=False,
|
|
1370
|
+
delete_branch=True,
|
|
1371
|
+
repo_path=repo_root,
|
|
1372
|
+
)
|
|
1373
|
+
if success:
|
|
1374
|
+
_success(f"Removed {wt.branch}")
|
|
1375
|
+
else:
|
|
1376
|
+
_warn(f"Failed to remove {wt.branch}: {error}")
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
@app.command("clean")
|
|
1380
|
+
def clean(
|
|
1381
|
+
merged: Annotated[
|
|
1382
|
+
bool,
|
|
1383
|
+
typer.Option("--merged", help="Remove worktrees with merged PRs (requires gh CLI)"),
|
|
1384
|
+
] = False,
|
|
1385
|
+
no_commits: Annotated[
|
|
1386
|
+
bool,
|
|
1387
|
+
typer.Option(
|
|
1388
|
+
"--no-commits",
|
|
1389
|
+
help="Remove worktrees with no commits ahead of default branch",
|
|
1390
|
+
),
|
|
1391
|
+
] = False,
|
|
1392
|
+
dry_run: Annotated[
|
|
1393
|
+
bool,
|
|
1394
|
+
typer.Option("--dry-run", "-n", help="Show what would be done without doing it"),
|
|
1395
|
+
] = False,
|
|
1396
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
|
|
1397
|
+
) -> None:
|
|
1398
|
+
"""Clean up stale worktrees and empty directories.
|
|
1399
|
+
|
|
1400
|
+
Runs `git worktree prune` and removes empty worktree directories.
|
|
1401
|
+
With --merged, also removes worktrees whose PRs have been merged.
|
|
1402
|
+
With --no-commits, removes worktrees with no commits ahead of the default branch.
|
|
1403
|
+
"""
|
|
1404
|
+
repo_root = _ensure_git_repo()
|
|
1405
|
+
|
|
1406
|
+
# Run git worktree prune
|
|
1407
|
+
_info("Pruning stale worktree references...")
|
|
1408
|
+
result = subprocess.run(
|
|
1409
|
+
["git", "worktree", "prune"], # noqa: S607
|
|
1410
|
+
cwd=repo_root,
|
|
1411
|
+
capture_output=True,
|
|
1412
|
+
text=True,
|
|
1413
|
+
check=False,
|
|
1414
|
+
)
|
|
1415
|
+
if result.returncode == 0:
|
|
1416
|
+
_success("Pruned stale worktree administrative files")
|
|
1417
|
+
else:
|
|
1418
|
+
_warn(f"Prune failed: {result.stderr}")
|
|
1419
|
+
|
|
1420
|
+
# Find and remove empty directories in worktrees base dir
|
|
1421
|
+
base_dir = worktree.resolve_worktree_base_dir(repo_root)
|
|
1422
|
+
if base_dir and base_dir.exists():
|
|
1423
|
+
cleaned = 0
|
|
1424
|
+
for item in base_dir.iterdir():
|
|
1425
|
+
if item.is_dir() and not any(item.iterdir()):
|
|
1426
|
+
if dry_run:
|
|
1427
|
+
_info(f"[dry-run] Would remove empty directory: {item.name}")
|
|
1428
|
+
else:
|
|
1429
|
+
item.rmdir()
|
|
1430
|
+
_info(f"Removed empty directory: {item.name}")
|
|
1431
|
+
cleaned += 1
|
|
1432
|
+
if cleaned > 0:
|
|
1433
|
+
_success(f"Cleaned {cleaned} empty director{'y' if cleaned == 1 else 'ies'}")
|
|
1434
|
+
|
|
1435
|
+
# --merged mode: remove worktrees with merged PRs
|
|
1436
|
+
if merged:
|
|
1437
|
+
_clean_merged_worktrees(repo_root, dry_run, yes)
|
|
1438
|
+
|
|
1439
|
+
# --no-commits mode: remove worktrees with no commits ahead of default branch
|
|
1440
|
+
if no_commits:
|
|
1441
|
+
_clean_no_commits_worktrees(repo_root, dry_run, yes)
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
@app.command("doctor")
|
|
1445
|
+
def doctor(
|
|
1446
|
+
json_output: Annotated[
|
|
1447
|
+
bool,
|
|
1448
|
+
typer.Option("--json", help="Output as JSON for automation"),
|
|
1449
|
+
] = False,
|
|
1450
|
+
) -> None:
|
|
1451
|
+
"""Check system requirements and available integrations."""
|
|
1452
|
+
current_editor = editors.detect_current_editor()
|
|
1453
|
+
current_agent = coding_agents.detect_current_agent()
|
|
1454
|
+
current_terminal = terminals.detect_current_terminal()
|
|
1455
|
+
|
|
1456
|
+
if json_output:
|
|
1457
|
+
repo_root = worktree.get_main_repo_root()
|
|
1458
|
+
data = {
|
|
1459
|
+
"git": {
|
|
1460
|
+
"is_available": worktree.git_available(),
|
|
1461
|
+
"repo_root": repo_root.as_posix() if repo_root else None,
|
|
1462
|
+
},
|
|
1463
|
+
"editors": [
|
|
1464
|
+
{
|
|
1465
|
+
"name": editor.name,
|
|
1466
|
+
"is_available": editor.is_available(),
|
|
1467
|
+
"is_current": current_editor is not None and editor.name == current_editor.name,
|
|
1468
|
+
}
|
|
1469
|
+
for editor in editors.get_all_editors()
|
|
1470
|
+
],
|
|
1471
|
+
"agents": [
|
|
1472
|
+
{
|
|
1473
|
+
"name": agent.name,
|
|
1474
|
+
"is_available": agent.is_available(),
|
|
1475
|
+
"is_current": current_agent is not None and agent.name == current_agent.name,
|
|
1476
|
+
}
|
|
1477
|
+
for agent in coding_agents.get_all_agents()
|
|
1478
|
+
],
|
|
1479
|
+
"terminals": [
|
|
1480
|
+
{
|
|
1481
|
+
"name": terminal.name,
|
|
1482
|
+
"is_available": terminal.is_available(),
|
|
1483
|
+
"is_current": current_terminal is not None
|
|
1484
|
+
and terminal.name == current_terminal.name,
|
|
1485
|
+
}
|
|
1486
|
+
for terminal in terminals.get_all_terminals()
|
|
1487
|
+
],
|
|
1488
|
+
}
|
|
1489
|
+
print(json.dumps(data))
|
|
1490
|
+
return
|
|
1491
|
+
|
|
1492
|
+
console.print("[bold]Dev Doctor[/bold]\n")
|
|
1493
|
+
|
|
1494
|
+
_doctor_check_git()
|
|
1495
|
+
console.print()
|
|
1496
|
+
|
|
1497
|
+
# Check editors
|
|
1498
|
+
console.print("[bold]Editors:[/bold]")
|
|
1499
|
+
for editor in editors.get_all_editors():
|
|
1500
|
+
is_current = current_editor is not None and editor.name == current_editor.name
|
|
1501
|
+
_print_item_status(editor.name, editor.is_available(), is_current)
|
|
1502
|
+
console.print()
|
|
1503
|
+
|
|
1504
|
+
# Check agents
|
|
1505
|
+
console.print("[bold]AI Coding Agents:[/bold]")
|
|
1506
|
+
for agent in coding_agents.get_all_agents():
|
|
1507
|
+
is_current = current_agent is not None and agent.name == current_agent.name
|
|
1508
|
+
_print_item_status(agent.name, agent.is_available(), is_current)
|
|
1509
|
+
console.print()
|
|
1510
|
+
|
|
1511
|
+
# Check terminals
|
|
1512
|
+
console.print("[bold]Terminals:[/bold]")
|
|
1513
|
+
for terminal in terminals.get_all_terminals():
|
|
1514
|
+
is_current = current_terminal is not None and terminal.name == current_terminal.name
|
|
1515
|
+
_print_item_status(terminal.name, terminal.is_available(), is_current, "not available")
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
def _get_skill_source_dir() -> Path:
|
|
1519
|
+
"""Get the path to the bundled skill files."""
|
|
1520
|
+
return Path(__file__).parent / "skill"
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def _get_current_repo_root() -> Path | None:
|
|
1524
|
+
"""Get the current repository root (works in worktrees too)."""
|
|
1525
|
+
result = subprocess.run(
|
|
1526
|
+
["git", "rev-parse", "--show-toplevel"], # noqa: S607
|
|
1527
|
+
capture_output=True,
|
|
1528
|
+
text=True,
|
|
1529
|
+
check=False,
|
|
1530
|
+
)
|
|
1531
|
+
if result.returncode == 0:
|
|
1532
|
+
return Path(result.stdout.strip())
|
|
1533
|
+
return None
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
@app.command("install-skill")
|
|
1537
|
+
def install_skill(
|
|
1538
|
+
force: Annotated[
|
|
1539
|
+
bool,
|
|
1540
|
+
typer.Option("--force", "-f", help="Overwrite existing skill files"),
|
|
1541
|
+
] = False,
|
|
1542
|
+
) -> None:
|
|
1543
|
+
"""Install Claude Code skill for parallel agent orchestration.
|
|
1544
|
+
|
|
1545
|
+
Installs a skill that teaches Claude Code how to use 'agent-cli dev' to
|
|
1546
|
+
spawn parallel AI coding agents in isolated git worktrees.
|
|
1547
|
+
|
|
1548
|
+
The skill is installed to .claude/skills/agent-cli-dev/ in the current
|
|
1549
|
+
repository. Once installed, Claude Code can automatically use it when
|
|
1550
|
+
you ask to work on multiple features or parallelize development tasks.
|
|
1551
|
+
"""
|
|
1552
|
+
# Use current repo root (works in worktrees too)
|
|
1553
|
+
repo_root = _get_current_repo_root()
|
|
1554
|
+
if repo_root is None:
|
|
1555
|
+
_error("Not in a git repository")
|
|
1556
|
+
|
|
1557
|
+
skill_source = _get_skill_source_dir()
|
|
1558
|
+
skill_dest = repo_root / ".claude" / "skills" / "agent-cli-dev"
|
|
1559
|
+
|
|
1560
|
+
# Check if skill source exists
|
|
1561
|
+
if not skill_source.exists():
|
|
1562
|
+
_error(f"Skill source not found: {skill_source}")
|
|
1563
|
+
|
|
1564
|
+
# Check if already installed
|
|
1565
|
+
if skill_dest.exists() and not force:
|
|
1566
|
+
_warn(f"Skill already installed at {skill_dest}")
|
|
1567
|
+
console.print("[dim]Use --force to overwrite[/dim]")
|
|
1568
|
+
raise typer.Exit(0)
|
|
1569
|
+
|
|
1570
|
+
# Create destination directory
|
|
1571
|
+
skill_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1572
|
+
|
|
1573
|
+
# Copy skill files
|
|
1574
|
+
if skill_dest.exists():
|
|
1575
|
+
shutil.rmtree(skill_dest)
|
|
1576
|
+
|
|
1577
|
+
shutil.copytree(skill_source, skill_dest)
|
|
1578
|
+
|
|
1579
|
+
_success(f"Installed skill to {skill_dest}")
|
|
1580
|
+
console.print()
|
|
1581
|
+
console.print("[bold]What's next?[/bold]")
|
|
1582
|
+
console.print(" • Claude Code will automatically use this skill when relevant")
|
|
1583
|
+
console.print(" • Ask Claude to 'work on multiple features in parallel'")
|
|
1584
|
+
console.print(" • Or ask 'spawn agents for auth, payments, and notifications'")
|
|
1585
|
+
console.print()
|
|
1586
|
+
console.print("[dim]Skill files:[/dim]")
|
|
1587
|
+
for f in sorted(skill_dest.iterdir()):
|
|
1588
|
+
console.print(f" • {f.name}")
|