From 60cd44bcfb898ecc42c19a3a324027186cd53de7 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 11 Sep 2026 22:31:08 -0300 Subject: [PATCH] feat(providers): implement Packet S4 agent-harness-setup CLI wrapper and provider handoff --- src/cmdforge/cli/__init__.py | 2 + src/cmdforge/cli/provider_commands.py | 352 ++++++++++++++++---------- tests/test_provider_install.py | 216 ++++++++++++++++ 3 files changed, 442 insertions(+), 128 deletions(-) create mode 100644 tests/test_provider_install.py diff --git a/src/cmdforge/cli/__init__.py b/src/cmdforge/cli/__init__.py index 741e039..f773fcf 100644 --- a/src/cmdforge/cli/__init__.py +++ b/src/cmdforge/cli/__init__.py @@ -190,6 +190,8 @@ def main(): # providers install p_prov_install = providers_sub.add_parser("install", help="Interactive guide to install AI providers") + p_prov_install.add_argument("--harness", help="Specific harness ID to install") + p_prov_install.add_argument("--dry-run", action="store_true", help="Preview installation without executing") p_prov_install.set_defaults(func=cmd_providers) # providers discover diff --git a/src/cmdforge/cli/provider_commands.py b/src/cmdforge/cli/provider_commands.py index d725d10..da7d447 100644 --- a/src/cmdforge/cli/provider_commands.py +++ b/src/cmdforge/cli/provider_commands.py @@ -1,12 +1,15 @@ -"""Provider management commands.""" - +import json import os import shlex import shutil import subprocess +import sys +import tempfile +import uuid from pathlib import Path from ..providers import ( + KNOWN_PROVIDER_CLIS, PRESET_CHAINS, Provider, add_provider, @@ -121,142 +124,235 @@ def cmd_providers(args): return 0 -def _cmd_providers_install(args): - """Interactive guide to install AI providers.""" - print("=" * 60) - print("CmdForge Provider Installation Guide") - print("=" * 60) - print() +def _find_setup_helper() -> list[str] | None: + """Locate the agent-harness-setup CLI executable or python module.""" + # 1. System/user PATH + path_bin = shutil.which("agent-harness-setup") + if path_bin: + return [path_bin] - # Check what's already installed - providers = load_providers() - installed_groups = set() - for p in providers: - if p.name.lower() == "mock" or p.type == "api": - continue - available, _ = _provider_status(p) - if available: - # Find which group this belongs to - for group, info in PROVIDER_INSTALL_INFO.items(): - if p.name in info.get("variants", []): - installed_groups.add(group) + # 2. Current environment virtualenv bin + venv_bin = Path(sys.prefix) / "bin" / "agent-harness-setup" + if venv_bin.is_file() and os.access(venv_bin, os.X_OK): + return [str(venv_bin)] - # Show available provider groups - print("Available AI Provider Groups:\n") - options = [] - for i, (key, info) in enumerate(PROVIDER_INSTALL_INFO.items(), 1): - status = "[INSTALLED]" if key in installed_groups else "" - print(f" {i}. {info['group']} {status}") - print(f" Cost: {info['cost']}") - print(f" Models: {', '.join(info['variants']) if info['variants'] else 'Custom'}") - print() - options.append(key) + # 3. User local bin + local_bin = Path.home() / ".local" / "bin" / "agent-harness-setup" + if local_bin.is_file() and os.access(local_bin, os.X_OK): + return [str(local_bin)] - print(" 0. Cancel") - print() + # 4. Workspace sibling repository + workspace_candidates = [ + Path(__file__).resolve().parents[4] / "agent-harness-setup" / ".venv" / "bin" / "agent-harness-setup", + Path("/home/rob/PycharmProjects/agent-harness-setup/.venv/bin/agent-harness-setup"), + ] + for cand in workspace_candidates: + if cand.is_file() and os.access(cand, os.X_OK): + return [str(cand)] + # 5. Check if agent_harness_setup is importable via python try: - choice = input("Select a provider to install (1-{}, or 0 to cancel): ".format(len(options))) - choice = int(choice) - except (ValueError, EOFError): - print("Cancelled.") - return 0 + import agent_harness_setup # type: ignore # noqa: F401 + return [sys.executable, "-m", "agent_harness_setup"] + except ImportError: + pass - if choice == 0 or choice > len(options): - print("Cancelled.") - return 0 + return None - selected = options[choice - 1] - info = PROVIDER_INSTALL_INFO[selected] - print() - print("=" * 60) - print(f"Installing: {info['group']}") - print("=" * 60) - print() - print(f"Requirements: {info['requires']}") - if info["install_cmd"]: - print(f"Install command: {info['install_cmd']}") - else: - print("Install method: Manual download") - print(f"Post-install: {info['setup']}") - print() - - if not info["install_cmd"]: - print("CmdForge cannot install this provider automatically.") - print(info["setup"]) - return 0 - - try: - confirm = input("Run installation command? (y/N): ").strip().lower() - except EOFError: - confirm = "n" - - if confirm == "y": - print() - print(f"Running: {info['install_cmd']}") - print("-" * 40) - result = subprocess.run(info['install_cmd'], shell=True) - print("-" * 40) - - if result.returncode == 0: - # Refresh PATH to pick up newly installed tools - import os - new_paths = [] - - # Common install locations that might have been added - potential_paths = [ - Path.home() / ".opencode" / "bin", # OpenCode - Path.home() / ".local" / "bin", # pip/pipx installs - Path("/usr/local/bin"), # Ollama, system installs - ] - - # Also try to get npm global bin path - try: - npm_result = subprocess.run( - ["npm", "bin", "-g"], - capture_output=True, text=True, timeout=5 - ) - if npm_result.returncode == 0: - npm_bin = npm_result.stdout.strip() - if npm_bin: - potential_paths.append(Path(npm_bin)) - except (subprocess.TimeoutExpired, FileNotFoundError): - pass - - current_path = os.environ.get("PATH", "") - for p in potential_paths: - if p.exists() and str(p) not in current_path: - new_paths.append(str(p)) - - if new_paths: - os.environ["PATH"] = ":".join(new_paths) + ":" + current_path - - print() - print("Installation completed!") - print() - print("IMPORTANT: Refresh your shell PATH before continuing:") - print(" source ~/.bashrc") - print() - print(f"Next steps:") - print(f" 1. source ~/.bashrc (required!)") - print(f" 2. {info['setup']}") - if info.get('post_install_note'): - print(f" 3. {info['post_install_note']}") - print(f" 4. Test with: cmdforge providers test {selected}") +def _get_provider_defaults_for_harness(harness_id: str, executable_path: str | None = None) -> dict: + """Derive CmdForge provider configuration defaults for an installed harness.""" + harness_map = { + "claude-code": "claude", + "claude": "claude", + "openai-codex": "codex", + "codex": "codex", + "agy": "agy", + "gemini-cli": "agy", + "opencode": "opencode", + "crush": "crush", + "ollama": "ollama", + } + cli_key = harness_map.get(harness_id, harness_id) + if cli_key in KNOWN_PROVIDER_CLIS: + base = dict(KNOWN_PROVIDER_CLIS[cli_key]) + if executable_path: + old_cmd = base.get("command", "") + parts = shlex.split(old_cmd) + if parts: + base["command"] = " ".join([executable_path] + parts[1:]) else: - print(f" 3. Test with: cmdforge providers test {info['variants'][0] if info['variants'] else selected}") + base["command"] = executable_path + return base + + cmd = executable_path if executable_path else harness_id + return { + "name": harness_id, + "command": cmd, + "description": f"{harness_id} CLI provider", + "tags": ["cli"], + "locality": "remote", + "capabilities": ["text"], + "data_policy": "public", + } + + +def _cmd_providers_install(args): + """Interactive guide to install AI harnesses and configure providers.""" + helper_argv = _find_setup_helper() + + if not helper_argv: + print("=" * 60) + print("CmdForge Provider Installation") + print("=" * 60) + print() + print("Notice: Agent Harness Setup Helper ('agent-harness-setup') was not") + print("found on your PATH or in active virtual environments.") + print() + print("To install the setup helper, run:") + print(" pip install -e /home/rob/PycharmProjects/agent-harness-setup") + print() + print("Or run the helper directly:") + print(" PYTHONPATH=/home/rob/PycharmProjects/agent-harness-setup/src python3 -m agent_harness_setup") + print() + print("Manual installation instructions are available in documentation:") + print(" https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview") + return 0 + + # Prepare secure temporary result file and correlation request_id + temp_dir = tempfile.mkdtemp(prefix="cmdforge-harness-setup-") + try: + os.chmod(temp_dir, 0o700) + except OSError: + pass + + result_file = Path(temp_dir) / "result.json" + req_id = str(uuid.uuid4()) + + cmd = list(helper_argv) + ["--result-file", str(result_file), "--request-id", req_id] + harness_arg = getattr(args, "harness", None) + if harness_arg: + cmd.extend(["--harness", harness_arg]) + if getattr(args, "dry_run", False): + cmd.append("--dry-run") + + try: + proc = subprocess.run(cmd) + helper_exit_code = proc.returncode + except Exception as e: + print(f"\nError executing agent-harness-setup: {e}") + shutil.rmtree(temp_dir, ignore_errors=True) + return 1 + + # Read and parse completion result + result_data = None + if result_file.is_file(): + try: + result_data = json.loads(result_file.read_text(encoding="utf-8")) + except Exception: + result_data = None + + # Clean up temporary directory + shutil.rmtree(temp_dir, ignore_errors=True) + + # Validate correlation request ID + if result_data is not None and result_data.get("request_id") != req_id: + print("\nWarning: Correlation request ID mismatch in setup result. Rejecting result.") + return 1 + + # Incoherence handling (Contract §C.5) + if result_data is not None: + status = result_data.get("status") + if helper_exit_code != 0 and status == "success": + print("\nWarning: Helper exited with non-zero code but result reported success. Treating as failure.") + return helper_exit_code + if helper_exit_code == 0 and status in ("failed", "cancelled"): + print("\nWarning: Helper exited with code 0 but result reported failure/cancellation. Treating as failure.") + return 1 + + # Dry-run completion + if getattr(args, "dry_run", False) or (result_data and result_data.get("status") == "dry_run"): + return 0 + + # Cancellation + if helper_exit_code in (10, 130, 143) or (result_data and result_data.get("status") == "cancelled"): + print("\nSetup cancelled. No provider configuration was changed.") + return 0 + + # Failure + if helper_exit_code != 0 or (result_data and result_data.get("status") == "failed"): + diag = result_data.get("error_diagnostics") if result_data else None + if diag: + print(f"\nSetup failed: {diag}") + return helper_exit_code if helper_exit_code != 0 else 1 + + # Verified Success + if helper_exit_code == 0 and result_data and result_data.get("status") == "success": + harness_id = result_data.get("harness_id") or "unknown" + verification = result_data.get("verification") or {} + executable_path = verification.get("executable_path") + path_updates = result_data.get("path_updates") or [] + + # Advisory PATH updates + if path_updates: + cur_path = os.environ.get("PATH", "") + new_dirs = [ + str(Path(os.path.expanduser(p))) + for p in path_updates + if Path(os.path.expanduser(p)).is_dir() and str(Path(os.path.expanduser(p))) not in cur_path + ] + if new_dirs: + os.environ["PATH"] = ":".join(new_dirs) + ":" + cur_path + + provider_defaults = _get_provider_defaults_for_harness(harness_id, executable_path) + provider_name = provider_defaults["name"] + + print() + print("=" * 60) + print(f"Harness Setup Verified: {harness_id}") + print("=" * 60) + if executable_path: + print(f"Verified executable: {executable_path}") + if result_data.get("manual_steps_remaining"): + print("\nRemaining setup steps:") + for step in result_data["manual_steps_remaining"]: + print(f" * {step}") + + existing = get_provider(provider_name) + if existing: + print(f"\nProvider '{provider_name}' is already configured in ~/.cmdforge/providers.yaml.") + print(f"Command: {existing.command}") + print(f"\nTest with: cmdforge providers test {provider_name}") + return 0 + + # Interactive confirmation prompt for provider configuration + print() + try: + prompt_resp = input( + f"Would you like to register '{provider_name}' as a model provider in ~/.cmdforge/providers.yaml? (Y/n): " + ).strip().lower() + except (EOFError, KeyboardInterrupt): + prompt_resp = "n" + + if prompt_resp in ("", "y", "yes"): + new_prov = Provider( + name=provider_name, + command=provider_defaults["command"], + description=provider_defaults.get("description", ""), + tags=provider_defaults.get("tags", []), + locality=provider_defaults.get("locality", "remote"), + capabilities=provider_defaults.get("capabilities", ["text"]), + data_policy=provider_defaults.get("data_policy", "public"), + ) + add_provider(new_prov) + print(f"\nProvider '{provider_name}' successfully added to ~/.cmdforge/providers.yaml.") + print(f"Command: {new_prov.command}") + print(f"\nTest with: cmdforge providers test {provider_name}") else: - print() - print(f"Installation failed (exit code {result.returncode})") - print("Try running the command manually:") - print(f" {info['install_cmd']}") - else: - print() - print("To install manually, run:") - print(f" {info['install_cmd']}") - print() - print(f"Then: {info['setup']}") + print("\nSkipped provider registration. You can add it later with:") + print(f" cmdforge providers add {provider_name} '{provider_defaults['command']}'") + + return 0 return 0 diff --git a/tests/test_provider_install.py b/tests/test_provider_install.py new file mode 100644 index 0000000..d8a93a9 --- /dev/null +++ b/tests/test_provider_install.py @@ -0,0 +1,216 @@ +"""Tests for Packet S4: CmdForge CLI integration with agent-harness-setup.""" + +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from cmdforge.cli import main +from cmdforge.cli.provider_commands import _find_setup_helper, _get_provider_defaults_for_harness +from cmdforge.providers import Provider, get_provider, load_providers, save_providers + + +@pytest.fixture +def temp_providers_file(tmp_path): + providers_file = tmp_path / ".cmdforge" / "providers.yaml" + with patch("cmdforge.providers.PROVIDERS_FILE", providers_file): + save_providers([]) + yield providers_file + + +def _mock_helper_run(status: str, harness_id: str = "claude-code", exit_code: int = 0, diagnostics: str | None = None, executable_path: str = "/usr/local/bin/claude"): + """Create a mock subprocess.run that writes a valid completion result to --result-file.""" + def _run(cmd, *args, **kwargs): + # Extract --result-file and --request-id from cmd + res_idx = cmd.index("--result-file") + result_file_path = Path(cmd[res_idx + 1]) + req_idx = cmd.index("--request-id") + request_id = cmd[req_idx + 1] + + data = { + "schema_version": 1, + "helper_version": "0.1.0", + "request_id": request_id, + "timestamp": "2026-09-11T22:00:00Z", + "status": status, + "harness_id": harness_id, + "recipe_id": harness_id, + "recipe_version": "2026-09-10", + "install_method": "argv", + "installer_exit_code": 0 if status == "success" else None, + "verification": { + "verified": True, + "executable_path": executable_path, + "version_output": f"{harness_id} 1.0.0", + } if status == "success" else None, + "path_updates": ["/usr/local/bin"], + "auth_status": "setup_required", + "manual_steps_remaining": ["Run 'claude' to complete browser sign-in."], + "partial_changes": { + "observed_created": [executable_path] if status == "success" else [], + "observed_modified": [], + "unknown_changes": True, + }, + "error_diagnostics": diagnostics, + } + result_file_path.write_text(json.dumps(data), encoding="utf-8") + return SimpleNamespace(returncode=exit_code) + + return _run + + +def test_helper_discovery_finds_executable(): + """Verify _find_setup_helper locates agent-harness-setup.""" + with patch("shutil.which", return_value="/usr/local/bin/agent-harness-setup"): + helper = _find_setup_helper() + assert helper == ["/usr/local/bin/agent-harness-setup"] + + +def test_get_provider_defaults_for_claude(): + """Verify provider defaults derived for claude-code.""" + defaults = _get_provider_defaults_for_harness("claude-code", "/custom/bin/claude") + assert defaults["name"] == "claude" + assert defaults["command"] == "/custom/bin/claude -p" + assert "Claude" in defaults["description"] + + +def test_providers_install_verified_success_registers_provider(temp_providers_file, capsys): + """Test verified installation prompts and registers provider in providers.yaml.""" + with ( + patch("cmdforge.cli.provider_commands._find_setup_helper", return_value=["agent-harness-setup"]), + patch("subprocess.run", side_effect=_mock_helper_run("success", "claude-code", 0)) as mock_run, + patch("builtins.input", return_value="y"), + patch("sys.argv", ["cmdforge", "providers", "install", "--harness", "claude-code"]), + ): + exit_code = main() + + assert exit_code == 0 + captured = capsys.readouterr() + assert "Harness Setup Verified: claude-code" in captured.out + assert "Provider 'claude' successfully added" in captured.out + + # Verify provider stored in providers.yaml + provider = get_provider("claude") + assert provider is not None + assert provider.command == "/usr/local/bin/claude -p" + assert "code" in provider.tags + + # Verify subprocess invocation arguments + call_args = mock_run.call_args[0][0] + assert call_args[0] == "agent-harness-setup" + assert "--result-file" in call_args + assert "--request-id" in call_args + assert "--harness" in call_args + assert "claude-code" in call_args + + +def test_providers_install_declined_registration(temp_providers_file, capsys): + """Test declining the provider registration prompt leaves providers.yaml unchanged.""" + with ( + patch("cmdforge.cli.provider_commands._find_setup_helper", return_value=["agent-harness-setup"]), + patch("subprocess.run", side_effect=_mock_helper_run("success", "claude-code", 0)), + patch("builtins.input", return_value="n"), + patch("sys.argv", ["cmdforge", "providers", "install"]), + ): + exit_code = main() + + assert exit_code == 0 + captured = capsys.readouterr() + assert "Harness Setup Verified: claude-code" in captured.out + assert "Skipped provider registration" in captured.out + + assert get_provider("claude") is None + + +def test_providers_install_already_configured(temp_providers_file, capsys): + """Test that existing provider is recognized and not re-registered.""" + save_providers([Provider("claude", "existing-claude -p")]) + + with ( + patch("cmdforge.cli.provider_commands._find_setup_helper", return_value=["agent-harness-setup"]), + patch("subprocess.run", side_effect=_mock_helper_run("success", "claude-code", 0)), + patch("sys.argv", ["cmdforge", "providers", "install"]), + ): + exit_code = main() + + assert exit_code == 0 + captured = capsys.readouterr() + assert "already configured in ~/.cmdforge/providers.yaml" in captured.out + assert get_provider("claude").command == "existing-claude -p" + + +def test_providers_install_cancelled(temp_providers_file, capsys): + """Test cancellation exits cleanly without changing providers.""" + with ( + patch("cmdforge.cli.provider_commands._find_setup_helper", return_value=["agent-harness-setup"]), + patch("subprocess.run", side_effect=_mock_helper_run("cancelled", "claude-code", 10)), + patch("sys.argv", ["cmdforge", "providers", "install"]), + ): + exit_code = main() + + assert exit_code == 0 + captured = capsys.readouterr() + assert "Setup cancelled. No provider configuration was changed." in captured.out + assert get_provider("claude") is None + + +def test_providers_install_execution_failure(temp_providers_file, capsys): + """Test helper failure reports error diagnostics and does not register provider.""" + with ( + patch("cmdforge.cli.provider_commands._find_setup_helper", return_value=["agent-harness-setup"]), + patch("subprocess.run", side_effect=_mock_helper_run("failed", "claude-code", 1, "Missing node.js")), + patch("sys.argv", ["cmdforge", "providers", "install"]), + ): + exit_code = main() + + assert exit_code == 1 + captured = capsys.readouterr() + assert "Setup failed: Missing node.js" in captured.out + assert get_provider("claude") is None + + +def test_providers_install_incoherent_result_rejected(temp_providers_file, capsys): + """Test exit code 1 with status 'success' is rejected as incoherent.""" + with ( + patch("cmdforge.cli.provider_commands._find_setup_helper", return_value=["agent-harness-setup"]), + patch("subprocess.run", side_effect=_mock_helper_run("success", "claude-code", 1)), + patch("sys.argv", ["cmdforge", "providers", "install"]), + ): + exit_code = main() + + assert exit_code == 1 + captured = capsys.readouterr() + assert "Treating as failure" in captured.out + assert get_provider("claude") is None + + +def test_providers_install_helper_not_found(temp_providers_file, capsys): + """Test that missing helper prints actionable instructions without crashing.""" + with ( + patch("cmdforge.cli.provider_commands._find_setup_helper", return_value=None), + patch("sys.argv", ["cmdforge", "providers", "install"]), + ): + exit_code = main() + + assert exit_code == 0 + captured = capsys.readouterr() + assert "agent-harness-setup" in captured.out + assert "pip install" in captured.out + assert "PYTHONPATH=" in captured.out + + +def test_providers_install_dry_run(temp_providers_file, capsys): + """Test --dry-run passes flag to helper and makes no provider changes.""" + with ( + patch("cmdforge.cli.provider_commands._find_setup_helper", return_value=["agent-harness-setup"]), + patch("subprocess.run", side_effect=_mock_helper_run("dry_run", "claude-code", 0)) as mock_run, + patch("sys.argv", ["cmdforge", "providers", "install", "--dry-run"]), + ): + exit_code = main() + + assert exit_code == 0 + call_args = mock_run.call_args[0][0] + assert "--dry-run" in call_args + assert get_provider("claude") is None