devs-cli 0.1.0__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.
- devs/__init__.py +18 -0
- devs/cli.py +834 -0
- devs/config.py +44 -0
- devs/core/__init__.py +8 -0
- devs/core/integration.py +290 -0
- devs/exceptions.py +24 -0
- devs/utils/__init__.py +28 -0
- devs_cli-0.1.0.dist-info/METADATA +185 -0
- devs_cli-0.1.0.dist-info/RECORD +13 -0
- devs_cli-0.1.0.dist-info/WHEEL +5 -0
- devs_cli-0.1.0.dist-info/entry_points.txt +2 -0
- devs_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- devs_cli-0.1.0.dist-info/top_level.txt +1 -0
devs/cli.py
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
"""Command-line interface for devs package."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import subprocess
|
|
6
|
+
from functools import wraps
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import yaml
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from .config import config
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from .core import Project, ContainerManager, WorkspaceManager
|
|
16
|
+
from .core.integration import VSCodeIntegration, ExternalToolIntegration
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
DevsError,
|
|
19
|
+
ProjectNotFoundError,
|
|
20
|
+
DevcontainerConfigError,
|
|
21
|
+
ContainerError,
|
|
22
|
+
WorkspaceError,
|
|
23
|
+
VSCodeError,
|
|
24
|
+
DependencyError
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_env_vars(env_tuples: tuple) -> dict:
|
|
31
|
+
"""Parse environment variables from --env options.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
env_tuples: Tuple of strings in format 'VAR=value'
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Dictionary of environment variables
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
click.BadParameter: If format is invalid
|
|
41
|
+
"""
|
|
42
|
+
env_dict = {}
|
|
43
|
+
for env_str in env_tuples:
|
|
44
|
+
if '=' not in env_str:
|
|
45
|
+
raise click.BadParameter(f"Environment variable must be in format VAR=value, got: {env_str}")
|
|
46
|
+
key, value = env_str.split('=', 1)
|
|
47
|
+
env_dict[key] = value
|
|
48
|
+
return env_dict
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_devs_env_vars(dev_name: str) -> dict:
|
|
52
|
+
"""Load environment variables from DEVS.yml for a specific dev environment.
|
|
53
|
+
|
|
54
|
+
Loads from multiple sources in priority order:
|
|
55
|
+
1. ~/.devs/envs/{org-repo}/DEVS.yml (user-specific overrides)
|
|
56
|
+
2. ~/.devs/envs/default/DEVS.yml (user defaults)
|
|
57
|
+
3. {project-root}/DEVS.yml (repository configuration)
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
dev_name: Development environment name
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Dictionary of environment variables from DEVS.yml files
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
result = {}
|
|
67
|
+
|
|
68
|
+
def _load_env_vars_from_file(file_path: Path) -> dict:
|
|
69
|
+
"""Load env_vars section from a DEVS.yml file."""
|
|
70
|
+
if not file_path.exists():
|
|
71
|
+
return {}
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
with open(file_path, 'r') as f:
|
|
75
|
+
data = yaml.safe_load(f)
|
|
76
|
+
|
|
77
|
+
if not data or 'env_vars' not in data:
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
env_vars = data['env_vars']
|
|
81
|
+
|
|
82
|
+
# Start with defaults
|
|
83
|
+
env_result = env_vars.get('default', {}).copy()
|
|
84
|
+
|
|
85
|
+
# Apply container-specific overrides
|
|
86
|
+
if dev_name in env_vars:
|
|
87
|
+
env_result.update(env_vars[dev_name])
|
|
88
|
+
|
|
89
|
+
return env_result
|
|
90
|
+
|
|
91
|
+
except Exception as e:
|
|
92
|
+
console.print(f"ā ļø Warning: Failed to parse {file_path} env_vars: {e}")
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
# 1. Load repository DEVS.yml (lowest priority)
|
|
96
|
+
repo_devs_yml = Path.cwd() / "DEVS.yml"
|
|
97
|
+
result.update(_load_env_vars_from_file(repo_devs_yml))
|
|
98
|
+
|
|
99
|
+
# 2. Load user default DEVS.yml
|
|
100
|
+
user_envs_dir = Path.home() / ".devs" / "envs"
|
|
101
|
+
default_devs_yml = user_envs_dir / "default" / "DEVS.yml"
|
|
102
|
+
result.update(_load_env_vars_from_file(default_devs_yml))
|
|
103
|
+
|
|
104
|
+
# 3. Load user project-specific DEVS.yml (highest priority)
|
|
105
|
+
try:
|
|
106
|
+
from .core import Project
|
|
107
|
+
project = Project()
|
|
108
|
+
project_name = project.info.name # This gives us the org-repo format
|
|
109
|
+
project_devs_yml = user_envs_dir / project_name / "DEVS.yml"
|
|
110
|
+
result.update(_load_env_vars_from_file(project_devs_yml))
|
|
111
|
+
except Exception:
|
|
112
|
+
# If we can't detect the project name, skip project-specific overrides
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def merge_env_vars(devs_env: dict, cli_env: dict) -> dict:
|
|
119
|
+
"""Merge environment variables with CLI taking priority over DEVS.yml.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
devs_env: Environment variables from DEVS.yml
|
|
123
|
+
cli_env: Environment variables from CLI --env flags
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Merged environment variables with CLI overrides applied
|
|
127
|
+
"""
|
|
128
|
+
if not devs_env and not cli_env:
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
# Start with DEVS.yml env vars
|
|
132
|
+
merged = devs_env.copy() if devs_env else {}
|
|
133
|
+
|
|
134
|
+
# CLI env vars take priority
|
|
135
|
+
if cli_env:
|
|
136
|
+
merged.update(cli_env)
|
|
137
|
+
|
|
138
|
+
return merged
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def debug_option(f):
|
|
142
|
+
"""Decorator to add debug option and handle debug flag inheritance."""
|
|
143
|
+
@click.option('--debug', is_flag=True, help='Show debug tracebacks on error')
|
|
144
|
+
@click.pass_context
|
|
145
|
+
@wraps(f)
|
|
146
|
+
def wrapper(ctx, *args, debug=False, **kwargs):
|
|
147
|
+
# Use command-level debug flag if provided, otherwise fall back to group-level
|
|
148
|
+
debug = debug or ctx.obj.get('DEBUG', False)
|
|
149
|
+
ctx.obj['DEBUG'] = debug # Update context for consistency
|
|
150
|
+
return f(*args, debug=debug, **kwargs)
|
|
151
|
+
return wrapper
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def check_dependencies() -> None:
|
|
155
|
+
"""Check and report on dependencies."""
|
|
156
|
+
integration = ExternalToolIntegration(Project())
|
|
157
|
+
missing = integration.get_missing_dependencies()
|
|
158
|
+
|
|
159
|
+
if missing:
|
|
160
|
+
console.print(f"ā Missing dependencies: {', '.join(missing)}")
|
|
161
|
+
console.print("\nInstall missing tools:")
|
|
162
|
+
for tool in missing:
|
|
163
|
+
if tool == 'devcontainer':
|
|
164
|
+
console.print(" npm install -g @devcontainers/cli")
|
|
165
|
+
elif tool == 'docker':
|
|
166
|
+
console.print(" Install Docker Desktop or Docker Engine")
|
|
167
|
+
elif tool == 'code':
|
|
168
|
+
console.print(" Install VS Code and ensure 'code' command is in PATH")
|
|
169
|
+
sys.exit(1)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_project() -> Project:
|
|
173
|
+
"""Get project instance with error handling."""
|
|
174
|
+
try:
|
|
175
|
+
project = Project()
|
|
176
|
+
# No longer require devcontainer config upfront -
|
|
177
|
+
# WorkspaceManager will provide default template if needed
|
|
178
|
+
return project
|
|
179
|
+
except ProjectNotFoundError as e:
|
|
180
|
+
console.print(f"ā {e}")
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@click.group()
|
|
185
|
+
@click.version_option(version="0.1.0", prog_name="devs")
|
|
186
|
+
@click.option('--debug', is_flag=True, help='Show debug tracebacks on error')
|
|
187
|
+
@click.pass_context
|
|
188
|
+
def cli(ctx, debug: bool) -> None:
|
|
189
|
+
"""DevContainer Management Tool
|
|
190
|
+
|
|
191
|
+
Manage multiple named devcontainers for any project.
|
|
192
|
+
"""
|
|
193
|
+
ctx.ensure_object(dict)
|
|
194
|
+
ctx.obj['DEBUG'] = debug
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@cli.command()
|
|
198
|
+
@click.argument('dev_names', nargs=-1, required=True)
|
|
199
|
+
@click.option('--rebuild', is_flag=True, help='Force rebuild of container images')
|
|
200
|
+
@click.option('--live', is_flag=True, help='Mount current directory as workspace instead of copying')
|
|
201
|
+
@click.option('--env', multiple=True, help='Environment variables to pass to container (format: VAR=value)')
|
|
202
|
+
@debug_option
|
|
203
|
+
def start(dev_names: tuple, rebuild: bool, live: bool, env: tuple, debug: bool) -> None:
|
|
204
|
+
"""Start named devcontainers.
|
|
205
|
+
|
|
206
|
+
DEV_NAMES: One or more development environment names to start
|
|
207
|
+
|
|
208
|
+
Example: devs start sally bob
|
|
209
|
+
Example: devs start sally --live # Mount current directory directly
|
|
210
|
+
Example: devs start sally --env QUART_PORT=5001 --env DB_HOST=localhost:3307
|
|
211
|
+
"""
|
|
212
|
+
check_dependencies()
|
|
213
|
+
project = get_project()
|
|
214
|
+
|
|
215
|
+
console.print(f"š Starting devcontainers for project: {project.info.name}")
|
|
216
|
+
|
|
217
|
+
container_manager = ContainerManager(project, config)
|
|
218
|
+
workspace_manager = WorkspaceManager(project, config)
|
|
219
|
+
|
|
220
|
+
for dev_name in dev_names:
|
|
221
|
+
console.print(f" Starting: {dev_name}")
|
|
222
|
+
|
|
223
|
+
# Load environment variables from DEVS.yml and merge with CLI --env flags
|
|
224
|
+
devs_env = load_devs_env_vars(dev_name)
|
|
225
|
+
cli_env = parse_env_vars(env) if env else {}
|
|
226
|
+
extra_env = merge_env_vars(devs_env, cli_env) if devs_env or cli_env else None
|
|
227
|
+
|
|
228
|
+
if extra_env:
|
|
229
|
+
console.print(f"š§ Environment variables: {', '.join(f'{k}={v}' for k, v in extra_env.items())}")
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
# Create/ensure workspace exists (handles live mode internally)
|
|
233
|
+
workspace_dir = workspace_manager.create_workspace(dev_name, live=live)
|
|
234
|
+
|
|
235
|
+
# Ensure container is running
|
|
236
|
+
if container_manager.ensure_container_running(
|
|
237
|
+
dev_name,
|
|
238
|
+
workspace_dir,
|
|
239
|
+
force_rebuild=rebuild,
|
|
240
|
+
debug=debug,
|
|
241
|
+
live=live,
|
|
242
|
+
extra_env=extra_env
|
|
243
|
+
):
|
|
244
|
+
continue
|
|
245
|
+
else:
|
|
246
|
+
console.print(f" ā ļø Failed to start {dev_name}, continuing with others...")
|
|
247
|
+
|
|
248
|
+
except (ContainerError, WorkspaceError) as e:
|
|
249
|
+
console.print(f" ā Error starting {dev_name}: {e}")
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
console.print("")
|
|
253
|
+
console.print("š” To open containers in VS Code:")
|
|
254
|
+
console.print(f" devs vscode {' '.join(dev_names)}")
|
|
255
|
+
console.print("")
|
|
256
|
+
console.print("š” To open containers in shell:")
|
|
257
|
+
console.print(f" devs shell {dev_names[0] if dev_names else '<dev-name>'}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@cli.command()
|
|
261
|
+
@click.argument('dev_names', nargs=-1, required=True)
|
|
262
|
+
@click.option('--delay', default=2.0, help='Delay between opening VS Code windows (seconds)')
|
|
263
|
+
@click.option('--live', is_flag=True, help='Start containers with current directory mounted as workspace')
|
|
264
|
+
@click.option('--env', multiple=True, help='Environment variables to pass to container (format: VAR=value)')
|
|
265
|
+
@debug_option
|
|
266
|
+
def vscode(dev_names: tuple, delay: float, live: bool, env: tuple, debug: bool) -> None:
|
|
267
|
+
"""Open devcontainers in VS Code.
|
|
268
|
+
|
|
269
|
+
DEV_NAMES: One or more development environment names to open
|
|
270
|
+
|
|
271
|
+
Example: devs vscode sally bob
|
|
272
|
+
Example: devs vscode sally --live # Start with current directory mounted
|
|
273
|
+
Example: devs vscode sally --env QUART_PORT=5001
|
|
274
|
+
"""
|
|
275
|
+
check_dependencies()
|
|
276
|
+
project = get_project()
|
|
277
|
+
|
|
278
|
+
container_manager = ContainerManager(project, config)
|
|
279
|
+
workspace_manager = WorkspaceManager(project, config)
|
|
280
|
+
vscode = VSCodeIntegration(project)
|
|
281
|
+
|
|
282
|
+
workspace_dirs = []
|
|
283
|
+
valid_dev_names = []
|
|
284
|
+
|
|
285
|
+
for dev_name in dev_names:
|
|
286
|
+
console.print(f" Preparing: {dev_name}")
|
|
287
|
+
|
|
288
|
+
# Load environment variables from DEVS.yml and merge with CLI --env flags
|
|
289
|
+
devs_env = load_devs_env_vars(dev_name)
|
|
290
|
+
cli_env = parse_env_vars(env) if env else {}
|
|
291
|
+
extra_env = merge_env_vars(devs_env, cli_env) if devs_env or cli_env else None
|
|
292
|
+
|
|
293
|
+
if extra_env:
|
|
294
|
+
console.print(f"š§ Environment variables: {', '.join(f'{k}={v}' for k, v in extra_env.items())}")
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
# Ensure workspace exists (handles live mode internally)
|
|
298
|
+
workspace_dir = workspace_manager.create_workspace(dev_name, live=live)
|
|
299
|
+
|
|
300
|
+
# Ensure container is running before launching VS Code
|
|
301
|
+
if container_manager.ensure_container_running(dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env):
|
|
302
|
+
workspace_dirs.append(workspace_dir)
|
|
303
|
+
valid_dev_names.append(dev_name)
|
|
304
|
+
else:
|
|
305
|
+
console.print(f" ā Failed to start container for {dev_name}, skipping...")
|
|
306
|
+
|
|
307
|
+
except (ContainerError, WorkspaceError) as e:
|
|
308
|
+
console.print(f" ā Error preparing {dev_name}: {e}")
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
if workspace_dirs:
|
|
312
|
+
try:
|
|
313
|
+
success_count = vscode.launch_multiple_devcontainers(
|
|
314
|
+
workspace_dirs,
|
|
315
|
+
valid_dev_names,
|
|
316
|
+
delay_between_windows=delay,
|
|
317
|
+
live=live
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if success_count == 0:
|
|
321
|
+
console.print("ā Failed to open any VS Code windows")
|
|
322
|
+
|
|
323
|
+
except VSCodeError as e:
|
|
324
|
+
console.print(f"ā VS Code integration error: {e}")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@cli.command()
|
|
328
|
+
@click.argument('dev_names', nargs=-1, required=True)
|
|
329
|
+
def stop(dev_names: tuple) -> None:
|
|
330
|
+
"""Stop and remove devcontainers.
|
|
331
|
+
|
|
332
|
+
DEV_NAMES: One or more development environment names to stop
|
|
333
|
+
|
|
334
|
+
Example: devs stop sally
|
|
335
|
+
"""
|
|
336
|
+
check_dependencies()
|
|
337
|
+
project = get_project()
|
|
338
|
+
|
|
339
|
+
console.print(f"š Stopping devcontainers for project: {project.info.name}")
|
|
340
|
+
|
|
341
|
+
container_manager = ContainerManager(project, config)
|
|
342
|
+
|
|
343
|
+
for dev_name in dev_names:
|
|
344
|
+
console.print(f" Stopping: {dev_name}")
|
|
345
|
+
container_manager.stop_container(dev_name)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@cli.command()
|
|
349
|
+
@click.argument('dev_name')
|
|
350
|
+
@click.option('--live', is_flag=True, help='Start container with current directory mounted as workspace')
|
|
351
|
+
@click.option('--env', multiple=True, help='Environment variables to pass to container (format: VAR=value)')
|
|
352
|
+
@debug_option
|
|
353
|
+
def shell(dev_name: str, live: bool, env: tuple, debug: bool) -> None:
|
|
354
|
+
"""Open shell in devcontainer.
|
|
355
|
+
|
|
356
|
+
DEV_NAME: Development environment name
|
|
357
|
+
|
|
358
|
+
Example: devs shell sally
|
|
359
|
+
Example: devs shell sally --live # Start with current directory mounted
|
|
360
|
+
Example: devs shell sally --env QUART_PORT=5001
|
|
361
|
+
"""
|
|
362
|
+
check_dependencies()
|
|
363
|
+
project = get_project()
|
|
364
|
+
|
|
365
|
+
# Load environment variables from DEVS.yml and merge with CLI --env flags
|
|
366
|
+
devs_env = load_devs_env_vars(dev_name)
|
|
367
|
+
cli_env = parse_env_vars(env) if env else {}
|
|
368
|
+
extra_env = merge_env_vars(devs_env, cli_env) if devs_env or cli_env else None
|
|
369
|
+
|
|
370
|
+
if extra_env:
|
|
371
|
+
console.print(f"š§ Environment variables: {', '.join(f'{k}={v}' for k, v in extra_env.items())}")
|
|
372
|
+
|
|
373
|
+
container_manager = ContainerManager(project, config)
|
|
374
|
+
workspace_manager = WorkspaceManager(project, config)
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
# Ensure workspace exists (handles live mode internally)
|
|
378
|
+
workspace_dir = workspace_manager.create_workspace(dev_name, live=live)
|
|
379
|
+
# Ensure container is running
|
|
380
|
+
container_manager.ensure_container_running(dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env)
|
|
381
|
+
|
|
382
|
+
# Open shell
|
|
383
|
+
container_manager.exec_shell(dev_name, workspace_dir, debug=debug, live=live)
|
|
384
|
+
|
|
385
|
+
except (ContainerError, WorkspaceError) as e:
|
|
386
|
+
console.print(f"ā Error opening shell for {dev_name}: {e}")
|
|
387
|
+
sys.exit(1)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@cli.command()
|
|
391
|
+
@click.argument('dev_name')
|
|
392
|
+
@click.argument('prompt')
|
|
393
|
+
@click.option('--reset-workspace', is_flag=True, help='Reset workspace contents before execution')
|
|
394
|
+
@click.option('--live', is_flag=True, help='Start container with current directory mounted as workspace')
|
|
395
|
+
@click.option('--env', multiple=True, help='Environment variables to pass to container (format: VAR=value)')
|
|
396
|
+
@debug_option
|
|
397
|
+
def claude(dev_name: str, prompt: str, reset_workspace: bool, live: bool, env: tuple, debug: bool) -> None:
|
|
398
|
+
"""Execute Claude CLI in devcontainer.
|
|
399
|
+
|
|
400
|
+
DEV_NAME: Development environment name
|
|
401
|
+
PROMPT: Prompt to send to Claude
|
|
402
|
+
|
|
403
|
+
Example: devs claude sally "Summarize this codebase"
|
|
404
|
+
Example: devs claude sally "Fix the tests" --reset-workspace
|
|
405
|
+
Example: devs claude sally "Fix the tests" --live # Run with current directory
|
|
406
|
+
Example: devs claude sally "Start the server" --env QUART_PORT=5001
|
|
407
|
+
"""
|
|
408
|
+
check_dependencies()
|
|
409
|
+
project = get_project()
|
|
410
|
+
|
|
411
|
+
# Load environment variables from DEVS.yml and merge with CLI --env flags
|
|
412
|
+
devs_env = load_devs_env_vars(dev_name)
|
|
413
|
+
cli_env = parse_env_vars(env) if env else {}
|
|
414
|
+
extra_env = merge_env_vars(devs_env, cli_env) if devs_env or cli_env else None
|
|
415
|
+
|
|
416
|
+
if extra_env:
|
|
417
|
+
console.print(f"š§ Environment variables: {', '.join(f'{k}={v}' for k, v in extra_env.items())}")
|
|
418
|
+
|
|
419
|
+
container_manager = ContainerManager(project, config)
|
|
420
|
+
workspace_manager = WorkspaceManager(project, config)
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
# Ensure workspace exists (handles live mode and reset internally)
|
|
424
|
+
workspace_dir = workspace_manager.create_workspace(dev_name, reset_contents=reset_workspace, live=live)
|
|
425
|
+
# Ensure container is running
|
|
426
|
+
container_manager.ensure_container_running(dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env)
|
|
427
|
+
|
|
428
|
+
# Execute Claude
|
|
429
|
+
console.print(f"š¤ Executing Claude in {dev_name}...")
|
|
430
|
+
if reset_workspace and not live:
|
|
431
|
+
console.print("šļø Workspace contents reset")
|
|
432
|
+
console.print(f"š Prompt: {prompt}")
|
|
433
|
+
console.print("")
|
|
434
|
+
|
|
435
|
+
success, output, error = container_manager.exec_claude(
|
|
436
|
+
dev_name, workspace_dir, prompt, debug=debug, stream=True, live=live, extra_env=extra_env
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
console.print("") # Add spacing after streamed output
|
|
440
|
+
if success:
|
|
441
|
+
console.print("ā
Claude execution completed")
|
|
442
|
+
else:
|
|
443
|
+
console.print("ā Claude execution failed")
|
|
444
|
+
if error:
|
|
445
|
+
console.print("")
|
|
446
|
+
console.print("š« Error:")
|
|
447
|
+
console.print(error)
|
|
448
|
+
sys.exit(1)
|
|
449
|
+
|
|
450
|
+
except (ContainerError, WorkspaceError) as e:
|
|
451
|
+
console.print(f"ā Error executing Claude in {dev_name}: {e}")
|
|
452
|
+
sys.exit(1)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@cli.command()
|
|
456
|
+
@click.argument('dev_name')
|
|
457
|
+
@click.option('--command', default='runtests.sh', help='Test command to run (default: runtests.sh)')
|
|
458
|
+
@click.option('--reset-workspace', is_flag=True, help='Reset workspace contents before execution')
|
|
459
|
+
@click.option('--live', is_flag=True, help='Start container with current directory mounted as workspace')
|
|
460
|
+
@click.option('--env', multiple=True, help='Environment variables to pass to container (format: VAR=value)')
|
|
461
|
+
@debug_option
|
|
462
|
+
def runtests(dev_name: str, command: str, reset_workspace: bool, live: bool, env: tuple, debug: bool) -> None:
|
|
463
|
+
"""Run tests in devcontainer.
|
|
464
|
+
|
|
465
|
+
DEV_NAME: Development environment name
|
|
466
|
+
|
|
467
|
+
Example: devs runtests sally
|
|
468
|
+
Example: devs runtests sally --command "npm test"
|
|
469
|
+
Example: devs runtests sally --reset-workspace
|
|
470
|
+
Example: devs runtests sally --live # Run with current directory
|
|
471
|
+
Example: devs runtests sally --env NODE_ENV=test
|
|
472
|
+
"""
|
|
473
|
+
check_dependencies()
|
|
474
|
+
project = get_project()
|
|
475
|
+
|
|
476
|
+
# Load environment variables from DEVS.yml and merge with CLI --env flags
|
|
477
|
+
devs_env = load_devs_env_vars(dev_name)
|
|
478
|
+
cli_env = parse_env_vars(env) if env else {}
|
|
479
|
+
extra_env = merge_env_vars(devs_env, cli_env) if devs_env or cli_env else None
|
|
480
|
+
|
|
481
|
+
if extra_env:
|
|
482
|
+
console.print(f"š§ Environment variables: {', '.join(f'{k}={v}' for k, v in extra_env.items())}")
|
|
483
|
+
|
|
484
|
+
container_manager = ContainerManager(project, config)
|
|
485
|
+
workspace_manager = WorkspaceManager(project, config)
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
# Ensure workspace exists (handles live mode and reset internally)
|
|
489
|
+
workspace_dir = workspace_manager.create_workspace(dev_name, reset_contents=reset_workspace, live=live)
|
|
490
|
+
# Ensure container is running
|
|
491
|
+
container_manager.ensure_container_running(dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env)
|
|
492
|
+
|
|
493
|
+
# Execute test command
|
|
494
|
+
console.print(f"š§Ŗ Running tests in {dev_name}...")
|
|
495
|
+
if reset_workspace and not live:
|
|
496
|
+
console.print("šļø Workspace contents reset")
|
|
497
|
+
console.print(f"š§ Command: {command}")
|
|
498
|
+
console.print("")
|
|
499
|
+
|
|
500
|
+
success, output, error = container_manager.exec_command(
|
|
501
|
+
dev_name, workspace_dir, command, debug=debug, stream=True, live=live, extra_env=extra_env
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
console.print("") # Add spacing after streamed output
|
|
505
|
+
if success:
|
|
506
|
+
console.print("ā
Tests completed successfully")
|
|
507
|
+
else:
|
|
508
|
+
console.print("ā Tests failed")
|
|
509
|
+
if error:
|
|
510
|
+
console.print("")
|
|
511
|
+
console.print("š« Error:")
|
|
512
|
+
console.print(error)
|
|
513
|
+
sys.exit(1)
|
|
514
|
+
|
|
515
|
+
except (ContainerError, WorkspaceError) as e:
|
|
516
|
+
console.print(f"ā Error running tests in {dev_name}: {e}")
|
|
517
|
+
sys.exit(1)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@cli.command('claude-auth')
|
|
521
|
+
@click.option('--api-key', help='Claude API key to authenticate with')
|
|
522
|
+
@debug_option
|
|
523
|
+
def claude_auth(api_key: str, debug: bool) -> None:
|
|
524
|
+
"""Set up Claude authentication for devcontainers.
|
|
525
|
+
|
|
526
|
+
This configures Claude authentication that will be shared across
|
|
527
|
+
all devcontainers for this project. The authentication is stored
|
|
528
|
+
on the host and bind-mounted into containers.
|
|
529
|
+
|
|
530
|
+
Example: devs claude-auth
|
|
531
|
+
Example: devs claude-auth --api-key <YOUR_API_KEY>
|
|
532
|
+
"""
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
# Ensure Claude config directory exists
|
|
536
|
+
config.ensure_directories()
|
|
537
|
+
|
|
538
|
+
console.print("š Setting up Claude authentication...")
|
|
539
|
+
console.print(f" Configuration will be saved to: {config.claude_config_dir}")
|
|
540
|
+
|
|
541
|
+
if api_key:
|
|
542
|
+
# Set API key directly using Claude CLI
|
|
543
|
+
console.print(" Using provided API key...")
|
|
544
|
+
|
|
545
|
+
# Set CLAUDE_CONFIG_DIR to our config directory and run auth with API key
|
|
546
|
+
env = os.environ.copy()
|
|
547
|
+
env['CLAUDE_CONFIG_DIR'] = str(config.claude_config_dir)
|
|
548
|
+
|
|
549
|
+
cmd = ['claude', 'auth', '--key', api_key]
|
|
550
|
+
|
|
551
|
+
if debug:
|
|
552
|
+
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
|
|
553
|
+
console.print(f"[dim]CLAUDE_CONFIG_DIR: {config.claude_config_dir}[/dim]")
|
|
554
|
+
|
|
555
|
+
result = subprocess.run(
|
|
556
|
+
cmd,
|
|
557
|
+
env=env,
|
|
558
|
+
capture_output=True,
|
|
559
|
+
text=True
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
if result.returncode != 0:
|
|
563
|
+
error_msg = result.stderr or result.stdout or "Unknown error"
|
|
564
|
+
raise Exception(f"Claude authentication failed: {error_msg}")
|
|
565
|
+
|
|
566
|
+
else:
|
|
567
|
+
# Interactive authentication
|
|
568
|
+
console.print(" Starting interactive authentication...")
|
|
569
|
+
console.print(" Follow the prompts to authenticate with Claude")
|
|
570
|
+
console.print("")
|
|
571
|
+
|
|
572
|
+
# Set CLAUDE_CONFIG_DIR to our config directory
|
|
573
|
+
env = os.environ.copy()
|
|
574
|
+
env['CLAUDE_CONFIG_DIR'] = str(config.claude_config_dir)
|
|
575
|
+
|
|
576
|
+
cmd = ['claude', 'auth']
|
|
577
|
+
|
|
578
|
+
if debug:
|
|
579
|
+
console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
|
|
580
|
+
console.print(f"[dim]CLAUDE_CONFIG_DIR: {config.claude_config_dir}[/dim]")
|
|
581
|
+
|
|
582
|
+
# Run interactively
|
|
583
|
+
result = subprocess.run(
|
|
584
|
+
cmd,
|
|
585
|
+
env=env,
|
|
586
|
+
check=False
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
if result.returncode != 0:
|
|
590
|
+
raise Exception("Claude authentication was cancelled or failed")
|
|
591
|
+
|
|
592
|
+
console.print("")
|
|
593
|
+
console.print("ā
Claude authentication configured successfully!")
|
|
594
|
+
console.print(f" Configuration saved to: {config.claude_config_dir}")
|
|
595
|
+
console.print(" This authentication will be shared across all devcontainers")
|
|
596
|
+
console.print("")
|
|
597
|
+
console.print("š” You can now use Claude in any devcontainer:")
|
|
598
|
+
console.print(" devs claude <dev-name> 'Your prompt here'")
|
|
599
|
+
|
|
600
|
+
except FileNotFoundError:
|
|
601
|
+
console.print("ā Claude CLI not found on host machine")
|
|
602
|
+
console.print("")
|
|
603
|
+
console.print("Please install Claude CLI first:")
|
|
604
|
+
console.print(" npm install -g @anthropic-ai/claude-cli")
|
|
605
|
+
console.print("")
|
|
606
|
+
console.print("Note: Claude needs to be installed on the host machine")
|
|
607
|
+
console.print(" for authentication. It's already available in containers.")
|
|
608
|
+
sys.exit(1)
|
|
609
|
+
|
|
610
|
+
except Exception as e:
|
|
611
|
+
console.print(f"ā Failed to configure Claude authentication: {e}")
|
|
612
|
+
if debug:
|
|
613
|
+
import traceback
|
|
614
|
+
console.print(traceback.format_exc())
|
|
615
|
+
sys.exit(1)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
@cli.command()
|
|
619
|
+
@click.option('--all-projects', is_flag=True, help='List containers for all projects')
|
|
620
|
+
def list(all_projects: bool) -> None:
|
|
621
|
+
"""List active devcontainers for current project."""
|
|
622
|
+
check_dependencies()
|
|
623
|
+
|
|
624
|
+
if all_projects:
|
|
625
|
+
console.print("š All devcontainers:")
|
|
626
|
+
# This would require a more complex implementation
|
|
627
|
+
console.print(" --all-projects not implemented yet")
|
|
628
|
+
return
|
|
629
|
+
|
|
630
|
+
project = get_project()
|
|
631
|
+
container_manager = ContainerManager(project, config)
|
|
632
|
+
|
|
633
|
+
console.print(f"š Active devcontainers for project: {project.info.name}")
|
|
634
|
+
console.print("")
|
|
635
|
+
|
|
636
|
+
try:
|
|
637
|
+
containers = container_manager.list_containers()
|
|
638
|
+
|
|
639
|
+
if not containers:
|
|
640
|
+
console.print(" No active devcontainers found")
|
|
641
|
+
console.print("")
|
|
642
|
+
console.print("š” Start some with: devs start <dev-name>")
|
|
643
|
+
return
|
|
644
|
+
|
|
645
|
+
# Create a table
|
|
646
|
+
table = Table()
|
|
647
|
+
table.add_column("Name", style="cyan")
|
|
648
|
+
table.add_column("Mode", style="yellow")
|
|
649
|
+
table.add_column("Status", style="green")
|
|
650
|
+
table.add_column("Container", style="dim")
|
|
651
|
+
table.add_column("Created", style="dim")
|
|
652
|
+
|
|
653
|
+
for container in containers:
|
|
654
|
+
created_str = container.created.strftime("%Y-%m-%d %H:%M") if container.created else "unknown"
|
|
655
|
+
mode = "live" if container.labels.get('devs.live') == 'true' else "copy"
|
|
656
|
+
table.add_row(
|
|
657
|
+
container.dev_name,
|
|
658
|
+
mode,
|
|
659
|
+
container.status,
|
|
660
|
+
container.name,
|
|
661
|
+
created_str
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
console.print(table)
|
|
665
|
+
console.print("")
|
|
666
|
+
console.print("š” Open with: devs vscode <dev-name>")
|
|
667
|
+
console.print("š” Shell into: devs shell <dev-name>")
|
|
668
|
+
console.print("š” Stop with: devs stop <dev-name>")
|
|
669
|
+
|
|
670
|
+
except ContainerError as e:
|
|
671
|
+
console.print(f"ā Error listing containers: {e}")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@cli.command()
|
|
675
|
+
def status() -> None:
|
|
676
|
+
"""Show project and dependency status."""
|
|
677
|
+
try:
|
|
678
|
+
project = Project()
|
|
679
|
+
|
|
680
|
+
console.print(f"š Project: {project.info.name}")
|
|
681
|
+
console.print(f" Directory: {project.info.directory}")
|
|
682
|
+
console.print(f" Git repo: {'Yes' if project.info.is_git_repo else 'No'}")
|
|
683
|
+
if project.info.git_remote_url:
|
|
684
|
+
console.print(f" Remote URL: {project.info.git_remote_url}")
|
|
685
|
+
|
|
686
|
+
# Check devcontainer config
|
|
687
|
+
try:
|
|
688
|
+
project.check_devcontainer_config()
|
|
689
|
+
console.print(" DevContainer config: ā
Found in project")
|
|
690
|
+
except DevcontainerConfigError:
|
|
691
|
+
console.print(" DevContainer config: š Will use default template")
|
|
692
|
+
|
|
693
|
+
# Show dependency status
|
|
694
|
+
integration = ExternalToolIntegration(project)
|
|
695
|
+
integration.print_dependency_status()
|
|
696
|
+
|
|
697
|
+
# Show workspace info
|
|
698
|
+
workspace_manager = WorkspaceManager(project, config)
|
|
699
|
+
workspaces = workspace_manager.list_workspaces()
|
|
700
|
+
if workspaces:
|
|
701
|
+
console.print(f"\nš Workspaces ({len(workspaces)}):")
|
|
702
|
+
for workspace in workspaces:
|
|
703
|
+
console.print(f" - {workspace}")
|
|
704
|
+
|
|
705
|
+
except ProjectNotFoundError as e:
|
|
706
|
+
console.print(f"ā {e}")
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
@cli.command()
|
|
710
|
+
@click.argument('dev_names', nargs=-1)
|
|
711
|
+
@click.option('--aborted', is_flag=True, help='Only clean up aborted/failed containers (skip workspaces)')
|
|
712
|
+
@click.option('--exclude-aborted', is_flag=True, help='Skip cleaning aborted containers (only clean workspaces)')
|
|
713
|
+
@click.option('--all-projects', is_flag=True, help='Clean aborted containers and unused workspaces from all projects')
|
|
714
|
+
def clean(dev_names: tuple, aborted: bool, exclude_aborted: bool, all_projects: bool) -> None:
|
|
715
|
+
"""Clean up workspaces and containers.
|
|
716
|
+
|
|
717
|
+
By default, cleans up aborted containers first, then unused workspaces.
|
|
718
|
+
|
|
719
|
+
DEV_NAMES: Specific development environments to clean up
|
|
720
|
+
"""
|
|
721
|
+
check_dependencies()
|
|
722
|
+
project = get_project()
|
|
723
|
+
|
|
724
|
+
workspace_manager = WorkspaceManager(project, config)
|
|
725
|
+
container_manager = ContainerManager(project, config)
|
|
726
|
+
|
|
727
|
+
if aborted:
|
|
728
|
+
# Clean up aborted/failed containers only
|
|
729
|
+
try:
|
|
730
|
+
console.print("š Looking for aborted containers...")
|
|
731
|
+
aborted_containers = container_manager.find_aborted_containers(all_projects=all_projects)
|
|
732
|
+
|
|
733
|
+
if not aborted_containers:
|
|
734
|
+
scope = "all projects" if all_projects else f"project: {project.info.name}"
|
|
735
|
+
console.print(f"ā
No aborted containers found for {scope}")
|
|
736
|
+
return
|
|
737
|
+
|
|
738
|
+
console.print(f"Found {len(aborted_containers)} aborted container(s):")
|
|
739
|
+
for container in aborted_containers:
|
|
740
|
+
console.print(f" - {container.name} ({container.project_name}/{container.dev_name}) - Status: {container.status}")
|
|
741
|
+
|
|
742
|
+
console.print("")
|
|
743
|
+
removed_count = container_manager.remove_aborted_containers(aborted_containers)
|
|
744
|
+
console.print(f"šļø Removed {removed_count} aborted container(s)")
|
|
745
|
+
|
|
746
|
+
except ContainerError as e:
|
|
747
|
+
console.print(f"ā Error cleaning aborted containers: {e}")
|
|
748
|
+
|
|
749
|
+
elif dev_names:
|
|
750
|
+
# Clean specific dev environments (both containers and workspaces)
|
|
751
|
+
for dev_name in dev_names:
|
|
752
|
+
console.print(f"šļø Cleaning up {dev_name}...")
|
|
753
|
+
# Stop and remove container if it exists
|
|
754
|
+
container_manager.stop_container(dev_name)
|
|
755
|
+
# Remove workspace
|
|
756
|
+
workspace_manager.remove_workspace(dev_name)
|
|
757
|
+
|
|
758
|
+
else:
|
|
759
|
+
# Default behavior: clean aborted containers first, then unused workspaces
|
|
760
|
+
aborted_count = 0
|
|
761
|
+
workspace_count = 0
|
|
762
|
+
|
|
763
|
+
# Step 1: Clean aborted containers (unless excluded)
|
|
764
|
+
if not exclude_aborted:
|
|
765
|
+
try:
|
|
766
|
+
console.print("š Looking for aborted containers...")
|
|
767
|
+
aborted_containers = container_manager.find_aborted_containers(all_projects=all_projects)
|
|
768
|
+
|
|
769
|
+
if aborted_containers:
|
|
770
|
+
console.print(f"Found {len(aborted_containers)} aborted container(s):")
|
|
771
|
+
for container in aborted_containers:
|
|
772
|
+
console.print(f" - {container.name} ({container.project_name}/{container.dev_name}) - Status: {container.status}")
|
|
773
|
+
|
|
774
|
+
console.print("")
|
|
775
|
+
aborted_count = container_manager.remove_aborted_containers(aborted_containers)
|
|
776
|
+
console.print(f"šļø Removed {aborted_count} aborted container(s)")
|
|
777
|
+
else:
|
|
778
|
+
console.print("ā
No aborted containers found")
|
|
779
|
+
|
|
780
|
+
if aborted_containers:
|
|
781
|
+
console.print("") # Add spacing between steps
|
|
782
|
+
|
|
783
|
+
except ContainerError as e:
|
|
784
|
+
console.print(f"ā Error cleaning aborted containers: {e}")
|
|
785
|
+
console.print("")
|
|
786
|
+
|
|
787
|
+
# Step 2: Clean unused workspaces
|
|
788
|
+
try:
|
|
789
|
+
if all_projects:
|
|
790
|
+
console.print("š Looking for unused workspaces across all projects...")
|
|
791
|
+
workspace_count = workspace_manager.cleanup_unused_workspaces_all_projects(container_manager.docker)
|
|
792
|
+
else:
|
|
793
|
+
console.print("š Looking for unused workspaces...")
|
|
794
|
+
containers = container_manager.list_containers()
|
|
795
|
+
active_dev_names = {c.dev_name for c in containers if c.status == 'running'}
|
|
796
|
+
workspace_count = workspace_manager.cleanup_unused_workspaces(active_dev_names)
|
|
797
|
+
|
|
798
|
+
if workspace_count > 0:
|
|
799
|
+
scope = "across all projects" if all_projects else f"for project: {project.info.name}"
|
|
800
|
+
console.print(f"šļø Cleaned up {workspace_count} unused workspace(s) {scope}")
|
|
801
|
+
else:
|
|
802
|
+
scope = "across all projects" if all_projects else f"for project: {project.info.name}"
|
|
803
|
+
console.print(f"ā
No unused workspaces found {scope}")
|
|
804
|
+
|
|
805
|
+
except ContainerError as e:
|
|
806
|
+
console.print(f"ā Error during workspace cleanup: {e}")
|
|
807
|
+
|
|
808
|
+
# Summary
|
|
809
|
+
if not exclude_aborted and (aborted_count > 0 or workspace_count > 0):
|
|
810
|
+
console.print("")
|
|
811
|
+
console.print(f"⨠Cleanup complete: {aborted_count} container(s) + {workspace_count} workspace(s) removed")
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def main() -> None:
|
|
815
|
+
"""Main entry point."""
|
|
816
|
+
try:
|
|
817
|
+
cli(standalone_mode=False, obj={})
|
|
818
|
+
except KeyboardInterrupt:
|
|
819
|
+
console.print("\nš Interrupted by user")
|
|
820
|
+
sys.exit(130)
|
|
821
|
+
except DevsError as e:
|
|
822
|
+
console.print(f"ā {e}")
|
|
823
|
+
sys.exit(1)
|
|
824
|
+
except Exception as e:
|
|
825
|
+
# Debug will be handled by each command now
|
|
826
|
+
console.print(f"ā Unexpected error: {e}")
|
|
827
|
+
# Show traceback if running in development mode (not ideal but safe fallback)
|
|
828
|
+
if os.environ.get('DEVS_DEBUG'):
|
|
829
|
+
raise
|
|
830
|
+
sys.exit(1)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
if __name__ == '__main__':
|
|
834
|
+
main()
|