diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md new file mode 100644 index 000000000..c6e8dea26 --- /dev/null +++ b/registry/coder-labs/modules/copilot/README.md @@ -0,0 +1,210 @@ +--- +display_name: Copilot +description: GitHub Copilot CLI agent for AI-powered terminal assistance +icon: ../../../../.icons/github.svg +verified: false +tags: [agent, copilot, ai, github, tasks] +--- + +# Copilot + +Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-copilot-cli) in your workspace for AI-powered coding assistance directly from the terminal. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for task reporting in the Coder UI. + +```tf +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "0.1.0" + agent_id = coder_agent.example.id + workdir = "/home/coder/projects" +} +``` + +> [!IMPORTANT] +> This example assumes you have [Coder external authentication](https://coder.com/docs/admin/external-auth) configured with `id = "github"`. If not, you can provide a direct token using the `github_token` variable or provide the correct external authentication id for GitHub by setting `external_auth_id = "my-github"`. + +> [!NOTE] +> By default, this module is configured to run the embedded chat interface as a path-based application. In production, we recommend that you configure a [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) and set `subdomain = true`. See [here](https://coder.com/docs/tutorials/best-practices/security-best-practices#disable-path-based-apps) for more details. + +## Prerequisites + +- **Node.js v22+** and **npm v10+** +- **[Active Copilot subscription](https://docs.github.com/en/copilot/about-github-copilot/subscription-plans-for-github-copilot)** (GitHub Copilot Pro, Pro+, Business, or Enterprise) +- **GitHub authentication** via one of: + - [Coder external authentication](https://coder.com/docs/admin/external-auth) (recommended) + - Direct token via `github_token` variable + - Interactive login in Copilot + +## Examples + +### Usage with Tasks + +For development environments where you want Copilot to have full access to tools and automatically resume sessions: + +```tf +data "coder_parameter" "ai_prompt" { + type = "string" + name = "AI Prompt" + default = "" + description = "Initial task prompt for Copilot." + mutable = true +} + +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "0.1.0" + agent_id = coder_agent.example.id + workdir = "/home/coder/projects" + + ai_prompt = data.coder_parameter.ai_prompt.value + copilot_model = "claude-sonnet-4.5" + allow_all_tools = true + resume_session = true + + trusted_directories = ["/home/coder/projects", "/tmp"] +} +``` + +### Advanced Configuration + +Customize tool permissions, MCP servers, and Copilot settings: + +```tf +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "0.1.0" + agent_id = coder_agent.example.id + workdir = "/home/coder/projects" + + # Version pinning (defaults to "0.0.334", use "latest" for newest version) + copilot_version = "latest" + + # Tool permissions + allow_tools = ["shell(git)", "shell(npm)", "write"] + trusted_directories = ["/home/coder/projects", "/tmp"] + + # Custom Copilot configuration + copilot_config = jsonencode({ + banner = "never" + theme = "dark" + }) + + # MCP server configuration + mcp_config = jsonencode({ + mcpServers = { + filesystem = { + command = "npx" + args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/coder/projects"] + description = "Provides file system access to the workspace" + name = "Filesystem" + timeout = 3000 + type = "local" + tools = ["*"] + trust = true + } + playwright = { + command = "npx" + args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated"] + description = "Browser automation for testing and previewing changes" + name = "Playwright" + timeout = 5000 + type = "local" + tools = ["*"] + trust = false + } + } + }) + + # Pre-install Node.js if needed + pre_install_script = <<-EOT + #!/bin/bash + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y nodejs + EOT +} +``` + +> [!NOTE] +> GitHub Copilot CLI does not automatically install MCP servers. You have two options: +> +> - Use `npx -y` in the MCP config (shown above) to auto-install on each run +> - Pre-install MCP servers in `pre_install_script` for faster startup (e.g., `npm install -g @modelcontextprotocol/server-filesystem`) + +### Direct Token Authentication + +Use this example when you want to provide a GitHub Personal Access Token instead of using Coder external auth: + +```tf +variable "github_token" { + type = string + description = "GitHub Personal Access Token" + sensitive = true +} + +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "0.1.0" + agent_id = coder_agent.example.id + workdir = "/home/coder/projects" + github_token = var.github_token +} +``` + +### Standalone Mode + +Run Copilot as a command-line tool without task reporting or web interface. This installs and configures Copilot, making it available as a CLI app in the Coder agent bar that you can launch to interact with Copilot directly from your terminal. Set `report_tasks = false` to disable integration with Coder Tasks. + +```tf +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "0.1.0" + agent_id = coder_agent.example.id + workdir = "/home/coder" + report_tasks = false + cli_app = true +} +``` + +## Authentication + +The module supports multiple authentication methods (in priority order): + +1. **[Coder External Auth](https://coder.com/docs/admin/external-auth) (Recommended)** - Automatic if GitHub external auth is configured in Coder +2. **Direct Token** - Pass `github_token` variable (OAuth or Personal Access Token) +3. **Interactive** - Copilot prompts for login via `/login` command if no auth found + +> [!NOTE] +> OAuth tokens work best with Copilot. Personal Access Tokens may have limited functionality. + +## Session Resumption + +By default, the module resumes the latest Copilot session when the workspace restarts. Set `resume_session = false` to always start fresh sessions. + +> [!NOTE] +> Session resumption requires persistent storage for the home directory or workspace volume. Without persistent storage, sessions will not resume across workspace restarts. + +## Troubleshooting + +If you encounter any issues, check the log files in the `~/.copilot-module` directory within your workspace for detailed information. + +```bash +# Installation logs +cat ~/.copilot-module/install.log + +# Startup logs +cat ~/.copilot-module/agentapi-start.log + +# Pre/post install script logs +cat ~/.copilot-module/pre_install.log +cat ~/.copilot-module/post_install.log +``` + +> [!NOTE] +> To use tasks with Copilot, you must have an active GitHub Copilot subscription. +> The `workdir` variable is required and specifies the directory where Copilot will run. + +## References + +- [GitHub Copilot CLI Documentation](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) +- [Installing GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli) +- [AgentAPI Documentation](https://github.com/coder/agentapi) +- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) diff --git a/registry/coder-labs/modules/copilot/copilot.tftest.hcl b/registry/coder-labs/modules/copilot/copilot.tftest.hcl new file mode 100644 index 000000000..185c019ba --- /dev/null +++ b/registry/coder-labs/modules/copilot/copilot.tftest.hcl @@ -0,0 +1,236 @@ +run "defaults_are_correct" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = var.copilot_model == "claude-sonnet-4.5" + error_message = "Default model should be 'claude-sonnet-4.5'" + } + + assert { + condition = var.report_tasks == true + error_message = "Task reporting should be enabled by default" + } + + assert { + condition = var.resume_session == true + error_message = "Session resumption should be enabled by default" + } + + assert { + condition = var.allow_all_tools == false + error_message = "allow_all_tools should be disabled by default" + } + + assert { + condition = resource.coder_env.mcp_app_status_slug.name == "CODER_MCP_APP_STATUS_SLUG" + error_message = "Status slug env var should be created" + } + + assert { + condition = resource.coder_env.mcp_app_status_slug.value == "copilot" + error_message = "Status slug value should be 'copilot'" + } +} + +run "github_token_creates_env_var" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + github_token = "test_github_token_abc123" + } + + assert { + condition = length(resource.coder_env.github_token) == 1 + error_message = "github_token env var should be created when token is provided" + } + + assert { + condition = resource.coder_env.github_token[0].name == "GITHUB_TOKEN" + error_message = "github_token env var name should be 'GITHUB_TOKEN'" + } + + assert { + condition = resource.coder_env.github_token[0].value == "test_github_token_abc123" + error_message = "github_token env var value should match input" + } +} + +run "github_token_not_created_when_empty" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + github_token = "" + } + + assert { + condition = length(resource.coder_env.github_token) == 0 + error_message = "github_token env var should not be created when empty" + } +} + +run "copilot_model_env_var_for_non_default" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + copilot_model = "claude-sonnet-4" + } + + assert { + condition = length(resource.coder_env.copilot_model) == 1 + error_message = "copilot_model env var should be created for non-default model" + } + + assert { + condition = resource.coder_env.copilot_model[0].name == "COPILOT_MODEL" + error_message = "copilot_model env var name should be 'COPILOT_MODEL'" + } + + assert { + condition = resource.coder_env.copilot_model[0].value == "claude-sonnet-4" + error_message = "copilot_model env var value should match input" + } +} + +run "copilot_model_not_created_for_default" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + copilot_model = "claude-sonnet-4.5" + } + + assert { + condition = length(resource.coder_env.copilot_model) == 0 + error_message = "copilot_model env var should not be created for default model" + } +} + +run "model_validation_accepts_valid_models" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + copilot_model = "gpt-5" + } + + assert { + condition = contains(["claude-sonnet-4", "claude-sonnet-4.5", "gpt-5"], var.copilot_model) + error_message = "Model should be one of the valid options" + } +} + +run "copilot_config_merges_with_trusted_directories" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project" + trusted_directories = ["/workspace", "/data"] + } + + assert { + condition = length(local.final_copilot_config) > 0 + error_message = "final_copilot_config should be computed" + } + + # Verify workdir is trimmed of trailing slash + assert { + condition = local.workdir == "/home/coder/project" + error_message = "workdir should be trimmed of trailing slash" + } +} + +run "custom_copilot_config_overrides_default" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + copilot_config = jsonencode({ + banner = "always" + theme = "dark" + }) + } + + assert { + condition = var.copilot_config != "" + error_message = "Custom copilot config should be set" + } + + assert { + condition = jsondecode(local.final_copilot_config).banner == "always" + error_message = "Custom banner setting should be applied" + } + + assert { + condition = jsondecode(local.final_copilot_config).theme == "dark" + error_message = "Custom theme setting should be applied" + } +} + +run "trusted_directories_merged_with_custom_config" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project" + copilot_config = jsonencode({ + banner = "always" + theme = "dark" + trusted_folders = ["/custom"] + }) + trusted_directories = ["/workspace", "/data"] + } + + assert { + condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/custom") + error_message = "Custom trusted folder should be included" + } + + assert { + condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/home/coder/project") + error_message = "Workdir should be included in trusted folders" + } + + assert { + condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/workspace") + error_message = "trusted_directories should be merged into config" + } + + assert { + condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/data") + error_message = "All trusted_directories should be merged into config" + } +} + +run "app_slug_is_consistent" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = local.app_slug == "copilot" + error_message = "app_slug should be 'copilot'" + } + + assert { + condition = local.module_dir_name == ".copilot-module" + error_message = "module_dir_name should be '.copilot-module'" + } +} diff --git a/registry/coder-labs/modules/copilot/main.test.ts b/registry/coder-labs/modules/copilot/main.test.ts new file mode 100644 index 000000000..1d438e33b --- /dev/null +++ b/registry/coder-labs/modules/copilot/main.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "bun:test"; +import { + findResourceInstance, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("copilot", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + }); + + it("creates mcp_app_status_slug env var", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + }); + + const statusSlugEnv = findResourceInstance( + state, + "coder_env", + "mcp_app_status_slug", + ); + expect(statusSlugEnv).toBeDefined(); + expect(statusSlugEnv.name).toBe("CODER_MCP_APP_STATUS_SLUG"); + expect(statusSlugEnv.value).toBe("copilot"); + }); + + it("creates github_token env var with correct value", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + github_token: "test_token_12345", + }); + + const githubTokenEnv = findResourceInstance( + state, + "coder_env", + "github_token", + ); + expect(githubTokenEnv).toBeDefined(); + expect(githubTokenEnv.name).toBe("GITHUB_TOKEN"); + expect(githubTokenEnv.value).toBe("test_token_12345"); + }); + + it("does not create github_token env var when empty", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + github_token: "", + }); + + const githubTokenEnvs = state.resources.filter( + (r) => r.type === "coder_env" && r.name === "github_token", + ); + expect(githubTokenEnvs.length).toBe(0); + }); + + it("creates copilot_model env var for non-default models", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + copilot_model: "claude-sonnet-4", + }); + + const modelEnv = findResourceInstance(state, "coder_env", "copilot_model"); + expect(modelEnv).toBeDefined(); + expect(modelEnv.name).toBe("COPILOT_MODEL"); + expect(modelEnv.value).toBe("claude-sonnet-4"); + }); + + it("does not create copilot_model env var for default model", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + copilot_model: "claude-sonnet-4.5", + }); + + const modelEnvs = state.resources.filter( + (r) => r.type === "coder_env" && r.name === "copilot_model", + ); + expect(modelEnvs.length).toBe(0); + }); + + it("creates coder_script resources via agentapi module", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + }); + + // The agentapi module should create coder_script resources for install and start + const scripts = state.resources.filter((r) => r.type === "coder_script"); + expect(scripts.length).toBeGreaterThan(0); + }); + + it("validates copilot_model accepts valid values", async () => { + // Test valid models don't throw errors + await expect( + runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + copilot_model: "gpt-5", + }), + ).resolves.toBeDefined(); + + await expect( + runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder", + copilot_model: "claude-sonnet-4.5", + }), + ).resolves.toBeDefined(); + }); + + it("merges trusted_directories with custom copilot_config", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + workdir: "/home/coder/project", + trusted_directories: JSON.stringify(["/workspace", "/data"]), + copilot_config: JSON.stringify({ + banner: "always", + theme: "dark", + trusted_folders: ["/custom"], + }), + }); + + // Verify that the state was created successfully with the merged config + // The actual merging logic is tested in the .tftest.hcl file + expect(state).toBeDefined(); + expect(state.resources).toBeDefined(); + }); +}); diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf new file mode 100644 index 000000000..fd93b048f --- /dev/null +++ b/registry/coder-labs/modules/copilot/main.tf @@ -0,0 +1,300 @@ +terraform { + required_version = ">= 1.0" + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.7" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "workdir" { + type = string + description = "The folder to run Copilot in." +} + +variable "external_auth_id" { + type = string + description = "ID of the GitHub external auth provider configured in Coder." + default = "github" +} + +variable "github_token" { + type = string + description = "GitHub OAuth token or Personal Access Token. If provided, this will be used instead of auto-detecting authentication." + default = "" + sensitive = true +} + +variable "copilot_model" { + type = string + description = "Model to use. Supported values: claude-sonnet-4, claude-sonnet-4.5 (default), gpt-5." + default = "claude-sonnet-4.5" + validation { + condition = contains(["claude-sonnet-4", "claude-sonnet-4.5", "gpt-5"], var.copilot_model) + error_message = "copilot_model must be one of: claude-sonnet-4, claude-sonnet-4.5, gpt-5." + } +} + +variable "copilot_config" { + type = string + description = "Custom Copilot configuration as JSON string. Leave empty to use default configuration with banner disabled, theme set to auto, and workdir as trusted folder." + default = "" +} + +variable "ai_prompt" { + type = string + description = "Initial task prompt for programmatic mode." + default = "" +} + +variable "system_prompt" { + type = string + description = "The system prompt to use for the Copilot server. Task reporting instructions are automatically added when report_tasks is enabled." + default = "You are a helpful coding assistant that helps developers write, debug, and understand code. Provide clear explanations, follow best practices, and help solve coding problems efficiently." +} + +variable "trusted_directories" { + type = list(string) + description = "Additional directories to trust for Copilot operations." + default = [] +} + +variable "allow_all_tools" { + type = bool + description = "Allow all tools without prompting (equivalent to --allow-all-tools)." + default = false +} + +variable "allow_tools" { + type = list(string) + description = "Specific tools to allow: shell(command), write, or MCP_SERVER_NAME." + default = [] +} + +variable "deny_tools" { + type = list(string) + description = "Specific tools to deny: shell(command), write, or MCP_SERVER_NAME." + default = [] +} + +variable "mcp_config" { + type = string + description = "Custom MCP server configuration as JSON string." + default = "" +} + +variable "install_agentapi" { + type = bool + description = "Whether to install AgentAPI." + default = true +} + +variable "agentapi_version" { + type = string + description = "The version of AgentAPI to install." + default = "v0.10.0" +} + +variable "copilot_version" { + type = string + description = "The version of GitHub Copilot CLI to install. Use 'latest' for the latest version or specify a version like '0.0.334'." + default = "0.0.334" +} + +variable "report_tasks" { + type = bool + description = "Whether to enable task reporting to Coder UI via AgentAPI." + default = true +} + +variable "subdomain" { + type = bool + description = "Whether to use a subdomain for AgentAPI." + default = false +} + +variable "order" { + type = number + description = "The order determines the position of app in the UI presentation." + default = null +} + +variable "group" { + type = string + description = "The name of a group that this app belongs to." + default = null +} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/github.svg" +} + +variable "web_app_display_name" { + type = string + description = "Display name for the web app." + default = "Copilot" +} + +variable "cli_app" { + type = bool + description = "Whether to create a CLI app for Copilot." + default = false +} + +variable "cli_app_display_name" { + type = string + description = "Display name for the CLI app." + default = "Copilot" +} + +variable "resume_session" { + type = bool + description = "Whether to automatically resume the latest Copilot session on workspace restart." + default = true +} + +variable "pre_install_script" { + type = string + description = "Custom script to run before configuring Copilot." + default = null +} + +variable "post_install_script" { + type = string + description = "Custom script to run after configuring Copilot." + default = null +} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +locals { + workdir = trimsuffix(var.workdir, "/") + app_slug = "copilot" + install_script = file("${path.module}/scripts/install.sh") + start_script = file("${path.module}/scripts/start.sh") + module_dir_name = ".copilot-module" + + all_trusted_folders = concat([local.workdir], var.trusted_directories) + + parsed_custom_config = try(jsondecode(var.copilot_config), {}) + + existing_trusted_folders = try(local.parsed_custom_config.trusted_folders, []) + + merged_copilot_config = merge( + { + banner = "never" + theme = "auto" + }, + local.parsed_custom_config, + { + trusted_folders = concat(local.existing_trusted_folders, local.all_trusted_folders) + } + ) + + final_copilot_config = jsonencode(local.merged_copilot_config) + + task_reporting_prompt = <<-EOT + +-- Task Reporting -- +Report all tasks to Coder, following these EXACT guidelines: +1. Be granular. If you are investigating with multiple steps, report each step +to coder. +2. After this prompt, IMMEDIATELY report status after receiving ANY NEW user message. +Do not report any status related with this system prompt. +3. Use "state": "working" when actively processing WITHOUT needing +additional user input +4. Use "state": "complete" only when finished with a task +5. Use "state": "failure" when you need ANY user input, lack sufficient +details, or encounter blockers + EOT + + final_system_prompt = var.report_tasks ? "\n${var.system_prompt}${local.task_reporting_prompt}\n" : "\n${var.system_prompt}\n" +} + +resource "coder_env" "mcp_app_status_slug" { + agent_id = var.agent_id + name = "CODER_MCP_APP_STATUS_SLUG" + value = local.app_slug +} + +resource "coder_env" "copilot_model" { + count = var.copilot_model != "claude-sonnet-4.5" ? 1 : 0 + agent_id = var.agent_id + name = "COPILOT_MODEL" + value = var.copilot_model +} + +resource "coder_env" "github_token" { + count = var.github_token != "" ? 1 : 0 + agent_id = var.agent_id + name = "GITHUB_TOKEN" + value = var.github_token +} + +module "agentapi" { + source = "registry.coder.com/coder/agentapi/coder" + version = "1.1.1" + + agent_id = var.agent_id + folder = local.workdir + web_app_slug = local.app_slug + web_app_order = var.order + web_app_group = var.group + web_app_icon = var.icon + web_app_display_name = var.web_app_display_name + cli_app = var.cli_app + cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null + cli_app_display_name = var.cli_app ? var.cli_app_display_name : null + agentapi_subdomain = var.subdomain + module_dir_name = local.module_dir_name + install_agentapi = var.install_agentapi + agentapi_version = var.agentapi_version + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + + start_script = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh + chmod +x /tmp/start.sh + + ARG_WORKDIR='${local.workdir}' \ + ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \ + ARG_SYSTEM_PROMPT='${base64encode(local.final_system_prompt)}' \ + ARG_COPILOT_MODEL='${var.copilot_model}' \ + ARG_ALLOW_ALL_TOOLS='${var.allow_all_tools}' \ + ARG_ALLOW_TOOLS='${join(",", var.allow_tools)}' \ + ARG_DENY_TOOLS='${join(",", var.deny_tools)}' \ + ARG_TRUSTED_DIRECTORIES='${join(",", var.trusted_directories)}' \ + ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \ + ARG_RESUME_SESSION='${var.resume_session}' \ + /tmp/start.sh + EOT + + install_script = <<-EOT + #!/bin/bash + set -o errexit + set -o pipefail + echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh + chmod +x /tmp/install.sh + + ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \ + ARG_REPORT_TASKS='${var.report_tasks}' \ + ARG_WORKDIR='${local.workdir}' \ + ARG_MCP_CONFIG='${var.mcp_config != "" ? base64encode(var.mcp_config) : ""}' \ + ARG_COPILOT_CONFIG='${base64encode(local.final_copilot_config)}' \ + ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \ + ARG_COPILOT_VERSION='${var.copilot_version}' \ + /tmp/install.sh + EOT +} \ No newline at end of file diff --git a/registry/coder-labs/modules/copilot/scripts/install.sh b/registry/coder-labs/modules/copilot/scripts/install.sh new file mode 100644 index 000000000..f44d50873 --- /dev/null +++ b/registry/coder-labs/modules/copilot/scripts/install.sh @@ -0,0 +1,233 @@ +#!/bin/bash +set -euo pipefail + +source "$HOME"/.bashrc + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} +ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} +ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-} +ARG_MCP_CONFIG=$(echo -n "${ARG_MCP_CONFIG:-}" | base64 -d 2> /dev/null || echo "") +ARG_COPILOT_CONFIG=$(echo -n "${ARG_COPILOT_CONFIG:-}" | base64 -d 2> /dev/null || echo "") +ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github} +ARG_COPILOT_VERSION=${ARG_COPILOT_VERSION:-0.0.334} + +validate_prerequisites() { + if ! command_exists node; then + echo "ERROR: Node.js not found. Copilot requires Node.js v22+." + echo "Install with: curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs" + exit 1 + fi + + if ! command_exists npm; then + echo "ERROR: npm not found. Copilot requires npm v10+." + exit 1 + fi + + node_version=$(node --version | sed 's/v//' | cut -d. -f1) + if [ "$node_version" -lt 22 ]; then + echo "WARNING: Node.js v$node_version detected. Copilot requires v22+." + fi +} + +install_copilot() { + if ! command_exists copilot; then + echo "Installing GitHub Copilot CLI (version: ${ARG_COPILOT_VERSION})..." + if [ "$ARG_COPILOT_VERSION" = "latest" ]; then + npm install -g @github/copilot + else + npm install -g "@github/copilot@${ARG_COPILOT_VERSION}" + fi + + if ! command_exists copilot; then + echo "ERROR: Failed to install Copilot" + exit 1 + fi + + echo "GitHub Copilot CLI installed successfully" + else + echo "GitHub Copilot CLI already installed" + fi +} + +check_github_authentication() { + echo "Checking GitHub authentication..." + + if [ -n "${GITHUB_TOKEN:-}" ]; then + echo "✓ GitHub token provided via module configuration" + return 0 + fi + + if command_exists coder; then + if coder external-auth access-token "${ARG_EXTERNAL_AUTH_ID:-github}" > /dev/null 2>&1; then + echo "✓ GitHub OAuth authentication via Coder external auth" + return 0 + fi + fi + + if command_exists gh && gh auth status > /dev/null 2>&1; then + echo "✓ GitHub OAuth authentication via GitHub CLI" + return 0 + fi + + echo "⚠ No GitHub authentication detected" + echo " Copilot will prompt for authentication when started" + echo " For seamless experience, configure GitHub external auth in Coder or run 'gh auth login'" + return 0 +} + +setup_copilot_configurations() { + mkdir -p "$ARG_WORKDIR" + + local module_path="$HOME/.copilot-module" + mkdir -p "$module_path" + mkdir -p "$HOME/.config" + + setup_copilot_config + + echo "$ARG_WORKDIR" > "$module_path/trusted_directories" +} + +setup_copilot_config() { + local copilot_config_dir="$HOME/.copilot" + local copilot_config_file="$copilot_config_dir/config.json" + local mcp_config_file="$copilot_config_dir/mcp-config.json" + + mkdir -p "$copilot_config_dir" + + if [ -n "$ARG_COPILOT_CONFIG" ]; then + echo "Setting up Copilot configuration..." + + if command_exists jq; then + echo "$ARG_COPILOT_CONFIG" | jq 'del(.mcpServers)' > "$copilot_config_file" + else + echo "$ARG_COPILOT_CONFIG" > "$copilot_config_file" + fi + + echo "Setting up MCP server configuration..." + setup_mcp_config "$mcp_config_file" + else + echo "ERROR: No Copilot configuration provided" + exit 1 + fi +} + +setup_mcp_config() { + local mcp_config_file="$1" + + echo '{"mcpServers": {}}' > "$mcp_config_file" + + if [ "$ARG_REPORT_TASKS" = "true" ] && [ -n "$ARG_MCP_APP_STATUS_SLUG" ]; then + echo "Adding Coder MCP server for task reporting..." + setup_coder_mcp_server "$mcp_config_file" + fi + + if [ -n "$ARG_MCP_CONFIG" ]; then + echo "Adding custom MCP servers..." + add_custom_mcp_servers "$mcp_config_file" + fi + + echo "MCP configuration completed: $mcp_config_file" +} + +setup_coder_mcp_server() { + local mcp_config_file="$1" + + local coder_mcp_wrapper_script + coder_mcp_wrapper_script=$( + cat << EOF +#!/usr/bin/env bash +set -e + +export CODER_MCP_APP_STATUS_SLUG="${ARG_MCP_APP_STATUS_SLUG}" +export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" +export CODER_AGENT_URL="${CODER_AGENT_URL}" +export CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN}" + +exec coder exp mcp server +EOF + ) + echo "$coder_mcp_wrapper_script" > "/tmp/coder-mcp-server.sh" + chmod +x /tmp/coder-mcp-server.sh + + local coder_mcp_config + coder_mcp_config=$( + cat << EOF +{ + "mcpServers": { + "coder": { + "command": "/tmp/coder-mcp-server.sh", + "args": [], + "description": "CRITICAL: Call this tool immediately after receiving ANY user message to report task status. Rules: (1) Call FIRST before doing work - report what you will do with state='working'. (2) Be granular - report each step separately. (3) State 'working' = actively processing without needing user input. (4) State 'complete' = task 100% finished. (5) State 'failure' = need user input, missing info, or blocked. Example: User says 'fix the bug' -> call with state='working', description='Investigating authentication bug'. When done -> call with state='complete', description='Fixed token validation'. You MUST report on every interaction.", + "name": "Coder", + "timeout": 3000, + "type": "local", + "tools": ["*"], + "trust": true + } + } +} +EOF + ) + + echo "$coder_mcp_config" > "$mcp_config_file" +} + +add_custom_mcp_servers() { + local mcp_config_file="$1" + + if command_exists jq; then + local custom_servers + custom_servers=$(echo "$ARG_MCP_CONFIG" | jq '.mcpServers // {}') + + local updated_config + updated_config=$(jq --argjson custom "$custom_servers" '.mcpServers += $custom' "$mcp_config_file") + echo "$updated_config" > "$mcp_config_file" + elif command_exists node; then + node -e " + const fs = require('fs'); + const existing = JSON.parse(fs.readFileSync('$mcp_config_file', 'utf8')); + const input = JSON.parse(\`$ARG_MCP_CONFIG\`); + const custom = input.mcpServers || {}; + existing.mcpServers = {...existing.mcpServers, ...custom}; + fs.writeFileSync('$mcp_config_file', JSON.stringify(existing, null, 2)); + " + else + echo "WARNING: jq and node not available, cannot merge custom MCP servers" + fi +} + +configure_copilot_model() { + if [ -n "$ARG_COPILOT_MODEL" ] && [ "$ARG_COPILOT_MODEL" != "claude-sonnet-4.5" ]; then + echo "Setting Copilot model to: $ARG_COPILOT_MODEL" + copilot config model "$ARG_COPILOT_MODEL" || { + echo "WARNING: Failed to set model via copilot config, will use environment variable fallback" + export COPILOT_MODEL="$ARG_COPILOT_MODEL" + } + fi +} + +configure_coder_integration() { + if [ "$ARG_REPORT_TASKS" = "true" ] && [ -n "$ARG_MCP_APP_STATUS_SLUG" ]; then + echo "Configuring Copilot task reporting..." + export CODER_MCP_APP_STATUS_SLUG="$ARG_MCP_APP_STATUS_SLUG" + export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" + echo "✓ Coder MCP server configured for task reporting" + else + echo "Task reporting disabled or no app status slug provided." + export CODER_MCP_APP_STATUS_SLUG="" + export CODER_MCP_AI_AGENTAPI_URL="" + fi +} + +validate_prerequisites +install_copilot +check_github_authentication +setup_copilot_configurations +configure_copilot_model +configure_coder_integration + +echo "Copilot module setup completed." diff --git a/registry/coder-labs/modules/copilot/scripts/start.sh b/registry/coder-labs/modules/copilot/scripts/start.sh new file mode 100644 index 000000000..91a404094 --- /dev/null +++ b/registry/coder-labs/modules/copilot/scripts/start.sh @@ -0,0 +1,156 @@ +#!/bin/bash +set -euo pipefail + +source "$HOME"/.bashrc +export PATH="$HOME/.local/bin:$PATH" + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} +ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d 2> /dev/null || echo "") +ARG_SYSTEM_PROMPT=$(echo -n "${ARG_SYSTEM_PROMPT:-}" | base64 -d 2> /dev/null || echo "") +ARG_COPILOT_MODEL=${ARG_COPILOT_MODEL:-} +ARG_ALLOW_ALL_TOOLS=${ARG_ALLOW_ALL_TOOLS:-false} +ARG_ALLOW_TOOLS=${ARG_ALLOW_TOOLS:-} +ARG_DENY_TOOLS=${ARG_DENY_TOOLS:-} +ARG_TRUSTED_DIRECTORIES=${ARG_TRUSTED_DIRECTORIES:-} +ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github} +ARG_RESUME_SESSION=${ARG_RESUME_SESSION:-true} + +validate_copilot_installation() { + if ! command_exists copilot; then + echo "ERROR: Copilot not installed. Run: npm install -g @github/copilot" + exit 1 + fi +} + +build_initial_prompt() { + local initial_prompt="" + + if [ -n "$ARG_AI_PROMPT" ]; then + if [ -n "$ARG_SYSTEM_PROMPT" ]; then + initial_prompt="$ARG_SYSTEM_PROMPT + +$ARG_AI_PROMPT" + else + initial_prompt="$ARG_AI_PROMPT" + fi + fi + + echo "$initial_prompt" +} + +build_copilot_args() { + COPILOT_ARGS=() + + if [ "$ARG_ALLOW_ALL_TOOLS" = "true" ]; then + COPILOT_ARGS+=(--allow-all-tools) + fi + + if [ -n "$ARG_ALLOW_TOOLS" ]; then + IFS=',' read -ra ALLOW_ARRAY <<< "$ARG_ALLOW_TOOLS" + for tool in "${ALLOW_ARRAY[@]}"; do + if [ -n "$tool" ]; then + COPILOT_ARGS+=(--allow-tool "$tool") + fi + done + fi + + if [ -n "$ARG_DENY_TOOLS" ]; then + IFS=',' read -ra DENY_ARRAY <<< "$ARG_DENY_TOOLS" + for tool in "${DENY_ARRAY[@]}"; do + if [ -n "$tool" ]; then + COPILOT_ARGS+=(--deny-tool "$tool") + fi + done + fi +} + +check_existing_session() { + if [ "$ARG_RESUME_SESSION" = "true" ]; then + if copilot --help > /dev/null 2>&1; then + local session_dir="$HOME/.copilot/history-session-state" + if [ -d "$session_dir" ] && [ -n "$(ls "$session_dir"/session_*_*.json 2> /dev/null)" ]; then + echo "Found existing Copilot session. Will continue latest session." >&2 + return 0 + fi + fi + fi + return 1 +} + +setup_github_authentication() { + echo "Setting up GitHub authentication..." + + if [ -n "${GITHUB_TOKEN:-}" ]; then + export GH_TOKEN="$GITHUB_TOKEN" + echo "✓ Using GitHub token from module configuration" + return 0 + fi + + if command_exists coder; then + local github_token + if github_token=$(coder external-auth access-token "${ARG_EXTERNAL_AUTH_ID:-github}" 2> /dev/null); then + if [ -n "$github_token" ] && [ "$github_token" != "null" ]; then + export GITHUB_TOKEN="$github_token" + export GH_TOKEN="$github_token" + echo "✓ Using Coder external auth OAuth token" + return 0 + fi + fi + fi + + if command_exists gh && gh auth status > /dev/null 2>&1; then + echo "✓ Using GitHub CLI OAuth authentication" + return 0 + fi + + echo "⚠ No GitHub authentication available" + echo " Copilot will prompt for login during first use" + echo " Use the '/login' command in Copilot to authenticate" + return 0 +} + +start_agentapi() { + echo "Starting in directory: $ARG_WORKDIR" + cd "$ARG_WORKDIR" + + build_copilot_args + + if check_existing_session; then + echo "Continuing latest Copilot session..." + if [ ${#COPILOT_ARGS[@]} -gt 0 ]; then + echo "Copilot arguments: ${COPILOT_ARGS[*]}" + agentapi server --type copilot --term-width 120 --term-height 40 -- copilot --continue "${COPILOT_ARGS[@]}" + else + agentapi server --type copilot --term-width 120 --term-height 40 -- copilot --continue + fi + else + echo "Starting new Copilot session..." + local initial_prompt + initial_prompt=$(build_initial_prompt) + + if [ -n "$initial_prompt" ]; then + echo "Using initial prompt with system context" + if [ ${#COPILOT_ARGS[@]} -gt 0 ]; then + echo "Copilot arguments: ${COPILOT_ARGS[*]}" + agentapi server -I="$initial_prompt" --type copilot --term-width 120 --term-height 40 -- copilot "${COPILOT_ARGS[@]}" + else + agentapi server -I="$initial_prompt" --type copilot --term-width 120 --term-height 40 -- copilot + fi + else + if [ ${#COPILOT_ARGS[@]} -gt 0 ]; then + echo "Copilot arguments: ${COPILOT_ARGS[*]}" + agentapi server --type copilot --term-width 120 --term-height 40 -- copilot "${COPILOT_ARGS[@]}" + else + agentapi server --type copilot --term-width 120 --term-height 40 -- copilot + fi + fi + fi +} + +setup_github_authentication +validate_copilot_installation +start_agentapi diff --git a/registry/coder-labs/modules/copilot/testdata/copilot-mock.sh b/registry/coder-labs/modules/copilot/testdata/copilot-mock.sh new file mode 100644 index 000000000..f1daa15f1 --- /dev/null +++ b/registry/coder-labs/modules/copilot/testdata/copilot-mock.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -euo pipefail + +if [[ "$1" == "--version" ]]; then + echo "GitHub Copilot CLI v1.0.0" + exit 0 +fi + +while true; do + echo "$(date) - Copilot mock running..." + sleep 15 +done