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,299 @@
1
+ """Audio format conversion utilities using FFmpeg."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import logging
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ import wave
11
+ from pathlib import Path
12
+ from typing import NamedTuple
13
+
14
+ from agent_cli import constants
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ VALID_EXTENSIONS = (".wav", ".mp3", ".m4a", ".flac", ".ogg", ".aac", ".webm")
19
+
20
+
21
+ class WavPcmData(NamedTuple):
22
+ """PCM data and parameters extracted from a WAV file."""
23
+
24
+ pcm_data: bytes
25
+ sample_rate: int
26
+ num_channels: int
27
+ sample_width: int
28
+
29
+
30
+ def extract_pcm_from_wav(wav_bytes: bytes) -> WavPcmData:
31
+ """Extract raw PCM data and WAV parameters from WAV bytes.
32
+
33
+ Args:
34
+ wav_bytes: WAV file data as bytes.
35
+
36
+ Returns:
37
+ WavPcmData with pcm_data, sample_rate, num_channels, sample_width.
38
+
39
+ Raises:
40
+ wave.Error: If the data is not a valid WAV file.
41
+
42
+ """
43
+ with io.BytesIO(wav_bytes) as buf, wave.open(buf, "rb") as wav_file:
44
+ return WavPcmData(
45
+ pcm_data=wav_file.readframes(wav_file.getnframes()),
46
+ sample_rate=wav_file.getframerate(),
47
+ num_channels=wav_file.getnchannels(),
48
+ sample_width=wav_file.getsampwidth(),
49
+ )
50
+
51
+
52
+ def is_valid_audio_file(value: object) -> bool:
53
+ """Check if the provided value is a valid audio file.
54
+
55
+ Works with FastAPI UploadFile or any object with filename and content_type attributes.
56
+
57
+ Args:
58
+ value: Object to check (typically an UploadFile).
59
+
60
+ Returns:
61
+ True if the object appears to be a valid audio file.
62
+
63
+ """
64
+ filename = getattr(value, "filename", None)
65
+ content_type = getattr(value, "content_type", None)
66
+
67
+ if not filename and not content_type:
68
+ return False
69
+
70
+ if content_type and content_type.startswith("audio/"):
71
+ return True
72
+ return bool(filename and str(filename).lower().endswith(VALID_EXTENSIONS))
73
+
74
+
75
+ def convert_audio_to_wyoming_format(
76
+ audio_data: bytes,
77
+ source_filename: str,
78
+ ) -> bytes:
79
+ """Convert audio data to Wyoming-compatible format using FFmpeg.
80
+
81
+ Args:
82
+ audio_data: Raw audio data
83
+ source_filename: Source filename to help FFmpeg detect format
84
+
85
+ Returns:
86
+ Converted audio data as raw PCM bytes (16kHz, 16-bit, mono)
87
+
88
+ Raises:
89
+ RuntimeError: If FFmpeg is not available or conversion fails
90
+
91
+ """
92
+ # Check if FFmpeg is available
93
+ if not shutil.which("ffmpeg"):
94
+ msg = "FFmpeg not found in PATH. Please install FFmpeg to convert audio formats."
95
+ raise RuntimeError(msg)
96
+
97
+ # Create temporary files for input and output
98
+ suffix = _get_file_extension(source_filename)
99
+ with (
100
+ tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as input_file,
101
+ tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as output_file,
102
+ ):
103
+ input_path = Path(input_file.name)
104
+ output_path = Path(output_file.name)
105
+
106
+ try:
107
+ # Write input audio data
108
+ input_file.write(audio_data)
109
+ input_file.flush()
110
+
111
+ # Build FFmpeg command to convert to Wyoming format
112
+ # -f s16le: 16-bit signed little-endian PCM
113
+ # -ar 16000: 16kHz sample rate
114
+ # -ac 1: mono (1 channel)
115
+ cmd = [
116
+ "ffmpeg",
117
+ "-y",
118
+ "-i",
119
+ str(input_path),
120
+ "-f",
121
+ "s16le",
122
+ "-ar",
123
+ str(constants.AUDIO_RATE),
124
+ "-ac",
125
+ str(constants.AUDIO_CHANNELS),
126
+ str(output_path),
127
+ ]
128
+
129
+ logger.debug("Running FFmpeg command: %s", " ".join(cmd))
130
+
131
+ # Run FFmpeg
132
+ result = subprocess.run(
133
+ cmd,
134
+ capture_output=True,
135
+ text=False,
136
+ check=False,
137
+ )
138
+
139
+ if result.returncode != 0:
140
+ stderr_text = result.stderr.decode("utf-8", errors="replace")
141
+ logger.error("FFmpeg failed with return code %d", result.returncode)
142
+ logger.error("FFmpeg stderr: %s", stderr_text)
143
+ msg = f"FFmpeg conversion failed: {stderr_text}"
144
+ raise RuntimeError(msg)
145
+
146
+ # Read converted audio data
147
+ return output_path.read_bytes()
148
+
149
+ finally:
150
+ # Clean up temporary files
151
+ input_path.unlink(missing_ok=True)
152
+ output_path.unlink(missing_ok=True)
153
+
154
+
155
+ def _get_file_extension(filename: str) -> str:
156
+ """Get file extension from filename, defaulting to .tmp.
157
+
158
+ Args:
159
+ filename: Source filename
160
+
161
+ Returns:
162
+ File extension including the dot
163
+
164
+ """
165
+ filename = str(filename).lower()
166
+
167
+ for ext in VALID_EXTENSIONS:
168
+ if filename.endswith(ext):
169
+ return ext
170
+
171
+ return ".tmp"
172
+
173
+
174
+ def check_ffmpeg_available() -> bool:
175
+ """Check if FFmpeg is available in the system PATH.
176
+
177
+ Returns:
178
+ True if FFmpeg is available, False otherwise
179
+
180
+ """
181
+ return shutil.which("ffmpeg") is not None
182
+
183
+
184
+ def _run_ffmpeg(
185
+ cmd: list[str],
186
+ timeout: int | None,
187
+ ) -> None:
188
+ """Run ffmpeg command with error handling."""
189
+ try:
190
+ result = subprocess.run(
191
+ cmd,
192
+ capture_output=True,
193
+ check=False,
194
+ timeout=timeout,
195
+ )
196
+ except subprocess.TimeoutExpired as e:
197
+ msg = f"FFmpeg conversion timed out after {timeout} seconds"
198
+ raise RuntimeError(msg) from e
199
+
200
+ if result.returncode != 0:
201
+ stderr_text = result.stderr.decode("utf-8", errors="replace")
202
+ logger.error("FFmpeg MP3 conversion failed: %s", stderr_text)
203
+ msg = f"FFmpeg MP3 conversion failed: {stderr_text}"
204
+ raise RuntimeError(msg)
205
+
206
+
207
+ def convert_to_mp3(
208
+ audio_data: bytes,
209
+ *,
210
+ input_format: str = "wav",
211
+ sample_rate: int | None = None,
212
+ channels: int | None = None,
213
+ bitrate: str = "128k",
214
+ timeout: int | None = 60,
215
+ ) -> bytes:
216
+ """Convert audio data to MP3 format using FFmpeg.
217
+
218
+ Args:
219
+ audio_data: Audio data as bytes.
220
+ input_format: Input format - "wav" (auto-detected) or "pcm" (raw s16le).
221
+ sample_rate: Sample rate in Hz (required if input_format is "pcm").
222
+ channels: Number of channels (required if input_format is "pcm").
223
+ bitrate: MP3 bitrate (e.g., "128k", "192k").
224
+ timeout: Timeout in seconds for FFmpeg, or None for no timeout.
225
+
226
+ Returns:
227
+ MP3 audio data as bytes.
228
+
229
+ Raises:
230
+ RuntimeError: If FFmpeg is not available or conversion fails.
231
+ ValueError: If input_format is "pcm" but sample_rate or channels not provided.
232
+
233
+ """
234
+ if input_format == "pcm" and (sample_rate is None or channels is None):
235
+ msg = "sample_rate and channels are required when input_format is 'pcm'"
236
+ raise ValueError(msg)
237
+
238
+ if not shutil.which("ffmpeg"):
239
+ msg = "FFmpeg not found in PATH. Please install FFmpeg for MP3 conversion."
240
+ raise RuntimeError(msg)
241
+
242
+ input_suffix = ".wav" if input_format == "wav" else ".raw"
243
+ tmp_dir = Path(tempfile.mkdtemp())
244
+ input_path = tmp_dir / f"input{input_suffix}"
245
+ output_path = tmp_dir / "output.mp3"
246
+
247
+ try:
248
+ input_path.write_bytes(audio_data)
249
+
250
+ cmd = ["ffmpeg", "-y"]
251
+ if input_format == "pcm":
252
+ cmd.extend(["-f", "s16le", "-ar", str(sample_rate), "-ac", str(channels)])
253
+ cmd.extend(["-i", str(input_path), "-b:a", bitrate, "-q:a", "2", str(output_path)])
254
+
255
+ logger.debug("Running FFmpeg MP3 conversion: %s", " ".join(cmd))
256
+ _run_ffmpeg(cmd, timeout)
257
+
258
+ return output_path.read_bytes()
259
+ finally:
260
+ shutil.rmtree(tmp_dir, ignore_errors=True)
261
+
262
+
263
+ def save_audio_as_mp3(
264
+ audio_data: bytes,
265
+ output_path: Path,
266
+ sample_rate: int = constants.AUDIO_RATE,
267
+ channels: int = constants.AUDIO_CHANNELS,
268
+ bitrate: str = "64k",
269
+ ) -> Path:
270
+ """Convert raw PCM audio data to MP3 format and save to file.
271
+
272
+ Args:
273
+ audio_data: Raw PCM audio data (16-bit signed little-endian).
274
+ output_path: Path where the MP3 file will be saved.
275
+ sample_rate: Audio sample rate in Hz.
276
+ channels: Number of audio channels.
277
+ bitrate: MP3 bitrate (e.g., "128k", "192k", "256k").
278
+
279
+ Returns:
280
+ Path to the saved MP3 file.
281
+
282
+ Raises:
283
+ RuntimeError: If FFmpeg is not available or conversion fails.
284
+
285
+ """
286
+ output_path.parent.mkdir(parents=True, exist_ok=True)
287
+
288
+ mp3_data = convert_to_mp3(
289
+ audio_data,
290
+ input_format="pcm",
291
+ sample_rate=sample_rate,
292
+ channels=channels,
293
+ bitrate=bitrate,
294
+ timeout=None,
295
+ )
296
+
297
+ output_path.write_bytes(mp3_data)
298
+ logger.debug("Saved MP3 to %s", output_path)
299
+ return output_path
@@ -0,0 +1,88 @@
1
+ """Shared ChromaDB helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from agent_cli.constants import DEFAULT_OPENAI_EMBEDDING_MODEL
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Mapping, Sequence
11
+ from pathlib import Path
12
+
13
+ from chromadb import Collection
14
+ from pydantic import BaseModel
15
+
16
+
17
+ def init_collection(
18
+ persistence_path: Path,
19
+ *,
20
+ name: str,
21
+ embedding_model: str = DEFAULT_OPENAI_EMBEDDING_MODEL,
22
+ openai_base_url: str | None = None,
23
+ openai_api_key: str | None = None,
24
+ subdir: str | None = None,
25
+ ) -> Collection:
26
+ """Initialize a Chroma collection with OpenAI-compatible embeddings."""
27
+ import chromadb # noqa: PLC0415
28
+ from chromadb.config import Settings # noqa: PLC0415
29
+ from chromadb.utils import embedding_functions # noqa: PLC0415
30
+
31
+ target_path = persistence_path / subdir if subdir else persistence_path
32
+ target_path.mkdir(parents=True, exist_ok=True)
33
+ client = chromadb.PersistentClient(
34
+ path=str(target_path),
35
+ settings=Settings(anonymized_telemetry=False),
36
+ )
37
+ embed_fn = embedding_functions.OpenAIEmbeddingFunction(
38
+ api_base=openai_base_url,
39
+ api_key=openai_api_key or "dummy",
40
+ model_name=embedding_model,
41
+ )
42
+ return client.get_or_create_collection(name=name, embedding_function=embed_fn)
43
+
44
+
45
+ def flatten_metadatas(metadatas: Sequence[BaseModel]) -> list[dict[str, Any]]:
46
+ """Serialize metadata models to JSON-safe dicts while preserving lists."""
47
+ return [meta.model_dump(mode="json", exclude_none=True) for meta in metadatas]
48
+
49
+
50
+ def upsert(
51
+ collection: Collection,
52
+ *,
53
+ ids: list[str],
54
+ documents: list[str],
55
+ metadatas: Sequence[BaseModel],
56
+ batch_size: int = 10,
57
+ ) -> None:
58
+ """Upsert documents with JSON-serialized metadata.
59
+
60
+ Args:
61
+ collection: ChromaDB collection.
62
+ ids: Document IDs.
63
+ documents: Document contents.
64
+ metadatas: Pydantic metadata models.
65
+ batch_size: Max documents per embedding API call (default: 10).
66
+
67
+ """
68
+ if not ids:
69
+ return
70
+ serialized = flatten_metadatas(metadatas)
71
+
72
+ # Process in batches to avoid overwhelming the embedding service
73
+ for i in range(0, len(ids), batch_size):
74
+ batch_ids = ids[i : i + batch_size]
75
+ batch_docs = documents[i : i + batch_size]
76
+ batch_metas = serialized[i : i + batch_size]
77
+ collection.upsert(ids=batch_ids, documents=batch_docs, metadatas=batch_metas)
78
+
79
+
80
+ def delete(collection: Collection, ids: list[str]) -> None:
81
+ """Delete documents by ID."""
82
+ if ids:
83
+ collection.delete(ids=ids)
84
+
85
+
86
+ def delete_where(collection: Collection, where: Mapping[str, Any]) -> None:
87
+ """Delete documents by a filter."""
88
+ collection.delete(where=where)
agent_cli/core/deps.py ADDED
@@ -0,0 +1,191 @@
1
+ """Helpers for optional dependency checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import json
7
+ import os
8
+ from importlib.util import find_spec
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, TypeVar
11
+
12
+ import typer
13
+
14
+ from agent_cli.config import load_config
15
+ from agent_cli.core.utils import console, print_error_message
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable
19
+
20
+ F = TypeVar("F", bound="Callable[..., object]")
21
+
22
+
23
+ def _get_auto_install_setting() -> bool:
24
+ """Check if auto-install is enabled (default: True)."""
25
+ if os.environ.get("AGENT_CLI_NO_AUTO_INSTALL", "").lower() in ("1", "true", "yes"):
26
+ return False
27
+ return load_config().get("settings", {}).get("auto_install_extras", True)
28
+
29
+
30
+ # Load extras from JSON file
31
+ _EXTRAS_FILE = Path(__file__).parent.parent / "_extras.json"
32
+ EXTRAS: dict[str, tuple[str, list[str]]] = {
33
+ k: (v[0], v[1]) for k, v in json.loads(_EXTRAS_FILE.read_text()).items()
34
+ }
35
+
36
+
37
+ def _check_package_installed(pkg: str) -> bool:
38
+ """Check if a single package is installed."""
39
+ top_module = pkg.split(".")[0]
40
+ try:
41
+ return find_spec(top_module) is not None
42
+ except (ValueError, ModuleNotFoundError):
43
+ return False
44
+
45
+
46
+ def check_extra_installed(extra: str) -> bool:
47
+ """Check if packages for an extra are installed using find_spec (no actual import).
48
+
49
+ Supports `|` syntax for alternatives: "piper|kokoro" means ANY of these extras.
50
+ For regular extras, ALL packages must be installed.
51
+ """
52
+ # Handle "extra1|extra2" syntax - any of these extras is sufficient
53
+ if "|" in extra:
54
+ return any(check_extra_installed(e) for e in extra.split("|"))
55
+
56
+ if extra not in EXTRAS:
57
+ return False # Unknown extra, trigger install attempt to surface error
58
+ _, packages = EXTRAS[extra]
59
+
60
+ # All packages must be installed
61
+ return all(_check_package_installed(pkg) for pkg in packages)
62
+
63
+
64
+ def _format_extra_item(extra: str) -> str:
65
+ """Format a single extra as a list item with description."""
66
+ desc, _ = EXTRAS.get(extra, ("", []))
67
+ if desc:
68
+ return f" - '{extra}' ({desc})"
69
+ return f" - '{extra}'"
70
+
71
+
72
+ def _format_install_commands(extras: list[str]) -> list[str]:
73
+ """Format install commands for one or more extras."""
74
+ combined = ",".join(extras)
75
+ extras_args = " ".join(extras)
76
+ return [
77
+ "Install with:",
78
+ f' [bold cyan]uv tool install -p 3.13 "agent-cli\\[{combined}]"[/bold cyan]',
79
+ " # or",
80
+ f" [bold cyan]agent-cli install-extras {extras_args}[/bold cyan]",
81
+ ]
82
+
83
+
84
+ def get_install_hint(extra: str) -> str:
85
+ """Get install command hint for a single extra.
86
+
87
+ Supports `|` syntax for alternatives: "piper|kokoro" shows both options.
88
+ """
89
+ # Handle "extra1|extra2" syntax - show all options
90
+ if "|" in extra:
91
+ alternatives = extra.split("|")
92
+ lines = ["This command requires one of:"]
93
+ lines.extend(_format_extra_item(alt) for alt in alternatives)
94
+ lines.append("")
95
+ lines.append("Install one with:")
96
+ lines.extend(
97
+ f' [bold cyan]uv tool install -p 3.13 "agent-cli\\[{alt}]"[/bold cyan]'
98
+ for alt in alternatives
99
+ )
100
+ lines.append(" # or")
101
+ lines.extend(
102
+ f" [bold cyan]agent-cli install-extras {alt}[/bold cyan]" for alt in alternatives
103
+ )
104
+ return "\n".join(lines)
105
+
106
+ desc, _ = EXTRAS.get(extra, ("", []))
107
+ header = f"This command requires the '{extra}' extra"
108
+ if desc:
109
+ header += f" ({desc})"
110
+ header += "."
111
+
112
+ lines = [header, ""]
113
+ lines.extend(_format_install_commands([extra]))
114
+ return "\n".join(lines)
115
+
116
+
117
+ def get_combined_install_hint(extras: list[str]) -> str:
118
+ """Get a combined install hint for multiple missing extras."""
119
+ if len(extras) == 1:
120
+ return get_install_hint(extras[0])
121
+
122
+ lines = ["This command requires the following extras:"]
123
+ lines.extend(_format_extra_item(extra) for extra in extras)
124
+ lines.append("")
125
+ lines.extend(_format_install_commands(extras))
126
+ return "\n".join(lines)
127
+
128
+
129
+ def _try_auto_install(missing: list[str]) -> bool:
130
+ """Attempt to auto-install missing extras. Returns True if successful."""
131
+ from agent_cli.install.extras import install_extras_programmatic # noqa: PLC0415
132
+
133
+ # Flatten alternatives (e.g., "piper|kokoro" -> just pick the first one)
134
+ extras_to_install = []
135
+ for extra in missing:
136
+ if "|" in extra:
137
+ # For alternatives, install the first option
138
+ extras_to_install.append(extra.split("|")[0])
139
+ else:
140
+ extras_to_install.append(extra)
141
+
142
+ console.print(
143
+ f"[yellow]Auto-installing missing extras: {', '.join(extras_to_install)}[/]",
144
+ )
145
+ return install_extras_programmatic(extras_to_install, quiet=True)
146
+
147
+
148
+ def _check_and_install_extras(extras: tuple[str, ...]) -> list[str]:
149
+ """Check for missing extras and attempt auto-install. Returns list of still-missing."""
150
+ missing = [e for e in extras if not check_extra_installed(e)]
151
+ if not missing:
152
+ return []
153
+
154
+ if not _get_auto_install_setting():
155
+ print_error_message(get_combined_install_hint(missing))
156
+ return missing
157
+
158
+ if not _try_auto_install(missing):
159
+ print_error_message("Auto-install failed.\n" + get_combined_install_hint(missing))
160
+ return missing
161
+
162
+ console.print("[green]Installation complete![/]")
163
+ still_missing = [e for e in extras if not check_extra_installed(e)]
164
+ if still_missing:
165
+ print_error_message(
166
+ "Auto-install completed but some dependencies are still missing.\n"
167
+ + get_combined_install_hint(still_missing),
168
+ )
169
+ return still_missing
170
+
171
+
172
+ def requires_extras(*extras: str) -> Callable[[F], F]:
173
+ """Decorator to declare required extras for a command.
174
+
175
+ Auto-installs missing extras by default. Disable via AGENT_CLI_NO_AUTO_INSTALL=1
176
+ or config [settings] auto_install_extras = false.
177
+ """
178
+
179
+ def decorator(func: F) -> F:
180
+ func._required_extras = extras # type: ignore[attr-defined]
181
+
182
+ @functools.wraps(func)
183
+ def wrapper(*args: object, **kwargs: object) -> object:
184
+ if _check_and_install_extras(extras):
185
+ raise typer.Exit(1)
186
+ return func(*args, **kwargs)
187
+
188
+ wrapper._required_extras = extras # type: ignore[attr-defined]
189
+ return wrapper # type: ignore[return-value]
190
+
191
+ return decorator