devs-cli 5.0.5__tar.gz → 6.0.0__tar.gz

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 (33) hide show
  1. {devs_cli-5.0.5/devs_cli.egg-info → devs_cli-6.0.0}/PKG-INFO +1 -1
  2. {devs_cli-5.0.5 → devs_cli-6.0.0}/devs/cli.py +145 -170
  3. {devs_cli-5.0.5 → devs_cli-6.0.0/devs_cli.egg-info}/PKG-INFO +1 -1
  4. {devs_cli-5.0.5 → devs_cli-6.0.0}/devs_cli.egg-info/SOURCES.txt +1 -0
  5. {devs_cli-5.0.5 → devs_cli-6.0.0}/pyproject.toml +1 -1
  6. devs_cli-6.0.0/tests/test_agents.py +45 -0
  7. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_cli.py +33 -9
  8. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_cli_misc.py +5 -5
  9. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_cli_stop.py +27 -8
  10. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_cli_vscode.py +22 -13
  11. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_live_mode.py +8 -16
  12. {devs_cli-5.0.5 → devs_cli-6.0.0}/LICENSE +0 -0
  13. {devs_cli-5.0.5 → devs_cli-6.0.0}/README.md +0 -0
  14. {devs_cli-5.0.5 → devs_cli-6.0.0}/devs/__init__.py +0 -0
  15. {devs_cli-5.0.5 → devs_cli-6.0.0}/devs/config.py +0 -0
  16. {devs_cli-5.0.5 → devs_cli-6.0.0}/devs/core/__init__.py +0 -0
  17. {devs_cli-5.0.5 → devs_cli-6.0.0}/devs/core/integration.py +0 -0
  18. {devs_cli-5.0.5 → devs_cli-6.0.0}/devs/exceptions.py +0 -0
  19. {devs_cli-5.0.5 → devs_cli-6.0.0}/devs/utils/__init__.py +0 -0
  20. {devs_cli-5.0.5 → devs_cli-6.0.0}/devs_cli.egg-info/dependency_links.txt +0 -0
  21. {devs_cli-5.0.5 → devs_cli-6.0.0}/devs_cli.egg-info/entry_points.txt +0 -0
  22. {devs_cli-5.0.5 → devs_cli-6.0.0}/devs_cli.egg-info/requires.txt +0 -0
  23. {devs_cli-5.0.5 → devs_cli-6.0.0}/devs_cli.egg-info/top_level.txt +0 -0
  24. {devs_cli-5.0.5 → devs_cli-6.0.0}/setup.cfg +0 -0
  25. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_cli_clean.py +0 -0
  26. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_cli_start.py +0 -0
  27. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_container_manager.py +0 -0
  28. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_e2e.py +0 -0
  29. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_error_parsing.py +0 -0
  30. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_integration.py +0 -0
  31. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_project.py +0 -0
  32. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_repo_cache.py +0 -0
  33. {devs_cli-5.0.5 → devs_cli-6.0.0}/tests/test_workspace_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-cli
3
- Version: 5.0.5
3
+ Version: 6.0.0
4
4
  Summary: DevContainer Management Tool - Manage multiple named devcontainers for any project
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -5,6 +5,7 @@ import sys
5
5
  import subprocess
6
6
  import traceback
7
7
  from functools import wraps
8
+ from typing import Callable, Sequence
8
9
  from importlib.metadata import version, PackageNotFoundError
9
10
 
10
11
  import click
@@ -14,6 +15,7 @@ from rich.table import Table
14
15
  from .config import config
15
16
  from .core import Project, ContainerManager, WorkspaceManager
16
17
  from .core.integration import VSCodeIntegration, ExternalToolIntegration
18
+ from devs_common.agents import AgentSpec, get_agent
17
19
  from devs_common.devs_config import DevsConfigLoader
18
20
  from devs_common.utils.repo_cache import RepoCache
