agent-cli 0.70.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_cli/__init__.py +5 -0
- agent_cli/__main__.py +6 -0
- agent_cli/_extras.json +14 -0
- agent_cli/_requirements/.gitkeep +0 -0
- agent_cli/_requirements/audio.txt +79 -0
- agent_cli/_requirements/faster-whisper.txt +215 -0
- agent_cli/_requirements/kokoro.txt +425 -0
- agent_cli/_requirements/llm.txt +183 -0
- agent_cli/_requirements/memory.txt +355 -0
- agent_cli/_requirements/mlx-whisper.txt +222 -0
- agent_cli/_requirements/piper.txt +176 -0
- agent_cli/_requirements/rag.txt +402 -0
- agent_cli/_requirements/server.txt +154 -0
- agent_cli/_requirements/speed.txt +77 -0
- agent_cli/_requirements/vad.txt +155 -0
- agent_cli/_requirements/wyoming.txt +71 -0
- agent_cli/_tools.py +368 -0
- agent_cli/agents/__init__.py +23 -0
- agent_cli/agents/_voice_agent_common.py +136 -0
- agent_cli/agents/assistant.py +383 -0
- agent_cli/agents/autocorrect.py +284 -0
- agent_cli/agents/chat.py +496 -0
- agent_cli/agents/memory/__init__.py +31 -0
- agent_cli/agents/memory/add.py +190 -0
- agent_cli/agents/memory/proxy.py +160 -0
- agent_cli/agents/rag_proxy.py +128 -0
- agent_cli/agents/speak.py +209 -0
- agent_cli/agents/transcribe.py +671 -0
- agent_cli/agents/transcribe_daemon.py +499 -0
- agent_cli/agents/voice_edit.py +291 -0
- agent_cli/api.py +22 -0
- agent_cli/cli.py +106 -0
- agent_cli/config.py +503 -0
- agent_cli/config_cmd.py +307 -0
- agent_cli/constants.py +27 -0
- agent_cli/core/__init__.py +1 -0
- agent_cli/core/audio.py +461 -0
- agent_cli/core/audio_format.py +299 -0
- agent_cli/core/chroma.py +88 -0
- agent_cli/core/deps.py +191 -0
- agent_cli/core/openai_proxy.py +139 -0
- agent_cli/core/process.py +195 -0
- agent_cli/core/reranker.py +120 -0
- agent_cli/core/sse.py +87 -0
- agent_cli/core/transcription_logger.py +70 -0
- agent_cli/core/utils.py +526 -0
- agent_cli/core/vad.py +175 -0
- agent_cli/core/watch.py +65 -0
- agent_cli/dev/__init__.py +14 -0
- agent_cli/dev/cli.py +1588 -0
- agent_cli/dev/coding_agents/__init__.py +19 -0
- agent_cli/dev/coding_agents/aider.py +24 -0
- agent_cli/dev/coding_agents/base.py +167 -0
- agent_cli/dev/coding_agents/claude.py +39 -0
- agent_cli/dev/coding_agents/codex.py +24 -0
- agent_cli/dev/coding_agents/continue_dev.py +15 -0
- agent_cli/dev/coding_agents/copilot.py +24 -0
- agent_cli/dev/coding_agents/cursor_agent.py +48 -0
- agent_cli/dev/coding_agents/gemini.py +28 -0
- agent_cli/dev/coding_agents/opencode.py +15 -0
- agent_cli/dev/coding_agents/registry.py +49 -0
- agent_cli/dev/editors/__init__.py +19 -0
- agent_cli/dev/editors/base.py +89 -0
- agent_cli/dev/editors/cursor.py +15 -0
- agent_cli/dev/editors/emacs.py +46 -0
- agent_cli/dev/editors/jetbrains.py +56 -0
- agent_cli/dev/editors/nano.py +31 -0
- agent_cli/dev/editors/neovim.py +33 -0
- agent_cli/dev/editors/registry.py +59 -0
- agent_cli/dev/editors/sublime.py +20 -0
- agent_cli/dev/editors/vim.py +42 -0
- agent_cli/dev/editors/vscode.py +15 -0
- agent_cli/dev/editors/zed.py +20 -0
- agent_cli/dev/project.py +568 -0
- agent_cli/dev/registry.py +52 -0
- agent_cli/dev/skill/SKILL.md +141 -0
- agent_cli/dev/skill/examples.md +571 -0
- agent_cli/dev/terminals/__init__.py +19 -0
- agent_cli/dev/terminals/apple_terminal.py +82 -0
- agent_cli/dev/terminals/base.py +56 -0
- agent_cli/dev/terminals/gnome.py +51 -0
- agent_cli/dev/terminals/iterm2.py +84 -0
- agent_cli/dev/terminals/kitty.py +77 -0
- agent_cli/dev/terminals/registry.py +48 -0
- agent_cli/dev/terminals/tmux.py +58 -0
- agent_cli/dev/terminals/warp.py +132 -0
- agent_cli/dev/terminals/zellij.py +78 -0
- agent_cli/dev/worktree.py +856 -0
- agent_cli/docs_gen.py +417 -0
- agent_cli/example-config.toml +185 -0
- agent_cli/install/__init__.py +5 -0
- agent_cli/install/common.py +89 -0
- agent_cli/install/extras.py +174 -0
- agent_cli/install/hotkeys.py +48 -0
- agent_cli/install/services.py +87 -0
- agent_cli/memory/__init__.py +7 -0
- agent_cli/memory/_files.py +250 -0
- agent_cli/memory/_filters.py +63 -0
- agent_cli/memory/_git.py +157 -0
- agent_cli/memory/_indexer.py +142 -0
- agent_cli/memory/_ingest.py +408 -0
- agent_cli/memory/_persistence.py +182 -0
- agent_cli/memory/_prompt.py +91 -0
- agent_cli/memory/_retrieval.py +294 -0
- agent_cli/memory/_store.py +169 -0
- agent_cli/memory/_streaming.py +44 -0
- agent_cli/memory/_tasks.py +48 -0
- agent_cli/memory/api.py +113 -0
- agent_cli/memory/client.py +272 -0
- agent_cli/memory/engine.py +361 -0
- agent_cli/memory/entities.py +43 -0
- agent_cli/memory/models.py +112 -0
- agent_cli/opts.py +433 -0
- agent_cli/py.typed +0 -0
- agent_cli/rag/__init__.py +3 -0
- agent_cli/rag/_indexer.py +67 -0
- agent_cli/rag/_indexing.py +226 -0
- agent_cli/rag/_prompt.py +30 -0
- agent_cli/rag/_retriever.py +156 -0
- agent_cli/rag/_store.py +48 -0
- agent_cli/rag/_utils.py +218 -0
- agent_cli/rag/api.py +175 -0
- agent_cli/rag/client.py +299 -0
- agent_cli/rag/engine.py +302 -0
- agent_cli/rag/models.py +55 -0
- agent_cli/scripts/.runtime/.gitkeep +0 -0
- agent_cli/scripts/__init__.py +1 -0
- agent_cli/scripts/check_plugin_skill_sync.py +50 -0
- agent_cli/scripts/linux-hotkeys/README.md +63 -0
- agent_cli/scripts/linux-hotkeys/toggle-autocorrect.sh +45 -0
- agent_cli/scripts/linux-hotkeys/toggle-transcription.sh +58 -0
- agent_cli/scripts/linux-hotkeys/toggle-voice-edit.sh +58 -0
- agent_cli/scripts/macos-hotkeys/README.md +45 -0
- agent_cli/scripts/macos-hotkeys/skhd-config-example +5 -0
- agent_cli/scripts/macos-hotkeys/toggle-autocorrect.sh +12 -0
- agent_cli/scripts/macos-hotkeys/toggle-transcription.sh +37 -0
- agent_cli/scripts/macos-hotkeys/toggle-voice-edit.sh +37 -0
- agent_cli/scripts/nvidia-asr-server/README.md +99 -0
- agent_cli/scripts/nvidia-asr-server/pyproject.toml +27 -0
- agent_cli/scripts/nvidia-asr-server/server.py +255 -0
- agent_cli/scripts/nvidia-asr-server/shell.nix +32 -0
- agent_cli/scripts/nvidia-asr-server/uv.lock +4654 -0
- agent_cli/scripts/run-openwakeword.sh +11 -0
- agent_cli/scripts/run-piper-windows.ps1 +30 -0
- agent_cli/scripts/run-piper.sh +24 -0
- agent_cli/scripts/run-whisper-linux.sh +40 -0
- agent_cli/scripts/run-whisper-macos.sh +6 -0
- agent_cli/scripts/run-whisper-windows.ps1 +51 -0
- agent_cli/scripts/run-whisper.sh +9 -0
- agent_cli/scripts/run_faster_whisper_server.py +136 -0
- agent_cli/scripts/setup-linux-hotkeys.sh +72 -0
- agent_cli/scripts/setup-linux.sh +108 -0
- agent_cli/scripts/setup-macos-hotkeys.sh +61 -0
- agent_cli/scripts/setup-macos.sh +76 -0
- agent_cli/scripts/setup-windows.ps1 +63 -0
- agent_cli/scripts/start-all-services-windows.ps1 +53 -0
- agent_cli/scripts/start-all-services.sh +178 -0
- agent_cli/scripts/sync_extras.py +138 -0
- agent_cli/server/__init__.py +3 -0
- agent_cli/server/cli.py +721 -0
- agent_cli/server/common.py +222 -0
- agent_cli/server/model_manager.py +288 -0
- agent_cli/server/model_registry.py +225 -0
- agent_cli/server/proxy/__init__.py +3 -0
- agent_cli/server/proxy/api.py +444 -0
- agent_cli/server/streaming.py +67 -0
- agent_cli/server/tts/__init__.py +3 -0
- agent_cli/server/tts/api.py +335 -0
- agent_cli/server/tts/backends/__init__.py +82 -0
- agent_cli/server/tts/backends/base.py +139 -0
- agent_cli/server/tts/backends/kokoro.py +403 -0
- agent_cli/server/tts/backends/piper.py +253 -0
- agent_cli/server/tts/model_manager.py +201 -0
- agent_cli/server/tts/model_registry.py +28 -0
- agent_cli/server/tts/wyoming_handler.py +249 -0
- agent_cli/server/whisper/__init__.py +3 -0
- agent_cli/server/whisper/api.py +413 -0
- agent_cli/server/whisper/backends/__init__.py +89 -0
- agent_cli/server/whisper/backends/base.py +97 -0
- agent_cli/server/whisper/backends/faster_whisper.py +225 -0
- agent_cli/server/whisper/backends/mlx.py +270 -0
- agent_cli/server/whisper/languages.py +116 -0
- agent_cli/server/whisper/model_manager.py +157 -0
- agent_cli/server/whisper/model_registry.py +28 -0
- agent_cli/server/whisper/wyoming_handler.py +203 -0
- agent_cli/services/__init__.py +343 -0
- agent_cli/services/_wyoming_utils.py +64 -0
- agent_cli/services/asr.py +506 -0
- agent_cli/services/llm.py +228 -0
- agent_cli/services/tts.py +450 -0
- agent_cli/services/wake_word.py +142 -0
- agent_cli-0.70.5.dist-info/METADATA +2118 -0
- agent_cli-0.70.5.dist-info/RECORD +196 -0
- agent_cli-0.70.5.dist-info/WHEEL +4 -0
- agent_cli-0.70.5.dist-info/entry_points.txt +4 -0
- agent_cli-0.70.5.dist-info/licenses/LICENSE +21 -0
agent_cli/dev/project.py
ADDED
|
@@ -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
|