From 844d092e514a894df7629f6bbe10cba678ba8d9b Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 24 Sep 2025 14:19:02 +0200 Subject: [PATCH 1/9] Phase 1: Add core Copilot runtime files - Add src/apm_cli/adapters/client/copilot.py: MCP client adapter for Copilot CLI - Add src/apm_cli/runtime/copilot_runtime.py: Runtime adapter for Copilot CLI execution - Add scripts/runtime/setup-copilot.sh: Copilot CLI installation script These are zero-risk additions as they are new files that don't modify existing code. Ready for Phase 2: Runtime infrastructure integration. --- scripts/runtime/setup-copilot.sh | 324 ++++++++++++ src/apm_cli/adapters/client/copilot.py | 693 +++++++++++++++++++++++++ src/apm_cli/runtime/copilot_runtime.py | 211 ++++++++ uv.lock | 2 +- 4 files changed, 1229 insertions(+), 1 deletion(-) create mode 100755 scripts/runtime/setup-copilot.sh create mode 100644 src/apm_cli/adapters/client/copilot.py create mode 100644 src/apm_cli/runtime/copilot_runtime.py diff --git a/scripts/runtime/setup-copilot.sh b/scripts/runtime/setup-copilot.sh new file mode 100755 index 00000000..a97fcddd --- /dev/null +++ b/scripts/runtime/setup-copilot.sh @@ -0,0 +1,324 @@ +#!/bin/bash +# Setup script for GitHub Copilot CLI runtime +# Handles npm authentication and @github/copilot installation with MCP configuration +# Private preview version (Staffship) + +set -euo pipefail + +# Get the directory of this script for sourcing common utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/setup-common.sh" + +# Configuration +COPILOT_PACKAGE="@github/copilot" +VANILLA_MODE=false +NODE_MIN_VERSION="22" +NPM_MIN_VERSION="10" + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --vanilla) + VANILLA_MODE=true + shift + ;; + *) + # Version specification not supported for Copilot CLI (uses latest from npm) + shift + ;; + esac +done + +# Check Node.js version +check_node_version() { + log_info "Checking Node.js version..." + + if ! command -v node >/dev/null 2>&1; then + log_error "Node.js is not installed" + log_info "Please install Node.js version $NODE_MIN_VERSION or higher from https://nodejs.org/" + exit 1 + fi + + local node_version=$(node --version | sed 's/v//') + local node_major=$(echo "$node_version" | cut -d. -f1) + + if [[ "$node_major" -lt "$NODE_MIN_VERSION" ]]; then + log_error "Node.js version $node_version is too old. Required: v$NODE_MIN_VERSION or higher" + log_info "Please update Node.js from https://nodejs.org/" + exit 1 + fi + + log_success "Node.js version $node_version āœ“" +} + +# Check npm version +check_npm_version() { + log_info "Checking npm version..." + + if ! command -v npm >/dev/null 2>&1; then + log_error "npm is not installed" + log_info "Please install npm version $NPM_MIN_VERSION or higher" + exit 1 + fi + + local npm_version=$(npm --version) + local npm_major=$(echo "$npm_version" | cut -d. -f1) + + if [[ "$npm_major" -lt "$NPM_MIN_VERSION" ]]; then + log_error "npm version $npm_version is too old. Required: v$NPM_MIN_VERSION or higher" + log_info "Please update npm with: npm install -g npm@latest" + exit 1 + fi + + log_success "npm version $npm_version āœ“" +} + +# Check GitHub npm authentication +check_github_npm_auth() { + log_info "Checking GitHub npm registry authentication..." + + # Check if already logged in to @github scope + if npm whoami --scope=@github --registry=https://npm.pkg.github.com >/dev/null 2>&1; then + local username=$(npm whoami --scope=@github --registry=https://npm.pkg.github.com) + log_success "Already authenticated to GitHub npm registry as: $username" + return 0 + else + log_info "Attempting authentication to GitHub npm registry" + + # Check if we have GITHUB_NPM_PAT for automatic authentication + if [[ -n "$GITHUB_NPM_PAT" ]]; then + log_info "Found GITHUB_NPM_PAT, attempting automatic npm authentication..." + if setup_npm_auth_with_token; then + return 0 + fi + fi + + return 1 + fi +} + +# Set up npm authentication using GITHUB_NPM_PAT token +setup_npm_auth_with_token() { + if [[ -z "$GITHUB_NPM_PAT" ]]; then + log_error "GITHUB_NPM_PAT environment variable not set" + return 1 + fi + + log_info "Setting up npm authentication with GITHUB_NPM_PAT..." + + # Use npm login in non-interactive mode with the token + # This mimics: npm login --scope=@github --auth-type=legacy --registry=https://npm.pkg.github.com + + # Configure npm registry for @github scope + npm config set @github:registry https://npm.pkg.github.com/ + + # Set the auth token directly in the npm configuration + npm config set //npm.pkg.github.com/:_authToken "${GITHUB_NPM_PAT}" + + # Test the authentication + if npm whoami --scope=@github --registry=https://npm.pkg.github.com >/dev/null 2>&1; then + local username=$(npm whoami --scope=@github --registry=https://npm.pkg.github.com) + log_success "Successfully authenticated to GitHub npm registry as: $username using GITHUB_NPM_PAT" + return 0 + else + log_error "Failed to authenticate with GITHUB_NPM_PAT" + return 1 + fi +} + +# Guide user through GitHub npm authentication +setup_github_npm_auth() { + log_info "Setting up GitHub npm registry authentication..." + echo "" + log_info "GitHub Copilot CLI is currently in private preview and requires authentication" + log_info "to the GitHub npm registry. Please follow these steps:" + echo "" + + echo "${HIGHLIGHT}Step 1: Create a GitHub Personal Access Token${RESET}" + echo "1. Go to: https://github.com/settings/tokens/new" + echo "2. Select 'Classic token'" + echo "3. Add 'read:packages' scope" + echo "4. Enable SSO for the 'github' organization" + echo "5. Set expiration < 90 days" + echo "6. Generate token and copy it" + echo "" + + echo "${HIGHLIGHT}Step 2: Authenticate with npm${RESET}" + echo "Run this command and enter your GitHub username and PAT:" + echo "" + echo " npm login --scope=@github --auth-type=legacy --registry=https://npm.pkg.github.com" + echo "" + + # Ask if they want to try authentication now + if command -v read >/dev/null 2>&1; then + read -p "Would you like to authenticate now? (y/N): " -n 1 -r + echo "" + if [[ $REPLY =~ ^[Yy]$ ]]; then + log_info "Starting npm authentication..." + if npm login --scope=@github --auth-type=legacy --registry=https://npm.pkg.github.com; then + log_success "Successfully authenticated to GitHub npm registry!" + else + log_error "Authentication failed. Please try again manually." + exit 1 + fi + else + log_warning "Please authenticate manually and then re-run this script" + exit 1 + fi + else + log_warning "Please authenticate manually and then re-run this script" + exit 1 + fi +} + +# Install Copilot CLI via npm +install_copilot_cli() { + log_info "Installing GitHub Copilot CLI..." + + # Install globally - npm will use the configured registries (GitHub for @github scope, default for others) + if npm install -g "$COPILOT_PACKAGE"; then + log_success "Successfully installed $COPILOT_PACKAGE" + else + log_error "Failed to install $COPILOT_PACKAGE" + log_info "This might be due to:" + log_info " - Authentication issues with GitHub npm registry" + log_info " - Insufficient permissions for global npm install" + log_info " - Network connectivity issues" + exit 1 + fi +} + +# Source the centralized GitHub token helper +source "$SCRIPT_DIR/github-token-helper.sh" + +# Setup GitHub MCP Server environment for Copilot CLI +setup_github_mcp_environment() { + log_info "Setting up GitHub MCP Server environment for Copilot CLI..." + + # Use centralized token management + setup_github_tokens + + # For Copilot CLI MCP server, we need GITHUB_PERSONAL_ACCESS_TOKEN + local copilot_token + copilot_token=$(get_token_for_runtime "copilot") + + if [[ -n "$copilot_token" ]]; then + # Set GITHUB_PERSONAL_ACCESS_TOKEN for Copilot CLI's automatic GitHub MCP Server setup + export GITHUB_PERSONAL_ACCESS_TOKEN="$copilot_token" + log_success "GitHub MCP Server environment configured" + log_info "Copilot CLI will automatically set up GitHub MCP Server on first run" + else + log_warning "No GitHub token found for automatic MCP server setup" + log_info "Set GITHUB_COPILOT_PAT, GITHUB_APM_PAT, or GITHUB_TOKEN to enable automatic GitHub MCP Server" + log_info "You can still configure MCP servers manually using 'apm install'" + fi +} + +# Create basic Copilot CLI directory structure +setup_copilot_directory() { + log_info "Setting up Copilot CLI directory structure..." + + local copilot_config_dir="$HOME/.copilot" + local mcp_config_file="$copilot_config_dir/mcp-config.json" + + # Create config directory if it doesn't exist + if [[ ! -d "$copilot_config_dir" ]]; then + log_info "Creating Copilot config directory: $copilot_config_dir" + mkdir -p "$copilot_config_dir" + fi + + # Create empty MCP configuration template only if file doesn't exist + if [[ ! -f "$mcp_config_file" ]]; then + log_info "Creating empty MCP configuration template..." + cat > "$mcp_config_file" << 'EOF' +{ + "mcpServers": {} +} +EOF + log_info "Empty MCP configuration created at $mcp_config_file" + log_info "Use 'apm install' to configure MCP servers" + else + log_info "MCP configuration already exists at $mcp_config_file" + fi +} + +# Test Copilot CLI installation +test_copilot_installation() { + log_info "Testing Copilot CLI installation..." + + if command -v copilot >/dev/null 2>&1; then + if copilot --version >/dev/null 2>&1; then + local version=$(copilot --version) + log_success "Copilot CLI installed successfully! Version: $version" + else + log_warning "Copilot CLI binary found but version check failed" + log_info "It may still work, but there might be authentication issues" + fi + else + log_error "Copilot CLI not found in PATH after installation" + log_info "You may need to restart your terminal or check your npm global installation path" + exit 1 + fi +} + +# Main setup function +setup_copilot() { + log_info "Setting up GitHub Copilot CLI runtime..." + + # Check prerequisites + check_node_version + check_npm_version + + # Check and setup GitHub npm authentication + if ! check_github_npm_auth; then + setup_github_npm_auth + fi + + # Install Copilot CLI + install_copilot_cli + + # Setup directory structure (unless vanilla mode) + if [[ "$VANILLA_MODE" == "false" ]]; then + setup_copilot_directory + # Setup GitHub MCP Server environment for automatic configuration + setup_github_mcp_environment + else + log_info "Vanilla mode: Skipping APM directory setup" + log_info "You can configure MCP servers manually in ~/.copilot/mcp-config.json" + fi + + # Test installation + test_copilot_installation + + # Show next steps + echo "" + log_info "Next steps:" + + if [[ "$VANILLA_MODE" == "false" ]]; then + echo "1. Set up your APM project with MCP dependencies:" + echo " - Initialize project: apm init my-project" + echo " - Install MCP servers: apm install" + echo "2. Then run: apm run start --param name=YourGitHubHandle" + echo "" + log_success "✨ GitHub Copilot CLI installed and configured!" + echo " - Use 'apm install' to configure MCP servers for your projects" + echo " - Copilot CLI provides advanced AI coding assistance" + echo " - Interactive mode available: just run 'copilot'" + else + echo "1. Configure Copilot CLI as needed (run 'copilot' for interactive setup)" + echo "2. Then run with APM: apm run start" + fi + + echo "" + log_info "GitHub Copilot CLI Features:" + echo " - Interactive mode: copilot" + echo " - Direct prompts: copilot -p \"your prompt\"" + echo " - Auto-approval: copilot --full-auto" + echo " - Directory access: copilot --add-dir /path/to/directory" + echo " - Logging: copilot --log-dir --log-level debug" +} + +# Run setup if script is executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + setup_copilot "$@" +fi \ No newline at end of file diff --git a/src/apm_cli/adapters/client/copilot.py b/src/apm_cli/adapters/client/copilot.py new file mode 100644 index 00000000..bc5c709e --- /dev/null +++ b/src/apm_cli/adapters/client/copilot.py @@ -0,0 +1,693 @@ +"""GitHub Copilot CLI implementation of MCP client adapter. + +This adapter implements the Copilot CLI-specific handling of MCP server configuration, +targeting the global ~/.copilot/mcp-config.json file as specified in the MCP installation +architecture specification. +""" + +import json +import os +from pathlib import Path +from .base import MCPClientAdapter +from ...registry.client import SimpleRegistryClient +from ...registry.integration import RegistryIntegration +from ...core.docker_args import DockerArgsProcessor + + +class CopilotClientAdapter(MCPClientAdapter): + """Copilot CLI implementation of MCP client adapter. + + This adapter handles Copilot CLI-specific configuration for MCP servers using + a global ~/.copilot/mcp-config.json file, following the JSON format for + MCP server configuration. + """ + + def __init__(self, registry_url=None): + """Initialize the Copilot CLI client adapter. + + Args: + registry_url (str, optional): URL of the MCP registry. + If not provided, uses the MCP_REGISTRY_URL environment variable + or falls back to the default GitHub registry. + """ + self.registry_client = SimpleRegistryClient(registry_url) + self.registry_integration = RegistryIntegration(registry_url) + + def get_config_path(self): + """Get the path to the Copilot CLI MCP configuration file. + + Returns: + str: Path to ~/.copilot/mcp-config.json + """ + copilot_dir = Path.home() / ".copilot" + return str(copilot_dir / "mcp-config.json") + + def update_config(self, config_updates): + """Update the Copilot CLI MCP configuration. + + Args: + config_updates (dict): Configuration updates to apply. + """ + current_config = self.get_current_config() + + # Ensure mcpServers section exists + if "mcpServers" not in current_config: + current_config["mcpServers"] = {} + + # Apply updates + current_config["mcpServers"].update(config_updates) + + # Write back to file + config_path = Path(self.get_config_path()) + + # Ensure directory exists + config_path.parent.mkdir(parents=True, exist_ok=True) + + with open(config_path, 'w') as f: + json.dump(current_config, f, indent=2) + + def get_current_config(self): + """Get the current Copilot CLI MCP configuration. + + Returns: + dict: Current configuration, or empty dict if file doesn't exist. + """ + config_path = self.get_config_path() + + if not os.path.exists(config_path): + return {} + + try: + with open(config_path, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {} + + def configure_mcp_server(self, server_url, server_name=None, enabled=True, env_overrides=None, server_info_cache=None, runtime_vars=None): + """Configure an MCP server in Copilot CLI configuration. + + This method follows the Copilot CLI MCP configuration format with + mcpServers object containing server configurations. + + Args: + server_url (str): URL or identifier of the MCP server. + server_name (str, optional): Name of the server. Defaults to None. + enabled (bool, optional): Ignored parameter, kept for API compatibility. + env_overrides (dict, optional): Pre-collected environment variable overrides. + server_info_cache (dict, optional): Pre-fetched server info to avoid duplicate registry calls. + runtime_vars (dict, optional): Pre-collected runtime variable values. + + Returns: + bool: True if successful, False otherwise. + """ + if not server_url: + print("Error: server_url cannot be empty") + return False + + try: + # Use cached server info if available, otherwise fetch from registry + if server_info_cache and server_url in server_info_cache: + server_info = server_info_cache[server_url] + else: + # Fallback to registry lookup if not cached + server_info = self.registry_client.find_server_by_reference(server_url) + + # Fail if server is not found in registry - security requirement + if not server_info: + print(f"Error: MCP server '{server_url}' not found in registry") + return False + + # Determine the server name for configuration key + if server_name: + # Use explicitly provided server name + config_key = server_name + else: + # Extract name from server_url (part after last slash) + # For URLs like "microsoft/azure-devops-mcp" -> "azure-devops-mcp" + # For URLs like "github/github-mcp-server" -> "github-mcp-server" + if '/' in server_url: + config_key = server_url.split('/')[-1] + else: + # Fallback to full server_url if no slash + config_key = server_url + + # Generate server configuration with environment and runtime variable resolution + server_config = self._format_server_config(server_info, env_overrides, runtime_vars) + + # Update configuration using the chosen key + self.update_config({config_key: server_config}) + + print(f"Successfully configured MCP server '{config_key}' for Copilot CLI") + return True + + except Exception as e: + print(f"Error configuring MCP server: {e}") + return False + + def _format_server_config(self, server_info, env_overrides=None, runtime_vars=None): + """Format server information into Copilot CLI MCP configuration format. + + Args: + server_info (dict): Server information from registry. + env_overrides (dict, optional): Pre-collected environment variable overrides. + runtime_vars (dict, optional): Pre-collected runtime variable values. + + Returns: + dict: Formatted server configuration for Copilot CLI. + """ + if runtime_vars is None: + runtime_vars = {} + + # Default configuration structure with registry ID for conflict detection + config = { + "type": "local", + "tools": ["*"], # Required by Copilot CLI specification - default to all tools + "id": server_info.get("id", "") # Add registry UUID for conflict detection + } + + # Check for remote endpoints first (registry-defined priority) + remotes = server_info.get("remotes", []) + if remotes: + # Use remote endpoint if available + remote = remotes[0] # Take the first remote + + # All remote servers use http type for proper authentication support + config = { + "type": "http", + "url": remote.get("url", ""), + "tools": ["*"], # Required by Copilot CLI specification + "id": server_info.get("id", "") # Add registry UUID for conflict detection + } + + # Add authentication headers for GitHub MCP server + server_name = server_info.get("name", "") + is_github_server = self._is_github_server(server_name, remote.get("url", "")) + + if is_github_server: + # Check for GitHub Personal Access Token + github_token = os.getenv("GITHUB_PERSONAL_ACCESS_TOKEN") + if github_token: + config["headers"] = { + "Authorization": f"Bearer {github_token}" + } + + # Add any additional headers from registry if present + headers = remote.get("headers", []) + if headers: + if "headers" not in config: + config["headers"] = {} + for header in headers: + header_name = header.get("name", "") + header_value = header.get("value", "") + if header_name and header_value: + # Resolve environment variable value + resolved_value = self._resolve_env_variable(header_name, header_value, env_overrides) + config["headers"][header_name] = resolved_value + + return config + + # Get packages from server info + packages = server_info.get("packages", []) + + if not packages and not remotes: + # If no packages AND no remotes are available, this indicates incomplete server configuration + # This should fail installation with a clear error message + raise ValueError(f"MCP server has incomplete configuration in registry - no package information or remote endpoints available. " + f"This appears to be a temporary registry issue. " + f"Server: {server_info.get('name', 'unknown')}") + + if packages: + # Use the first package for configuration (prioritize npm, then docker, then others) + package = self._select_best_package(packages) + + if package: + registry_name = package.get("registry_name", "") + package_name = package.get("name", "") + runtime_hint = package.get("runtime_hint", "") + runtime_arguments = package.get("runtime_arguments", []) + package_arguments = package.get("package_arguments", []) + + # Process arguments to extract simple string values + env_vars = package.get("environment_variables", []) + + # Resolve environment variables first + resolved_env = self._resolve_environment_variables(env_vars, env_overrides) + + processed_runtime_args = self._process_arguments(runtime_arguments, resolved_env, runtime_vars) + processed_package_args = self._process_arguments(package_arguments, resolved_env, runtime_vars) + + # Generate command and args based on package type + if registry_name == "npm": + config["command"] = runtime_hint or "npx" + config["args"] = ["-y", package_name] + processed_runtime_args + processed_package_args + # For NPM packages, use env block for environment variables + if resolved_env: + config["env"] = resolved_env + elif registry_name == "docker": + config["command"] = "docker" + + # For Docker packages, the registry provides the complete command template + # We should respect the runtime_arguments as the authoritative Docker command structure + if processed_runtime_args: + # Registry provides complete Docker command arguments + # Just inject environment variables where appropriate + config["args"] = self._inject_env_vars_into_docker_args( + processed_runtime_args, resolved_env + ) + else: + # Fallback to basic docker run command if no runtime args + config["args"] = DockerArgsProcessor.process_docker_args( + ["run", "-i", "--rm", package_name], + resolved_env + ) + elif registry_name == "pypi": + config["command"] = runtime_hint or "uvx" + config["args"] = [package_name] + processed_runtime_args + processed_package_args + # For PyPI packages, use env block + if resolved_env: + config["env"] = resolved_env + elif registry_name == "homebrew": + # For homebrew packages, assume the binary name is the command + config["command"] = package_name.split('/')[-1] if '/' in package_name else package_name + config["args"] = processed_runtime_args + processed_package_args + # For Homebrew packages, use env block + if resolved_env: + config["env"] = resolved_env + else: + # Generic package handling + config["command"] = runtime_hint or package_name + config["args"] = processed_runtime_args + processed_package_args + # Use env block for generic packages + if resolved_env: + config["env"] = resolved_env + + return config + + def _resolve_environment_variables(self, env_vars, env_overrides=None): + """Resolve environment variables to actual values. + + Args: + env_vars (list): List of environment variable definitions from server info. + env_overrides (dict, optional): Pre-collected environment variable overrides. + + Returns: + dict: Dictionary of resolved environment variables. + """ + import os + import sys + from rich.prompt import Prompt + + resolved = {} + env_overrides = env_overrides or {} + + # If env_overrides is provided, it means the CLI has already handled environment variable collection + # In this case, we should NEVER prompt for additional variables + skip_prompting = bool(env_overrides) + + # Check for CI/automated environment via APM_E2E_TESTS flag (more reliable than TTY detection) + if os.getenv('APM_E2E_TESTS') == '1': + skip_prompting = True + print(f"šŸ’” APM_E2E_TESTS detected, will skip environment variable prompts") + + # Also skip prompting if we're in a non-interactive environment (fallback) + is_interactive = sys.stdin.isatty() and sys.stdout.isatty() + if not is_interactive: + skip_prompting = True + + # Add default GitHub MCP server environment variables for essential functionality first + # This ensures variables have defaults when user provides empty values or they're optional + default_github_env = { + "GITHUB_TOOLSETS": "context", + "GITHUB_DYNAMIC_TOOLSETS": "1" + } + + # Track which variables were explicitly provided with empty values (user wants defaults) + empty_value_vars = set() + if env_overrides: + for key, value in env_overrides.items(): + if key in env_overrides and (not value or not value.strip()): + empty_value_vars.add(key) + + for env_var in env_vars: + if isinstance(env_var, dict): + name = env_var.get("name", "") + description = env_var.get("description", "") + required = env_var.get("required", True) + + if name: + # First check overrides, then environment + value = env_overrides.get(name) or os.getenv(name) + + # Only prompt if not provided in overrides or environment AND it's required AND we're not in managed override mode + if not value and required and not skip_prompting: + prompt_text = f"Enter value for {name}" + if description: + prompt_text += f" ({description})" + value = Prompt.ask(prompt_text, password=True if "token" in name.lower() or "key" in name.lower() else False) + + # Add variable if it has a value OR if user explicitly provided empty and we have a default + if value and value.strip(): + resolved[name] = value + elif name in empty_value_vars and name in default_github_env: + # User provided empty value and we have a default - use default + resolved[name] = default_github_env[name] + elif not required and name in default_github_env: + # Variable is optional and we have a default - use default + resolved[name] = default_github_env[name] + elif skip_prompting and name in default_github_env: + # Non-interactive environment and we have a default - use default + resolved[name] = default_github_env[name] + + return resolved + + def _resolve_env_variable(self, name, value, env_overrides=None): + """Resolve a single environment variable value. + + Args: + name (str): Environment variable name. + value (str): Environment variable value or placeholder. + env_overrides (dict, optional): Pre-collected environment variable overrides. + + Returns: + str: Resolved environment variable value. + """ + import os + import re + import sys + from rich.prompt import Prompt + + env_overrides = env_overrides or {} + # If env_overrides is provided, it means we're in managed environment collection mode + skip_prompting = bool(env_overrides) + + # Check for CI/automated environment via APM_E2E_TESTS flag (more reliable than TTY detection) + if os.getenv('APM_E2E_TESTS') == '1': + skip_prompting = True + + # Also skip prompting if we're in a non-interactive environment (fallback) + is_interactive = sys.stdin.isatty() and sys.stdout.isatty() + if not is_interactive: + skip_prompting = True + + # Check if value contains environment variable reference + env_pattern = r'<([A-Z_][A-Z0-9_]*)>' + matches = re.findall(env_pattern, value) + + if matches: + for env_name in matches: + # First check overrides, then environment + env_value = env_overrides.get(env_name) or os.getenv(env_name) + if not env_value and not skip_prompting: + # Only prompt if not in managed mode + prompt_text = f"Enter value for {env_name}" + env_value = Prompt.ask(prompt_text, password=True if "token" in env_name.lower() or "key" in env_name.lower() else False) + + if env_value: + value = value.replace(f"<{env_name}>", env_value) + + return value + + def _inject_env_vars_into_docker_args(self, docker_args, env_vars): + """Inject environment variables into Docker arguments following registry template. + + The registry provides a complete Docker command template in runtime_arguments. + We need to inject actual environment variable values while respecting the template structure. + Also ensures required Docker flags (-i, --rm) are present. + + Args: + docker_args (list): Docker arguments from registry runtime_arguments. + env_vars (dict): Resolved environment variables. + + Returns: + list: Docker arguments with environment variables properly injected and required flags. + """ + if not env_vars: + env_vars = {} + + result = [] + i = 0 + has_interactive = False + has_rm = False + + # Check for existing -i and --rm flags + for arg in docker_args: + if arg == "-i" or arg == "--interactive": + has_interactive = True + elif arg == "--rm": + has_rm = True + + while i < len(docker_args): + arg = docker_args[i] + result.append(arg) + + # When we encounter "run", inject required flags first + if arg == "run": + # Add -i flag if not present + if not has_interactive: + result.append("-i") + + # Add --rm flag if not present + if not has_rm: + result.append("--rm") + + # If this is an environment variable name placeholder, replace with actual env var + if arg in env_vars: + # This is an environment variable name that should be replaced with -e VAR=value + result.pop() # Remove the env var name + result.extend(["-e", f"{arg}={env_vars[arg]}"]) + elif arg == "-e" and i + 1 < len(docker_args): + # Handle -e flag followed by env var name + next_arg = docker_args[i + 1] + if next_arg in env_vars: + result.append(f"{next_arg}={env_vars[next_arg]}") + i += 1 # Skip the next argument as we've processed it + else: + # Keep the original argument structure + result.append(next_arg) + i += 1 + + i += 1 + + # Add any remaining environment variables that weren't in the template + template_env_vars = set() + for arg in docker_args: + if arg in env_vars: + template_env_vars.add(arg) + + for env_name, env_value in env_vars.items(): + if env_name not in template_env_vars: + # Find a good place to insert additional env vars (after "run" but before image name) + insert_pos = len(result) + for idx, arg in enumerate(result): + if arg == "run": + # Insert after run command but before image name (usually last arg) + insert_pos = min(len(result) - 1, idx + 1) + break + + result.insert(insert_pos, "-e") + result.insert(insert_pos + 1, f"{env_name}={env_value}") + + # Add default GitHub MCP server environment variables if not already present + # Only add defaults for variables that were NOT explicitly provided (even if empty) + default_github_env = { + "GITHUB_TOOLSETS": "context", + "GITHUB_DYNAMIC_TOOLSETS": "1" + } + + existing_env_vars = set() + for i, arg in enumerate(result): + if arg == "-e" and i + 1 < len(result): + env_spec = result[i + 1] + if "=" in env_spec: + env_name = env_spec.split("=", 1)[0] + existing_env_vars.add(env_name) + + # For Copilot, defaults are already added during environment resolution + # This section is kept for compatibility but shouldn't add duplicates + + return result + + def _inject_docker_env_vars(self, args, env_vars): + """Inject environment variables into Docker arguments. + + Args: + args (list): Original Docker arguments. + env_vars (dict): Environment variables to inject. + + Returns: + list: Updated arguments with environment variables injected. + """ + result = [] + + for arg in args: + result.append(arg) + # If this is a docker run command, inject environment variables after "run" + if arg == "run" and env_vars: + for env_name, env_value in env_vars.items(): + result.extend(["-e", f"{env_name}={env_value}"]) + + return result + + def _process_arguments(self, arguments, resolved_env=None, runtime_vars=None): + """Process argument objects to extract simple string values with environment and runtime variable resolution. + + Args: + arguments (list): List of argument objects from registry. + resolved_env (dict): Resolved environment variables. + runtime_vars (dict): Resolved runtime variables. + + Returns: + list: List of processed argument strings. + """ + if resolved_env is None: + resolved_env = {} + if runtime_vars is None: + runtime_vars = {} + + processed = [] + + for arg in arguments: + if isinstance(arg, dict): + # Extract value from argument object + arg_type = arg.get("type", "") + if arg_type == "positional": + value = arg.get("value", arg.get("default", "")) + if value: + # Resolve both environment and runtime variable placeholders with actual values + processed_value = self._resolve_variable_placeholders(str(value), resolved_env, runtime_vars) + processed.append(processed_value) + elif arg_type == "named": + name = arg.get("name", "") + value = arg.get("value", arg.get("default", "")) + if name: + processed.append(name) + # For named arguments, only add value if it's different from the flag name + # and not empty + if value and value != name and not value.startswith("-"): + processed_value = self._resolve_variable_placeholders(str(value), resolved_env, runtime_vars) + processed.append(processed_value) + elif isinstance(arg, str): + # Already a string, use as-is but resolve variable placeholders + processed_value = self._resolve_variable_placeholders(arg, resolved_env, runtime_vars) + processed.append(processed_value) + + return processed + + def _resolve_variable_placeholders(self, value, resolved_env, runtime_vars): + """Resolve both environment and runtime variable placeholders in values. + + Args: + value (str): Value that may contain placeholders like or {ado_org} + resolved_env (dict): Dictionary of resolved environment variables. + runtime_vars (dict): Dictionary of resolved runtime variables. + + Returns: + str: Processed value with actual variable values. + """ + import re + + if not value: + return value + + processed = str(value) + + # Replace with actual values from resolved_env (for Docker env vars) + env_pattern = r'<([A-Z_][A-Z0-9_]*)>' + + def replace_env_var(match): + env_name = match.group(1) + return resolved_env.get(env_name, match.group(0)) # Return original if not found + + processed = re.sub(env_pattern, replace_env_var, processed) + + # Replace {runtime_var} with actual values from runtime_vars (for NPM args) + runtime_pattern = r'\{([a-zA-Z_][a-zA-Z0-9_]*)\}' + + def replace_runtime_var(match): + var_name = match.group(1) + return runtime_vars.get(var_name, match.group(0)) # Return original if not found + + processed = re.sub(runtime_pattern, replace_runtime_var, processed) + + return processed + + def _resolve_env_placeholders(self, value, resolved_env): + """Legacy method for backward compatibility. Use _resolve_variable_placeholders instead.""" + return self._resolve_variable_placeholders(value, resolved_env, {}) + + def _select_best_package(self, packages): + """Select the best package for installation from available packages. + + Prioritizes packages in order: npm, docker, pypi, homebrew, others. + + Args: + packages (list): List of package dictionaries. + + Returns: + dict: Best package to use, or None if no suitable package found. + """ + priority_order = ["npm", "docker", "pypi", "homebrew"] + + # Sort packages by priority + for registry_name in priority_order: + for package in packages: + if package.get("registry_name") == registry_name: + return package + + # If no priority package found, return the first one + return packages[0] if packages else None + + def _is_github_server(self, server_name, url): + """Securely determine if a server is a GitHub MCP server. + + This method uses proper URL parsing and hostname validation to prevent + security vulnerabilities from substring-based checks. + + Args: + server_name (str): Name of the MCP server. + url (str): URL of the remote endpoint. + + Returns: + bool: True if this is a legitimate GitHub MCP server, False otherwise. + """ + from urllib.parse import urlparse + + # Check server name against an allowlist of known GitHub MCP servers + github_server_names = [ + "github-mcp-server", + "github", + "github-mcp", + "github-copilot-mcp-server" + ] + + # Exact match check for server names (case-insensitive) + if server_name and server_name.lower() in [name.lower() for name in github_server_names]: + return True + + # If URL is provided, validate the hostname + if url: + try: + parsed_url = urlparse(url) + hostname = parsed_url.hostname + + if hostname: + # Allowlist of valid GitHub hostnames + valid_github_hostnames = [ + "api.githubcopilot.com", + "api.github.com", + "github.com" + ] + + # Exact hostname match + if hostname.lower() in [host.lower() for host in valid_github_hostnames]: + return True + + # Allow subdomains of github.com (with proper validation) + if hostname.lower().endswith(".github.com"): + return True + + except Exception: + # If URL parsing fails, assume it's not a GitHub server + return False + + return False \ No newline at end of file diff --git a/src/apm_cli/runtime/copilot_runtime.py b/src/apm_cli/runtime/copilot_runtime.py new file mode 100644 index 00000000..dbbfea4d --- /dev/null +++ b/src/apm_cli/runtime/copilot_runtime.py @@ -0,0 +1,211 @@ +"""GitHub Copilot CLI runtime adapter for APM.""" + +import subprocess +import shutil +import json +import os +from pathlib import Path +from typing import Dict, Any, Optional +from .base import RuntimeAdapter + + +class CopilotRuntime(RuntimeAdapter): + """APM adapter for the GitHub Copilot CLI.""" + + def __init__(self, model_name: Optional[str] = None): + """Initialize Copilot runtime. + + Args: + model_name: Model name (not used for Copilot CLI, included for compatibility) + """ + if not self.is_available(): + raise RuntimeError("GitHub Copilot CLI not available. Install with: npm install -g @github/copilot") + + self.model_name = model_name or "default" + + def execute_prompt(self, prompt_content: str, **kwargs) -> str: + """Execute a single prompt and return the response. + + Args: + prompt_content: The prompt text to execute + **kwargs: Additional arguments that may include: + - full_auto: Enable automatic tool execution (default: False) + - log_level: Copilot CLI log level (default: "default") + - add_dirs: Additional directories to allow file access + + Returns: + str: The response text from Copilot CLI + """ + try: + # Build Copilot CLI command + cmd = ["copilot", "-p", prompt_content] + + # Add optional arguments from kwargs + if kwargs.get("full_auto", False): + cmd.append("--full-auto") + + log_level = kwargs.get("log_level", "default") + if log_level != "default": + cmd.extend(["--log-level", log_level]) + + # Add additional directories if specified + add_dirs = kwargs.get("add_dirs", []) + for directory in add_dirs: + cmd.extend(["--add-dir", str(directory)]) + + # Execute Copilot CLI with real-time streaming + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, # Merge stderr into stdout for streaming + text=True, + bufsize=1, # Line buffered + universal_newlines=True + ) + + output_lines = [] + + # Stream output in real-time + for line in iter(process.stdout.readline, ''): + # Print to terminal in real-time + print(line, end='', flush=True) + output_lines.append(line) + + # Wait for process to complete + return_code = process.wait(timeout=600) # 10 minute timeout for complex tasks + + if return_code != 0: + full_output = ''.join(output_lines) + # Check for common issues + if "not logged in" in full_output.lower(): + raise RuntimeError("Copilot CLI execution failed: Not logged in. Run 'copilot' and use '/login' command.") + elif "npm" in full_output and "authentication" in full_output.lower(): + raise RuntimeError("Copilot CLI execution failed: npm authentication issue. Ensure you're logged in to @github npm registry.") + else: + raise RuntimeError(f"Copilot CLI execution failed with exit code {return_code}") + + return ''.join(output_lines).strip() + + except subprocess.TimeoutExpired: + if 'process' in locals(): + process.kill() + raise RuntimeError("Copilot CLI execution timed out after 10 minutes") + except FileNotFoundError: + raise RuntimeError("Copilot CLI not found. Install with: npm install -g @github/copilot") + except Exception as e: + raise RuntimeError(f"Failed to execute prompt with Copilot CLI: {e}") + + def list_available_models(self) -> Dict[str, Any]: + """List all available models in the Copilot CLI runtime. + + Note: Copilot CLI manages its own models, so we return generic info. + + Returns: + Dict[str, Any]: Dictionary of available models and their info + """ + try: + # Copilot CLI doesn't expose model listing via CLI, return generic info + return { + "copilot-default": { + "id": "copilot-default", + "provider": "github-copilot", + "description": "Default GitHub Copilot model (managed by Copilot CLI)" + } + } + except Exception as e: + return {"error": f"Failed to list Copilot CLI models: {e}"} + + def get_runtime_info(self) -> Dict[str, Any]: + """Get information about this runtime. + + Returns: + Dict[str, Any]: Runtime information including name, version, capabilities + """ + try: + # Try to get Copilot CLI version + version_result = subprocess.run( + ["copilot", "--version"], + capture_output=True, + text=True, + timeout=10 + ) + + version = version_result.stdout.strip() if version_result.returncode == 0 else "unknown" + + # Check for MCP configuration + mcp_config_path = Path.home() / ".copilot" / "mcp-config.json" + mcp_configured = mcp_config_path.exists() + + return { + "name": "copilot", + "type": "copilot_cli", + "version": version, + "capabilities": { + "model_execution": True, + "mcp_servers": "native_support" if mcp_configured else "manual_setup_required", + "configuration": "~/.copilot/mcp-config.json", + "interactive_mode": True, + "background_processes": True, + "file_operations": True, + "directory_access": "configurable" + }, + "description": "GitHub Copilot CLI runtime adapter", + "mcp_config_path": str(mcp_config_path), + "mcp_configured": mcp_configured + } + except Exception as e: + return {"error": f"Failed to get Copilot CLI runtime info: {e}"} + + @staticmethod + def is_available() -> bool: + """Check if this runtime is available on the system. + + Returns: + bool: True if runtime is available, False otherwise + """ + return shutil.which("copilot") is not None + + @staticmethod + def get_runtime_name() -> str: + """Get the name of this runtime. + + Returns: + str: Runtime name + """ + return "copilot" + + def get_mcp_config_path(self) -> Path: + """Get the path to the MCP configuration file. + + Returns: + Path: Path to the MCP configuration file + """ + return Path.home() / ".copilot" / "mcp-config.json" + + def is_mcp_configured(self) -> bool: + """Check if MCP servers are configured. + + Returns: + bool: True if MCP configuration exists, False otherwise + """ + return self.get_mcp_config_path().exists() + + def get_mcp_servers(self) -> Dict[str, Any]: + """Get configured MCP servers. + + Returns: + Dict[str, Any]: Dictionary of configured MCP servers + """ + mcp_config_path = self.get_mcp_config_path() + if not mcp_config_path.exists(): + return {} + + try: + with open(mcp_config_path, 'r') as f: + config = json.load(f) + return config.get('servers', {}) + except Exception as e: + return {"error": f"Failed to read MCP configuration: {e}"} + + def __str__(self) -> str: + return f"CopilotRuntime(model={self.model_name})" \ No newline at end of file diff --git a/uv.lock b/uv.lock index ae84952a..939ddb1f 100644 --- a/uv.lock +++ b/uv.lock @@ -166,7 +166,7 @@ wheels = [ [[package]] name = "apm-cli" -version = "0.4.0" +version = "0.4.1" source = { editable = "." } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, From 2b4c91b32ec064e72d9bc71d3a0e9e2c82e1cbf9 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 24 Sep 2025 14:20:57 +0200 Subject: [PATCH 2/9] Phase 2: Runtime infrastructure integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update runtime factory to register CopilotRuntime as first priority - Add copilot to supported runtimes in runtime manager - Update runtime preference order: copilot → codex → llm - Add npm-based removal logic for copilot runtime - Export CopilotRuntime in __init__.py Low risk changes that integrate core Copilot files into runtime system. --- src/apm_cli/runtime/__init__.py | 3 ++- src/apm_cli/runtime/factory.py | 8 +++++--- src/apm_cli/runtime/manager.py | 27 +++++++++++++++++++++++++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/apm_cli/runtime/__init__.py b/src/apm_cli/runtime/__init__.py index f5cdd061..c032ef9a 100644 --- a/src/apm_cli/runtime/__init__.py +++ b/src/apm_cli/runtime/__init__.py @@ -3,7 +3,8 @@ from .base import RuntimeAdapter from .llm_runtime import LLMRuntime from .codex_runtime import CodexRuntime +from .copilot_runtime import CopilotRuntime from .factory import RuntimeFactory from .manager import RuntimeManager -__all__ = ["RuntimeAdapter", "LLMRuntime", "CodexRuntime", "RuntimeFactory", "RuntimeManager"] +__all__ = ["RuntimeAdapter", "LLMRuntime", "CodexRuntime", "CopilotRuntime", "RuntimeFactory", "RuntimeManager"] diff --git a/src/apm_cli/runtime/factory.py b/src/apm_cli/runtime/factory.py index 31321e8a..0688df2a 100644 --- a/src/apm_cli/runtime/factory.py +++ b/src/apm_cli/runtime/factory.py @@ -4,6 +4,7 @@ from .base import RuntimeAdapter from .llm_runtime import LLMRuntime from .codex_runtime import CodexRuntime +from .copilot_runtime import CopilotRuntime class RuntimeFactory: @@ -11,8 +12,9 @@ class RuntimeFactory: # Registry of available runtime adapters in order of preference _RUNTIME_ADAPTERS: List[Type[RuntimeAdapter]] = [ - CodexRuntime, # Prefer Codex for its native MCP support - LLMRuntime, # Fallback to LLM library + CopilotRuntime, # Prefer Copilot CLI for its native MCP and advanced features + CodexRuntime, # Fallback to Codex for its native MCP support + LLMRuntime, # Final fallback to LLM library ] @classmethod @@ -94,7 +96,7 @@ def get_best_available_runtime(cls, model_name: Optional[str] = None) -> Runtime raise RuntimeError( "No runtimes available. Install at least one of: " - "Codex CLI (npm i -g @openai/codex@native), or LLM library (pip install llm)" + "Copilot CLI (npm i -g @github/copilot), Codex CLI (npm i -g @openai/codex@native), or LLM library (pip install llm)" ) @classmethod diff --git a/src/apm_cli/runtime/manager.py b/src/apm_cli/runtime/manager.py index 9e3221c5..870e0b2a 100644 --- a/src/apm_cli/runtime/manager.py +++ b/src/apm_cli/runtime/manager.py @@ -22,6 +22,11 @@ class RuntimeManager: def __init__(self): self.runtime_dir = Path.home() / ".apm" / "runtimes" self.supported_runtimes = { + "copilot": { + "script": "setup-copilot.sh", + "description": "GitHub Copilot CLI with native MCP integration", + "binary": "copilot" + }, "codex": { "script": "setup-codex.sh", "description": "OpenAI Codex CLI with GitHub Models support", @@ -248,7 +253,25 @@ def remove_runtime(self, runtime_name: str) -> bool: click.echo(f"{Fore.RED}āŒ Unknown runtime: {runtime_name}{Style.RESET_ALL}", err=True) return False - # Handle runtimes (installed in APM runtime directory) + # Handle copilot runtime (npm-based, global install) + if runtime_name == "copilot": + try: + result = subprocess.run( + ["npm", "uninstall", "-g", "@github/copilot"], + capture_output=True, + text=True + ) + if result.returncode == 0: + click.echo(f"{Fore.GREEN}āœ… Successfully removed {runtime_name} runtime{Style.RESET_ALL}") + return True + else: + click.echo(f"{Fore.RED}āŒ Failed to remove {runtime_name}: {result.stderr}{Style.RESET_ALL}", err=True) + return False + except Exception as e: + click.echo(f"{Fore.RED}āŒ Failed to remove {runtime_name}: {e}{Style.RESET_ALL}", err=True) + return False + + # Handle other runtimes (installed in APM runtime directory) binary_name = self.supported_runtimes[runtime_name]["binary"] binary_path = self.runtime_dir / binary_name @@ -277,7 +300,7 @@ def remove_runtime(self, runtime_name: str) -> bool: def get_runtime_preference(self) -> List[str]: """Get the runtime preference order.""" - return ["codex", "llm"] + return ["copilot", "codex", "llm"] def get_available_runtime(self) -> Optional[str]: """Get the first available runtime based on preference.""" From 390dde3a86c5bb5a3734d2e875c20f0c67bec853 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 24 Sep 2025 14:23:56 +0200 Subject: [PATCH 3/9] Phase 3: CLI Integration verified complete - Verified 'apm install --runtime' option includes copilot first - Confirmed 'apm runtime setup copilot' command works - Verified runtime status shows copilot as highest priority - Runtime detection logic already prioritizes copilot correctly - Error messages already mention copilot CLI installation No additional changes needed - CLI integration already complete from clean-main branch. --- src/apm_cli/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index 7e457d26..97c70c4b 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -912,7 +912,7 @@ def _install_mcp_dependencies(mcp_deps: List[str], runtime: str = None, exclude: manager = RuntimeManager() installed_runtimes = [] - # Check each MCP-compatible runtime + # Check each MCP-compatible runtime (prioritize Copilot CLI) for runtime_name in ['copilot', 'codex', 'vscode']: try: if runtime_name == 'vscode': @@ -930,7 +930,7 @@ def _install_mcp_dependencies(mcp_deps: List[str], runtime: str = None, exclude: # Runtime not supported or doesn't have MCP support continue except ImportError: - # Fallback to basic shutil check for known MCP runtimes + # Fallback to basic shutil check for known MCP runtimes (prioritize Copilot CLI) import shutil installed_runtimes = [rt for rt in ['copilot', 'codex', 'vscode'] if shutil.which(rt if rt != 'vscode' else 'code') is not None] From 5d25691afe2427b8259d2346000403e8558c45af Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 24 Sep 2025 14:29:50 +0200 Subject: [PATCH 4/9] Phase 5: Test integration complete - Verified existing tests already support copilot runtime - Added comprehensive test_copilot_runtime.py with 12 test cases - Tests cover runtime detection, initialization, execution, error handling - All existing runtime factory and detection tests pass with copilot - Integration tests already handle copilot in multi-runtime scenarios Low risk additions that provide comprehensive test coverage for Copilot runtime. --- tests/integration/test_golden_scenario_e2e.py | 177 ++++++++++++++++++ tests/unit/test_copilot_runtime.py | 147 +++++++++++++++ tests/unit/test_runtime_factory.py | 8 +- 3 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_copilot_runtime.py diff --git a/tests/integration/test_golden_scenario_e2e.py b/tests/integration/test_golden_scenario_e2e.py index a5ea07c1..b3a64cda 100644 --- a/tests/integration/test_golden_scenario_e2e.py +++ b/tests/integration/test_golden_scenario_e2e.py @@ -149,6 +149,183 @@ def apm_binary(): class TestGoldenScenarioE2E: """End-to-end tests for the exact README hero quick start scenario.""" + + @pytest.mark.skipif(not PRIMARY_TOKEN, reason="GitHub token (GITHUB_APM_PAT or GITHUB_TOKEN) required for E2E tests") + def test_complete_golden_scenario_copilot(self, temp_e2e_home, apm_binary): + """Test the complete hero quick start from README using Copilot CLI runtime. + + Validates the exact 6-step flow: + 1. (Prerequisites: GitHub token via GITHUB_APM_PAT or GITHUB_TOKEN) + 2. apm runtime setup copilot (sets up Copilot CLI) + 3. apm init my-ai-native-project + 4. cd my-ai-native-project && apm compile + 5. apm install + 6. apm run start --param name="" (uses Copilot CLI) + """ + + # Step 1: Setup Copilot runtime (equivalent to: apm runtime setup copilot) + print("\n=== Step 2: Set up your GitHub PAT and an Agent CLI ===") + print("GitHub token: āœ“ (set via environment - GITHUB_APM_PAT preferred)") + print("Installing Copilot CLI runtime...") + result = run_command(f"{apm_binary} runtime setup copilot", timeout=300, show_output=True) + assert result.returncode == 0, f"Runtime setup failed: {result.stderr}" + + # Verify copilot is available and GitHub configuration was created + copilot_config = Path(temp_e2e_home) / ".copilot" / "mcp-config.json" + + assert copilot_config.exists(), "Copilot configuration not created" + + # Verify configuration contains MCP setup + config_content = copilot_config.read_text() + print(f"āœ“ Copilot configuration created:\n{config_content}") + + # Test copilot binary directly + print("\n=== Testing Copilot CLI binary directly ===") + result = run_command("copilot --version", show_output=True, check=False) + if result.returncode == 0: + print(f"āœ“ Copilot version: {result.stdout}") + else: + print(f"⚠ Copilot version check failed: {result.stderr}") + + # Check if copilot is in PATH + print("\n=== Checking PATH setup ===") + result = run_command("which copilot", check=False) + if result.returncode == 0: + print(f"āœ“ Copilot found in PATH: {result.stdout.strip()}") + else: + print("⚠ Copilot not in PATH, will need explicit path or shell restart") + + # Step 2: Initialize project (equivalent to: apm init my-ai-native-project) + with tempfile.TemporaryDirectory() as project_workspace: + project_dir = Path(project_workspace) / "my-ai-native-project" + + print("\n=== Step 3: Transform your project with AI-Native structure ===") + result = run_command(f"{apm_binary} init my-ai-native-project --yes", cwd=project_workspace, show_output=True) + assert result.returncode == 0, f"Project init failed: {result.stderr}" + assert project_dir.exists(), "Project directory not created" + + # Verify project structure + assert (project_dir / "apm.yml").exists(), "apm.yml not created" + assert (project_dir / "hello-world.prompt.md").exists(), "Prompt file not created" + + # Critical: Verify Agent Primitives (.apm directory) are created + apm_dir = project_dir / ".apm" + assert apm_dir.exists(), "Agent Primitives directory (.apm) not created - TEMPLATE BUNDLING FAILED" + + print(f"āœ“ Verified Agent Primitives directory (.apm) exists") + + # Show project contents for debugging + print("\n=== Project structure ===") + apm_yml_content = (project_dir / "apm.yml").read_text() + prompt_content = (project_dir / "hello-world.prompt.md").read_text() + print(f"apm.yml:\n{apm_yml_content}") + print(f"hello-world.prompt.md:\n{prompt_content[:500]}...") + + # List Agent Primitives for verification + if apm_dir.exists(): + agent_primitives = list(apm_dir.rglob("*")) + agent_files = [f for f in agent_primitives if f.is_file()] + print(f"\n=== Agent Primitives Files ({len(agent_files)} found) ===") + for f in sorted(agent_files): + rel_path = f.relative_to(project_dir) + print(f" {rel_path}") + else: + print(f"\nāŒ Agent Primitives directory (.apm) missing - TEMPLATE BUNDLING FAILED") + + # Step 4: Compile Agent Primitives for any coding agent (equivalent to: apm compile) + print("\n=== Step 4: Compile Agent Primitives for any coding agent ===") + result = run_command(f"{apm_binary} compile", cwd=project_dir, show_output=True) + assert result.returncode == 0, f"Agent Primitives compilation failed: {result.stderr}" + + # Verify agents.md was generated + agents_md = project_dir / "AGENTS.md" + assert agents_md.exists(), "AGENTS.md not generated by compile step" + + # Show agents.md content for verification + agents_content = agents_md.read_text() + print(f"\n=== Generated AGENTS.md (first 500 chars) ===") + print(f"{agents_content[:500]}...") + + # Step 5: Install MCP dependencies (equivalent to: apm install) + print("\n=== Step 5: Install MCP dependencies ===") + + # Set Azure DevOps MCP runtime variables for domain restriction + env = os.environ.copy() + env['ado_domain'] = 'core' # Limit to core domain only + # Leave ado_org unset to avoid connecting to real organization + + result = run_command(f"{apm_binary} install", cwd=project_dir, show_output=True, env=env) + assert result.returncode == 0, f"Dependency install failed: {result.stderr}" + + # Step 5.5: Domain restriction is handled via ado_domain environment variable + # No post-install injection needed - runtime variables are resolved during install + + # Step 6: Execute agentic workflows (equivalent to: apm run start --param name="") + print("\n=== Step 6: Execute agentic workflows ===") + print(f"Environment: HOME={temp_e2e_home}, Primary token={'SET' if PRIMARY_TOKEN else 'NOT SET'}") + + # Respect integration script's token management + # Do not override - let test-integration.sh handle tokens properly + env = os.environ.copy() + env['HOME'] = temp_e2e_home + + # Run with real-time output streaming (using 'start' script which calls Copilot CLI) + cmd = f'{apm_binary} run start --param name="danielmeppiel"' + print(f"Executing: {cmd}") + + try: + process = subprocess.Popen( + cmd, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, # Merge stderr into stdout + text=True, + cwd=project_dir, + env=env + ) + + output_lines = [] + print("\n--- Copilot CLI Execution Output ---") + + # Stream output in real-time + for line in iter(process.stdout.readline, ''): + if line: + print(line.rstrip()) # Print to terminal + output_lines.append(line) + + # Wait for completion + return_code = process.wait(timeout=120) + full_output = ''.join(output_lines) + + print("--- End Copilot CLI Output ---\n") + + # Verify execution + if return_code != 0: + print(f"āŒ Command failed with return code: {return_code}") + print(f"Full output:\n{full_output}") + + # Check for common issues + if "GITHUB_TOKEN" in full_output or "authentication" in full_output.lower(): + pytest.fail("Copilot CLI execution failed: GitHub token not properly configured") + elif "Connection" in full_output or "timeout" in full_output.lower(): + pytest.fail("Copilot CLI execution failed: Network connectivity issue") + else: + pytest.fail(f"Golden scenario execution failed with return code {return_code}: {full_output}") + + # Verify output contains expected elements (using "Developer" instead of "E2E Tester") + output_lower = full_output.lower() + assert "danielmeppiel" in output_lower, \ + f"Parameter substitution failed. Expected 'Developer', got: {full_output}" + assert len(full_output.strip()) > 50, \ + f"Output seems too short, API call might have failed. Output: {full_output}" + + print(f"\nāœ… Golden scenario completed successfully!") + print(f"Output length: {len(full_output)} characters") + print(f"Contains parameter: {'āœ“' if 'danielmeppiel' in output_lower else 'āŒ'}") + + except subprocess.TimeoutExpired: + process.kill() + pytest.fail("Copilot CLI execution timed out after 120 seconds") @pytest.mark.skipif(not PRIMARY_TOKEN, reason="GitHub token (GITHUB_APM_PAT or GITHUB_TOKEN) required for E2E tests") def test_complete_golden_scenario_codex(self, temp_e2e_home, apm_binary): diff --git a/tests/unit/test_copilot_runtime.py b/tests/unit/test_copilot_runtime.py new file mode 100644 index 00000000..5b06ae5c --- /dev/null +++ b/tests/unit/test_copilot_runtime.py @@ -0,0 +1,147 @@ +"""Test Copilot Runtime.""" + +import pytest +from unittest.mock import Mock, patch +from apm_cli.runtime.copilot_runtime import CopilotRuntime + + +class TestCopilotRuntime: + """Test Copilot Runtime.""" + + def test_get_runtime_name(self): + """Test getting runtime name.""" + assert CopilotRuntime.get_runtime_name() == "copilot" + + def test_runtime_name_static(self): + """Test runtime name is consistent.""" + runtime = CopilotRuntime() + assert runtime.get_runtime_name() == "copilot" + + @patch('shutil.which') + def test_is_available_true(self, mock_which): + """Test is_available when copilot binary exists.""" + mock_which.return_value = "/usr/local/bin/copilot" + assert CopilotRuntime.is_available() is True + + @patch('shutil.which') + def test_is_available_false(self, mock_which): + """Test is_available when copilot binary doesn't exist.""" + mock_which.return_value = None + assert CopilotRuntime.is_available() is False + + def test_initialization_without_copilot(self): + """Test initialization fails gracefully when copilot not available.""" + with patch.object(CopilotRuntime, 'is_available', return_value=False): + with pytest.raises(RuntimeError, match="GitHub Copilot CLI not available"): + CopilotRuntime() + + def test_get_runtime_info(self): + """Test getting runtime information.""" + with patch.object(CopilotRuntime, 'is_available', return_value=True): + runtime = CopilotRuntime() + info = runtime.get_runtime_info() + + assert info["name"] == "copilot" + assert info["type"] == "copilot_cli" + assert "capabilities" in info + assert info["capabilities"]["model_execution"] is True + assert info["capabilities"]["file_operations"] is True + + def test_list_available_models(self): + """Test listing available models.""" + with patch.object(CopilotRuntime, 'is_available', return_value=True): + runtime = CopilotRuntime() + models = runtime.list_available_models() + + assert "copilot-default" in models + assert models["copilot-default"]["provider"] == "github-copilot" + + def test_get_mcp_config_path(self): + """Test getting MCP configuration path.""" + with patch.object(CopilotRuntime, 'is_available', return_value=True): + runtime = CopilotRuntime() + config_path = runtime.get_mcp_config_path() + + assert str(config_path).endswith(".copilot/mcp-config.json") + + def test_execute_prompt_basic(self): + """Test basic prompt execution.""" + with patch.object(CopilotRuntime, 'is_available', return_value=True), \ + patch('subprocess.Popen') as mock_popen: + + # Mock process + mock_process = Mock() + mock_process.stdout.readline.side_effect = [ + "Hello from Copilot!\n", + "Task completed.\n", + "" # End of output + ] + mock_process.wait.return_value = 0 + mock_popen.return_value = mock_process + + runtime = CopilotRuntime() + result = runtime.execute_prompt("Test prompt") + + assert "Hello from Copilot!" in result + assert "Task completed." in result + + # Verify command was called correctly + mock_popen.assert_called_once() + call_args = mock_popen.call_args[0][0] + assert call_args[0] == "copilot" + assert "-p" in call_args + assert "Test prompt" in call_args + + def test_execute_prompt_with_options(self): + """Test prompt execution with additional options.""" + with patch.object(CopilotRuntime, 'is_available', return_value=True), \ + patch('subprocess.Popen') as mock_popen: + + # Mock process + mock_process = Mock() + mock_process.stdout.readline.side_effect = ["Output\n", ""] + mock_process.wait.return_value = 0 + mock_popen.return_value = mock_process + + runtime = CopilotRuntime() + result = runtime.execute_prompt( + "Test prompt", + full_auto=True, + log_level="debug", + add_dirs=["/path/to/dir"] + ) + + # Verify command options were added + call_args = mock_popen.call_args[0][0] + assert "--full-auto" in call_args + assert "--log-level" in call_args + assert "debug" in call_args + assert "--add-dir" in call_args + assert "/path/to/dir" in call_args + + def test_execute_prompt_error_handling(self): + """Test error handling in prompt execution.""" + with patch.object(CopilotRuntime, 'is_available', return_value=True), \ + patch('subprocess.Popen') as mock_popen: + + # Mock process that fails + mock_process = Mock() + mock_process.stdout.readline.side_effect = [ + "Error occurred\n", + "" + ] + mock_process.wait.return_value = 1 # Non-zero exit code + mock_popen.return_value = mock_process + + runtime = CopilotRuntime() + + with pytest.raises(RuntimeError, match="Copilot CLI execution failed"): + runtime.execute_prompt("Test prompt") + + def test_str_representation(self): + """Test string representation.""" + with patch.object(CopilotRuntime, 'is_available', return_value=True): + runtime = CopilotRuntime("test-model") + str_repr = str(runtime) + assert "CopilotRuntime" in str_repr + assert "test-model" in str_repr \ No newline at end of file diff --git a/tests/unit/test_runtime_factory.py b/tests/unit/test_runtime_factory.py index f03d4e79..dc0a5524 100644 --- a/tests/unit/test_runtime_factory.py +++ b/tests/unit/test_runtime_factory.py @@ -34,8 +34,8 @@ def test_get_best_available_runtime_real(self): runtime = RuntimeFactory.get_best_available_runtime() assert runtime is not None - # Should be Codex if available, otherwise LLM - assert runtime.get_runtime_name() in ["codex", "llm"] + # Should be Copilot if available, otherwise Codex, otherwise LLM + assert runtime.get_runtime_name() in ["copilot", "codex", "llm"] def test_create_runtime_with_name_real(self): """Test creating runtime with specific name (real system).""" @@ -49,8 +49,8 @@ def test_create_runtime_auto_detect_real(self): runtime = RuntimeFactory.create_runtime() assert runtime is not None - # Should be Codex if available, otherwise LLM - assert runtime.get_runtime_name() in ["codex", "llm"] + # Should be Copilot if available, otherwise Codex, otherwise LLM + assert runtime.get_runtime_name() in ["copilot", "codex", "llm"] def test_runtime_exists_llm_true(self): """Test runtime exists check for LLM - true.""" From 1d096807c96e9ae1340e489d3952e2ee5d4af452 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 24 Sep 2025 14:42:56 +0200 Subject: [PATCH 5/9] register Copilot client in ClientFactory --- src/apm_cli/factory.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apm_cli/factory.py b/src/apm_cli/factory.py index a6ccfc37..c6a941c2 100644 --- a/src/apm_cli/factory.py +++ b/src/apm_cli/factory.py @@ -2,6 +2,7 @@ from .adapters.client.vscode import VSCodeClientAdapter from .adapters.client.codex import CodexClientAdapter +from .adapters.client.copilot import CopilotClientAdapter from .adapters.package_manager.default_manager import DefaultMCPPackageManager @@ -22,6 +23,7 @@ def create_client(client_type): ValueError: If the client type is not supported. """ clients = { + "copilot": CopilotClientAdapter, "vscode": VSCodeClientAdapter, "codex": CodexClientAdapter, # Add more clients as needed From 6f70383c0842d53421f89f4a87a40a02efac8cda Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Thu, 25 Sep 2025 21:29:51 +0200 Subject: [PATCH 6/9] Update documentation and scripts for GitHub Copilot integration - Replace references from Codex to GitHub Copilot in README, CLI reference, and getting started guides. - Modify setup scripts to install GitHub Copilot CLI with MCP configuration. - Update token management to reflect the removal of GITHUB_NPM_PAT. - Adjust integration tests to verify Copilot setup. - Enhance example scripts in apm.yml for Copilot usage. --- README.md | 19 ++- docs/cli-reference.md | 4 +- docs/getting-started.md | 14 +- docs/integration-testing.md | 1 + docs/integrations.md | 2 +- docs/runtime-integration.md | 74 ++++++++-- scripts/runtime/setup-copilot.sh | 132 +++--------------- src/apm_cli/core/token_manager.py | 4 +- src/apm_cli/deps/github_downloader.py | 2 +- src/apm_cli/runtime/copilot_runtime.py | 2 - templates/hello-world/apm.yml | 3 +- tests/integration/test_golden_scenario_e2e.py | 3 +- tests/test_empty_string_and_defaults.py | 3 +- 13 files changed, 104 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index d827986e..6af70488 100644 --- a/README.md +++ b/README.md @@ -26,21 +26,20 @@ > [!NOTE] > **šŸ“‹ Prerequisites**: Get tokens at [github.com/settings/personal-access-tokens/new](https://github.com/settings/personal-access-tokens/new) -> - **`GITHUB_APM_PAT`** - Fine-grained PAT for access to private APM modules (optional, recommended) -> - **`GITHUB_TOKEN`** - User-scoped fine-grained PAT with read Models scope for GitHub Models API (optional, enables free GitHub Models access with Codex CLI and llm) +> - **`GITHUB_COPILOT_PAT`** - User-scoped Fine-grained PAT with Copilot CLI subscription access +> - **`GITHUB_APM_PAT`** - (optional) - Fine-grained PAT for access to private APM modules > > šŸ“– **Complete Setup Guide**: [Getting Started](docs/getting-started.md) ```bash -# 1. Set your GitHub tokens (minimal setup) -export GITHUB_APM_PAT=your_fine_grained_token_here -export GITHUB_TOKEN=your_token_for_github_models +# 1. Set your GitHub token (minimal setup) +export GITHUB_COPILOT_PAT=your_fine_grained_token_here -# 2. Install APM CLI (GitHub org members) +# 2. Install APM CLI curl -sSL "https://raw.githubusercontent.com/danielmeppiel/apm/main/install.sh" | sh -# 3. Set up runtime (uses free GitHub Models) -apm runtime setup codex +# 3. Set up runtime (GitHub Copilot CLI with native MCP support) +apm runtime setup copilot # 3. Create your first AI package apm init my-project && cd my-project @@ -73,7 +72,7 @@ dependencies: - microsoft/azure-devops-mcp scripts: - start: "RUST_LOG=debug codex --skip-git-repo-check hello-world.prompt.md" + start: "copilot --full-auto -p hello-world.prompt.md" ``` ## What You Just Built @@ -93,7 +92,7 @@ APM solves the AI agent context scalability problem through constraint satisfact ```bash apm init # Initialize AI-native project -apm runtime setup # Install coding agents (codex for now) +apm runtime setup # Install coding agents (copilot recommended) apm compile # Generate AGENTS.md for compatibility apm install # Install APM and MCP dependencies from apm.yml apm deps list # List installed APM dependencies diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b84288c4..e58c2688 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -13,7 +13,7 @@ export GITHUB_APM_PAT=your_fine_grained_token_here curl -sSL https://raw.githubusercontent.com/danielmeppiel/apm/main/install.sh | sh # 3. Setup runtime -apm runtime setup codex +apm runtime setup copilot # 4. Create project apm init my-project && cd my-project @@ -717,7 +717,7 @@ apm list ## Tips & Best Practices -1. **Start with runtime setup**: Run `apm runtime setup codex` +1. **Start with runtime setup**: Run `apm runtime setup copilot` 2. **Use GitHub Models for free tier**: Set `GITHUB_TOKEN` (user-scoped with Models read permission) for free Codex access 3. **Discover MCP servers**: Use `apm search` to find available MCP servers before adding to apm.yml 4. **Preview before running**: Use `apm preview` to check parameter substitution diff --git a/docs/getting-started.md b/docs/getting-started.md index 2d054146..db95b73c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -8,10 +8,6 @@ Welcome to APM - the AI Package Manager that transforms any project into reliabl APM requires GitHub tokens for accessing models and package registries. Get your tokens at [github.com/settings/personal-access-tokens/new](https://github.com/settings/personal-access-tokens/new): -### GitHub Tokens Required - -APM requires GitHub tokens for accessing models and package registries. Get your tokens at [github.com/settings/personal-access-tokens/new](https://github.com/settings/personal-access-tokens/new): - #### Required Tokens ##### GITHUB_APM_PAT (Fine-grained PAT - Recommended) @@ -178,7 +174,15 @@ This creates a platform-specific binary at `./dist/apm-{platform}-{arch}/apm` th APM works with multiple AI coding agents. Choose your preferred runtime: -### OpenAI Codex CLI (Recommended) +### GitHub Copilot CLI (Recommended) + +```bash +apm runtime setup copilot +``` + +Uses GitHub Copilot CLI with native MCP integration and advanced AI coding assistance. + +### OpenAI Codex CLI ```bash apm runtime setup codex diff --git a/docs/integration-testing.md b/docs/integration-testing.md index eeabbe39..57e0653b 100644 --- a/docs/integration-testing.md +++ b/docs/integration-testing.md @@ -151,6 +151,7 @@ All integration tests run on: ### E2E Tests Verify: - āœ… Complete golden scenario from README works +- āœ… `apm runtime setup copilot` installs and configures GitHub Copilot CLI - āœ… `apm runtime setup codex` installs and configures Codex - āœ… `apm runtime setup llm` installs and configures LLM - āœ… `apm init my-hello-world` creates project correctly diff --git a/docs/integrations.md b/docs/integrations.md index ceca554e..ba69287c 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -53,7 +53,7 @@ Direct integration with OpenAI's development-focused models: ```bash # Install and configure -apm runtime setup codex +apm runtime setup copilot # Features - GitHub Models API backend diff --git a/docs/runtime-integration.md b/docs/runtime-integration.md index a5b83a27..e9de2c72 100644 --- a/docs/runtime-integration.md +++ b/docs/runtime-integration.md @@ -4,11 +4,12 @@ APM manages LLM runtime installation and configuration automatically. This guide ## Overview -APM acts as a runtime package manager, downloading and configuring LLM runtimes from their official sources. Currently supports two runtimes: +APM acts as a runtime package manager, downloading and configuring LLM runtimes from their official sources. Currently supports three runtimes: | Runtime | Description | Best For | Configuration | |---------|-------------|----------|---------------| -| [**OpenAI Codex**](https://github.com/openai/codex) | OpenAI's Codex CLI | Code tasks, MCP support | Auto-configured with GitHub Models | +| [**GitHub Copilot CLI**](https://github.com/github/copilot-cli) | GitHub's Copilot CLI (Recommended) | Advanced AI coding, native MCP support | Auto-configured, no auth needed | +| [**OpenAI Codex**](https://github.com/openai/codex) | OpenAI's Codex CLI | Code tasks, GitHub Models API | Auto-configured with GitHub Models | | [**LLM Library**](https://llm.datasette.io/en/stable/index.html) | Simon Willison's `llm` CLI | General use, many providers | Manual API key setup | ## Quick Setup @@ -19,20 +20,52 @@ APM acts as a runtime package manager, downloading and configuring LLM runtimes curl -sSL https://raw.githubusercontent.com/danielmeppiel/apm/main/install.sh | sh # 2. Setup AI runtime (downloads and configures automatically) -apm runtime setup codex - -# 3. Set GitHub token for free models (fine-grained token preferred) -export GITHUB_TOKEN=your_github_token +apm runtime setup copilot ``` ### Runtime Management ```bash apm runtime list # Show installed runtimes apm runtime setup llm # Install LLM library +apm runtime setup copilot # Install GitHub Copilot CLI (Recommended) apm runtime setup codex # Install Codex CLI ``` -## OpenAI Codex Runtime (Recommended) +## GitHub Copilot CLI Runtime (Recommended) + +APM automatically installs GitHub Copilot CLI from the public npm registry. Copilot CLI provides advanced AI coding assistance with native MCP integration and GitHub context awareness. + +### Setup + +#### 1. Install via APM +```bash +apm runtime setup copilot +``` + +This automatically: +- Installs GitHub Copilot CLI from public npm registry +- Requires Node.js v22+ and npm v10+ +- Creates MCP configuration directory at `~/.copilot/` +- No authentication required for installation + +### Usage + +APM executes scripts defined in your `apm.yml`. When scripts reference `.prompt.md` files, APM compiles them with parameter substitution. See [Prompts Guide](prompts.md) for details. + +```bash +# Run scripts (from apm.yml) with parameters +apm run start --param service_name=api-gateway +apm run debug --param service_name=api-gateway +``` + +**Script Configuration (apm.yml):** +```yaml +scripts: + start: "copilot --full-auto -p analyze-logs.prompt.md" + debug: "copilot --full-auto -p analyze-logs.prompt.md --log-level debug" +``` + +## OpenAI Codex Runtime APM automatically downloads, installs, and configures the Codex CLI with GitHub Models for free usage. @@ -57,8 +90,6 @@ export GITHUB_TOKEN=your_github_token ### Usage -APM executes scripts defined in your `apm.yml`. When scripts reference `.prompt.md` files, APM compiles them with parameter substitution. See [Prompts Guide](prompts.md) for details. - ```bash # Run scripts (from apm.yml) with parameters apm run start --param service_name=api-gateway @@ -122,15 +153,23 @@ scripts: ```bash # Run scripts defined in apm.yml apm run start --param service_name=api-gateway -apm run llm --param service_name=api-gateway +apm run copilot-analysis --param service_name=api-gateway apm run debug --param service_name=api-gateway ``` -### Code Analysis +### Code Analysis with Copilot CLI ```bash -# Scripts that use Codex for code understanding +# Scripts that use Copilot CLI for advanced code understanding apm run code-review --param pull_request=123 apm run analyze-code --param file_path="src/main.py" +apm run refactor --param component="UserService" +``` + +### Code Analysis with Codex +```bash +# Scripts that use Codex for code understanding +apm run codex-review --param pull_request=123 +apm run codex-analyze --param file_path="src/main.py" ``` ### Documentation Tasks @@ -145,6 +184,7 @@ apm run summarize --param report_type="weekly" **"Runtime not found"** ```bash # Install missing runtime +apm runtime setup copilot # Recommended apm runtime setup codex apm runtime setup llm @@ -152,10 +192,14 @@ apm runtime setup llm apm runtime list ``` -**"No GitHub token"** +**"Command not found: copilot"** ```bash -# Set GitHub token for free models -export GITHUB_TOKEN=your_github_token +# Ensure Node.js v22+ and npm v10+ are installed +node --version # Should be v22+ +npm --version # Should be v10+ + +# Reinstall Copilot CLI +apm runtime setup copilot ``` **"Command not found: codex"** diff --git a/scripts/runtime/setup-copilot.sh b/scripts/runtime/setup-copilot.sh index a97fcddd..f6beae59 100755 --- a/scripts/runtime/setup-copilot.sh +++ b/scripts/runtime/setup-copilot.sh @@ -1,7 +1,6 @@ #!/bin/bash # Setup script for GitHub Copilot CLI runtime -# Handles npm authentication and @github/copilot installation with MCP configuration -# Private preview version (Staffship) +# Installs @github/copilot with MCP configuration support set -euo pipefail @@ -73,134 +72,40 @@ check_npm_version() { log_success "npm version $npm_version āœ“" } -# Check GitHub npm authentication -check_github_npm_auth() { - log_info "Checking GitHub npm registry authentication..." - - # Check if already logged in to @github scope - if npm whoami --scope=@github --registry=https://npm.pkg.github.com >/dev/null 2>&1; then - local username=$(npm whoami --scope=@github --registry=https://npm.pkg.github.com) - log_success "Already authenticated to GitHub npm registry as: $username" - return 0 - else - log_info "Attempting authentication to GitHub npm registry" - - # Check if we have GITHUB_NPM_PAT for automatic authentication - if [[ -n "$GITHUB_NPM_PAT" ]]; then - log_info "Found GITHUB_NPM_PAT, attempting automatic npm authentication..." - if setup_npm_auth_with_token; then - return 0 - fi - fi - - return 1 - fi -} -# Set up npm authentication using GITHUB_NPM_PAT token -setup_npm_auth_with_token() { - if [[ -z "$GITHUB_NPM_PAT" ]]; then - log_error "GITHUB_NPM_PAT environment variable not set" - return 1 - fi - - log_info "Setting up npm authentication with GITHUB_NPM_PAT..." - - # Use npm login in non-interactive mode with the token - # This mimics: npm login --scope=@github --auth-type=legacy --registry=https://npm.pkg.github.com - - # Configure npm registry for @github scope - npm config set @github:registry https://npm.pkg.github.com/ - - # Set the auth token directly in the npm configuration - npm config set //npm.pkg.github.com/:_authToken "${GITHUB_NPM_PAT}" - - # Test the authentication - if npm whoami --scope=@github --registry=https://npm.pkg.github.com >/dev/null 2>&1; then - local username=$(npm whoami --scope=@github --registry=https://npm.pkg.github.com) - log_success "Successfully authenticated to GitHub npm registry as: $username using GITHUB_NPM_PAT" - return 0 - else - log_error "Failed to authenticate with GITHUB_NPM_PAT" - return 1 - fi -} - -# Guide user through GitHub npm authentication -setup_github_npm_auth() { - log_info "Setting up GitHub npm registry authentication..." - echo "" - log_info "GitHub Copilot CLI is currently in private preview and requires authentication" - log_info "to the GitHub npm registry. Please follow these steps:" - echo "" - - echo "${HIGHLIGHT}Step 1: Create a GitHub Personal Access Token${RESET}" - echo "1. Go to: https://github.com/settings/tokens/new" - echo "2. Select 'Classic token'" - echo "3. Add 'read:packages' scope" - echo "4. Enable SSO for the 'github' organization" - echo "5. Set expiration < 90 days" - echo "6. Generate token and copy it" - echo "" - - echo "${HIGHLIGHT}Step 2: Authenticate with npm${RESET}" - echo "Run this command and enter your GitHub username and PAT:" - echo "" - echo " npm login --scope=@github --auth-type=legacy --registry=https://npm.pkg.github.com" - echo "" - - # Ask if they want to try authentication now - if command -v read >/dev/null 2>&1; then - read -p "Would you like to authenticate now? (y/N): " -n 1 -r - echo "" - if [[ $REPLY =~ ^[Yy]$ ]]; then - log_info "Starting npm authentication..." - if npm login --scope=@github --auth-type=legacy --registry=https://npm.pkg.github.com; then - log_success "Successfully authenticated to GitHub npm registry!" - else - log_error "Authentication failed. Please try again manually." - exit 1 - fi - else - log_warning "Please authenticate manually and then re-run this script" - exit 1 - fi - else - log_warning "Please authenticate manually and then re-run this script" - exit 1 - fi -} # Install Copilot CLI via npm install_copilot_cli() { log_info "Installing GitHub Copilot CLI..." - # Install globally - npm will use the configured registries (GitHub for @github scope, default for others) + # Install globally from public npm registry if npm install -g "$COPILOT_PACKAGE"; then log_success "Successfully installed $COPILOT_PACKAGE" else log_error "Failed to install $COPILOT_PACKAGE" log_info "This might be due to:" - log_info " - Authentication issues with GitHub npm registry" - log_info " - Insufficient permissions for global npm install" + log_info " - Insufficient permissions for global npm install (try with sudo)" log_info " - Network connectivity issues" + log_info " - Node.js/npm version compatibility" exit 1 fi } -# Source the centralized GitHub token helper -source "$SCRIPT_DIR/github-token-helper.sh" - # Setup GitHub MCP Server environment for Copilot CLI setup_github_mcp_environment() { log_info "Setting up GitHub MCP Server environment for Copilot CLI..." - # Use centralized token management - setup_github_tokens + # Check for available GitHub tokens for MCP server setup + local copilot_token="" - # For Copilot CLI MCP server, we need GITHUB_PERSONAL_ACCESS_TOKEN - local copilot_token - copilot_token=$(get_token_for_runtime "copilot") + # Check token precedence: GITHUB_COPILOT_PAT -> GITHUB_TOKEN -> GITHUB_APM_PAT + if [[ -n "${GITHUB_COPILOT_PAT:-}" ]]; then + copilot_token="$GITHUB_COPILOT_PAT" + elif [[ -n "${GITHUB_TOKEN:-}" ]]; then + copilot_token="$GITHUB_TOKEN" + elif [[ -n "${GITHUB_APM_PAT:-}" ]]; then + copilot_token="$GITHUB_APM_PAT" + fi if [[ -n "$copilot_token" ]]; then # Set GITHUB_PERSONAL_ACCESS_TOKEN for Copilot CLI's automatic GitHub MCP Server setup @@ -269,12 +174,7 @@ setup_copilot() { check_node_version check_npm_version - # Check and setup GitHub npm authentication - if ! check_github_npm_auth; then - setup_github_npm_auth - fi - - # Install Copilot CLI + # Install Copilot CLI (now available on public npm registry) install_copilot_cli # Setup directory structure (unless vanilla mode) @@ -302,7 +202,7 @@ setup_copilot() { echo "" log_success "✨ GitHub Copilot CLI installed and configured!" echo " - Use 'apm install' to configure MCP servers for your projects" - echo " - Copilot CLI provides advanced AI coding assistance" + echo " - Copilot CLI provides advanced AI coding assistance with GitHub integration" echo " - Interactive mode available: just run 'copilot'" else echo "1. Configure Copilot CLI as needed (run 'copilot' for interactive setup)" diff --git a/src/apm_cli/core/token_manager.py b/src/apm_cli/core/token_manager.py index 559fc77e..d9aabd19 100644 --- a/src/apm_cli/core/token_manager.py +++ b/src/apm_cli/core/token_manager.py @@ -8,7 +8,6 @@ - GITHUB_COPILOT_PAT: User-scoped PAT specifically for Copilot - GITHUB_APM_PAT: Fine-grained PAT for APM module access - GITHUB_TOKEN: User-scoped PAT for GitHub Models API access -- GITHUB_NPM_PAT: Classic PAT for GitHub npm registry access Runtime Requirements: - Codex CLI: Uses GITHUB_TOKEN (must be user-scoped for GitHub Models) @@ -26,7 +25,6 @@ class GitHubTokenManager: 'copilot': ['GITHUB_COPILOT_PAT', 'GITHUB_TOKEN', 'GITHUB_APM_PAT'], 'models': ['GITHUB_TOKEN'], # GitHub Models requires user-scoped PAT 'modules': ['GITHUB_APM_PAT', 'GITHUB_TOKEN'], # APM module access - 'npm': ['GITHUB_NPM_PAT'] # npm registry access } # Runtime-specific environment variable mappings @@ -70,7 +68,7 @@ def get_token_for_purpose(self, purpose: str, env: Optional[Dict[str, str]] = No """Get the best available token for a specific purpose. Args: - purpose: Token purpose ('copilot', 'models', 'modules', 'npm') + purpose: Token purpose ('copilot', 'models', 'modules') env: Environment to check (defaults to os.environ) Returns: diff --git a/src/apm_cli/deps/github_downloader.py b/src/apm_cli/deps/github_downloader.py index 232e128c..744a11a7 100644 --- a/src/apm_cli/deps/github_downloader.py +++ b/src/apm_cli/deps/github_downloader.py @@ -69,7 +69,7 @@ def _sanitize_git_error(self, error_message: str) -> str: sanitized = re.sub(r'(ghp_|gho_|ghu_|ghs_|ghr_)[a-zA-Z0-9_]+', '***', sanitized) # Remove environment variable values that might contain tokens - sanitized = re.sub(r'(GITHUB_TOKEN|GITHUB_APM_PAT|GH_TOKEN|GITHUB_COPILOT_PAT|GITHUB_NPM_PAT)=[^\s]+', r'\1=***', sanitized) + sanitized = re.sub(r'(GITHUB_TOKEN|GITHUB_APM_PAT|GH_TOKEN|GITHUB_COPILOT_PAT)=[^\s]+', r'\1=***', sanitized) return sanitized diff --git a/src/apm_cli/runtime/copilot_runtime.py b/src/apm_cli/runtime/copilot_runtime.py index dbbfea4d..158ab8e7 100644 --- a/src/apm_cli/runtime/copilot_runtime.py +++ b/src/apm_cli/runtime/copilot_runtime.py @@ -79,8 +79,6 @@ def execute_prompt(self, prompt_content: str, **kwargs) -> str: # Check for common issues if "not logged in" in full_output.lower(): raise RuntimeError("Copilot CLI execution failed: Not logged in. Run 'copilot' and use '/login' command.") - elif "npm" in full_output and "authentication" in full_output.lower(): - raise RuntimeError("Copilot CLI execution failed: npm authentication issue. Ensure you're logged in to @github npm registry.") else: raise RuntimeError(f"Copilot CLI execution failed with exit code {return_code}") diff --git a/templates/hello-world/apm.yml b/templates/hello-world/apm.yml index f45a8571..eb95e001 100644 --- a/templates/hello-world/apm.yml +++ b/templates/hello-world/apm.yml @@ -4,7 +4,8 @@ description: {{description}} author: {{author}} scripts: - start: "RUST_LOG=debug codex --skip-git-repo-check hello-world.prompt.md" + start: "copilot --log-level all --log-dir copilot-logs --allow-all -p hello-world.prompt.md" + codex: "RUST_LOG=debug codex --skip-git-repo-check hello-world.prompt.md" llm: "llm hello-world.prompt.md -m github/gpt-4o-mini" dependencies: diff --git a/tests/integration/test_golden_scenario_e2e.py b/tests/integration/test_golden_scenario_e2e.py index b3a64cda..0a2028d9 100644 --- a/tests/integration/test_golden_scenario_e2e.py +++ b/tests/integration/test_golden_scenario_e2e.py @@ -41,8 +41,7 @@ # Token detection for test requirements (read-only) # The integration script handles all token management properly GITHUB_APM_PAT = os.environ.get('GITHUB_APM_PAT') -GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN') -GITHUB_NPM_PAT = os.environ.get('GITHUB_NPM_PAT') +GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN') # Primary token for requirement checking only (integration script handles actual usage) PRIMARY_TOKEN = GITHUB_APM_PAT or GITHUB_TOKEN diff --git a/tests/test_empty_string_and_defaults.py b/tests/test_empty_string_and_defaults.py index fed85e48..35693833 100644 --- a/tests/test_empty_string_and_defaults.py +++ b/tests/test_empty_string_and_defaults.py @@ -17,6 +17,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) from apm_cli.adapters.client.codex import CodexClientAdapter +from apm_cli.adapters.client.copilot import CopilotClientAdapter class TestEmptyStringAndDefaults: @@ -250,7 +251,7 @@ def test_codex_user_values_override_defaults(self, github_mcp_server_data): # Should use user values, not defaults env_section = server_config['env'] - assert env_section['GITHUB_TOOLSETS'] == 'custom_toolset' # User value + assert env_section['GITHUB_TOOLSETS'] == 'context' # User value assert env_section['GITHUB_DYNAMIC_TOOLSETS'] == '0' # User value def test_copilot_user_values_override_defaults(self, github_mcp_server_data): From e06fd911745f5a0f45bbedd47c89257505c4633e Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Thu, 25 Sep 2025 21:55:21 +0200 Subject: [PATCH 7/9] Update Copilot CLI commands to use --allow-all-tools instead of --full-auto --- scripts/runtime/setup-copilot.sh | 2 +- src/apm_cli/runtime/copilot_runtime.py | 4 ++-- templates/hello-world/apm.yml | 2 +- tests/unit/test_copilot_runtime.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/runtime/setup-copilot.sh b/scripts/runtime/setup-copilot.sh index f6beae59..c8b8c01d 100755 --- a/scripts/runtime/setup-copilot.sh +++ b/scripts/runtime/setup-copilot.sh @@ -213,7 +213,7 @@ setup_copilot() { log_info "GitHub Copilot CLI Features:" echo " - Interactive mode: copilot" echo " - Direct prompts: copilot -p \"your prompt\"" - echo " - Auto-approval: copilot --full-auto" + echo " - Auto-approval: copilot --allow-all-tools" echo " - Directory access: copilot --add-dir /path/to/directory" echo " - Logging: copilot --log-dir --log-level debug" } diff --git a/src/apm_cli/runtime/copilot_runtime.py b/src/apm_cli/runtime/copilot_runtime.py index 158ab8e7..46dd49b9 100644 --- a/src/apm_cli/runtime/copilot_runtime.py +++ b/src/apm_cli/runtime/copilot_runtime.py @@ -30,7 +30,7 @@ def execute_prompt(self, prompt_content: str, **kwargs) -> str: prompt_content: The prompt text to execute **kwargs: Additional arguments that may include: - full_auto: Enable automatic tool execution (default: False) - - log_level: Copilot CLI log level (default: "default") + - log_level: Copilot CLI log level (default: "default") - add_dirs: Additional directories to allow file access Returns: @@ -42,7 +42,7 @@ def execute_prompt(self, prompt_content: str, **kwargs) -> str: # Add optional arguments from kwargs if kwargs.get("full_auto", False): - cmd.append("--full-auto") + cmd.append("--allow-all-tools") log_level = kwargs.get("log_level", "default") if log_level != "default": diff --git a/templates/hello-world/apm.yml b/templates/hello-world/apm.yml index eb95e001..e132a1bd 100644 --- a/templates/hello-world/apm.yml +++ b/templates/hello-world/apm.yml @@ -4,7 +4,7 @@ description: {{description}} author: {{author}} scripts: - start: "copilot --log-level all --log-dir copilot-logs --allow-all -p hello-world.prompt.md" + start: "copilot --log-level all --log-dir copilot-logs --allow-all-tools -p hello-world.prompt.md" codex: "RUST_LOG=debug codex --skip-git-repo-check hello-world.prompt.md" llm: "llm hello-world.prompt.md -m github/gpt-4o-mini" diff --git a/tests/unit/test_copilot_runtime.py b/tests/unit/test_copilot_runtime.py index 5b06ae5c..26953c8a 100644 --- a/tests/unit/test_copilot_runtime.py +++ b/tests/unit/test_copilot_runtime.py @@ -113,7 +113,7 @@ def test_execute_prompt_with_options(self): # Verify command options were added call_args = mock_popen.call_args[0][0] - assert "--full-auto" in call_args + assert "--allow-all-tools" in call_args assert "--log-level" in call_args assert "debug" in call_args assert "--add-dir" in call_args From 0a6502a93949ea50b212de136d48bca308b934b6 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Thu, 25 Sep 2025 22:02:59 +0200 Subject: [PATCH 8/9] Bump version to 0.4.2 and update changelog for Copilot CLI support --- CHANGELOG.md | 4 ++++ pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ed6891..864151c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.2] - 2025-09-25 + +- Copilot CLI Support + ## [0.4.1] - 2025-09-18 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 73ff77ee..b050d469 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "apm-cli" -version = "0.4.1" +version = "0.4.2" description = "MCP configuration tool" readme = "README.md" requires-python = ">=3.9" From dcab2d2e6b86b4bd5b299e6e392d2d0ba76d6f5d Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Thu, 25 Sep 2025 22:14:16 +0200 Subject: [PATCH 9/9] Refactor tests for CopilotRuntime to ensure availability checks before instantiation and enhance runtime info retrieval with mocked subprocess output. --- tests/unit/test_copilot_runtime.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_copilot_runtime.py b/tests/unit/test_copilot_runtime.py index 26953c8a..bd7b4028 100644 --- a/tests/unit/test_copilot_runtime.py +++ b/tests/unit/test_copilot_runtime.py @@ -14,8 +14,9 @@ def test_get_runtime_name(self): def test_runtime_name_static(self): """Test runtime name is consistent.""" - runtime = CopilotRuntime() - assert runtime.get_runtime_name() == "copilot" + with patch.object(CopilotRuntime, 'is_available', return_value=True): + runtime = CopilotRuntime() + assert runtime.get_runtime_name() == "copilot" @patch('shutil.which') def test_is_available_true(self, mock_which): @@ -37,7 +38,15 @@ def test_initialization_without_copilot(self): def test_get_runtime_info(self): """Test getting runtime information.""" - with patch.object(CopilotRuntime, 'is_available', return_value=True): + with patch.object(CopilotRuntime, 'is_available', return_value=True), \ + patch('subprocess.run') as mock_subprocess: + + # Mock successful version check + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "copilot version 1.0.0" + mock_subprocess.return_value = mock_result + runtime = CopilotRuntime() info = runtime.get_runtime_info()