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/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()