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
@@ -0,0 +1,568 @@
1
+ """Project type detection and setup for the dev module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+
15
+
16
+ @dataclass
17
+ class ProjectType:
18
+ """Detected project type with setup commands."""
19
+
20
+ name: str
21
+ setup_commands: list[str]
22
+ description: str
23
+
24
+
25
+ def get_conda_env_name(path: Path) -> str:
26
+ """Get conda environment name, prefixed with repo name for worktrees.
27
+
28
+ For worktrees in `{repo}-worktrees/{branch}`, returns `{repo}-{branch}`.
29
+ For main repos or non-worktree directories, returns just the directory name.
30
+
31
+ This prevents conda env name collisions when working on multiple repos
32
+ with similarly named branches (e.g., both repos having a 'cool-bear' branch).
33
+
34
+ Evidence: Worktree directories follow the pattern established in worktree.py
35
+ line 239: `repo_root.parent / f"{repo_root.name}-worktrees"`
36
+ """
37
+ parent_name = path.parent.name
38
+ if parent_name.endswith("-worktrees"):
39
+ # Extract repo name by removing '-worktrees' suffix
40
+ repo_name = parent_name[: -len("-worktrees")]
41
+ return f"{repo_name}-{path.name}"
42
+ return path.name
43
+
44
+
45
+ def _is_unidep_monorepo(path: Path) -> bool:
46
+ """Check if this is a unidep monorepo with multiple requirements.yaml files.
47
+
48
+ A monorepo is detected when there are requirements.yaml files in subdirectories,
49
+ indicating multiple packages managed together. Searches up to 2 levels deep.
50
+ Excludes common test/example directories to avoid false positives.
51
+ """
52
+ # Directories to exclude from monorepo detection (test fixtures, examples, etc.)
53
+ excluded_dirs = {"test", "tests", "example", "examples", "docs", "doc", "scripts"}
54
+
55
+ # Check for requirements.yaml or [tool.unidep] in subdirectories (depth 1-2)
56
+ for subdir in path.iterdir():
57
+ if not subdir.is_dir() or subdir.name.startswith("."):
58
+ continue
59
+ if subdir.name.lower() in excluded_dirs:
60
+ continue
61
+ # Check immediate children
62
+ if (subdir / "requirements.yaml").exists():
63
+ return True
64
+ pyproject = subdir / "pyproject.toml"
65
+ if pyproject.exists() and "[tool.unidep]" in pyproject.read_text():
66
+ return True
67
+ # Check one level deeper (e.g., packages/pkg1/)
68
+ for nested in subdir.iterdir():
69
+ if not nested.is_dir() or nested.name.startswith("."):
70
+ continue
71
+ if (nested / "requirements.yaml").exists():
72
+ return True
73
+ nested_pyproject = nested / "pyproject.toml"
74
+ if nested_pyproject.exists() and "[tool.unidep]" in nested_pyproject.read_text():
75
+ return True
76
+ return False
77
+
78
+
79
+ def _unidep_cmd(subcommand: str) -> str | None:
80
+ """Generate unidep command, checking availability.
81
+
82
+ Returns the command to run, or None if neither unidep nor uvx is available.
83
+ Prefers unidep if installed, falls back to uvx.
84
+ """
85
+ if shutil.which("unidep"):
86
+ return f"unidep {subcommand}"
87
+ if shutil.which("uvx"):
88
+ return f"uvx unidep {subcommand}"
89
+ return None
90
+
91
+
92
+ def _detect_unidep_project(path: Path) -> ProjectType | None:
93
+ """Detect unidep project and determine the appropriate install command.
94
+
95
+ For single projects: unidep install -e . -n {env_name}
96
+ For monorepos: unidep install-all -e -n {env_name}
97
+
98
+ If conda-lock.yml exists, adds -f conda-lock.yml to use the locked dependencies.
99
+
100
+ Falls back to `uvx unidep` if unidep is not installed globally.
101
+ The {env_name} placeholder is replaced with path.name at runtime by run_setup().
102
+
103
+ Evidence: https://github.com/basnijholt/unidep README documents these commands.
104
+ """
105
+ has_requirements_yaml = (path / "requirements.yaml").exists()
106
+ has_tool_unidep = False
107
+
108
+ if (path / "pyproject.toml").exists():
109
+ pyproject_content = (path / "pyproject.toml").read_text()
110
+ has_tool_unidep = "[tool.unidep]" in pyproject_content
111
+
112
+ # Check for conda-lock.yml to use locked dependencies
113
+ lock_flag = " -f conda-lock.yml" if (path / "conda-lock.yml").exists() else ""
114
+
115
+ # Determine if this is a monorepo (multiple requirements.yaml in subdirs)
116
+ is_monorepo = _is_unidep_monorepo(path)
117
+
118
+ # Detect monorepo even without root requirements.yaml
119
+ # (subdirs with requirements.yaml is enough)
120
+ if is_monorepo:
121
+ cmd = _unidep_cmd(f"install-all -e{lock_flag} -n {{env_name}}")
122
+ if cmd is None:
123
+ return None # Neither unidep nor uvx available
124
+ return ProjectType(
125
+ name="python-unidep-monorepo",
126
+ # -n creates a named conda environment matching the worktree directory
127
+ setup_commands=[cmd],
128
+ description="Python monorepo with unidep",
129
+ )
130
+
131
+ # Single project requires root requirements.yaml or [tool.unidep]
132
+ if has_requirements_yaml or has_tool_unidep:
133
+ cmd = _unidep_cmd(f"install -e .{lock_flag} -n {{env_name}}")
134
+ if cmd is None:
135
+ return None # Neither unidep nor uvx available
136
+ return ProjectType(
137
+ name="python-unidep",
138
+ # -n creates a named conda environment matching the worktree directory
139
+ setup_commands=[cmd],
140
+ description="Python project with unidep",
141
+ )
142
+
143
+ return None
144
+
145
+
146
+ def detect_project_type(path: Path) -> ProjectType | None: # noqa: PLR0911
147
+ """Detect the project type based on files present.
148
+
149
+ Returns the first matching project type with setup commands.
150
+ """
151
+ # Python with uv (highest priority for Python)
152
+ if (path / "uv.lock").exists() or (
153
+ (path / "pyproject.toml").exists() and "uv" in (path / "pyproject.toml").read_text()
154
+ ):
155
+ return ProjectType(
156
+ name="python-uv",
157
+ setup_commands=["uv sync --all-extras"],
158
+ description="Python project with uv",
159
+ )
160
+
161
+ # Pixi (cross-platform package manager from prefix.dev)
162
+ # Evidence: https://pixi.sh/latest/ - pixi.toml is the config file, pixi.lock is the lockfile
163
+ if (path / "pixi.toml").exists() or (path / "pixi.lock").exists():
164
+ return ProjectType(
165
+ name="pixi",
166
+ setup_commands=["pixi install"],
167
+ description="Project with pixi",
168
+ )
169
+
170
+ # Python with unidep (Conda + Pip unified dependency management)
171
+ # Check for requirements.yaml (primary unidep config) or [tool.unidep] in pyproject.toml
172
+ unidep_project = _detect_unidep_project(path)
173
+ if unidep_project is not None:
174
+ return unidep_project
175
+
176
+ # Python with Poetry
177
+ if (path / "poetry.lock").exists():
178
+ return ProjectType(
179
+ name="python-poetry",
180
+ setup_commands=["poetry install"],
181
+ description="Python project with Poetry",
182
+ )
183
+
184
+ # Python with pip/requirements.txt
185
+ if (path / "requirements.txt").exists():
186
+ return ProjectType(
187
+ name="python-pip",
188
+ setup_commands=["pip install -r requirements.txt"],
189
+ description="Python project with pip",
190
+ )
191
+
192
+ # Python with pyproject.toml (generic)
193
+ if (path / "pyproject.toml").exists():
194
+ return ProjectType(
195
+ name="python",
196
+ setup_commands=["pip install -e ."],
197
+ description="Python project",
198
+ )
199
+
200
+ # Node.js with pnpm
201
+ if (path / "pnpm-lock.yaml").exists():
202
+ return ProjectType(
203
+ name="node-pnpm",
204
+ setup_commands=["pnpm install"],
205
+ description="Node.js project with pnpm",
206
+ )
207
+
208
+ # Node.js with yarn
209
+ if (path / "yarn.lock").exists():
210
+ return ProjectType(
211
+ name="node-yarn",
212
+ setup_commands=["yarn install"],
213
+ description="Node.js project with Yarn",
214
+ )
215
+
216
+ # Node.js with npm
217
+ if (path / "package-lock.json").exists() or (path / "package.json").exists():
218
+ return ProjectType(
219
+ name="node-npm",
220
+ setup_commands=["npm install"],
221
+ description="Node.js project with npm",
222
+ )
223
+
224
+ # Rust
225
+ if (path / "Cargo.toml").exists():
226
+ return ProjectType(
227
+ name="rust",
228
+ setup_commands=["cargo build"],
229
+ description="Rust project",
230
+ )
231
+
232
+ # Go
233
+ if (path / "go.mod").exists():
234
+ return ProjectType(
235
+ name="go",
236
+ setup_commands=["go mod download"],
237
+ description="Go project",
238
+ )
239
+
240
+ # Ruby with Bundler
241
+ if (path / "Gemfile.lock").exists() or (path / "Gemfile").exists():
242
+ return ProjectType(
243
+ name="ruby",
244
+ setup_commands=["bundle install"],
245
+ description="Ruby project with Bundler",
246
+ )
247
+
248
+ return None
249
+
250
+
251
+ def run_setup(
252
+ path: Path,
253
+ project_type: ProjectType | None = None,
254
+ *,
255
+ capture_output: bool = True,
256
+ on_log: Callable[[str], None] | None = None,
257
+ ) -> tuple[bool, str]:
258
+ """Run the setup commands for a project.
259
+
260
+ Args:
261
+ path: Path to the project directory
262
+ project_type: Detected project type (auto-detected if None)
263
+ capture_output: Whether to capture output or stream to console
264
+ on_log: Optional callback for logging status messages
265
+
266
+ Returns:
267
+ Tuple of (success, output_or_error)
268
+
269
+ """
270
+ if project_type is None:
271
+ project_type = detect_project_type(path)
272
+
273
+ if project_type is None:
274
+ return True, "No project type detected, skipping setup"
275
+
276
+ outputs: list[str] = []
277
+
278
+ for cmd_template in project_type.setup_commands:
279
+ # Substitute {env_name} placeholder with conda env name (used by unidep)
280
+ cmd = cmd_template.replace("{env_name}", get_conda_env_name(path))
281
+
282
+ if on_log:
283
+ on_log(f"Running: {cmd}")
284
+
285
+ try:
286
+ # Clear virtual environment variables to avoid warnings from uv/pip
287
+ # when running from within an activated environment
288
+ env = os.environ.copy()
289
+ env.pop("VIRTUAL_ENV", None)
290
+ env.pop("CONDA_PREFIX", None)
291
+ env.pop("CONDA_DEFAULT_ENV", None)
292
+
293
+ result = subprocess.run( # noqa: S602
294
+ cmd,
295
+ check=False,
296
+ shell=True,
297
+ cwd=path,
298
+ capture_output=capture_output,
299
+ text=True,
300
+ env=env,
301
+ )
302
+ if result.returncode != 0:
303
+ error = result.stderr.strip() if result.stderr else f"Command failed: {cmd}"
304
+ return False, error
305
+ if result.stdout:
306
+ outputs.append(result.stdout.strip())
307
+ except Exception as e:
308
+ return False, str(e)
309
+
310
+ return True, "\n".join(outputs) if outputs else f"Setup complete: {project_type.name}"
311
+
312
+
313
+ def copy_env_files(
314
+ source: Path,
315
+ dest: Path,
316
+ patterns: list[str] | None = None,
317
+ ) -> list[Path]:
318
+ """Copy environment and config files from source to destination.
319
+
320
+ Args:
321
+ source: Source directory (main repo)
322
+ dest: Destination directory (worktree)
323
+ patterns: File patterns to copy (default: common env files)
324
+
325
+ Returns:
326
+ List of copied file paths
327
+
328
+ """
329
+ if patterns is None:
330
+ patterns = [
331
+ ".env",
332
+ ".env.local",
333
+ ".env.example",
334
+ ".envrc",
335
+ ]
336
+
337
+ copied: list[Path] = []
338
+
339
+ for pattern in patterns:
340
+ # Handle both exact matches and glob patterns
341
+ if "*" in pattern:
342
+ source_files = list(source.glob(pattern))
343
+ else:
344
+ source_file = source / pattern
345
+ source_files = [source_file] if source_file.exists() else []
346
+
347
+ for src_file in source_files:
348
+ if src_file.is_file():
349
+ dest_file = dest / src_file.relative_to(source)
350
+ dest_file.parent.mkdir(parents=True, exist_ok=True)
351
+ dest_file.write_bytes(src_file.read_bytes())
352
+ copied.append(dest_file)
353
+
354
+ return copied
355
+
356
+
357
+ def is_direnv_available() -> bool:
358
+ """Check if direnv is installed and available."""
359
+ return shutil.which("direnv") is not None
360
+
361
+
362
+ def detect_venv_path(path: Path) -> Path | None:
363
+ """Detect the virtual environment path in a project.
364
+
365
+ Checks common venv directory names.
366
+ """
367
+ venv_names = [".venv", "venv", ".env", "env"]
368
+ for name in venv_names:
369
+ venv_path = path / name
370
+ # Check for Python venv structure (has bin/activate or Scripts/activate)
371
+ if (venv_path / "bin" / "activate").exists():
372
+ return venv_path
373
+ if (venv_path / "Scripts" / "activate").exists(): # Windows
374
+ return venv_path
375
+ return None
376
+
377
+
378
+ def _get_python_envrc(path: Path, project_name: str) -> str | None:
379
+ """Get .envrc content for Python projects."""
380
+ if project_name == "python-uv":
381
+ venv_path = detect_venv_path(path)
382
+ return f"source {venv_path.name}/bin/activate" if venv_path else "source .venv/bin/activate"
383
+ if project_name == "python-poetry":
384
+ return 'source "$(poetry env info --path)/bin/activate"'
385
+ if project_name in ("python-unidep", "python-unidep-monorepo"):
386
+ # unidep projects use conda/micromamba environments
387
+ # Inline the activation logic (inspired by layout_micromamba pattern)
388
+ # Uses ${SHELL##*/} to detect shell at runtime (zsh, bash, etc.)
389
+ # Redirect stderr to suppress "complete: command not found" from shell hooks
390
+ # (completion setup commands aren't available in direnv's subshell)
391
+ env_name = get_conda_env_name(path)
392
+ return f"""\
393
+ # Activate micromamba/conda environment: {env_name}
394
+ if command -v micromamba &> /dev/null; then
395
+ eval "$(micromamba shell hook --shell=${{SHELL##*/}})" 2>/dev/null
396
+ micromamba activate {env_name}
397
+ elif command -v conda &> /dev/null; then
398
+ eval "$(conda shell.${{SHELL##*/}} hook)" 2>/dev/null
399
+ conda activate {env_name}
400
+ fi"""
401
+ # Generic Python - look for existing venv
402
+ venv_path = detect_venv_path(path)
403
+ return f"source {venv_path.name}/bin/activate" if venv_path else None
404
+
405
+
406
+ def _get_envrc_for_project(path: Path, project_type: ProjectType) -> str | None:
407
+ """Get .envrc content for a specific project type."""
408
+ name = project_type.name
409
+
410
+ if name == "pixi":
411
+ # Evidence: https://pixi.sh/latest/features/environment/#direnv
412
+ # watch_file ensures direnv reloads when dependencies change
413
+ return 'watch_file pixi.lock\neval "$(pixi shell-hook)"'
414
+
415
+ if name.startswith("python"):
416
+ return _get_python_envrc(path, name)
417
+
418
+ if name.startswith("node"):
419
+ has_node_version = (path / ".nvmrc").exists() or (path / ".node-version").exists()
420
+ return "use node" if has_node_version else None
421
+
422
+ if name == "go":
423
+ return "layout go"
424
+
425
+ if name == "ruby":
426
+ return "layout ruby"
427
+
428
+ return None
429
+
430
+
431
+ def _is_nix_available() -> bool:
432
+ """Check if nix is available on the system."""
433
+ return shutil.which("nix") is not None
434
+
435
+
436
+ def _get_nix_envrc(path: Path) -> str | None:
437
+ """Get .envrc content for Nix projects.
438
+
439
+ Returns 'use flake' for flake.nix, 'use nix' for shell.nix.
440
+ """
441
+ if not _is_nix_available():
442
+ return None
443
+
444
+ # Prefer flake.nix over shell.nix
445
+ if (path / "flake.nix").exists():
446
+ return "use flake"
447
+ if (path / "shell.nix").exists():
448
+ return "use nix"
449
+
450
+ return None
451
+
452
+
453
+ def generate_envrc_content(path: Path, project_type: ProjectType | None = None) -> str | None:
454
+ """Generate .envrc content based on project type and environment.
455
+
456
+ Args:
457
+ path: Path to the project directory
458
+ project_type: Detected project type (auto-detected if None)
459
+
460
+ Returns:
461
+ Content for .envrc file, or None if no direnv config needed
462
+
463
+ """
464
+ if project_type is None:
465
+ project_type = detect_project_type(path)
466
+
467
+ lines: list[str] = []
468
+
469
+ # Check for Nix first (sets up the base environment)
470
+ nix_content = _get_nix_envrc(path)
471
+ if nix_content:
472
+ lines.append(nix_content)
473
+
474
+ # Add project-specific content
475
+ if project_type:
476
+ project_content = _get_envrc_for_project(path, project_type)
477
+ if project_content:
478
+ lines.append(project_content)
479
+
480
+ # Fallback: check for Python venv without detected project type
481
+ if not lines:
482
+ venv_path = detect_venv_path(path)
483
+ if venv_path:
484
+ lines.append(f"source {venv_path.name}/bin/activate")
485
+
486
+ if not lines:
487
+ return None
488
+
489
+ return "\n".join(lines) + "\n"
490
+
491
+
492
+ def _run_direnv_allow(
493
+ path: Path,
494
+ on_log: Callable[[str], None] | None = None,
495
+ capture_output: bool = True,
496
+ ) -> str | None:
497
+ """Run `direnv allow` in the given path.
498
+
499
+ Returns:
500
+ None on success, error message on failure.
501
+
502
+ """
503
+ if on_log:
504
+ on_log("Running: direnv allow")
505
+ result = subprocess.run(
506
+ ["direnv", "allow"], # noqa: S607
507
+ cwd=path,
508
+ capture_output=capture_output,
509
+ text=True,
510
+ check=False,
511
+ )
512
+ return result.stderr if result.returncode != 0 and result.stderr else None
513
+
514
+
515
+ def setup_direnv(
516
+ path: Path,
517
+ project_type: ProjectType | None = None,
518
+ *,
519
+ allow: bool = True,
520
+ on_log: Callable[[str], None] | None = None,
521
+ capture_output: bool = True,
522
+ ) -> tuple[bool, str]:
523
+ """Set up direnv for a project by creating .envrc file.
524
+
525
+ Args:
526
+ path: Path to the project directory
527
+ project_type: Detected project type (auto-detected if None)
528
+ allow: Whether to run `direnv allow` after creating .envrc
529
+ on_log: Optional callback for logging status messages
530
+ capture_output: Whether to capture command output (False to stream)
531
+
532
+ Returns:
533
+ Tuple of (success, message)
534
+
535
+ """
536
+ if not is_direnv_available():
537
+ return False, "direnv is not installed"
538
+
539
+ envrc_path = path / ".envrc"
540
+
541
+ # If .envrc already exists, just run direnv allow on it
542
+ if envrc_path.exists():
543
+ if not allow:
544
+ return True, "direnv: .envrc already exists (skipped direnv allow)"
545
+ error = _run_direnv_allow(path, on_log, capture_output=capture_output)
546
+ msg = (
547
+ "direnv: allowed existing .envrc"
548
+ if not error
549
+ else f"direnv: .envrc exists but 'direnv allow' failed: {error}"
550
+ )
551
+ return True, msg
552
+
553
+ content = generate_envrc_content(path, project_type)
554
+ if content is None:
555
+ return True, "direnv: no configuration needed for this project type"
556
+
557
+ # Write .envrc file
558
+ if on_log:
559
+ on_log("Creating .envrc file for direnv")
560
+ envrc_path.write_text(content)
561
+
562
+ # Run direnv allow to trust the file
563
+ if allow:
564
+ error = _run_direnv_allow(path, on_log, capture_output=capture_output)
565
+ if error:
566
+ return True, f"direnv: created .envrc but 'direnv allow' failed: {error}"
567
+
568
+ return True, f"direnv: created .envrc ({content.strip()})"
@@ -0,0 +1,52 @@
1
+ """Generic registry for adapter classes (editors, agents, terminals)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Generic, TypeVar
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ class Registry(Generic[T]):
11
+ """Generic registry for adapter instances with caching and detection."""
12
+
13
+ def __init__(self, adapter_classes: list[type[T]]) -> None:
14
+ """Initialize the registry with adapter classes.
15
+
16
+ Args:
17
+ adapter_classes: List of adapter classes in priority order for detection.
18
+
19
+ """
20
+ self._classes = adapter_classes
21
+ self._instances: dict[str, T] = {}
22
+
23
+ def get_all(self) -> list[T]:
24
+ """Get instances of all registered adapters."""
25
+ adapters = []
26
+ for cls in self._classes:
27
+ name = cls.name # type: ignore[attr-defined]
28
+ if name not in self._instances:
29
+ self._instances[name] = cls()
30
+ adapters.append(self._instances[name])
31
+ return adapters
32
+
33
+ def get_available(self) -> list[T]:
34
+ """Get all installed/available adapters."""
35
+ return [adapter for adapter in self.get_all() if adapter.is_available()] # type: ignore[attr-defined]
36
+
37
+ def detect_current(self) -> T | None:
38
+ """Detect which adapter is currently active in the environment."""
39
+ for adapter in self.get_all():
40
+ if adapter.detect(): # type: ignore[attr-defined]
41
+ return adapter
42
+ return None
43
+
44
+ def get_by_name(self, name: str) -> T | None:
45
+ """Get an adapter by name or command."""
46
+ name_lower = name.lower()
47
+ for adapter in self.get_all():
48
+ if adapter.name.lower() == name_lower: # type: ignore[attr-defined]
49
+ return adapter
50
+ if hasattr(adapter, "command") and adapter.command.lower() == name_lower: # type: ignore[attr-defined]
51
+ return adapter
52
+ return None