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.
Files changed (196) hide show
  1. agent_cli/__init__.py +5 -0
  2. agent_cli/__main__.py +6 -0
  3. agent_cli/_extras.json +14 -0
  4. agent_cli/_requirements/.gitkeep +0 -0
  5. agent_cli/_requirements/audio.txt +79 -0
  6. agent_cli/_requirements/faster-whisper.txt +215 -0
  7. agent_cli/_requirements/kokoro.txt +425 -0
  8. agent_cli/_requirements/llm.txt +183 -0
  9. agent_cli/_requirements/memory.txt +355 -0
  10. agent_cli/_requirements/mlx-whisper.txt +222 -0
  11. agent_cli/_requirements/piper.txt +176 -0
  12. agent_cli/_requirements/rag.txt +402 -0
  13. agent_cli/_requirements/server.txt +154 -0
  14. agent_cli/_requirements/speed.txt +77 -0
  15. agent_cli/_requirements/vad.txt +155 -0
  16. agent_cli/_requirements/wyoming.txt +71 -0
  17. agent_cli/_tools.py +368 -0
  18. agent_cli/agents/__init__.py +23 -0
  19. agent_cli/agents/_voice_agent_common.py +136 -0
  20. agent_cli/agents/assistant.py +383 -0
  21. agent_cli/agents/autocorrect.py +284 -0
  22. agent_cli/agents/chat.py +496 -0
  23. agent_cli/agents/memory/__init__.py +31 -0
  24. agent_cli/agents/memory/add.py +190 -0
  25. agent_cli/agents/memory/proxy.py +160 -0
  26. agent_cli/agents/rag_proxy.py +128 -0
  27. agent_cli/agents/speak.py +209 -0
  28. agent_cli/agents/transcribe.py +671 -0
  29. agent_cli/agents/transcribe_daemon.py +499 -0
  30. agent_cli/agents/voice_edit.py +291 -0
  31. agent_cli/api.py +22 -0
  32. agent_cli/cli.py +106 -0
  33. agent_cli/config.py +503 -0
  34. agent_cli/config_cmd.py +307 -0
  35. agent_cli/constants.py +27 -0
  36. agent_cli/core/__init__.py +1 -0
  37. agent_cli/core/audio.py +461 -0
  38. agent_cli/core/audio_format.py +299 -0
  39. agent_cli/core/chroma.py +88 -0
  40. agent_cli/core/deps.py +191 -0
  41. agent_cli/core/openai_proxy.py +139 -0
  42. agent_cli/core/process.py +195 -0
  43. agent_cli/core/reranker.py +120 -0
  44. agent_cli/core/sse.py +87 -0
  45. agent_cli/core/transcription_logger.py +70 -0
  46. agent_cli/core/utils.py +526 -0
  47. agent_cli/core/vad.py +175 -0
  48. agent_cli/core/watch.py +65 -0
  49. agent_cli/dev/__init__.py +14 -0
  50. agent_cli/dev/cli.py +1588 -0
  51. agent_cli/dev/coding_agents/__init__.py +19 -0
  52. agent_cli/dev/coding_agents/aider.py +24 -0
  53. agent_cli/dev/coding_agents/base.py +167 -0
  54. agent_cli/dev/coding_agents/claude.py +39 -0
  55. agent_cli/dev/coding_agents/codex.py +24 -0
  56. agent_cli/dev/coding_agents/continue_dev.py +15 -0
  57. agent_cli/dev/coding_agents/copilot.py +24 -0
  58. agent_cli/dev/coding_agents/cursor_agent.py +48 -0
  59. agent_cli/dev/coding_agents/gemini.py +28 -0
  60. agent_cli/dev/coding_agents/opencode.py +15 -0
  61. agent_cli/dev/coding_agents/registry.py +49 -0
  62. agent_cli/dev/editors/__init__.py +19 -0
  63. agent_cli/dev/editors/base.py +89 -0
  64. agent_cli/dev/editors/cursor.py +15 -0
  65. agent_cli/dev/editors/emacs.py +46 -0
  66. agent_cli/dev/editors/jetbrains.py +56 -0
  67. agent_cli/dev/editors/nano.py +31 -0
  68. agent_cli/dev/editors/neovim.py +33 -0
  69. agent_cli/dev/editors/registry.py +59 -0
  70. agent_cli/dev/editors/sublime.py +20 -0
  71. agent_cli/dev/editors/vim.py +42 -0
  72. agent_cli/dev/editors/vscode.py +15 -0
  73. agent_cli/dev/editors/zed.py +20 -0
  74. agent_cli/dev/project.py +568 -0
  75. agent_cli/dev/registry.py +52 -0
  76. agent_cli/dev/skill/SKILL.md +141 -0
  77. agent_cli/dev/skill/examples.md +571 -0
  78. agent_cli/dev/terminals/__init__.py +19 -0
  79. agent_cli/dev/terminals/apple_terminal.py +82 -0
  80. agent_cli/dev/terminals/base.py +56 -0
  81. agent_cli/dev/terminals/gnome.py +51 -0
  82. agent_cli/dev/terminals/iterm2.py +84 -0
  83. agent_cli/dev/terminals/kitty.py +77 -0
  84. agent_cli/dev/terminals/registry.py +48 -0
  85. agent_cli/dev/terminals/tmux.py +58 -0
  86. agent_cli/dev/terminals/warp.py +132 -0
  87. agent_cli/dev/terminals/zellij.py +78 -0
  88. agent_cli/dev/worktree.py +856 -0
  89. agent_cli/docs_gen.py +417 -0
  90. agent_cli/example-config.toml +185 -0
  91. agent_cli/install/__init__.py +5 -0
  92. agent_cli/install/common.py +89 -0
  93. agent_cli/install/extras.py +174 -0
  94. agent_cli/install/hotkeys.py +48 -0
  95. agent_cli/install/services.py +87 -0
  96. agent_cli/memory/__init__.py +7 -0
  97. agent_cli/memory/_files.py +250 -0
  98. agent_cli/memory/_filters.py +63 -0
  99. agent_cli/memory/_git.py +157 -0
  100. agent_cli/memory/_indexer.py +142 -0
  101. agent_cli/memory/_ingest.py +408 -0
  102. agent_cli/memory/_persistence.py +182 -0
  103. agent_cli/memory/_prompt.py +91 -0
  104. agent_cli/memory/_retrieval.py +294 -0
  105. agent_cli/memory/_store.py +169 -0
  106. agent_cli/memory/_streaming.py +44 -0
  107. agent_cli/memory/_tasks.py +48 -0
  108. agent_cli/memory/api.py +113 -0
  109. agent_cli/memory/client.py +272 -0
  110. agent_cli/memory/engine.py +361 -0
  111. agent_cli/memory/entities.py +43 -0
  112. agent_cli/memory/models.py +112 -0
  113. agent_cli/opts.py +433 -0
  114. agent_cli/py.typed +0 -0
  115. agent_cli/rag/__init__.py +3 -0
  116. agent_cli/rag/_indexer.py +67 -0
  117. agent_cli/rag/_indexing.py +226 -0
  118. agent_cli/rag/_prompt.py +30 -0
  119. agent_cli/rag/_retriever.py +156 -0
  120. agent_cli/rag/_store.py +48 -0
  121. agent_cli/rag/_utils.py +218 -0
  122. agent_cli/rag/api.py +175 -0
  123. agent_cli/rag/client.py +299 -0
  124. agent_cli/rag/engine.py +302 -0
  125. agent_cli/rag/models.py +55 -0
  126. agent_cli/scripts/.runtime/.gitkeep +0 -0
  127. agent_cli/scripts/__init__.py +1 -0
  128. agent_cli/scripts/check_plugin_skill_sync.py +50 -0
  129. agent_cli/scripts/linux-hotkeys/README.md +63 -0
  130. agent_cli/scripts/linux-hotkeys/toggle-autocorrect.sh +45 -0
  131. agent_cli/scripts/linux-hotkeys/toggle-transcription.sh +58 -0
  132. agent_cli/scripts/linux-hotkeys/toggle-voice-edit.sh +58 -0
  133. agent_cli/scripts/macos-hotkeys/README.md +45 -0
  134. agent_cli/scripts/macos-hotkeys/skhd-config-example +5 -0
  135. agent_cli/scripts/macos-hotkeys/toggle-autocorrect.sh +12 -0
  136. agent_cli/scripts/macos-hotkeys/toggle-transcription.sh +37 -0
  137. agent_cli/scripts/macos-hotkeys/toggle-voice-edit.sh +37 -0
  138. agent_cli/scripts/nvidia-asr-server/README.md +99 -0
  139. agent_cli/scripts/nvidia-asr-server/pyproject.toml +27 -0
  140. agent_cli/scripts/nvidia-asr-server/server.py +255 -0
  141. agent_cli/scripts/nvidia-asr-server/shell.nix +32 -0
  142. agent_cli/scripts/nvidia-asr-server/uv.lock +4654 -0
  143. agent_cli/scripts/run-openwakeword.sh +11 -0
  144. agent_cli/scripts/run-piper-windows.ps1 +30 -0
  145. agent_cli/scripts/run-piper.sh +24 -0
  146. agent_cli/scripts/run-whisper-linux.sh +40 -0
  147. agent_cli/scripts/run-whisper-macos.sh +6 -0
  148. agent_cli/scripts/run-whisper-windows.ps1 +51 -0
  149. agent_cli/scripts/run-whisper.sh +9 -0
  150. agent_cli/scripts/run_faster_whisper_server.py +136 -0
  151. agent_cli/scripts/setup-linux-hotkeys.sh +72 -0
  152. agent_cli/scripts/setup-linux.sh +108 -0
  153. agent_cli/scripts/setup-macos-hotkeys.sh +61 -0
  154. agent_cli/scripts/setup-macos.sh +76 -0
  155. agent_cli/scripts/setup-windows.ps1 +63 -0
  156. agent_cli/scripts/start-all-services-windows.ps1 +53 -0
  157. agent_cli/scripts/start-all-services.sh +178 -0
  158. agent_cli/scripts/sync_extras.py +138 -0
  159. agent_cli/server/__init__.py +3 -0
  160. agent_cli/server/cli.py +721 -0
  161. agent_cli/server/common.py +222 -0
  162. agent_cli/server/model_manager.py +288 -0
  163. agent_cli/server/model_registry.py +225 -0
  164. agent_cli/server/proxy/__init__.py +3 -0
  165. agent_cli/server/proxy/api.py +444 -0
  166. agent_cli/server/streaming.py +67 -0
  167. agent_cli/server/tts/__init__.py +3 -0
  168. agent_cli/server/tts/api.py +335 -0
  169. agent_cli/server/tts/backends/__init__.py +82 -0
  170. agent_cli/server/tts/backends/base.py +139 -0
  171. agent_cli/server/tts/backends/kokoro.py +403 -0
  172. agent_cli/server/tts/backends/piper.py +253 -0
  173. agent_cli/server/tts/model_manager.py +201 -0
  174. agent_cli/server/tts/model_registry.py +28 -0
  175. agent_cli/server/tts/wyoming_handler.py +249 -0
  176. agent_cli/server/whisper/__init__.py +3 -0
  177. agent_cli/server/whisper/api.py +413 -0
  178. agent_cli/server/whisper/backends/__init__.py +89 -0
  179. agent_cli/server/whisper/backends/base.py +97 -0
  180. agent_cli/server/whisper/backends/faster_whisper.py +225 -0
  181. agent_cli/server/whisper/backends/mlx.py +270 -0
  182. agent_cli/server/whisper/languages.py +116 -0
  183. agent_cli/server/whisper/model_manager.py +157 -0
  184. agent_cli/server/whisper/model_registry.py +28 -0
  185. agent_cli/server/whisper/wyoming_handler.py +203 -0
  186. agent_cli/services/__init__.py +343 -0
  187. agent_cli/services/_wyoming_utils.py +64 -0
  188. agent_cli/services/asr.py +506 -0
  189. agent_cli/services/llm.py +228 -0
  190. agent_cli/services/tts.py +450 -0
  191. agent_cli/services/wake_word.py +142 -0
  192. agent_cli-0.70.5.dist-info/METADATA +2118 -0
  193. agent_cli-0.70.5.dist-info/RECORD +196 -0
  194. agent_cli-0.70.5.dist-info/WHEEL +4 -0
  195. agent_cli-0.70.5.dist-info/entry_points.txt +4 -0
  196. 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}")