diff --git a/.github/aw/lsp.md b/.github/aw/lsp.md new file mode 100644 index 00000000000..10e79ac0902 --- /dev/null +++ b/.github/aw/lsp.md @@ -0,0 +1,171 @@ +--- +description: Language Server Protocol (LSP) configuration reference for gh-aw Copilot workflows — frontmatter syntax, supported servers, and file extension mapping. +--- + +# LSP Configuration + +> ⚠️ **Experimental feature.** The `lsp` frontmatter field is experimental. Using it will emit a compile-time warning. The interface may change in future releases. + +The `lsp` frontmatter field lets Copilot-engine workflows declare language servers. At compile time, the compiler: + +1. Validates the configuration and rejects the workflow if `lsp` is used with a non-Copilot engine. +2. Generates `~/.copilot/settings.json` with an `lspServers` block the Copilot CLI reads at startup. +3. Injects install steps for known server ecosystems into the agent setup job. + +> ⚠️ **`lsp` is only supported with `engine: copilot`**. Using it with any other engine causes a compile-time error. + +## Syntax + +```yaml +engine: + id: copilot + +lsp: + : + command: + args: [, ] # optional + fileExtensions: + ".": # at least one required +``` + +Each key under `lsp` is a language identifier (lowercase, alphanumeric, hyphens, or underscores). It maps to a server definition with: + +| Field | Required | Description | +|---|---|---| +| `command` | **yes** | Executable name or path for the language server | +| `args` | no | Command-line arguments passed to the server on startup | +| `fileExtensions` | **yes** | Map of file extension (with leading `.`) to LSP language ID | +| `version` | no | Package version to install (e.g. `"5.8.3"`). Overrides the built-in pinned default for known servers. | + +## Built-in Servers + +For the languages below, the compiler automatically injects an install step — no manual `steps:` entry is needed. Each server is pinned to a known-good release by default; use the `version` field to override. + +| Language key | Default version | Install command | Example `command` | +|---|---|---|---| +| `bash` | `5.4.0` | `npm install -g --ignore-scripts bash-language-server@5.4.0` | `bash-language-server` | +| `go` | `0.18.1` | `go install golang.org/x/tools/gopls@v0.18.1` | `gopls` | +| `php` | `1.14.1` | `npm install -g --ignore-scripts intelephense@1.14.1` | `intelephense` | +| `python` | `1.1.399` | `npm install -g --ignore-scripts pyright@1.1.399` | `pyright-langserver` | +| `ruby` | `0.50.0` | `gem install solargraph -v 0.50.0` | `solargraph` | +| `rust` | n/a | `rustup component add rust-analyzer` | `rust-analyzer` | +| `typescript` | `5.8.3` / `4.3.3` | `npm install -g --ignore-scripts typescript@5.8.3 typescript-language-server@4.3.3` | `typescript-language-server` | +| `yaml` | `1.15.0` | `npm install -g --ignore-scripts yaml-language-server@1.15.0` | `yaml-language-server` | + +> The `version` field overrides the pinned version for the primary language server package (the last package in the install list). For `typescript`, it controls `typescript-language-server`; `typescript` itself stays at its hardcoded companion version (`5.8.3`). + +Language keys not in this table still work — the compiler simply skips the auto-install step. Add a manual `steps:` entry to install the server yourself. + +## Examples + +### TypeScript / JavaScript + +```yaml +engine: + id: copilot + +lsp: + typescript: + command: typescript-language-server + args: ["--stdio"] + fileExtensions: + ".ts": typescript + ".tsx": typescriptreact + ".js": javascript + ".cjs": javascript + ".mjs": javascript +``` + +### Python + +```yaml +engine: + id: copilot + +lsp: + python: + command: pyright-langserver + args: ["--stdio"] + fileExtensions: + ".py": python +``` + +### Go + +```yaml +engine: + id: copilot + +lsp: + go: + command: gopls + fileExtensions: + ".go": go +``` + +### Multiple Languages + +```yaml +engine: + id: copilot + +lsp: + typescript: + command: typescript-language-server + args: ["--stdio"] + fileExtensions: + ".ts": typescript + ".js": javascript + python: + command: pyright-langserver + args: ["--stdio"] + fileExtensions: + ".py": python +``` + +### Custom Server (no built-in install) + +For servers without a built-in install spec, add a manual install step: + +```yaml +engine: + id: copilot + +steps: + - name: Install custom language server + run: npm install -g my-custom-language-server + +lsp: + mylang: + command: my-custom-language-server + args: ["--stdio"] + fileExtensions: + ".ml": mylang +``` + +## Network Requirements + +Installing LSP servers requires network access to the appropriate package registry. Add the matching ecosystem to `network.allowed`: + +| Language | Ecosystem to add | +|---|---| +| `bash`, `php`, `python`, `typescript`, `yaml` | `node` | +| `go` | `go` | +| `ruby` | `ruby` | +| `rust` | `rust` | + +```yaml +network: + allowed: + - node # for npm-installed servers (typescript, yaml, python/pyright, etc.) + - go # for gopls +``` + +## Compile-time Validation + +The compiler enforces these rules at compile time: + +- `lsp` requires `engine: copilot` — any other engine causes an error. +- Each language entry must have a non-empty `command`. +- Each language entry must define at least one `fileExtensions` mapping. +- Language keys are case-insensitive and trimmed; duplicate keys that collapse to the same lowercase value cause nondeterministic behavior and should be avoided. diff --git a/.github/skills/agentic-workflows/SKILL.md b/.github/skills/agentic-workflows/SKILL.md index 17532c59425..0de83e89d40 100644 --- a/.github/skills/agentic-workflows/SKILL.md +++ b/.github/skills/agentic-workflows/SKILL.md @@ -29,6 +29,7 @@ Load these files from `github/gh-aw` (they are not available locally). - `.github/aw/github-mcp-server.md` - `.github/aw/llms.md` - `.github/aw/loop.md` +- `.github/aw/lsp.md` - `.github/aw/mcp-clis.md` - `.github/aw/memory-stateful-patterns.md` - `.github/aw/memory.md` diff --git a/.github/workflows/jsweep.lock.yml b/.github/workflows/jsweep.lock.yml index 9544f2881a6..a07ec666d7b 100644 --- a/.github/workflows/jsweep.lock.yml +++ b/.github/workflows/jsweep.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8b645f570da34a11144ef35f4f6c7f69b67aae1e5d4450cc2a9fd25befe0c12c","body_hash":"9ce30f5cb169db7ddfaefbc090c94365da3069e5f11b7d47e842a1046c70c8c1","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.65","copilot-sdk":"1.0.4"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"ebc1df2775f1711593bbaabfb5a951318f4318f5c15b3309ff5bc80a5f3e02e5","body_hash":"9ce30f5cb169db7ddfaefbc090c94365da3069e5f11b7d47e842a1046c70c8c1","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.65","copilot-sdk":"1.0.4"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11","digest":"sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11","digest":"sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11@sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.11"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11","digest":"sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11@sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.30","digest":"sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.30@sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -534,6 +534,11 @@ jobs: run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.11 --rootless - name: Install GitHub Copilot SDK (Node.js) run: cd "${GITHUB_WORKSPACE}" && npm install --ignore-scripts --no-save @github/copilot-sdk@1.0.4 + - name: Install TypeScript LSP dependencies + run: npm install -g --ignore-scripts typescript@5.8.3 typescript-language-server@4.3.3 + env: + NPM_CONFIG_MIN_RELEASE_AGE: '3' + timeout-minutes: 10 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) @@ -833,7 +838,7 @@ jobs: printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt trap 'rm -f "$HOME/.copilot/settings.json"' EXIT mkdir -p "$HOME/.copilot" - printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" + printf '%s' '{"builtInAgents":{"rubberDuck":false},"lspServers":{"typescript":{"command":"typescript-language-server","args":["--stdio"],"fileExtensions":{".cjs":"javascript",".js":"javascript",".mjs":"javascript",".ts":"typescript",".tsx":"typescriptreact"}}}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" touch /tmp/gh-aw/agent-step-summary.md diff --git a/.github/workflows/jsweep.md b/.github/workflows/jsweep.md index 232e1fa46b8..af8c05d309e 100644 --- a/.github/workflows/jsweep.md +++ b/.github/workflows/jsweep.md @@ -25,6 +25,16 @@ tools: edit: bash: ["*"] cache-memory: true +lsp: + typescript: + command: typescript-language-server + args: ["--stdio"] + fileExtensions: + ".js": javascript + ".cjs": javascript + ".mjs": javascript + ".ts": typescript + ".tsx": typescriptreact steps: - name: Install Node.js dependencies working-directory: actions/setup/js diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 5960f3c1321..c5b0c3b01bc 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"75f5b82d2fdf957d6700834c82ba9bdbe38c9961d82211ec32ea40fe3851580d","body_hash":"812899a64607d2d204003410dba1febf488c85700602e8702b89dd7db3609096","strict":true,"agent_id":"copilot","agent_model":"gpt-5.4","engine_versions":{"copilot":"1.0.65"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"16ce3056a07e9a41d38b404bbd8dc1ccb2de9962ce637c211ade0ad8977c9af5","body_hash":"05f195a76bd7e9205e793331c9962f6c401e1b29da2e99592ee537c3289df44e","strict":true,"agent_id":"copilot","agent_model":"gpt-5.4","engine_versions":{"copilot":"1.0.65"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"4a3601121dd01d1626a1e23e37211e3254c1c06c","version":"v6.4.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"docker/build-push-action","sha":"f9f3042f7e2789586610d6e8b85c8f03e5195baf","version":"v7.2.0"},{"repo":"docker/setup-buildx-action","sha":"d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5","version":"v4.1.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11","digest":"sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11","digest":"sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11@sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.11"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11","digest":"sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11@sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.30","digest":"sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.30@sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"},{"image":"ghcr.io/github/serena-mcp-server:latest","digest":"sha256:bf343399e3725c45528f531a230f3a04521d4cdef29f9a5af6282ff0d3c393c5","pinned_image":"ghcr.io/github/serena-mcp-server:latest@sha256:bf343399e3725c45528f531a230f3a04521d4cdef29f9a5af6282ff0d3c393c5"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -654,6 +654,11 @@ jobs: cache: false - name: Capture GOROOT for AWF chroot mode run: echo "GOROOT=$(go env GOROOT)" >> "$GITHUB_ENV" + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '20' + package-manager-cache: false - name: Create gh-aw temp directory run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" - name: Configure gh CLI for GitHub Enterprise @@ -702,6 +707,11 @@ jobs: GH_HOST: github.com - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.11 + - name: Install TypeScript LSP dependencies + run: npm install -g --ignore-scripts typescript@5.8.3 typescript-language-server@4.3.3 + env: + NPM_CONFIG_MIN_RELEASE_AGE: '3' + timeout-minutes: 10 - name: Install Playwright CLI run: npm install -g @playwright/cli@0.1.14 env: @@ -1864,7 +1874,7 @@ jobs: printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt trap 'rm -f "$HOME/.copilot/settings.json"' EXIT mkdir -p "$HOME/.copilot" - printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json" + printf '%s' '{"builtInAgents":{"rubberDuck":false},"lspServers":{"typescript":{"command":"typescript-language-server","args":["--stdio"],"fileExtensions":{".cjs":"javascript",".js":"javascript",".mjs":"javascript",".ts":"typescript",".tsx":"typescriptreact"}}}}' > "$HOME/.copilot/settings.json" export XDG_CONFIG_HOME="$HOME" export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json" touch /tmp/gh-aw/agent-step-summary.md diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md index bbaec983d5e..3193e2b1bbf 100644 --- a/.github/workflows/smoke-copilot.md +++ b/.github/workflows/smoke-copilot.md @@ -55,9 +55,21 @@ tools: mode: cli web-fetch: cli-proxy: true +lsp: + typescript: + command: typescript-language-server + args: ["--stdio"] + fileExtensions: + ".js": javascript + ".cjs": javascript + ".mjs": javascript + ".ts": typescript + ".tsx": typescriptreact runtimes: go: version: "1.26" + node: + version: "20" models: providers: anthropic: @@ -189,6 +201,10 @@ Run these checks and mark each as ✅/❌: 13. Comment memory: append an original 3-line haiku to `/tmp/gh-aw/comment-memory/*.md`. 14. Sub-agent: use `file-summarizer` on `README.md`. 15. Check run: call `create_check_run` with `conclusion=success`, title `Smoke Copilot - Run ${{ github.run_id }}`, summary `All smoke tests completed.`, text `Detailed results attached.` +16. **LSP TypeScript Testing**: Use the TypeScript language server (configured via `lsp.typescript` frontmatter) to count the number of functions in `${{ github.workspace }}/actions/setup/js/safe_output_helpers.cjs`: + - Open the file `${{ github.workspace }}/actions/setup/js/safe_output_helpers.cjs` via LSP + - Use LSP document symbols to list all symbols in the file and count functions + - Report the total function count as ✅ if at least 1 function is found, ❌ otherwise ## Output @@ -196,7 +212,7 @@ Run these checks and mark each as ✅/❌: - Use the temporary ID `aw_smoke1` for the issue so you can reference it later - Title: "Smoke Test: Copilot - ${{ github.run_id }}" - Body should include: - - Test results (✅ or ❌ for each test) + - Test results (✅ or ❌ for each test, including test #16 LSP TypeScript) - Overall status: PASS or FAIL - Run URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - Timestamp diff --git a/pkg/cli/data/agentic_workflows_fallback_aw_files.json b/pkg/cli/data/agentic_workflows_fallback_aw_files.json index 0aab4596d11..c11d7fb9d99 100644 --- a/pkg/cli/data/agentic_workflows_fallback_aw_files.json +++ b/pkg/cli/data/agentic_workflows_fallback_aw_files.json @@ -17,6 +17,7 @@ "github-mcp-server.md", "llms.md", "loop.md", + "lsp.md", "mcp-clis.md", "memory-stateful-patterns.md", "memory.md", diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 97d05cbf35b..ef88f72c1f5 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -491,6 +491,29 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_ToolsEditBoolean(t } } +func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_LSPConfig(t *testing.T) { + t.Parallel() + + frontmatter := map[string]any{ + "on": "push", + "engine": "copilot", + "lsp": map[string]any{ + "typescript": map[string]any{ + "command": "typescript-language-server", + "args": []any{"--stdio"}, + "fileExtensions": map[string]any{ + ".ts": "typescript", + }, + }, + }, + } + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/lsp-config-test.md") + if err != nil { + t.Fatalf("expected valid lsp configuration to pass schema validation, got: %v", err) + } +} + func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_MaxLimitsAllowExpressions(t *testing.T) { t.Parallel() diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 107db0f6db7..d4ff6273c59 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4710,6 +4710,46 @@ }, "additionalProperties": false }, + "lsp": { + "type": "object", + "description": "⚠️ Experimental. Top-level Language Server Protocol (LSP) configuration for Copilot CLI. Each key is a language identifier and each value defines the server command, args, and file extension mappings. Using this field emits a compile-time warning.", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "type": "object", + "properties": { + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command for the language server" + }, + "args": { + "type": "array", + "description": "Optional command-line arguments passed to the language server executable", + "items": { + "type": "string" + } + }, + "fileExtensions": { + "type": "object", + "description": "Map of file extension to language id, for example {\".ts\": \"typescript\"}", + "minProperties": 1, + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "version": { + "type": "string", + "minLength": 1, + "description": "Package version to install for this language server (e.g. \"5.8.3\"). Overrides the built-in pinned default. Has no effect for custom servers not in the built-in install spec table." + } + }, + "required": ["command", "fileExtensions"], + "additionalProperties": false + } + }, + "additionalProperties": false + }, "cache": { "description": "Cache configuration for workflow (uses actions/cache syntax)", "oneOf": [ diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 506e89b6c6c..e4f3c21d3ef 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -145,6 +145,7 @@ func (c *Compiler) validateWorkflowEngineSettings(cleanPath string, workflowData c.validateRunInstallScripts, c.validateEngineVersion, c.validatePlaywrightMode, + c.validateLSPSupport, c.validateEngineHarnessScript, c.validateEngineDriver, c.validateEngineMCPSessionTimeout, diff --git a/pkg/workflow/compiler_orchestrator_workflow_test.go b/pkg/workflow/compiler_orchestrator_workflow_test.go index dc241190a9d..b74b0f27a0e 100644 --- a/pkg/workflow/compiler_orchestrator_workflow_test.go +++ b/pkg/workflow/compiler_orchestrator_workflow_test.go @@ -321,6 +321,26 @@ func TestValidateWorkflowEngineSettings_PreservesLegacyErrorOrder(t *testing.T) assert.NotContains(t, err.Error(), "engine.harness") } +func TestValidateWorkflowEngineSettings_LSPRequiresCopilot(t *testing.T) { + compiler := NewCompiler() + workflowData := &WorkflowData{ + AI: "codex", + LSP: map[string]LSPServerConfig{ + "go": { + Command: "gopls", + Args: []string{"serve"}, + FileExtensions: map[string]string{ + ".go": "go", + }, + }, + }, + } + + err := compiler.validateWorkflowEngineSettings("workflow.md", workflowData) + require.Error(t, err) + assert.Contains(t, err.Error(), "workflow.md: lsp is currently only supported for engine: copilot") +} + func TestMergeRawOTLPEndpoints_DedupesAndCountsSources(t *testing.T) { mainObs := map[string]any{ "otlp": map[string]any{ diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 4b6a1369b44..7e085984236 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -505,7 +505,8 @@ type WorkflowData struct { Container string // container setting for the main job Services string // services setting for the main job Tools map[string]any - ParsedTools *Tools // Structured tools configuration (NEW: parsed from Tools map) + LSP map[string]LSPServerConfig // top-level LSP server configuration for Copilot CLI + ParsedTools *Tools // Structured tools configuration (NEW: parsed from Tools map) MarkdownContent string AI string // "claude" or "codex" (for backwards compatibility) EngineConfig *EngineConfig // Extended engine configuration diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index 4a0a529e9a4..29fa8f21f84 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -304,6 +304,7 @@ func (c *Compiler) emitExperimentalFeatureWarnings(workflowData *WorkflowData) { {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.ReplaceLabel != nil, message: "Using experimental feature: replace-label"}, {enabled: workflowData.EngineConfig != nil && workflowData.EngineConfig.CopilotSDK, message: "Using experimental feature: engine.copilot-sdk"}, {enabled: isFeatureEnabled(constants.GHAWDetectionFeatureFlag, workflowData), message: "Using experimental feature: gh-aw-detection"}, + {enabled: len(workflowData.LSP) > 0, message: "Using experimental feature: lsp"}, } for _, warning := range warnings { if warning.enabled { diff --git a/pkg/workflow/copilot_engine_execution.go b/pkg/workflow/copilot_engine_execution.go index b003bc5a17c..f30f3471479 100644 --- a/pkg/workflow/copilot_engine_execution.go +++ b/pkg/workflow/copilot_engine_execution.go @@ -47,22 +47,42 @@ const customEngineCommandScriptPath = "/tmp/gh-aw/engine-command.sh" // other generators (copilot_mcp.go, mcp_setup_generator.go) rely on it the same way. const copilotSettingsPath = "$HOME/.copilot/settings.json" -// copilotSettingsContent is the JSON content written to the Copilot CLI settings file. -// Setting builtInAgents.rubberDuck to false disables the rubber-duck sub-agent, which -// would otherwise be invoked proactively for non-trivial tasks. In Copilot engine runs -// this adds unnecessary token overhead and latency with little benefit, since the primary -// model already has strong reasoning capabilities. -const copilotSettingsContent = `{"builtInAgents":{"rubberDuck":false}}` +// copilotSettingsDefaultContent is the default JSON content written to the Copilot CLI +// settings file when no additional settings are configured. +const copilotSettingsDefaultContent = `{"builtInAgents":{"rubberDuck":false}}` + +type copilotSettings struct { + BuiltInAgents map[string]bool `json:"builtInAgents"` + LSPServers map[string]LSPServerConfig `json:"lspServers,omitempty"` +} + +func buildCopilotSettingsContent(workflowData *WorkflowData) string { + settings := copilotSettings{ + BuiltInAgents: map[string]bool{"rubberDuck": false}, + } + if workflowData != nil { + manager := NewLSPManager(workflowData.LSP) + settings.LSPServers = manager.CopilotLSPServers() + } + settingsBytes, err := json.Marshal(settings) + if err != nil { + return copilotSettingsDefaultContent + } + return string(settingsBytes) +} // buildCopilotSettingsSetup returns shell commands that write the Copilot CLI settings // file before the agent runs, disabling the rubber-duck sub-agent. -func buildCopilotSettingsSetup(fixOwnershipForCustomCommand bool) string { +func buildCopilotSettingsSetup(settingsContent string, fixOwnershipForCustomCommand bool) string { + if settingsContent == "" { + settingsContent = copilotSettingsDefaultContent + } setup := "mkdir -p \"$HOME/.copilot\"\n" if fixOwnershipForCustomCommand { setup += "sudo chown -R \"$(id -u):$(id -g)\" \"$HOME/.copilot\"\n" } return setup + fmt.Sprintf("printf '%%s' %s > \"%s\"\n", - shellEscapeArg(copilotSettingsContent), copilotSettingsPath) + shellEscapeArg(settingsContent), copilotSettingsPath) } // buildCopilotSettingsCleanupTrap returns a shell trap command that removes the @@ -413,7 +433,7 @@ func (e *CopilotEngine) buildCopilotAWFPathSetup(workflowData *WorkflowData, cus } // Write the Copilot settings file before AWF starts. The file is created on the host and mounted // into the container, where the Copilot CLI reads it to disable the rubber-duck sub-agent. - return buildCopilotSettingsCleanupTrap() + buildCopilotSettingsSetup(customCommandScriptSetup != "") + buildCopilotMCPConfigExport(workflowData) + pathSetup + return buildCopilotSettingsCleanupTrap() + buildCopilotSettingsSetup(buildCopilotSettingsContent(workflowData), customCommandScriptSetup != "") + buildCopilotMCPConfigExport(workflowData) + pathSetup } func (e *CopilotEngine) buildCopilotDirectCommand(workflowData *WorkflowData, copilotCommand, customCommandScriptSetup, mkdirCommands, logFile string) string { @@ -424,7 +444,7 @@ func (e *CopilotEngine) buildCopilotDirectCommand(workflowData *WorkflowData, co preCommandSetup = customCommandScriptSetup + "\n" + preCommandSetup } // Write the Copilot settings file before the agent runs to disable the rubber-duck sub-agent. - preCommandSetup = buildCopilotSettingsCleanupTrap() + buildCopilotSettingsSetup(customCommandScriptSetup != "") + buildCopilotMCPConfigExport(workflowData) + preCommandSetup + preCommandSetup = buildCopilotSettingsCleanupTrap() + buildCopilotSettingsSetup(buildCopilotSettingsContent(workflowData), customCommandScriptSetup != "") + buildCopilotMCPConfigExport(workflowData) + preCommandSetup return fmt.Sprintf(`set -o pipefail printf '%%s' "$(date +%%s%%3N)" > %s touch %s diff --git a/pkg/workflow/copilot_engine_installation.go b/pkg/workflow/copilot_engine_installation.go index 89c79ff4e0d..59a27dedf5e 100644 --- a/pkg/workflow/copilot_engine_installation.go +++ b/pkg/workflow/copilot_engine_installation.go @@ -96,14 +96,14 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu if len(sdkInstallStep) > 0 { steps = append(steps, sdkInstallStep) } - return BuildNpmEngineInstallStepsWithAWF(steps, workflowData) + return appendCopilotLSPInstallSteps(BuildNpmEngineInstallStepsWithAWF(steps, workflowData), workflowData) } if len(sdkInstallStep) > 0 { copilotInstallLog.Printf("Skipping Copilot CLI installation: custom command specified (%s); keeping Copilot SDK install step", workflowData.EngineConfig.Command) - return []GitHubActionStep{sdkInstallStep} + return appendCopilotLSPInstallSteps([]GitHubActionStep{sdkInstallStep}, workflowData) } copilotInstallLog.Printf("Skipping installation steps: custom command specified (%s)", workflowData.EngineConfig.Command) - return []GitHubActionStep{} + return appendCopilotLSPInstallSteps([]GitHubActionStep{}, workflowData) } // Copilot CLI is pinned to the default version constant. @@ -131,7 +131,20 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu } steps := BuildNpmEngineInstallStepsWithAWF(npmSteps, workflowData) - return steps + return appendCopilotLSPInstallSteps(steps, workflowData) +} + +func appendCopilotLSPInstallSteps(steps []GitHubActionStep, workflowData *WorkflowData) []GitHubActionStep { + if workflowData == nil { + return steps + } + manager := NewLSPManager(workflowData.LSP) + lspSteps := manager.GenerateInstallSteps(workflowData) + if len(lspSteps) == 0 { + return steps + } + copilotInstallLog.Printf("Adding %d LSP dependency installation step(s)", len(lspSteps)) + return append(steps, lspSteps...) } func buildCopilotSDKInstallStep(workflowData *WorkflowData) GitHubActionStep { diff --git a/pkg/workflow/copilot_engine_test.go b/pkg/workflow/copilot_engine_test.go index 1037dcecb1f..7199bcca871 100644 --- a/pkg/workflow/copilot_engine_test.go +++ b/pkg/workflow/copilot_engine_test.go @@ -248,8 +248,8 @@ func TestCopilotEngineDisablesRubberDuck(t *testing.T) { if !strings.Contains(stepContent, "mkdir -p \"$HOME/.copilot\"") { t.Errorf("Expected 'mkdir -p \"$HOME/.copilot\"' in step content:\n%s", stepContent) } - if !strings.Contains(stepContent, copilotSettingsContent) { - t.Errorf("Expected copilot settings content %q in step content:\n%s", copilotSettingsContent, stepContent) + if !strings.Contains(stepContent, copilotSettingsDefaultContent) { + t.Errorf("Expected copilot settings content %q in step content:\n%s", copilotSettingsDefaultContent, stepContent) } if !strings.Contains(stepContent, copilotSettingsPath) { t.Errorf("Expected copilot settings path %q in step content:\n%s", copilotSettingsPath, stepContent) @@ -259,6 +259,62 @@ func TestCopilotEngineDisablesRubberDuck(t *testing.T) { } } +func TestCopilotEngineExecutionSteps_WithLSPConfig(t *testing.T) { + engine := NewCopilotEngine() + workflowData := &WorkflowData{ + Name: "test-workflow", + LSP: map[string]LSPServerConfig{ + "typescript": { + Command: "typescript-language-server", + Args: []string{"--stdio"}, + FileExtensions: map[string]string{ + ".ts": "typescript", + }, + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/test.log") + if len(steps) != 1 { + t.Fatalf("Expected 1 execution step, got %d", len(steps)) + } + + stepContent := strings.Join([]string(steps[0]), "\n") + if !strings.Contains(stepContent, `"lspServers":{"typescript":{"command":"typescript-language-server","args":["--stdio"],"fileExtensions":{".ts":"typescript"}}}`) { + t.Fatalf("Expected lspServers config in step content, got:\n%s", stepContent) + } +} + +func TestCopilotEngineInstallationSteps_WithLSPConfig(t *testing.T) { + engine := NewCopilotEngine() + workflowData := &WorkflowData{ + Name: "test-workflow", + LSP: map[string]LSPServerConfig{ + "python": { + Command: "pyright-langserver", + Args: []string{"--stdio"}, + FileExtensions: map[string]string{ + ".py": "python", + }, + }, + }, + } + + steps := engine.GetInstallationSteps(workflowData) + var allLines strings.Builder + for _, step := range steps { + allLines.WriteString(strings.Join(step, "\n")) + allLines.WriteByte('\n') + } + allLinesStr := allLines.String() + if !strings.Contains(allLinesStr, "Install Python LSP dependencies") { + t.Fatalf("Expected Python LSP install step, got:\n%s", allLinesStr) + } + if !strings.Contains(allLinesStr, "npm install -g --ignore-scripts pyright") { + t.Fatalf("Expected pyright install command, got:\n%s", allLinesStr) + } +} + func TestCopilotEngineExecutionStepsWithOutput(t *testing.T) { engine := NewCopilotEngine() workflowData := &WorkflowData{ diff --git a/pkg/workflow/copilot_home_expansion_test.go b/pkg/workflow/copilot_home_expansion_test.go index 7a1c65b4f2b..df96f16b314 100644 --- a/pkg/workflow/copilot_home_expansion_test.go +++ b/pkg/workflow/copilot_home_expansion_test.go @@ -74,7 +74,7 @@ func TestBuildCopilotSettingsSetup_UsesHomeExpansion(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := buildCopilotSettingsSetup(tt.fixOwnershipForCustom) + got := buildCopilotSettingsSetup(copilotSettingsDefaultContent, tt.fixOwnershipForCustom) // Must reference $HOME, never the literal /home/runner. assert.Contains(t, got, `mkdir -p "$HOME/.copilot"`, @@ -304,7 +304,7 @@ func TestBashIntegration_SettingsSetupAndCleanupTrap(t *testing.T) { // The setup helper unconditionally tries to run `sudo` if the chown flag // is true; the second variant covers the no-sudo path so the test does not // depend on sudoers being configured. - setup := buildCopilotSettingsSetup(false) + setup := buildCopilotSettingsSetup(copilotSettingsDefaultContent, false) trap := buildCopilotSettingsCleanupTrap() for _, hv := range homeValuesUnderTest { diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 44fd4a160fe..975fa3d7fff 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -310,14 +310,15 @@ type FrontmatterConfig struct { Labels []string `json:"labels,omitempty"` // Configuration sections - using strongly-typed structs - Tools *ToolsConfig `json:"tools,omitempty"` - MCPServers map[string]any `json:"mcp-servers,omitempty"` // Legacy field, use Tools instead - RuntimesTyped *RuntimesConfig `json:"-"` // New typed field (not in JSON to avoid conflict) - Runtimes map[string]any `json:"runtimes,omitempty"` // Deprecated: use RuntimesTyped - Jobs map[string]any `json:"jobs,omitempty"` // Custom workflow jobs (too dynamic to type) - SafeOutputs *SafeOutputsConfig `json:"safe-outputs,omitempty"` - MCPScripts *MCPScriptsConfig `json:"mcp-scripts,omitempty"` - PermissionsTyped *PermissionsConfig `json:"-"` // New typed field (not in JSON to avoid conflict) + Tools *ToolsConfig `json:"tools,omitempty"` + LSP map[string]LSPServerConfig `json:"lsp,omitempty"` + MCPServers map[string]any `json:"mcp-servers,omitempty"` // Legacy field, use Tools instead + RuntimesTyped *RuntimesConfig `json:"-"` // New typed field (not in JSON to avoid conflict) + Runtimes map[string]any `json:"runtimes,omitempty"` // Deprecated: use RuntimesTyped + Jobs map[string]any `json:"jobs,omitempty"` // Custom workflow jobs (too dynamic to type) + SafeOutputs *SafeOutputsConfig `json:"safe-outputs,omitempty"` + MCPScripts *MCPScriptsConfig `json:"mcp-scripts,omitempty"` + PermissionsTyped *PermissionsConfig `json:"-"` // New typed field (not in JSON to avoid conflict) // Event and trigger configuration On map[string]any `json:"on,omitempty"` // Complex trigger config with many variants (too dynamic to type) diff --git a/pkg/workflow/lsp_experimental_warning_test.go b/pkg/workflow/lsp_experimental_warning_test.go new file mode 100644 index 00000000000..5a84a2a9ca8 --- /dev/null +++ b/pkg/workflow/lsp_experimental_warning_test.go @@ -0,0 +1,103 @@ +//go:build integration + +package workflow + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/testutil" +) + +// TestLSPExperimentalWarning tests that using the lsp frontmatter field +// emits an experimental feature warning at compile time. +func TestLSPExperimentalWarning(t *testing.T) { + tests := []struct { + name string + content string + expectWarning bool + }{ + { + name: "lsp field produces experimental warning", + content: `--- +on: workflow_dispatch +engine: copilot +permissions: + contents: read +lsp: + typescript: + command: typescript-language-server + args: ["--stdio"] + fileExtensions: + ".ts": typescript +--- + +# Test Workflow +`, + expectWarning: true, + }, + { + name: "no lsp field does not produce experimental warning", + content: `--- +on: workflow_dispatch +engine: copilot +permissions: + contents: read +--- + +# Test Workflow +`, + expectWarning: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := testutil.TempDir(t, "lsp-experimental-warning-test") + + testFile := filepath.Join(tmpDir, "test-workflow.md") + if err := os.WriteFile(testFile, []byte(tt.content), 0644); err != nil { + t.Fatal(err) + } + + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + compiler := NewCompiler() + compiler.SetStrictMode(false) + err := compiler.CompileWorkflow(testFile) + + w.Close() + os.Stderr = oldStderr + var buf bytes.Buffer + io.Copy(&buf, r) + stderrOutput := buf.String() + + if err != nil { + t.Errorf("Expected compilation to succeed but it failed: %v", err) + return + } + + expectedMessage := "Using experimental feature: lsp" + + if tt.expectWarning { + if !strings.Contains(stderrOutput, expectedMessage) { + t.Errorf("Expected warning containing '%s', got stderr:\n%s", expectedMessage, stderrOutput) + } + if compiler.GetWarningCount() == 0 { + t.Error("Expected warning count > 0 but got 0") + } + return + } + + if strings.Contains(stderrOutput, expectedMessage) { + t.Errorf("Did not expect warning '%s', but got stderr:\n%s", expectedMessage, stderrOutput) + } + }) + } +} diff --git a/pkg/workflow/lsp_manager.go b/pkg/workflow/lsp_manager.go new file mode 100644 index 00000000000..944fa08e7b5 --- /dev/null +++ b/pkg/workflow/lsp_manager.go @@ -0,0 +1,314 @@ +package workflow + +import ( + "fmt" + "maps" + "sort" + "strings" + + "github.com/github/gh-aw/pkg/logger" +) + +var lspManagerLog = logger.New("workflow:lsp_manager") + +// LSPServerConfig defines a single language server entry under top-level frontmatter "lsp:". +type LSPServerConfig struct { + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + FileExtensions map[string]string `json:"fileExtensions,omitempty"` + // Version pins the package version for the language server. When set, it overrides the + // built-in default version for known LSP servers. Accepts standard semver version strings + // (e.g. "5.3.0") without a leading "v". Has no effect for custom servers not in the + // built-in install spec table. + Version string `json:"version,omitempty"` +} + +// LSPManager handles LSP configuration normalization, validation, and generation. +type LSPManager struct { + servers map[string]LSPServerConfig +} + +func NewLSPManager(servers map[string]LSPServerConfig) *LSPManager { + // Sort keys for deterministic normalization order so that when two keys + // collapse to the same lowercase value (e.g. "TypeScript" and "typescript"), + // the lexicographically first original key always wins and the duplicate is + // logged rather than silently lost. + keys := make([]string, 0, len(servers)) + for k := range servers { + keys = append(keys, k) + } + sort.Strings(keys) + + normalized := make(map[string]LSPServerConfig, len(servers)) + for _, key := range keys { + language := strings.TrimSpace(strings.ToLower(key)) + if language == "" { + lspManagerLog.Printf("Skipping invalid LSP language key: %q", key) + continue + } + if _, exists := normalized[language]; exists { + lspManagerLog.Printf("Duplicate LSP language key %q (normalizes to %q): entry ignored", key, language) + continue + } + config := servers[key] + config.Command = strings.TrimSpace(config.Command) + normalized[language] = config + } + return &LSPManager{servers: normalized} +} + +func (m *LSPManager) HasServers() bool { + return m != nil && len(m.servers) > 0 +} + +func (m *LSPManager) Validate() error { + if !m.HasServers() { + return nil + } + for language, config := range m.servers { + if config.Command == "" { + return fmt.Errorf("lsp.%s.command is required", language) + } + if len(config.FileExtensions) == 0 { + return fmt.Errorf("lsp.%s.fileExtensions must define at least one file extension mapping", language) + } + } + return nil +} + +func (m *LSPManager) CopilotLSPServers() map[string]LSPServerConfig { + if !m.HasServers() { + return nil + } + result := make(map[string]LSPServerConfig, len(m.servers)) + maps.Copy(result, m.servers) + return result +} + +// GenerateInstallSteps generates GitHub Actions steps that install the LSP server +// binary dependencies for all configured LSP servers. +// +// For npm-based servers the generated install command respects the workflow's +// runtime-manager settings: +// - workflowData.RunInstallScripts (runtimes.node.run-install-scripts) controls +// whether --ignore-scripts is omitted (default: scripts disabled). +// - resolveRuntimeCooldown (runtimes.node.cooldown) controls whether +// NPM_CONFIG_MIN_RELEASE_AGE is injected (default: cooldown enabled). +// +// Pass nil for workflowData to get secure defaults (--ignore-scripts, cooldown on). +func (m *LSPManager) GenerateInstallSteps(workflowData *WorkflowData) []GitHubActionStep { + if !m.HasServers() { + return nil + } + + // Determine npm install flags from runtime-manager settings. + // Defaults match the runtime manager's secure defaults: + // - --ignore-scripts ON (supply-chain protection) + // - cooldown ON (NPM_CONFIG_MIN_RELEASE_AGE) + runInstallScripts := false + cooldownEnabled := true + if workflowData != nil { + runInstallScripts = workflowData.RunInstallScripts + cooldownEnabled = resolveRuntimeCooldown(workflowData, "node") + } + + langs := make([]string, 0, len(m.servers)) + for language := range m.servers { + langs = append(langs, language) + } + sort.Strings(langs) + + var steps []GitHubActionStep + for _, language := range langs { + spec, ok := lspInstallSpecs[language] + if !ok { + continue + } + + // Resolve effective version: frontmatter overrides the spec default. + // Strip any leading 'v' prefix so both "0.17.0" and "v0.17.0" are handled + // consistently, avoiding malformed version strings like "@vv0.17.0". + config := m.servers[language] + effectiveVersion := strings.TrimPrefix(config.Version, "v") + + var step GitHubActionStep + if len(spec.NpmPackages) > 0 { + // npm-based LSP server: build install command from runtime-manager settings. + args := []string{"npm", "install", "-g"} + if !runInstallScripts { + args = append(args, "--ignore-scripts") + } + // Pin each npm package to its version. The primary (last) package is the + // LSP server binary itself; its version can be overridden via the frontmatter + // 'version' field. All other packages use their hardcoded default version. + primaryPkg := spec.NpmPackages[len(spec.NpmPackages)-1] + for _, pkg := range spec.NpmPackages { + ver := spec.NpmPackageVersions[pkg] + if pkg == primaryPkg && effectiveVersion != "" { + ver = effectiveVersion + } + if ver != "" { + args = append(args, pkg+"@"+ver) + } else { + args = append(args, pkg) + } + } + installCmd := strings.Join(args, " ") + step = GitHubActionStep{ + " - name: " + spec.StepName, + " run: " + installCmd, + } + if cooldownEnabled { + step = append(step, + " env:", + fmt.Sprintf(" NPM_CONFIG_MIN_RELEASE_AGE: '%d'", npmDefaultCooldownDays), + ) + } + step = append(step, " timeout-minutes: 10") + } else { + // Non-npm LSP server (go install, gem install, rustup): build versioned command. + var installCmd string + switch language { + case "go": + ver := spec.DefaultVersion + if effectiveVersion != "" { + ver = effectiveVersion + } + if ver != "" { + installCmd = "go install golang.org/x/tools/gopls@v" + ver + } else { + installCmd = "go install golang.org/x/tools/gopls@latest" + } + case "ruby": + ver := spec.DefaultVersion + if effectiveVersion != "" { + ver = effectiveVersion + } + if ver != "" { + installCmd = "gem install solargraph -v " + ver + } else { + installCmd = "gem install solargraph" + } + default: + installCmd = "rustup component add rust-analyzer" + } + step = GitHubActionStep{ + " - name: " + spec.StepName, + " run: " + installCmd, + " timeout-minutes: 10", + } + } + steps = append(steps, step) + } + + return steps +} + +// RuntimeRequirements returns the set of runtime requirements for all configured LSP +// servers. These are returned as [RuntimeRequirement] values so that the caller can +// feed them into the standard runtime manager (DetectRuntimeRequirements / +// GenerateRuntimeSetupSteps), which emits properly SHA-pinned setup actions. +// +// Only languages that have a matching entry in knownRuntimes are included; languages +// whose runtime is not tracked by the runtime manager (e.g. "rust") are silently +// skipped — their install commands still appear in GenerateInstallSteps. +// +// Note: Node.js-based LSP servers (bash, php, python, typescript, yaml) map to the +// "node" runtime, but the Copilot engine already sets up Node.js unconditionally via +// BuildNpmEngineInstallStepsWithAWF. Returning "node" here is correct and harmless: +// DetectRuntimeRequirements deduplicates by runtime ID, so at most one Node.js setup +// step is emitted regardless of how many node-based LSP servers are configured. +func (m *LSPManager) RuntimeRequirements() []RuntimeRequirement { + if !m.HasServers() { + return nil + } + + seen := make(map[string]bool) + var result []RuntimeRequirement + + langs := make([]string, 0, len(m.servers)) + for language := range m.servers { + langs = append(langs, language) + } + sort.Strings(langs) + + for _, language := range langs { + spec, ok := lspInstallSpecs[language] + if !ok || spec.RuntimeID == "" { + continue + } + if seen[spec.RuntimeID] { + continue + } + seen[spec.RuntimeID] = true + runtime := findRuntimeByID(spec.RuntimeID) + if runtime == nil { + lspManagerLog.Printf("LSP language %q specifies unknown runtime ID %q; skipping runtime requirement", language, spec.RuntimeID) + continue + } + result = append(result, RuntimeRequirement{ + Runtime: runtime, + Version: "", + Cooldown: true, + }) + } + return result +} + +type lspInstallSpec struct { + StepName string + NpmPackages []string // Non-nil: install these packages globally via npm (respects RunInstallScripts + cooldown) + NpmPackageVersions map[string]string // Default pinned version for each npm package; key = package name + DefaultVersion string // Default pinned version for non-npm installs (go, gem) + RuntimeID string // runtime manager ID for the runtime needed to run this LSP server +} + +var lspInstallSpecs = map[string]lspInstallSpec{ + "bash": { + StepName: "Install Bash LSP dependencies", + NpmPackages: []string{"bash-language-server"}, + NpmPackageVersions: map[string]string{"bash-language-server": "5.4.0"}, + RuntimeID: "node", + }, + "go": { + StepName: "Install Go LSP dependencies", + DefaultVersion: "0.18.1", + RuntimeID: "go", + }, + "php": { + StepName: "Install PHP LSP dependencies", + NpmPackages: []string{"intelephense"}, + NpmPackageVersions: map[string]string{"intelephense": "1.14.1"}, + RuntimeID: "node", + }, + "python": { + StepName: "Install Python LSP dependencies", + NpmPackages: []string{"pyright"}, + NpmPackageVersions: map[string]string{"pyright": "1.1.399"}, + RuntimeID: "node", + }, + "ruby": { + StepName: "Install Ruby LSP dependencies", + DefaultVersion: "0.50.0", + RuntimeID: "ruby", + }, + "rust": { + StepName: "Install Rust LSP dependencies", + RuntimeID: "", // Rust is not in knownRuntimes; runtime setup is done via rustup + }, + "typescript": { + StepName: "Install TypeScript LSP dependencies", + NpmPackages: []string{"typescript", "typescript-language-server"}, + NpmPackageVersions: map[string]string{ + "typescript": "5.8.3", + "typescript-language-server": "4.3.3", + }, + RuntimeID: "node", + }, + "yaml": { + StepName: "Install YAML LSP dependencies", + NpmPackages: []string{"yaml-language-server"}, + NpmPackageVersions: map[string]string{"yaml-language-server": "1.15.0"}, + RuntimeID: "node", + }, +} diff --git a/pkg/workflow/lsp_manager_test.go b/pkg/workflow/lsp_manager_test.go new file mode 100644 index 00000000000..0fc47e8ff45 --- /dev/null +++ b/pkg/workflow/lsp_manager_test.go @@ -0,0 +1,297 @@ +//go:build !integration + +package workflow + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLSPManagerValidate(t *testing.T) { + manager := NewLSPManager(map[string]LSPServerConfig{ + "typescript": { + Command: "typescript-language-server", + Args: []string{"--stdio"}, + FileExtensions: map[string]string{ + ".ts": "typescript", + }, + }, + }) + require.NoError(t, manager.Validate()) + + invalid := NewLSPManager(map[string]LSPServerConfig{ + "python": { + Command: "pyright-langserver", + }, + }) + require.Error(t, invalid.Validate()) +} + +func TestLSPManagerDuplicateKeyNormalization(t *testing.T) { + // "TypeScript" and "typescript" both normalize to "typescript". + // Sorted order puts "TypeScript" first (uppercase < lowercase in ASCII), + // so the "TypeScript" entry should win deterministically. + manager := NewLSPManager(map[string]LSPServerConfig{ + "TypeScript": { + Command: "first-server", + FileExtensions: map[string]string{ + ".ts": "typescript", + }, + }, + "typescript": { + Command: "second-server", + FileExtensions: map[string]string{ + ".ts": "typescript", + }, + }, + }) + + servers := manager.CopilotLSPServers() + require.Len(t, servers, 1) + assert.Equal(t, "first-server", servers["typescript"].Command) +} + +func TestLSPManagerGenerateInstallSteps(t *testing.T) { + manager := NewLSPManager(map[string]LSPServerConfig{ + "typescript": { + Command: "typescript-language-server", + FileExtensions: map[string]string{ + ".ts": "typescript", + }, + }, + "unknown": { + Command: "my-lsp", + FileExtensions: map[string]string{ + ".foo": "foo", + }, + }, + }) + + // Default: --ignore-scripts + cooldown enabled + pinned versions + steps := manager.GenerateInstallSteps(nil) + require.Len(t, steps, 1) + content := strings.Join(steps[0], "\n") + assert.Contains(t, content, "Install TypeScript LSP dependencies") + assert.Contains(t, content, "npm install -g --ignore-scripts typescript@5.8.3 typescript-language-server@4.3.3") + assert.Contains(t, content, "NPM_CONFIG_MIN_RELEASE_AGE") +} + +func TestLSPManagerGenerateInstallSteps_RunInstallScripts(t *testing.T) { + // When run-install-scripts is enabled, --ignore-scripts must be omitted. + manager := NewLSPManager(map[string]LSPServerConfig{ + "typescript": { + Command: "typescript-language-server", + FileExtensions: map[string]string{ + ".ts": "typescript", + }, + }, + }) + workflowData := &WorkflowData{ + RunInstallScripts: true, + } + steps := manager.GenerateInstallSteps(workflowData) + require.Len(t, steps, 1) + content := strings.Join(steps[0], "\n") + assert.Contains(t, content, "npm install -g typescript@5.8.3 typescript-language-server@4.3.3") + assert.NotContains(t, content, "--ignore-scripts") +} + +func TestLSPManagerGenerateInstallSteps_CooldownDisabled(t *testing.T) { + // When runtimes.node.cooldown: false, NPM_CONFIG_MIN_RELEASE_AGE must not appear. + manager := NewLSPManager(map[string]LSPServerConfig{ + "yaml": { + Command: "yaml-language-server", + FileExtensions: map[string]string{ + ".yaml": "yaml", + }, + }, + }) + falseVal := false + workflowData := &WorkflowData{ + ParsedFrontmatter: &FrontmatterConfig{ + RuntimesTyped: &RuntimesConfig{ + Node: &RuntimeConfig{Cooldown: &falseVal}, + }, + }, + } + steps := manager.GenerateInstallSteps(workflowData) + require.Len(t, steps, 1) + content := strings.Join(steps[0], "\n") + assert.Contains(t, content, "--ignore-scripts") + assert.NotContains(t, content, "NPM_CONFIG_MIN_RELEASE_AGE") +} + +func TestLSPManagerGenerateInstallSteps_DefaultVersionPinning(t *testing.T) { + // Default pinned versions are injected for known npm-based servers. + manager := NewLSPManager(map[string]LSPServerConfig{ + "yaml": { + Command: "yaml-language-server", + FileExtensions: map[string]string{".yaml": "yaml"}, + }, + }) + steps := manager.GenerateInstallSteps(nil) + require.Len(t, steps, 1) + content := strings.Join(steps[0], "\n") + assert.Contains(t, content, "yaml-language-server@1.15.0") +} + +func TestLSPManagerGenerateInstallSteps_VersionOverride(t *testing.T) { + // A Version field in the frontmatter overrides the pinned default for the primary package. + manager := NewLSPManager(map[string]LSPServerConfig{ + "typescript": { + Command: "typescript-language-server", + Version: "5.0.0", + FileExtensions: map[string]string{".ts": "typescript"}, + }, + }) + steps := manager.GenerateInstallSteps(nil) + require.Len(t, steps, 1) + content := strings.Join(steps[0], "\n") + // typescript-language-server is the primary (last) package; its version is overridden. + assert.Contains(t, content, "typescript-language-server@5.0.0") + // typescript (secondary package) retains its default version. + assert.Contains(t, content, "typescript@5.8.3") +} + +func TestLSPManagerGenerateInstallSteps_GoDefaultVersion(t *testing.T) { + // gopls is installed with its pinned default version. + manager := NewLSPManager(map[string]LSPServerConfig{ + "go": {Command: "gopls", FileExtensions: map[string]string{".go": "go"}}, + }) + steps := manager.GenerateInstallSteps(nil) + require.Len(t, steps, 1) + content := strings.Join(steps[0], "\n") + assert.Contains(t, content, "go install golang.org/x/tools/gopls@v0.18.1") +} + +func TestLSPManagerGenerateInstallSteps_GoVersionOverride(t *testing.T) { + // A Version field overrides gopls install version. + manager := NewLSPManager(map[string]LSPServerConfig{ + "go": {Command: "gopls", Version: "0.17.0", FileExtensions: map[string]string{".go": "go"}}, + }) + steps := manager.GenerateInstallSteps(nil) + require.Len(t, steps, 1) + content := strings.Join(steps[0], "\n") + assert.Contains(t, content, "go install golang.org/x/tools/gopls@v0.17.0") +} + +func TestLSPManagerGenerateInstallSteps_GoVersionOverride_VPrefix(t *testing.T) { + // A Version field with a leading 'v' prefix must not produce '@vv...' in the command. + manager := NewLSPManager(map[string]LSPServerConfig{ + "go": {Command: "gopls", Version: "v0.17.0", FileExtensions: map[string]string{".go": "go"}}, + }) + steps := manager.GenerateInstallSteps(nil) + require.Len(t, steps, 1) + content := strings.Join(steps[0], "\n") + assert.Contains(t, content, "go install golang.org/x/tools/gopls@v0.17.0") + assert.NotContains(t, content, "@vv") +} + +func TestLSPManagerGenerateInstallSteps_RubyDefaultVersion(t *testing.T) { + // solargraph is installed with its pinned default version. + manager := NewLSPManager(map[string]LSPServerConfig{ + "ruby": {Command: "solargraph", FileExtensions: map[string]string{".rb": "ruby"}}, + }) + steps := manager.GenerateInstallSteps(nil) + require.Len(t, steps, 1) + content := strings.Join(steps[0], "\n") + assert.Contains(t, content, "gem install solargraph -v 0.50.0") +} + +func TestLSPManagerGenerateInstallSteps_RubyVersionOverride(t *testing.T) { + // A Version field overrides solargraph gem install version. + manager := NewLSPManager(map[string]LSPServerConfig{ + "ruby": {Command: "solargraph", Version: "0.48.0", FileExtensions: map[string]string{".rb": "ruby"}}, + }) + steps := manager.GenerateInstallSteps(nil) + require.Len(t, steps, 1) + content := strings.Join(steps[0], "\n") + assert.Contains(t, content, "gem install solargraph -v 0.48.0") +} + +func TestLSPManagerRuntimeRequirements_NodeBased(t *testing.T) { + // Node.js-based LSP servers (typescript, python/pyright, bash, php, yaml) should all + // resolve to the "node" runtime — deduplicated to a single requirement. + manager := NewLSPManager(map[string]LSPServerConfig{ + "typescript": {Command: "typescript-language-server", FileExtensions: map[string]string{".ts": "typescript"}}, + "python": {Command: "pyright-langserver", FileExtensions: map[string]string{".py": "python"}}, + }) + reqs := manager.RuntimeRequirements() + require.Len(t, reqs, 1, "typescript and python both need node; expect exactly one node requirement") + assert.Equal(t, "node", reqs[0].Runtime.ID) +} + +func TestLSPManagerRuntimeRequirements_GoLSP(t *testing.T) { + // gopls requires the Go runtime. + manager := NewLSPManager(map[string]LSPServerConfig{ + "go": {Command: "gopls", FileExtensions: map[string]string{".go": "go"}}, + }) + reqs := manager.RuntimeRequirements() + require.Len(t, reqs, 1) + assert.Equal(t, "go", reqs[0].Runtime.ID) +} + +func TestLSPManagerRuntimeRequirements_RubyLSP(t *testing.T) { + // solargraph requires the Ruby runtime. + manager := NewLSPManager(map[string]LSPServerConfig{ + "ruby": {Command: "solargraph", FileExtensions: map[string]string{".rb": "ruby"}}, + }) + reqs := manager.RuntimeRequirements() + require.Len(t, reqs, 1) + assert.Equal(t, "ruby", reqs[0].Runtime.ID) +} + +func TestLSPManagerRuntimeRequirements_RustLSP(t *testing.T) { + // rust-analyzer uses rustup; Rust is not in knownRuntimes so no runtime requirement is returned. + manager := NewLSPManager(map[string]LSPServerConfig{ + "rust": {Command: "rust-analyzer", FileExtensions: map[string]string{".rs": "rust"}}, + }) + reqs := manager.RuntimeRequirements() + assert.Empty(t, reqs, "Rust is not in knownRuntimes; expect no runtime requirement") +} + +func TestLSPManagerRuntimeRequirements_UnknownLanguage(t *testing.T) { + // A language not in lspInstallSpecs contributes no runtime requirement. + manager := NewLSPManager(map[string]LSPServerConfig{ + "cobol": {Command: "cobol-lsp", FileExtensions: map[string]string{".cbl": "cobol"}}, + }) + reqs := manager.RuntimeRequirements() + assert.Empty(t, reqs) +} + +func TestLSPManagerRuntimeRequirements_MultipleRuntimes(t *testing.T) { + // A workflow with both a Go LSP and a TypeScript LSP needs both Go and Node.js. + manager := NewLSPManager(map[string]LSPServerConfig{ + "go": {Command: "gopls", FileExtensions: map[string]string{".go": "go"}}, + "typescript": {Command: "typescript-language-server", FileExtensions: map[string]string{".ts": "typescript"}}, + }) + reqs := manager.RuntimeRequirements() + require.Len(t, reqs, 2) + ids := map[string]bool{} + for _, r := range reqs { + ids[r.Runtime.ID] = true + } + assert.True(t, ids["go"], "expected go runtime requirement") + assert.True(t, ids["node"], "expected node runtime requirement") +} + +func TestDetectRuntimeRequirements_LSPServers(t *testing.T) { + // DetectRuntimeRequirements should pick up runtime requirements from LSP config. + data := &WorkflowData{ + LSP: map[string]LSPServerConfig{ + "go": {Command: "gopls", FileExtensions: map[string]string{".go": "go"}}, + }, + } + reqs := DetectRuntimeRequirements(data) + found := false + for _, r := range reqs { + if r.Runtime.ID == "go" { + found = true + break + } + } + assert.True(t, found, "expected Go runtime requirement from LSP config") +} diff --git a/pkg/workflow/lsp_validation.go b/pkg/workflow/lsp_validation.go new file mode 100644 index 00000000000..80f023f9b39 --- /dev/null +++ b/pkg/workflow/lsp_validation.go @@ -0,0 +1,25 @@ +package workflow + +import "fmt" + +func (c *Compiler) validateLSPSupport(workflowData *WorkflowData) error { + if workflowData == nil || len(workflowData.LSP) == 0 { + return nil + } + + manager := NewLSPManager(workflowData.LSP) + if err := manager.Validate(); err != nil { + return err + } + + engineID := ResolveEngineID(workflowData) + if engineID == "" { + engineID = "copilot" + } + + if engineID != "copilot" { + return fmt.Errorf("lsp is currently only supported for engine: copilot (found engine: %s)", engineID) + } + + return nil +} diff --git a/pkg/workflow/lsp_validation_test.go b/pkg/workflow/lsp_validation_test.go new file mode 100644 index 00000000000..3083eb48548 --- /dev/null +++ b/pkg/workflow/lsp_validation_test.go @@ -0,0 +1,57 @@ +//go:build !integration + +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateLSPSupport(t *testing.T) { + compiler := NewCompiler() + + validLSP := map[string]LSPServerConfig{ + "typescript": { + Command: "typescript-language-server", + Args: []string{"--stdio"}, + FileExtensions: map[string]string{ + ".ts": "typescript", + }, + }, + } + + t.Run("copilot engine accepts lsp", func(t *testing.T) { + err := compiler.validateLSPSupport(&WorkflowData{ + AI: "copilot", + LSP: validLSP, + }) + require.NoError(t, err) + }) + + t.Run("non-copilot engine rejects lsp", func(t *testing.T) { + err := compiler.validateLSPSupport(&WorkflowData{ + AI: "codex", + LSP: validLSP, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "only supported for engine: copilot") + }) + + t.Run("invalid lsp config fails validation", func(t *testing.T) { + err := compiler.validateLSPSupport(&WorkflowData{ + AI: "copilot", + LSP: map[string]LSPServerConfig{ + "python": { + Command: "", + FileExtensions: map[string]string{ + ".py": "python", + }, + }, + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "lsp.python.command is required") + }) +} diff --git a/pkg/workflow/runtime_detection.go b/pkg/workflow/runtime_detection.go index 0c9e9804570..7ab6e7328f0 100644 --- a/pkg/workflow/runtime_detection.go +++ b/pkg/workflow/runtime_detection.go @@ -52,6 +52,15 @@ func DetectRuntimeRequirements(workflowData *WorkflowData) []RuntimeRequirement } } + // Detect runtimes required by LSP server configurations. + // Each known LSP server declares the runtime it needs (e.g. "go" for gopls, + // "ruby" for solargraph). Feeding these through the runtime manager ensures + // the runtime is set up via a properly SHA-pinned setup action rather than + // relying on whatever version happens to be pre-installed on the runner. + if len(workflowData.LSP) > 0 { + detectFromLSPServers(workflowData.LSP, requirements) + } + // Apply runtime overrides from frontmatter if workflowData.Runtimes != nil { applyRuntimeOverrides(workflowData.Runtimes, requirements) @@ -233,6 +242,23 @@ func detectFromMCPScripts(mcpScripts *MCPScriptsConfig, requirements map[string] } } +// detectFromLSPServers adds runtime requirements for the runtimes needed by configured +// LSP servers. Each entry in lspInstallSpecs declares the runtime ID it needs (e.g. +// "go" for gopls, "ruby" for solargraph, "node" for npm-based servers). Feeding these +// through the runtime manager ensures that the runtime is set up via a properly +// SHA-pinned setup action (e.g. actions/setup-go, ruby/setup-ruby) and with a pinned +// version, rather than relying on whatever happens to be pre-installed on the runner. +func detectFromLSPServers(lsp map[string]LSPServerConfig, requirements map[string]*RuntimeRequirement) { + if len(lsp) == 0 { + return + } + manager := NewLSPManager(lsp) + for _, req := range manager.RuntimeRequirements() { + runtimeSetupLog.Printf("LSP server requires runtime: %s", req.Runtime.ID) + updateRequiredRuntime(req.Runtime, req.Version, requirements) + } +} + // updateRequiredRuntime updates the version requirement, choosing the highest version func updateRequiredRuntime(runtime *Runtime, newVersion string, requirements map[string]*RuntimeRequirement) { existing, exists := requirements[runtime.ID] diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index 60a2f340af8..8bd374e877e 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -53,6 +53,7 @@ func (c *Compiler) buildInitialWorkflowData( IncludedFiles: toolsResult.allIncludedFiles, ImportInputs: importsResult.ImportInputs, Tools: toolsResult.tools, + LSP: extractLSPConfig(toolsResult.parsedFrontmatter, result.Frontmatter), ParsedTools: NewTools(toolsResult.tools), Runtimes: toolsResult.runtimes, RunInstallScripts: toolsResult.runInstallScripts, @@ -155,6 +156,34 @@ func (c *Compiler) buildInitialWorkflowData( return workflowData } +func extractLSPConfig(parsedFrontmatter *FrontmatterConfig, frontmatter map[string]any) map[string]LSPServerConfig { + if parsedFrontmatter != nil && len(parsedFrontmatter.LSP) > 0 { + return parsedFrontmatter.LSP + } + + rawLSP, ok := frontmatter["lsp"] + if !ok { + return nil + } + + jsonBytes, err := json.Marshal(rawLSP) + if err != nil { + workflowBuilderLog.Printf("Failed to marshal lsp frontmatter config: %v", err) + return nil + } + + var lsp map[string]LSPServerConfig + if err := json.Unmarshal(jsonBytes, &lsp); err != nil { + workflowBuilderLog.Printf("Failed to unmarshal lsp frontmatter config: %v", err) + return nil + } + + if len(lsp) == 0 { + return nil + } + return lsp +} + func extractMainModelCostsOverlay(toolsResult *toolsProcessingResult, frontmatter map[string]any) map[string]any { // Fall back to raw frontmatter when ParseFrontmatterConfig failed (e.g. due to unrecognized // tool config shapes like bash: ["*"]).