diff --git a/.changeset/patch-add-mcp-flag.md b/.changeset/patch-add-mcp-flag.md new file mode 100644 index 00000000000..de78d684cfc --- /dev/null +++ b/.changeset/patch-add-mcp-flag.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Add --mcp flag to init command for Copilot Agent MCP configuration diff --git a/docs/src/content/docs/tools/cli.md b/docs/src/content/docs/tools/cli.md index d9e07217721..c6106975a88 100644 --- a/docs/src/content/docs/tools/cli.md +++ b/docs/src/content/docs/tools/cli.md @@ -86,8 +86,19 @@ The `init` command prepares your repository for agentic workflows by configuring ```bash gh aw init +gh aw init --mcp # Configure GitHub Copilot Agent MCP integration ``` +**What it does:** +- Configures `.gitattributes` to mark `.lock.yml` files as generated +- Creates GitHub Copilot custom instructions at `.github/instructions/github-agentic-workflows.instructions.md` +- Creates the `/create-agentic-workflow` prompt at `.github/prompts/create-agentic-workflow.prompt.md` + +**With `--mcp` flag:** +- Creates `.github/workflows/copilot-setup-steps.yml` with steps to install the gh-aw extension +- Creates `.vscode/mcp.json` with gh-aw MCP server configuration +- Enables the gh-aw MCP server in GitHub Copilot Agent, providing tools like `status`, `compile`, `logs`, and `audit` + After initialization, start a chat with an AI agent and use the following prompt to create a new workflow: ``` diff --git a/docs/src/content/docs/tools/mcp-server.md b/docs/src/content/docs/tools/mcp-server.md index 37c383e26a4..9419a061966 100644 --- a/docs/src/content/docs/tools/mcp-server.md +++ b/docs/src/content/docs/tools/mcp-server.md @@ -60,6 +60,26 @@ Use the `--port` flag to run the server with HTTP/SSE transport instead of stdio gh aw mcp-server --port 8080 ``` +## Configuring with GitHub Copilot Agent + +GitHub Copilot Agent can use the gh-aw MCP server in your workflows to manage agentic workflows directly. + +**Quick Setup:** + +Use the `init` command with the `--mcp` flag to automatically configure GitHub Copilot Agent: + +```bash +gh aw init --mcp +``` + +This creates `.github/workflows/copilot-setup-steps.yml` with: +- Go environment setup +- GitHub CLI installation +- gh-aw extension installation and build from source +- Verification steps + +The workflow runs before the Copilot Agent starts, ensuring gh-aw is available as an MCP server in all agent sessions. Once configured, the agent can use tools like `status`, `compile`, `logs`, and `audit` to manage workflows in your repository. + ## Configuring with Copilot CLI The GitHub Copilot CLI can use the gh-aw MCP server to access workflow management tools. @@ -76,7 +96,21 @@ This registers the server with Copilot CLI, making workflow management tools ava VS Code can use the gh-aw MCP server through the Copilot Chat extension. -Create or update `.vscode/mcp.json` in your repository: +**Quick Setup:** + +Use the `init` command with the `--mcp` flag to automatically configure VS Code and GitHub Copilot Agent: + +```bash +gh aw init --mcp +``` + +This creates: +- `.vscode/mcp.json` with gh-aw MCP server configuration +- `.github/workflows/copilot-setup-steps.yml` with gh-aw installation steps for GitHub Copilot Agent + +**Manual Configuration:** + +Alternatively, create or update `.vscode/mcp.json` in your repository: ```json { diff --git a/pkg/cli/copilot_setup.go b/pkg/cli/copilot_setup.go new file mode 100644 index 00000000000..81b075e079e --- /dev/null +++ b/pkg/cli/copilot_setup.go @@ -0,0 +1,206 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/githubnext/gh-aw/pkg/logger" + "github.com/goccy/go-yaml" +) + +var copilotSetupLog = logger.New("cli:copilot_setup") + +const copilotSetupStepsYAML = `name: "Copilot Setup Steps" + +# This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent + copilot-setup-steps: + runs-on: ubuntu-latest + + # Set minimal permissions for setup steps + # Copilot Agent receives its own token with appropriate permissions + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Install gh-aw extension + run: gh extension install githubnext/gh-aw + env: + GH_TOKEN: ${{ github.token }} + + - name: Build gh-aw from source + run: | + echo "Building gh-aw from source for latest features..." + make build + continue-on-error: true + + - name: Verify gh-aw installation + run: | + gh aw version || ./gh-aw version +` + +// WorkflowStep represents a GitHub Actions workflow step +type WorkflowStep struct { + Name string `yaml:"name,omitempty"` + Uses string `yaml:"uses,omitempty"` + Run string `yaml:"run,omitempty"` + With map[string]any `yaml:"with,omitempty"` + Env map[string]any `yaml:"env,omitempty"` +} + +// WorkflowJob represents a GitHub Actions workflow job +type WorkflowJob struct { + RunsOn any `yaml:"runs-on,omitempty"` + Permissions map[string]any `yaml:"permissions,omitempty"` + Steps []WorkflowStep `yaml:"steps,omitempty"` +} + +// Workflow represents a GitHub Actions workflow file +type Workflow struct { + Name string `yaml:"name,omitempty"` + On any `yaml:"on,omitempty"` + Jobs map[string]WorkflowJob `yaml:"jobs,omitempty"` +} + +// ensureCopilotSetupSteps creates or updates .github/workflows/copilot-setup-steps.yml +func ensureCopilotSetupSteps(verbose bool) error { + copilotSetupLog.Print("Creating copilot-setup-steps.yml") + + // Create .github/workflows directory if it doesn't exist + workflowsDir := filepath.Join(".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + return fmt.Errorf("failed to create workflows directory: %w", err) + } + copilotSetupLog.Printf("Ensured directory exists: %s", workflowsDir) + + // Write copilot-setup-steps.yml + setupStepsPath := filepath.Join(workflowsDir, "copilot-setup-steps.yml") + + // Check if file already exists + if _, err := os.Stat(setupStepsPath); err == nil { + copilotSetupLog.Printf("File already exists: %s", setupStepsPath) + + // Read existing file to check if extension install step exists + content, err := os.ReadFile(setupStepsPath) + if err != nil { + return fmt.Errorf("failed to read existing copilot-setup-steps.yml: %w", err) + } + + // Check if the extension install step is already present (quick check) + contentStr := string(content) + if strings.Contains(contentStr, "gh extension install githubnext/gh-aw") || + strings.Contains(contentStr, "Install gh-aw extension") { + copilotSetupLog.Print("Extension install step already exists, skipping update") + if verbose { + fmt.Fprintf(os.Stderr, "Skipping %s (already has gh-aw extension install step)\n", setupStepsPath) + } + return nil + } + + // Parse existing workflow + var workflow Workflow + if err := yaml.Unmarshal(content, &workflow); err != nil { + return fmt.Errorf("failed to parse existing copilot-setup-steps.yml: %w", err) + } + + // Inject the extension install step + copilotSetupLog.Print("Injecting extension install step into existing file") + if err := injectExtensionInstallStep(&workflow); err != nil { + return fmt.Errorf("failed to inject extension install step: %w", err) + } + + // Marshal back to YAML + updatedContent, err := yaml.Marshal(&workflow) + if err != nil { + return fmt.Errorf("failed to marshal updated workflow: %w", err) + } + + if err := os.WriteFile(setupStepsPath, updatedContent, 0644); err != nil { + return fmt.Errorf("failed to update copilot-setup-steps.yml: %w", err) + } + copilotSetupLog.Printf("Updated file with extension install step: %s", setupStepsPath) + + if verbose { + fmt.Fprintf(os.Stderr, "Updated %s with gh-aw extension install step\n", setupStepsPath) + } + return nil + } + + if err := os.WriteFile(setupStepsPath, []byte(copilotSetupStepsYAML), 0644); err != nil { + return fmt.Errorf("failed to write copilot-setup-steps.yml: %w", err) + } + copilotSetupLog.Printf("Created file: %s", setupStepsPath) + + return nil +} + +// injectExtensionInstallStep injects the gh-aw extension install step into an existing workflow +func injectExtensionInstallStep(workflow *Workflow) error { + // Define the extension install step to inject + extensionStep := WorkflowStep{ + Name: "Install gh-aw extension", + Run: "gh extension install githubnext/gh-aw", + Env: map[string]any{ + "GH_TOKEN": "${{ github.token }}", + }, + } + + // Find the copilot-setup-steps job + job, exists := workflow.Jobs["copilot-setup-steps"] + if !exists { + return fmt.Errorf("copilot-setup-steps job not found in workflow") + } + + // Find the position to insert the step (after "Set up Go" or after "Checkout code") + insertPosition := -1 + for i, step := range job.Steps { + if strings.Contains(step.Name, "Set up Go") { + insertPosition = i + 1 + break + } + } + + // If Set up Go not found, try after Checkout + if insertPosition == -1 { + for i, step := range job.Steps { + if strings.Contains(step.Name, "Checkout") || strings.Contains(step.Uses, "checkout@") { + insertPosition = i + 1 + break + } + } + } + + // If still not found, append at the end + if insertPosition == -1 { + insertPosition = len(job.Steps) + } + + // Insert the step at the determined position + newSteps := make([]WorkflowStep, 0, len(job.Steps)+1) + newSteps = append(newSteps, job.Steps[:insertPosition]...) + newSteps = append(newSteps, extensionStep) + newSteps = append(newSteps, job.Steps[insertPosition:]...) + + job.Steps = newSteps + workflow.Jobs["copilot-setup-steps"] = job + + return nil +} diff --git a/pkg/cli/init.go b/pkg/cli/init.go index fb6a514fe82..ae81b32cf0c 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -12,7 +12,7 @@ import ( var initLog = logger.New("cli:init") // InitRepository initializes the repository for agentic workflows -func InitRepository(verbose bool) error { +func InitRepository(verbose bool, mcp bool) error { initLog.Print("Starting repository initialization for agentic workflows") // Ensure we're in a git repository @@ -72,12 +72,39 @@ func InitRepository(verbose bool) error { fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Created getting started guide")) } + // Configure MCP if requested + if mcp { + initLog.Print("Configuring GitHub Copilot Agent MCP integration") + + // Create copilot-setup-steps.yml + if err := ensureCopilotSetupSteps(verbose); err != nil { + initLog.Printf("Failed to create copilot-setup-steps.yml: %v", err) + return fmt.Errorf("failed to create copilot-setup-steps.yml: %w", err) + } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Created .github/workflows/copilot-setup-steps.yml")) + } + + // Create .vscode/mcp.json + if err := ensureMCPConfig(verbose); err != nil { + initLog.Printf("Failed to create MCP config: %v", err) + return fmt.Errorf("failed to create MCP config: %w", err) + } + if verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Created .vscode/mcp.json")) + } + } + initLog.Print("Repository initialization completed successfully") // Display success message with next steps fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("✓ Repository initialized for agentic workflows!")) fmt.Fprintln(os.Stderr, "") + if mcp { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("✓ GitHub Copilot Agent MCP integration configured")) + fmt.Fprintln(os.Stderr, "") + } fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Start a chat and copy the following prompt to create a new workflow:")) fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, " activate @.github/prompts/create-agentic-workflow.prompt.md") diff --git a/pkg/cli/init_command.go b/pkg/cli/init_command.go index 0e6d2d2dc3b..5422f51ff48 100644 --- a/pkg/cli/init_command.go +++ b/pkg/cli/init_command.go @@ -11,7 +11,7 @@ import ( // NewInitCommand creates the init command func NewInitCommand() *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "init", Short: "Initialize repository for agentic workflows", Long: `Initialize the repository for agentic workflows by configuring .gitattributes and creating GitHub Copilot instruction files. @@ -21,6 +21,10 @@ This command: - Creates GitHub Copilot custom instructions at .github/instructions/github-agentic-workflows.instructions.md - Creates the /create-agentic-workflow prompt at .github/prompts/create-agentic-workflow.prompt.md +With --mcp flag: +- Creates .github/workflows/copilot-setup-steps.yml with gh-aw installation steps +- Creates .vscode/mcp.json with gh-aw MCP server configuration + After running this command, you can: - Use GitHub Copilot Chat with /create-agentic-workflow to create workflows interactively - Add workflows from the catalog with: ` + constants.CLIExtensionPrefix + ` add @@ -28,13 +32,19 @@ After running this command, you can: Examples: ` + constants.CLIExtensionPrefix + ` init - ` + constants.CLIExtensionPrefix + ` init -v`, + ` + constants.CLIExtensionPrefix + ` init -v + ` + constants.CLIExtensionPrefix + ` init --mcp`, Run: func(cmd *cobra.Command, args []string) { verbose, _ := cmd.Flags().GetBool("verbose") - if err := InitRepository(verbose); err != nil { + mcp, _ := cmd.Flags().GetBool("mcp") + if err := InitRepository(verbose, mcp); err != nil { fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) os.Exit(1) } }, } + + cmd.Flags().Bool("mcp", false, "Configure GitHub Copilot Agent MCP server integration") + + return cmd } diff --git a/pkg/cli/init_mcp_test.go b/pkg/cli/init_mcp_test.go new file mode 100644 index 00000000000..24d15b5d59b --- /dev/null +++ b/pkg/cli/init_mcp_test.go @@ -0,0 +1,357 @@ +package cli + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestInitRepository_WithMCP(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Change to temp directory + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(oldWd) + }() + err = os.Chdir(tempDir) + if err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Initialize git repo + if err := exec.Command("git", "init").Run(); err != nil { + t.Fatalf("Failed to init git repo: %v", err) + } + + // Create go.mod for the copilot-setup-steps.yml to reference + goModContent := []byte("module github.com/test/repo\n\ngo 1.23\n") + if err := os.WriteFile("go.mod", goModContent, 0644); err != nil { + t.Fatalf("Failed to create go.mod: %v", err) + } + + // Call the function with MCP flag + err = InitRepository(false, true) + if err != nil { + t.Fatalf("InitRepository() with MCP returned error: %v", err) + } + + // Verify standard files were created + gitAttributesPath := filepath.Join(tempDir, ".gitattributes") + if _, err := os.Stat(gitAttributesPath); os.IsNotExist(err) { + t.Errorf("Expected .gitattributes file to exist") + } + + // Verify copilot-setup-steps.yml was created + setupStepsPath := filepath.Join(tempDir, ".github", "workflows", "copilot-setup-steps.yml") + if _, err := os.Stat(setupStepsPath); os.IsNotExist(err) { + t.Errorf("Expected copilot-setup-steps.yml to exist") + } else { + // Verify content contains key elements + content, err := os.ReadFile(setupStepsPath) + if err != nil { + t.Fatalf("Failed to read copilot-setup-steps.yml: %v", err) + } + contentStr := string(content) + + if !strings.Contains(contentStr, "name: \"Copilot Setup Steps\"") { + t.Errorf("Expected copilot-setup-steps.yml to contain workflow name") + } + if !strings.Contains(contentStr, "copilot-setup-steps:") { + t.Errorf("Expected copilot-setup-steps.yml to contain job name") + } + if !strings.Contains(contentStr, "gh extension install githubnext/gh-aw") { + t.Errorf("Expected copilot-setup-steps.yml to contain gh-aw installation steps") + } + } + + // Verify .vscode/mcp.json was created + mcpConfigPath := filepath.Join(tempDir, ".vscode", "mcp.json") + if _, err := os.Stat(mcpConfigPath); os.IsNotExist(err) { + t.Errorf("Expected .vscode/mcp.json to exist") + } else { + // Verify content is valid JSON with gh-aw server + content, err := os.ReadFile(mcpConfigPath) + if err != nil { + t.Fatalf("Failed to read .vscode/mcp.json: %v", err) + } + + var config MCPConfig + if err := json.Unmarshal(content, &config); err != nil { + t.Fatalf("Failed to parse .vscode/mcp.json: %v", err) + } + + if _, exists := config.Servers["github-agentic-workflows"]; !exists { + t.Errorf("Expected .vscode/mcp.json to contain github-agentic-workflows server") + } + + server := config.Servers["github-agentic-workflows"] + if server.Command != "gh" { + t.Errorf("Expected command to be 'gh', got %s", server.Command) + } + if len(server.Args) != 2 || server.Args[0] != "aw" || server.Args[1] != "mcp-server" { + t.Errorf("Expected args to be ['aw', 'mcp-server'], got %v", server.Args) + } + } +} + +func TestInitRepository_MCP_Idempotent(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Change to temp directory + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(oldWd) + }() + err = os.Chdir(tempDir) + if err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Initialize git repo + if err := exec.Command("git", "init").Run(); err != nil { + t.Fatalf("Failed to init git repo: %v", err) + } + + // Create go.mod + goModContent := []byte("module github.com/test/repo\n\ngo 1.23\n") + if err := os.WriteFile("go.mod", goModContent, 0644); err != nil { + t.Fatalf("Failed to create go.mod: %v", err) + } + + // Call the function first time with MCP + err = InitRepository(false, true) + if err != nil { + t.Fatalf("InitRepository() with MCP returned error on first call: %v", err) + } + + // Call the function second time with MCP + err = InitRepository(false, true) + if err != nil { + t.Fatalf("InitRepository() with MCP returned error on second call: %v", err) + } + + // Verify files still exist + setupStepsPath := filepath.Join(tempDir, ".github", "workflows", "copilot-setup-steps.yml") + if _, err := os.Stat(setupStepsPath); os.IsNotExist(err) { + t.Errorf("Expected copilot-setup-steps.yml to exist after second call") + } + + mcpConfigPath := filepath.Join(tempDir, ".vscode", "mcp.json") + if _, err := os.Stat(mcpConfigPath); os.IsNotExist(err) { + t.Errorf("Expected .vscode/mcp.json to exist after second call") + } +} + +func TestEnsureMCPConfig_UpdatesExisting(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Change to temp directory + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(oldWd) + }() + err = os.Chdir(tempDir) + if err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Create .vscode directory + if err := os.MkdirAll(".vscode", 0755); err != nil { + t.Fatalf("Failed to create .vscode directory: %v", err) + } + + // Create initial mcp.json with a different server + initialConfig := MCPConfig{ + Servers: map[string]MCPServerConfig{ + "other-server": { + Command: "other-command", + Args: []string{"arg1"}, + }, + }, + } + initialData, _ := json.MarshalIndent(initialConfig, "", " ") + mcpConfigPath := filepath.Join(tempDir, ".vscode", "mcp.json") + if err := os.WriteFile(mcpConfigPath, initialData, 0644); err != nil { + t.Fatalf("Failed to write initial mcp.json: %v", err) + } + + // Call ensureMCPConfig + if err := ensureMCPConfig(false); err != nil { + t.Fatalf("ensureMCPConfig() returned error: %v", err) + } + + // Verify the config now contains both servers + content, err := os.ReadFile(mcpConfigPath) + if err != nil { + t.Fatalf("Failed to read mcp.json: %v", err) + } + + var config MCPConfig + if err := json.Unmarshal(content, &config); err != nil { + t.Fatalf("Failed to parse mcp.json: %v", err) + } + + // Check both servers exist + if _, exists := config.Servers["other-server"]; !exists { + t.Errorf("Expected existing 'other-server' to be preserved") + } + + if _, exists := config.Servers["github-agentic-workflows"]; !exists { + t.Errorf("Expected 'github-agentic-workflows' server to be added") + } +} + +func TestEnsureCopilotSetupSteps_InjectsStep(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Change to temp directory + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(oldWd) + }() + err = os.Chdir(tempDir) + if err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Create .github/workflows directory + workflowsDir := filepath.Join(".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + // Create custom copilot-setup-steps.yml without extension install step + setupStepsPath := filepath.Join(workflowsDir, "copilot-setup-steps.yml") + customContent := `name: "Copilot Setup Steps" + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build code + run: make build +` + if err := os.WriteFile(setupStepsPath, []byte(customContent), 0644); err != nil { + t.Fatalf("Failed to write custom setup steps: %v", err) + } + + // Call ensureCopilotSetupSteps + if err := ensureCopilotSetupSteps(false); err != nil { + t.Fatalf("ensureCopilotSetupSteps() returned error: %v", err) + } + + // Verify the extension install step was injected + content, err := os.ReadFile(setupStepsPath) + if err != nil { + t.Fatalf("Failed to read setup steps file: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "Install gh-aw extension") { + t.Errorf("Expected extension install step to be injected") + } + if !strings.Contains(contentStr, "gh extension install githubnext/gh-aw") { + t.Errorf("Expected extension install command to be present") + } + + // Verify it was injected after Set up Go step + goIndex := strings.Index(contentStr, "Set up Go") + extensionIndex := strings.Index(contentStr, "Install gh-aw extension") + buildIndex := strings.Index(contentStr, "Build code") + + if goIndex == -1 || extensionIndex == -1 || buildIndex == -1 { + t.Fatalf("Could not find expected steps in file") + } + + if !(goIndex < extensionIndex && extensionIndex < buildIndex) { + t.Errorf("Extension install step not in correct position (should be after Go setup, before Build)") + } +} + +func TestEnsureCopilotSetupSteps_SkipsWhenStepExists(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Change to temp directory + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + _ = os.Chdir(oldWd) + }() + err = os.Chdir(tempDir) + if err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Create .github/workflows directory + workflowsDir := filepath.Join(".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + // Create copilot-setup-steps.yml that already has the extension install step + setupStepsPath := filepath.Join(workflowsDir, "copilot-setup-steps.yml") + customContent := `name: "Copilot Setup Steps" + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Install gh-aw extension + run: gh extension install githubnext/gh-aw + + - name: Build code + run: make build +` + if err := os.WriteFile(setupStepsPath, []byte(customContent), 0644); err != nil { + t.Fatalf("Failed to write custom setup steps: %v", err) + } + + // Call ensureCopilotSetupSteps + if err := ensureCopilotSetupSteps(false); err != nil { + t.Fatalf("ensureCopilotSetupSteps() returned error: %v", err) + } + + // Verify the file was not modified (content should be the same) + content, err := os.ReadFile(setupStepsPath) + if err != nil { + t.Fatalf("Failed to read setup steps file: %v", err) + } + + if string(content) != customContent { + t.Errorf("Expected file to remain unchanged when extension step already exists") + } +} diff --git a/pkg/cli/init_test.go b/pkg/cli/init_test.go index 6630f7d6acc..1c8a1a5420f 100644 --- a/pkg/cli/init_test.go +++ b/pkg/cli/init_test.go @@ -52,7 +52,7 @@ func TestInitRepository(t *testing.T) { } // Call the function - err = InitRepository(false) + err = InitRepository(false, false) // Check error expectation if tt.wantError { @@ -119,13 +119,13 @@ func TestInitRepository_Idempotent(t *testing.T) { } // Call the function first time - err = InitRepository(false) + err = InitRepository(false, false) if err != nil { t.Fatalf("InitRepository() returned error on first call: %v", err) } // Call the function second time - err = InitRepository(false) + err = InitRepository(false, false) if err != nil { t.Fatalf("InitRepository() returned error on second call: %v", err) } @@ -170,7 +170,7 @@ func TestInitRepository_Verbose(t *testing.T) { } // Call the function with verbose=true (should not error) - err = InitRepository(true) + err = InitRepository(true, false) if err != nil { t.Fatalf("InitRepository() returned error with verbose=true: %v", err) } diff --git a/pkg/cli/mcp_config_file.go b/pkg/cli/mcp_config_file.go new file mode 100644 index 00000000000..14b5a2c6594 --- /dev/null +++ b/pkg/cli/mcp_config_file.go @@ -0,0 +1,89 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/githubnext/gh-aw/pkg/logger" +) + +var mcpConfigLog = logger.New("cli:mcp_config_file") + +// MCPServerConfig represents a single MCP server configuration +type MCPServerConfig struct { + Command string `json:"command"` + Args []string `json:"args"` + CWD string `json:"cwd,omitempty"` +} + +// MCPConfig represents the structure of mcp.json +type MCPConfig struct { + Servers map[string]MCPServerConfig `json:"servers"` +} + +// ensureMCPConfig creates or updates .vscode/mcp.json with gh-aw MCP server configuration +func ensureMCPConfig(verbose bool) error { + mcpConfigLog.Print("Creating or updating .vscode/mcp.json") + + // Create .vscode directory if it doesn't exist + vscodeDir := ".vscode" + if err := os.MkdirAll(vscodeDir, 0755); err != nil { + return fmt.Errorf("failed to create .vscode directory: %w", err) + } + mcpConfigLog.Printf("Ensured directory exists: %s", vscodeDir) + + mcpConfigPath := filepath.Join(vscodeDir, "mcp.json") + + // Read existing config if it exists + var config MCPConfig + if data, err := os.ReadFile(mcpConfigPath); err == nil { + mcpConfigLog.Printf("Reading existing config from: %s", mcpConfigPath) + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse existing mcp.json: %w", err) + } + } else { + mcpConfigLog.Print("No existing config found, creating new one") + config.Servers = make(map[string]MCPServerConfig) + } + + // Add or update gh-aw MCP server configuration + ghAwServerName := "github-agentic-workflows" + ghAwConfig := MCPServerConfig{ + Command: "gh", + Args: []string{"aw", "mcp-server"}, + CWD: "${workspaceFolder}", + } + + // Check if the server is already configured + if existingConfig, exists := config.Servers[ghAwServerName]; exists { + mcpConfigLog.Printf("Server '%s' already exists in config", ghAwServerName) + // Check if configuration is different + existingJSON, _ := json.Marshal(existingConfig) + newJSON, _ := json.Marshal(ghAwConfig) + if string(existingJSON) == string(newJSON) { + mcpConfigLog.Print("Configuration is identical, skipping update") + if verbose { + fmt.Fprintf(os.Stderr, "MCP server '%s' already configured in %s\n", ghAwServerName, mcpConfigPath) + } + return nil + } + mcpConfigLog.Print("Configuration differs, updating") + } + + config.Servers[ghAwServerName] = ghAwConfig + + // Write config file with proper indentation + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal mcp.json: %w", err) + } + + if err := os.WriteFile(mcpConfigPath, data, 0644); err != nil { + return fmt.Errorf("failed to write mcp.json: %w", err) + } + mcpConfigLog.Printf("Wrote config to: %s", mcpConfigPath) + + return nil +}