19
21
  from .exceptions import (
@@ -379,9 +381,21 @@ def stop(dev_names: tuple) -> None:
379
381
 
380
382
  container_manager = ContainerManager(project, config)
381
383
 
384
+ failed = []
382
385
  for dev_name in dev_names:
383
386
  console.print(f" Stopping: {dev_name}")
384
- container_manager.stop_container(dev_name, remove=False)
387
+ # Keep going past a failure so one bad container doesn't leave the rest running.
388
+ # stop_container reports Docker errors and missing containers itself, returning False.
389
+ try:
390
+ stopped = container_manager.stop_container(dev_name, remove=False)
391
+ except ContainerError as e:
392
+ console.print(f" ❌ Error stopping {dev_name}: {e}")
393
+ stopped = False
394
+ if stopped is False:
395
+ failed.append(dev_name)
396
+
397
+ if failed:
398
+ sys.exit(1)
385
399
 
386
400
 
387
401
  @cli.command()
@@ -424,46 +438,8 @@ def shell(dev_name: str, live: bool, env: tuple, debug: bool) -> None:
424
438
  sys.exit(1)
425
439
 
426
440
 
427
- @cli.command()
428
- @click.argument('dev_name', required=False)
429
- @click.argument('prompt', required=False)
430
- @click.option('--auth', is_flag=True, help='Show Claude authentication setup instructions')
431
- @click.option('--reset-workspace', is_flag=True, help='Reset workspace contents before execution')
432
- @click.option('--live', is_flag=True, help='Start container with current directory mounted as workspace')
433
- @click.option('--env', multiple=True, help='Environment variables to pass to container (format: VAR=value)')
434
- @debug_option
435
- def claude(dev_name: str, prompt: str, auth: bool, reset_workspace: bool, live: bool, env: tuple, debug: bool) -> None:
436
- """Execute Claude CLI in devcontainer or set up authentication.
437
-
438
- DEV_NAME: Development environment name
439
- PROMPT: Prompt to send to Claude
440
-
441
- Example: devs claude sally "Summarize this codebase"
442
- Example: devs claude sally "Fix the tests" --reset-workspace
443
- Example: devs claude sally "Fix the tests" --live # Run with current directory
444
- Example: devs claude sally "Start the server" --env QUART_PORT=5001
445
- Example: devs claude --auth # Show auth setup instructions
446
- """
447
- # Handle authentication mode
448
- if auth:
449
- console.print("🔐 Claude authentication for devcontainers")
450
- console.print("")
451
- console.print("1. Generate a token (on a machine with a browser):")
452
- console.print(" [cyan]claude setup-token[/cyan]")
453
- console.print("")
454
- console.print("2. Add the token to your environment:")
455
- console.print(" [cyan]export CLAUDE_CODE_OAUTH_TOKEN=<token>[/cyan]")
456
- console.print("")
457
- console.print(" Or add it to [cyan]~/.devs/envs/default/.env[/cyan]:")
458
- console.print(" [dim]CLAUDE_CODE_OAUTH_TOKEN=<token>[/dim]")
459
- console.print("")
460
- console.print("The token will be automatically passed to all devcontainers.")
461
- return
462
-
463
- # Validate required arguments for execution mode
464
- if not dev_name or not prompt:
465
- raise click.UsageError("DEV_NAME and PROMPT are required unless using --auth")
466
-
441
+ def _run_agent(agent: AgentSpec, dev_name: str, prompt: str, reset_workspace: bool, live: bool, env: tuple, debug: bool) -> None:
442
+ """Run a coding agent against a prompt in a devcontainer, streaming its output."""
467
443
  check_dependencies()
468
444
  project = get_project()
469
445
 
@@ -482,14 +458,15 @@ def claude(dev_name: str, prompt: str, auth: bool, reset_workspace: bool, live:
482
458
  # Ensure workspace exists (handles live mode and reset internally)
483
459
  workspace_dir = workspace_manager.create_workspace(dev_name, reset_contents=reset_workspace, live=live)
484
460
 
485
- # Execute Claude (ensure_container_running is called internally)
486
- console.print(f"🤖 Executing Claude in {dev_name}...")
461
+ # Execute the agent (ensure_container_running is called internally)
462
+ console.print(f"🤖 Executing {agent.display_name} in {dev_name}...")
487
463
  if reset_workspace and not live:
488
464
  console.print("🗑️ Workspace contents reset")
489
465
  console.print(f"📝 Prompt: {prompt}")
490
466
  console.print("")
491
467
 
492
- success, output, error, _ = container_manager.exec_claude(
468
+ success, output, error, _ = container_manager.exec_agent(
469
+ agent,
493
470
  dev_name=dev_name,
494
471
  workspace_dir=workspace_dir,
495
472
  prompt=prompt,
@@ -501,9 +478,9 @@ def claude(dev_name: str, prompt: str, auth: bool, reset_workspace: bool, live:
501
478
 
502
479
  console.print("") # Add spacing after streamed output
503
480
  if success:
504
- console.print("✅ Claude execution completed")
481
+ console.print(f"✅ {agent.display_name} execution completed")
505
482
  else:
506
- console.print("❌ Claude execution failed")
483
+ console.print(f"❌ {agent.display_name} execution failed")
507
484
  if error:
508
485
  console.print("")
509
486
  console.print("🚫 Error:")
@@ -511,98 +488,99 @@ def claude(dev_name: str, prompt: str, auth: bool, reset_workspace: bool, live:
511
488
  sys.exit(1)
512
489
 
513
490
  except (ContainerError, WorkspaceError) as e:
514
- console.print(f"❌ Error executing Claude in {dev_name}: {e}")
491
+ console.print(f"❌ Error executing {agent.display_name} in {dev_name}: {e}")
515
492
  sys.exit(1)
516
493
 
517
494
 
495
+ def _add_agent_command(
496
+ name: str,
497
+ auth_help: str,
498
+ handle_auth: Callable[..., None],
499
+ auth_options: Sequence[Callable] = (),
500
+ auth_examples: Sequence[str] = (),
501
+ ) -> None:
502
+ """Register a `devs <agent> DEV_NAME PROMPT` command for an agent in devs_common.agents.
518
503
 
519
- except Exception as e:
520
- console.print(f"❌ Failed to configure Claude authentication: {e}")
521
- if debug:
522
- console.print(traceback.format_exc())
523
- sys.exit(1)
524
-
525
-
526
- @cli.command()
527
- @click.argument('dev_name', required=False)
528
- @click.argument('prompt', required=False)
529
- @click.option('--auth', is_flag=True, help='Set up Codex authentication for devcontainers')
530
- @click.option('--api-key', help='OpenAI API key to authenticate with (use with --auth)')
531
- @click.option('--reset-workspace', is_flag=True, help='Reset workspace contents before execution')
532
- @click.option('--live', is_flag=True, help='Start container with current directory mounted as workspace')
533
- @click.option('--env', multiple=True, help='Environment variables to pass to container (format: VAR=value)')
534
- @debug_option
535
- def codex(dev_name: str, prompt: str, auth: bool, api_key: str, reset_workspace: bool, live: bool, env: tuple, debug: bool) -> None:
536
- """Execute OpenAI Codex CLI in devcontainer or set up authentication.
537
-
538
- DEV_NAME: Development environment name
539
- PROMPT: Prompt to send to Codex
540
-
541
- Example: devs codex sally "Summarize this codebase"
542
- Example: devs codex sally "Fix the tests" --reset-workspace
543
- Example: devs codex sally "Fix the tests" --live # Run with current directory
544
- Example: devs codex sally "Start the server" --env QUART_PORT=5001
545
- Example: devs codex --auth # Interactive authentication
546
- Example: devs codex --auth --api-key <YOUR_KEY> # API key authentication
504
+ Args:
505
+ name: Agent name in the AGENTS registry
506
+ auth_help: Help text for the --auth flag
507
+ handle_auth: Called for `--auth` with debug= plus any auth_options values
508
+ auth_options: Extra click options that only apply to --auth (e.g. --api-key)
509
+ auth_examples: Extra `devs <agent> --auth ...` example lines for the help text
547
510
  """
548
- # Handle authentication mode
549
- if auth:
550
- _handle_codex_auth(api_key=api_key, debug=debug)
551
- return
552
-
553
- # Validate required arguments for execution mode
554
- if not dev_name or not prompt:
555
- raise click.UsageError("DEV_NAME and PROMPT are required unless using --auth")
556
-
557
- check_dependencies()
558
- project = get_project()
559
-
560
- # Load environment variables from DEVS.yml and merge with CLI --env flags
561
- devs_env = DevsConfigLoader.load_env_vars(dev_name, project.info.name)
562
- cli_env = parse_env_vars(env) if env else {}
563
- extra_env = merge_env_vars(devs_env, cli_env) if devs_env or cli_env else None
564
-
565
- if extra_env:
566
- console.print(f"🔧 Environment variables: {', '.join(f'{k}={v}' for k, v in extra_env.items())}")
567
-
568
- container_manager = ContainerManager(project, config)
569
- workspace_manager = WorkspaceManager(project, config)
511
+ agent = get_agent(name)
570
512
 
571
- try:
572
- # Ensure workspace exists (handles live mode and reset internally)
573
- workspace_dir = workspace_manager.create_workspace(dev_name, reset_contents=reset_workspace, live=live)
574
-
575
- # Execute Codex (ensure_container_running is called internally)
576
- console.print(f"🤖 Executing Codex in {dev_name}...")
577
- if reset_workspace and not live:
578
- console.print("🗑️ Workspace contents reset")
579
- console.print(f"📝 Prompt: {prompt}")
580
- console.print("")
581
-
582
- success, output, error, _ = container_manager.exec_codex(
583
- dev_name=dev_name,
584
- workspace_dir=workspace_dir,
585
- prompt=prompt,
586
- debug=debug,
587
- stream=True,
588
- live=live,
589
- extra_env=extra_env
590
- )
513
+ def command(dev_name: str, prompt: str, auth: bool, reset_workspace: bool, live: bool,
514
+ env: tuple, debug: bool, **auth_values) -> None:
515
+ if auth:
516
+ handle_auth(debug=debug, **auth_values)
517
+ return
518
+ # Validate required arguments for execution mode
519
+ if not dev_name or not prompt:
520
+ raise click.UsageError("DEV_NAME and PROMPT are required unless using --auth")
521
+ _run_agent(agent, dev_name, prompt, reset_workspace, live, env, debug)
522
+
523
+ examples = [
524
+ f'devs {name} sally "Summarize this codebase"',
525
+ f'devs {name} sally "Fix the tests" --reset-workspace',
526
+ f'devs {name} sally "Fix the tests" --live # Run with current directory',
527
+ f'devs {name} sally "Start the server" --env QUART_PORT=5001',
528
+ f'devs {name} --auth',
529
+ *auth_examples,
530
+ ]
531
+ command.__name__ = name
532
+ command.__doc__ = (
533
+ f"Execute {agent.description} in devcontainer or set up authentication.\n\n"
534
+ f"DEV_NAME: Development environment name\n\n"
535
+ f"PROMPT: Prompt to send to {agent.display_name}\n\n"
536
+ + "\n\n".join(f"Example: {example}" for example in examples)
537
+ )
538
+
539
+ decorators = [
540
+ cli.command(name=name),
541
+ click.argument('dev_name', required=False),
542
+ click.argument('prompt', required=False),
543
+ click.option('--auth', is_flag=True, help=auth_help),
544
+ *auth_options,
545
+ click.option('--reset-workspace', is_flag=True, help='Reset workspace contents before execution'),
546
+ click.option('--live', is_flag=True, help='Start container with current directory mounted as workspace'),
547
+ click.option('--env', multiple=True, help='Environment variables to pass to container (format: VAR=value)'),
548
+ debug_option,
549
+ ]
550
+ for decorator in reversed(decorators):
551
+ command = decorator(command)
552
+
553
+
554
+ def _show_claude_auth(debug: bool) -> None:
555
+ """Explain how to give devcontainers a Claude token."""
556
+ console.print("🔐 Claude authentication for devcontainers")
557
+ console.print("")
558
+ console.print("1. Generate a token (on a machine with a browser):")
559
+ console.print(" [cyan]claude setup-token[/cyan]")
560
+ console.print("")
561
+ console.print("2. Add the token to your environment:")
562
+ console.print(" [cyan]export CLAUDE_CODE_OAUTH_TOKEN=<token>[/cyan]")
563
+ console.print("")
564
+ console.print(" Or add it to [cyan]~/.devs/envs/default/.env[/cyan]:")
565
+ console.print(" [dim]CLAUDE_CODE_OAUTH_TOKEN=<token>[/dim]")
566
+ console.print("")
567
+ console.print("The token will be automatically passed to all devcontainers.")
591
568
 
592
- console.print("") # Add spacing after streamed output
593
- if success:
594
- console.print("✅ Codex execution completed")
595
- else:
596
- console.print("❌ Codex execution failed")
597
- if error:
598
- console.print("")
599
- console.print("🚫 Error:")
600
- console.print(error)
601
- sys.exit(1)
602
569
 
603
- except (ContainerError, WorkspaceError) as e:
604
- console.print(f"❌ Error executing Codex in {dev_name}: {e}")
605
- sys.exit(1)
570
+ def _show_hermes_auth(debug: bool) -> None:
571
+ """Explain how to give devcontainers an OpenRouter key for Hermes."""
572
+ console.print("🔐 Hermes Agent authentication for devcontainers")
573
+ console.print("")
574
+ console.print("Hermes runs against OpenRouter. Create an API key at:")
575
+ console.print(" [cyan]https://openrouter.ai/keys[/cyan]")
576
+ console.print("")
577
+ console.print("Add it to [cyan]~/.devs/envs/default/.env[/cyan] (or a project-specific env dir):")
578
+ console.print(" [dim]OPENROUTER_API_KEY=<key>[/dim]")
579
+ console.print("")
580
+ console.print("The default model is [cyan]z-ai/glm-5.3[/cyan]. To use another OpenRouter model:")
581
+ console.print(" [dim]HERMES_INFERENCE_MODEL=<provider/model>[/dim]")
582
+ console.print("")
583
+ console.print("The env file is mounted into every devcontainer and loaded on each shell.")
606
584
 
607
585
 
608
586
  def _handle_codex_auth(api_key: str, debug: bool) -> None:
@@ -619,54 +597,32 @@ def _handle_codex_auth(api_key: str, debug: bool) -> None:
619
597
  console.print("🔐 Setting up Codex authentication...")
620
598
  console.print(f" Configuration will be saved to: {config.codex_config_dir}")
621
599
 
600
+ # Codex reads its state directory from CODEX_HOME (default ~/.codex); pointing it
601
+ # at the directory that is bind-mounted into containers shares the login with them.
602
+ env = os.environ.copy()
603
+ env['CODEX_HOME'] = str(config.codex_config_dir)
604
+
622
605
  if api_key:
623
- # Set API key directly using Codex CLI
624
606
  console.print(" Using provided API key...")
625
-
626
- # Set CODEX_CONFIG_HOME to our config directory and run auth with API key
627
- env = os.environ.copy()
628
- env['CODEX_CONFIG_HOME'] = str(config.codex_config_dir)
629
-
630
- cmd = ['codex', 'auth', '--api-key', api_key]
631
-
632
- if debug:
633
- console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
634
- console.print(f"[dim]CODEX_CONFIG_HOME: {config.codex_config_dir}[/dim]")
635
-
636
- result = subprocess.run(
637
- cmd,
638
- env=env,
639
- capture_output=True,
640
- text=True
641
- )
642
-
643
- if result.returncode != 0:
644
- error_msg = result.stderr or result.stdout or "Unknown error"
645
- raise Exception(f"Codex authentication failed: {error_msg}")
646
-
607
+ # The key goes in on stdin so it never shows up in the process list
608
+ cmd = ['codex', 'login', '--with-api-key']
647
609
  else:
648
- # Interactive authentication
649
610
  console.print(" Starting interactive authentication...")
650
611
  console.print(" Follow the prompts to authenticate with Codex")
651
612
  console.print("")
613
+ cmd = ['codex', 'login']
652
614
 
653
- # Set CODEX_CONFIG_HOME to our config directory
654
- env = os.environ.copy()
655
- env['CODEX_CONFIG_HOME'] = str(config.codex_config_dir)
656
-
657
- cmd = ['codex', 'auth']
658
-
659
- if debug:
660
- console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
661
- console.print(f"[dim]CODEX_CONFIG_HOME: {config.codex_config_dir}[/dim]")
662
-
663
- # Run interactively
664
- result = subprocess.run(
665
- cmd,
666
- env=env,
667
- check=False
668
- )
615
+ if debug:
616
+ console.print(f"[dim]Running: {' '.join(cmd)}[/dim]")
617
+ console.print(f"[dim]CODEX_HOME: {config.codex_config_dir}[/dim]")
669
618
 
619
+ if api_key:
620
+ result = subprocess.run(cmd, env=env, input=api_key, capture_output=True, text=True)
621
+ if result.returncode != 0:
622
+ error_msg = result.stderr or result.stdout or "Unknown error"
623
+ raise Exception(f"Codex authentication failed: {error_msg}")
624
+ else:
625
+ result = subprocess.run(cmd, env=env, check=False)
670
626
  if result.returncode != 0:
671
627
  raise Exception("Codex authentication was cancelled or failed")
672
628
 
@@ -695,6 +651,25 @@ def _handle_codex_auth(api_key: str, debug: bool) -> None:
695
651
  sys.exit(1)
696
652
 
697
653
 
654
+ _add_agent_command(
655
+ "claude",
656
+ auth_help='Show Claude authentication setup instructions',
657
+ handle_auth=_show_claude_auth,
658
+ )
659
+ _add_agent_command(
660
+ "codex",
661
+ auth_help='Set up Codex authentication for devcontainers',
662
+ handle_auth=_handle_codex_auth,
663
+ auth_options=[click.option('--api-key', help='OpenAI API key to authenticate with (use with --auth)')],
664
+ auth_examples=['devs codex --auth --api-key <YOUR_KEY> # API key authentication'],
665
+ )
666
+ _add_agent_command(
667
+ "hermes",
668
+ auth_help='Show Hermes (OpenRouter) authentication setup instructions',
669
+ handle_auth=_show_hermes_auth,
670
+ )
671
+
672
+
698
673
  @cli.command()
699
674
  @click.argument('dev_name')
700
675
  @click.option('--reset-workspace', is_flag=True, help='Reset workspace contents before execution')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devs-cli
3
- Version: 5.0.5
3
+ Version: 6.0.0
4
4
  Summary: DevContainer Management Tool - Manage multiple named devcontainers for any project
5
5
  Author: Dan Lester
6
6
  License-Expression: MIT
@@ -14,6 +14,7 @@ devs_cli.egg-info/dependency_links.txt
14
14
  devs_cli.egg-info/entry_points.txt
15
15
  devs_cli.egg-info/requires.txt
16
16
  devs_cli.egg-info/top_level.txt
17
+ tests/test_agents.py
17
18
  tests/test_cli.py
18
19
  tests/test_cli_clean.py
19
20
  tests/test_cli_misc.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devs-cli"
7
- version = "5.0.5"
7
+ version = "6.0.0"
8
8
  description = "DevContainer Management Tool - Manage multiple named devcontainers for any project"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -0,0 +1,45 @@
1
+ """Tests for the coding agent registry and the commands generated from it."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+ from click.testing import CliRunner
7
+
8
+ from devs.cli import cli
9
+ from devs_common.agents import AGENTS, get_agent
10
+ from devs_common.core.container import ContainerManager
11
+
12
+
13
+ def test_every_registered_agent_has_a_cli_command():
14
+ """Each AgentSpec gets a `devs <name>` command."""
15
+ for name in AGENTS:
16
+ assert name in cli.commands, f"missing `devs {name}` command"
17
+
18
+
19
+ @pytest.mark.parametrize("name", list(AGENTS))
20
+ def test_agent_command_help_uses_spec(name):
21
+ result = CliRunner().invoke(cli, [name, '--help'])
22
+
23
+ assert result.exit_code == 0
24
+ assert f"Execute {AGENTS[name].description} in devcontainer" in result.output
25
+ assert f"devs {name} sally" in result.output
26
+
27
+
28
+ def test_get_agent_unknown_name():
29
+ with pytest.raises(ValueError, match="Unknown agent 'nope'"):
30
+ get_agent('nope')
31
+
32
+
33
+ @pytest.mark.parametrize("name", list(AGENTS))
34
+ def test_exec_agent_runs_spec_command_with_prompt_on_stdin(name, tmp_path):
35
+ # exec_agent only delegates to exec_command, so skip __init__ (which needs Docker)
36
+ manager = ContainerManager.__new__(ContainerManager)
37
+ with patch.object(manager, 'exec_command', return_value=(True, "ok", "", 0)) as exec_command:
38
+ result = manager.exec_agent(name, dev_name='sally', workspace_dir=tmp_path,
39
+ prompt='Fix the tests', live=True)
40
+
41
+ assert result == (True, "ok", "", 0)
42
+ kwargs = exec_command.call_args.kwargs
43
+ assert kwargs['command'] == AGENTS[name].command
44
+ assert kwargs['stdin_input'] == 'Fix the tests'
45
+ assert kwargs['live'] is True
@@ -29,7 +29,7 @@ class TestCLI:
29
29
  result = runner.invoke(cli, ['--version'])
30
30
 
31
31
  assert result.exit_code == 0
32
- assert "0.1.0" in result.output
32
+ assert "devs-cli" in result.output
33
33
 
34
34
  def test_start_command_help(self):
35
35
  """Test start command help."""
@@ -55,7 +55,7 @@ class TestCLI:
55
55
  result = runner.invoke(cli, ['stop', '--help'])
56
56
 
57
57
  assert result.exit_code == 0
58
- assert "Stop and remove devcontainers" in result.output
58
+ assert "Stop devcontainers" in result.output
59
59
  assert "DEV_NAMES" in result.output
60
60
 
61
61
  def test_shell_command_help(self):
@@ -178,10 +178,10 @@ class TestCLI:
178
178
  # Verify subprocess was called with correct arguments
179
179
  mock_subprocess.assert_called_once()
180
180
  call_args = mock_subprocess.call_args
181
- assert 'codex' in call_args[0][0]
182
- assert 'auth' in call_args[0][0]
183
- assert '--api-key' in call_args[0][0]
184
- assert 'test-key-123' in call_args[0][0]
181
+ assert call_args[0][0] == ['codex', 'login', '--with-api-key']
182
+ # Key is passed on stdin, not argv, and login state goes to the mounted dir
183
+ assert call_args.kwargs['input'] == 'test-key-123'
184
+ assert call_args.kwargs['env']['CODEX_HOME'] == '/tmp/test-codex-config'
185
185
 
186
186
  @patch('devs.cli.subprocess.run')
187
187
  @patch('devs.cli.config')
@@ -203,9 +203,8 @@ class TestCLI:
203
203
  # Verify subprocess was called for interactive auth
204
204
  mock_subprocess.assert_called_once()
205
205
  call_args = mock_subprocess.call_args
206
- assert 'codex' in call_args[0][0]
207
- assert 'auth' in call_args[0][0]
208
- assert '--api-key' not in call_args[0][0]
206
+ assert call_args[0][0] == ['codex', 'login']
207
+ assert call_args.kwargs['env']['CODEX_HOME'] == '/tmp/test-codex-config'
209
208
 
210
209
  @patch('devs.cli.subprocess.run')
211
210
  @patch('devs.cli.config')
@@ -223,6 +222,31 @@ class TestCLI:
223
222
  assert "Codex CLI not found" in result.output
224
223
  assert "npm install -g @openai/codex" in result.output
225
224
 
225
+ def test_hermes_command_help(self):
226
+ """Test hermes command help."""
227
+ runner = CliRunner()
228
+ result = runner.invoke(cli, ['hermes', '--help'])
229
+
230
+ assert result.exit_code == 0
231
+ assert "Execute Hermes Agent" in result.output
232
+ assert "--auth" in result.output
233
+
234
+ def test_hermes_auth_shows_instructions(self):
235
+ """Test hermes --auth shows OpenRouter setup instructions."""
236
+ runner = CliRunner()
237
+ result = runner.invoke(cli, ['hermes', '--auth'])
238
+
239
+ assert result.exit_code == 0
240
+ assert "OPENROUTER_API_KEY" in result.output
241
+
242
+ def test_hermes_missing_args(self):
243
+ """Test hermes command without required args (not using --auth)."""
244
+ runner = CliRunner()
245
+ result = runner.invoke(cli, ['hermes'])
246
+
247
+ assert result.exit_code != 0
248
+ assert "DEV_NAME and PROMPT are required unless using --auth" in result.output
249
+
226
250
  def test_codex_missing_args(self):
227
251
  """Test codex command without required args (not using --auth)."""
228
252
  runner = CliRunner()
@@ -288,7 +288,7 @@ class TestShellCommand:
288
288
 
289
289
  # Container manager raises error
290
290
  mock_container_manager = Mock()
291
- mock_container_manager.ensure_container_running.side_effect = ContainerError("Container failed")
291
+ mock_container_manager.exec_shell.side_effect = ContainerError("Container failed")
292
292
  mock_container_manager_class.return_value = mock_container_manager
293
293
 
294
294
  # Run command
@@ -319,7 +319,7 @@ class TestClaudeCommand:
319
319
 
320
320
  mock_container_manager = Mock()
321
321
  mock_container_manager.ensure_container_running.return_value = True
322
- mock_container_manager.exec_claude.return_value = (True, "Claude response", None, None)
322
+ mock_container_manager.exec_agent.return_value = (True, "Claude response", None, None)
323
323
  mock_container_manager_class.return_value = mock_container_manager
324
324
 
325
325
  # Run command
@@ -328,7 +328,7 @@ class TestClaudeCommand:
328
328
  # Verify
329
329
  assert result.exit_code == 0
330
330
  assert "Executing Claude in alice" in result.output
331
- mock_container_manager.exec_claude.assert_called_once()
331
+ mock_container_manager.exec_agent.assert_called_once()
332
332
 
333
333
  @patch('devs.cli.get_project')
334
334
  @patch('devs.cli.WorkspaceManager')
@@ -349,7 +349,7 @@ class TestClaudeCommand:
349
349
 
350
350
  # Container manager raises error
351
351
  mock_container_manager = Mock()
352
- mock_container_manager.ensure_container_running.side_effect = ContainerError("Container failed")
352
+ mock_container_manager.exec_agent.side_effect = ContainerError("Container failed")
353
353
  mock_container_manager_class.return_value = mock_container_manager
354
354
 
355
355
  # Run command
@@ -376,7 +376,7 @@ class TestClaudeCommand:
376
376
 
377
377
  mock_container_manager = Mock()
378
378
  mock_container_manager.ensure_container_running.return_value = True
379
- mock_container_manager.exec_claude.return_value = (True, "Claude response", None, None)
379
+ mock_container_manager.exec_agent.return_value = (True, "Claude response", None, None)
380
380
  mock_container_manager_class.return_value = mock_container_manager
381
381
 
382
382
  # Run command with env vars
@@ -11,6 +11,24 @@ from devs.cli import cli
11
11
  class TestStopCommand:
12
12
  """Test suite for 'devs stop' command."""
13
13
 
14
+ @patch('devs.cli.get_project')
15
+ @patch('devs.cli.ContainerManager')
16
+ def test_stop_exits_nonzero_when_stop_fails(self, mock_container_manager_class, mock_get_project,
17
+ cli_runner, temp_project):
18
+ """stop_container returns False (container missing or Docker error) -> exit 1."""
19
+ mock_project = Mock()
20
+ mock_project.info.name = "test-org-test-repo"
21
+ mock_get_project.return_value = mock_project
22
+
23
+ mock_container_manager = Mock()
24
+ mock_container_manager.stop_container.side_effect = [False, True]
25
+ mock_container_manager_class.return_value = mock_container_manager
26
+
27
+ result = cli_runner.invoke(cli, ['stop', 'alice', 'bob'])
28
+
29
+ assert result.exit_code == 1
30
+ assert mock_container_manager.stop_container.call_count == 2
31
+
14
32
  @patch('devs.cli.get_project')
15
33
  @patch('devs.cli.ContainerManager')
16
34
  def test_stop_single_container(self, mock_container_manager_class, mock_get_project,
@@ -32,7 +50,7 @@ class TestStopCommand:
32
50
  assert result.exit_code == 0
33
51
  assert "Stopping" in result.output
34
52
  assert "alice" in result.output
35
- mock_container_manager.stop_container.assert_called_once_with("alice")
53
+ mock_container_manager.stop_container.assert_called_once_with("alice", remove=False)
36
54
 
37
55
  @patch('devs.cli.get_project')
38
56
  @patch('devs.cli.ContainerManager')
@@ -77,7 +95,7 @@ class TestStopCommand:
77
95
 
78
96
  # Verify it completes
79
97
  assert result.exit_code == 0
80
- mock_container_manager.stop_container.assert_called_once_with("alice")
98
+ mock_container_manager.stop_container.assert_called_once_with("alice", remove=False)
81
99
 
82
100
  @patch('devs.cli.get_project')
83
101
  @patch('devs.cli.ContainerManager')
@@ -112,15 +130,16 @@ class TestStopCommand:
112
130
  mock_get_project.return_value = mock_project
113
131
 
114
132
  mock_container_manager = Mock()
115
- mock_container_manager.stop_container.side_effect = ContainerError("Failed to stop")
133
+ mock_container_manager.stop_container.side_effect = [ContainerError("Failed to stop"), True]
116
134
  mock_container_manager_class.return_value = mock_container_manager
117
135
 
118
- # Run command - CLI doesn't catch this exception currently
119
- result = cli_runner.invoke(cli, ['stop', 'alice'])
136
+ result = cli_runner.invoke(cli, ['stop', 'alice', 'bob'])
120
137
 
121
- # The command may fail or show error
122
- # Just verify it was called
123
- mock_container_manager.stop_container.assert_called_once_with("alice")
138
+ # The error is reported, bob is still stopped, and the command exits non-zero
139
+ assert result.exit_code == 1
140
+ assert "Error stopping alice: Failed to stop" in result.output
141
+ assert mock_container_manager.stop_container.call_count == 2
142
+ mock_container_manager.stop_container.assert_called_with("bob", remove=False)
124
143
 
125
144
  @patch('devs.cli.get_project')
126
145
  @patch('devs.cli.ContainerManager')
@@ -1,5 +1,4 @@
1
1
  """Integration tests for the 'vscode' command."""
2
- import json
3
2
  from pathlib import Path
4
3
  from unittest.mock import MagicMock, Mock, patch
5
4
 
@@ -12,7 +11,14 @@ from devs.exceptions import VSCodeError, WorkspaceError
12
11
 
13
12
 
14
13
  class TestVSCodeCommand:
15
- """Test suite for 'devs vscode' command."""
14
+ """Test suite for 'devs vscode' command in local mode (no SSH host)."""
15
+
16
+ @pytest.fixture(autouse=True)
17
+ def no_ssh_host(self, monkeypatch):
18
+ """Keep these tests on the local path: no --ssh from env or DEVS.yml."""
19
+ monkeypatch.delenv('DEVS_SSH_HOST', raising=False)
20
+ with patch('devs.cli.DevsConfigLoader.load_ssh_host', return_value=None):
21
+ yield
16
22
 
17
23
  @patch('devs.cli.get_project')
18
24
  @patch('devs.cli.ContainerManager')
@@ -36,6 +42,7 @@ class TestVSCodeCommand:
36
42
  mock_container_manager_class.return_value = mock_container_manager
37
43
 
38
44
  mock_vscode = Mock()
45
+ mock_vscode.resolve_tailnet_ssh_host.return_value = None # not on the tailnet
39
46
  mock_vscode.launch_multiple_devcontainers.return_value = 1
40
47
  mock_vscode_class.return_value = mock_vscode
41
48
 
@@ -68,6 +75,7 @@ class TestVSCodeCommand:
68
75
  mock_container_manager_class.return_value = mock_container_manager
69
76
 
70
77
  mock_vscode = Mock()
78
+ mock_vscode.resolve_tailnet_ssh_host.return_value = None # not on the tailnet
71
79
  mock_vscode.launch_multiple_devcontainers.return_value = 1
72
80
  mock_vscode_class.return_value = mock_vscode
73
81
 
@@ -100,6 +108,7 @@ class TestVSCodeCommand:
100
108
  mock_container_manager_class.return_value = mock_container_manager
101
109
 
102
110
  mock_vscode = Mock()
111
+ mock_vscode.resolve_tailnet_ssh_host.return_value = None # not on the tailnet
103
112
  mock_vscode_class.return_value = mock_vscode
104
113
 
105
114
  # Run command
@@ -126,7 +135,7 @@ class TestVSCodeCommand:
126
135
  mock_workspace_manager_class.return_value = mock_workspace_manager
127
136
 
128
137
  mock_container_manager_class.return_value = Mock()
129
- mock_vscode_class.return_value = Mock()
138
+ mock_vscode_class.return_value = Mock(**{'resolve_tailnet_ssh_host.return_value': None})
130
139
 
131
140
  # Run command
132
141
  result = cli_runner.invoke(cli, ['vscode', 'alice'])
@@ -156,6 +165,7 @@ class TestVSCodeCommand:
156
165
  mock_container_manager_class.return_value = mock_container_manager
157
166
 
158
167
  mock_vscode = Mock()
168
+ mock_vscode.resolve_tailnet_ssh_host.return_value = None # not on the tailnet
159
169
  mock_vscode.launch_multiple_devcontainers.return_value = 1
160
170
  mock_vscode_class.return_value = mock_vscode
161
171
 
@@ -187,6 +197,7 @@ class TestVSCodeCommand:
187
197
  mock_container_manager_class.return_value = mock_container_manager
188
198
 
189
199
  mock_vscode = Mock()
200
+ mock_vscode.resolve_tailnet_ssh_host.return_value = None # not on the tailnet
190
201
  mock_vscode.launch_multiple_devcontainers.side_effect = VSCodeError("Failed to open")
191
202
  mock_vscode_class.return_value = mock_vscode
192
203
 
@@ -220,6 +231,7 @@ class TestVSCodeCommand:
220
231
  mock_container_manager_class.return_value = mock_container_manager
221
232
 
222
233
  mock_vscode = Mock()
234
+ mock_vscode.resolve_tailnet_ssh_host.return_value = None # not on the tailnet
223
235
  mock_vscode.launch_multiple_devcontainers.return_value = 1
224
236
  mock_vscode_class.return_value = mock_vscode
225
237
 
@@ -251,6 +263,7 @@ class TestVSCodeCommand:
251
263
  mock_container_manager_class.return_value = mock_container_manager
252
264
 
253
265
  mock_vscode = Mock()
266
+ mock_vscode.resolve_tailnet_ssh_host.return_value = None # not on the tailnet
254
267
  mock_vscode.launch_multiple_devcontainers.return_value = 1
255
268
  mock_vscode_class.return_value = mock_vscode
256
269
 
@@ -351,7 +364,8 @@ class TestVSCodeSSHMode:
351
364
  mock_vscode.launch_multiple_devcontainers.return_value = 1
352
365
  mock_vscode_class.return_value = mock_vscode
353
366
 
354
- result = cli_runner.invoke(cli, ['vscode', 'alice'])
367
+ # DEVS_SSH_HOST in the caller's environment would take priority over DEVS.yml
368
+ result = cli_runner.invoke(cli, ['vscode', 'alice'], env={'DEVS_SSH_HOST': None})
355
369
 
356
370
  assert result.exit_code == 0
357
371
  mock_container_manager_class.assert_not_called()
@@ -382,15 +396,12 @@ class TestSSHURIGeneration:
382
396
  assert decoded == 'dev-test-org-repo-alice'
383
397
  assert '/workspaces/test-org-repo-alice' in uri
384
398
 
385
- def test_ssh_uri_contains_json_with_host(self):
386
- """SSH URI encodes JSON with containerName (leading /) and settings.host."""
399
+ def test_ssh_uri_is_direct_remote_ssh(self):
400
+ """SSH URI opens the container's workspace directly over Remote-SSH (no attach)."""
387
401
  vsi = self._make_vsi()
388
402
  workspace_dir = Path('/home/user/.devs/workspaces/test-org-repo-alice')
389
403
  uri = vsi.generate_devcontainer_uri(workspace_dir, 'alice', ssh_host='myhost.ts.net')
390
- hex_part = uri.split('attached-container+')[1].split('/')[0]
391
- decoded = json.loads(bytes.fromhex(hex_part).decode('utf-8'))
392
- assert decoded['containerName'] == '/dev-test-org-repo-alice'
393
- assert decoded['settings']['host'] == 'ssh://myhost.ts.net'
404
+ assert uri == 'vscode-remote://ssh-remote+myhost.ts.net/workspaces/test-org-repo-alice'
394
405
 
395
406
  def test_ssh_uri_workspace_path_correct(self):
396
407
  """SSH URI workspace path matches the devs naming convention."""
@@ -404,6 +415,4 @@ class TestSSHURIGeneration:
404
415
  vsi = self._make_vsi()
405
416
  workspace_dir = Path('/home/user/.devs/workspaces/test-org-repo-alice')
406
417
  uri = vsi.generate_devcontainer_uri(workspace_dir, 'alice', ssh_host='myhost.ts.net:2222')
407
- hex_part = uri.split('attached-container+')[1].split('/')[0]
408
- decoded = json.loads(bytes.fromhex(hex_part).decode('utf-8'))
409
- assert decoded['settings']['host'] == 'ssh://myhost.ts.net:2222'
418
+ assert uri.startswith('vscode-remote://ssh-remote+myhost.ts.net:2222/')
@@ -96,12 +96,14 @@ class TestLiveMode:
96
96
  # Mock VSCodeIntegration
97
97
  with patch('devs.cli.VSCodeIntegration') as mock_vscode:
98
98
  mock_vscode_instance = Mock()
99
+ mock_vscode_instance.resolve_tailnet_ssh_host.return_value = None # not on the tailnet
99
100
  mock_vscode_instance.launch_multiple_devcontainers.return_value = 1
100
101
  mock_vscode.return_value = mock_vscode_instance
101
102
 
102
103
  # Run command
103
104
  runner = CliRunner()
104
- result = runner.invoke(cli, ['vscode', 'test-dev', '--live'])
105
+ with patch('devs.cli.DevsConfigLoader.load_ssh_host', return_value=None):
106
+ result = runner.invoke(cli, ['vscode', 'test-dev', '--live'], env={'DEVS_SSH_HOST': None})
105
107
 
106
108
  # Verify live mode was passed
107
109
  mock_container.ensure_container_running.assert_called_once()
@@ -135,12 +137,7 @@ class TestLiveMode:
135
137
  runner = CliRunner()
136
138
  result = runner.invoke(cli, ['shell', 'test-dev', '--live'])
137
139
 
138
- # Verify live mode was passed
139
- mock_container.ensure_container_running.assert_called_once()
140
- call_args = mock_container.ensure_container_running.call_args
141
- assert call_args.kwargs.get('live') is True
142
-
143
- # Verify exec_shell was called with live=True
140
+ # Verify exec_shell was called with live=True (it starts the container internally)
144
141
  mock_container.exec_shell.assert_called_once()
145
142
  call_args = mock_container.exec_shell.call_args
146
143
  assert call_args.kwargs.get('live') is True
@@ -161,7 +158,7 @@ class TestLiveMode:
161
158
 
162
159
  mock_container = Mock()
163
160
  mock_container.ensure_container_running.return_value = True
164
- mock_container.exec_claude = Mock(return_value=(True, "output", ""))
161
+ mock_container.exec_agent = Mock(return_value=(True, "output", ""))
165
162
  mock_container_mgr.return_value = mock_container
166
163
 
167
164
  mock_workspace = Mock()
@@ -172,14 +169,9 @@ class TestLiveMode:
172
169
  runner = CliRunner()
173
170
  result = runner.invoke(cli, ['claude', 'test-dev', 'test prompt', '--live'])
174
171
 
175
- # Verify live mode was passed
176
- mock_container.ensure_container_running.assert_called_once()
177
- call_args = mock_container.ensure_container_running.call_args
178
- assert call_args.kwargs.get('live') is True
179
-
180
- # Verify exec_claude was called with live=True
181
- mock_container.exec_claude.assert_called_once()
182
- call_args = mock_container.exec_claude.call_args
172
+ # Verify exec_agent was called with live=True (it starts the container internally)
173
+ mock_container.exec_agent.assert_called_once()
174
+ call_args = mock_container.exec_agent.call_args
183
175
  assert call_args.kwargs.get('live') is True
184
176
 
185
177
  # Verify workspace.create_workspace was called with live=True and reset_contents=False
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes