diff --git a/.gitignore b/.gitignore index 8d1b05c91..85c21773c 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,6 @@ gha-creds-*.json # IDEs .idea + +# Local git worktrees +.worktrees/ diff --git a/.icons/pi.svg b/.icons/pi.svg new file mode 100644 index 000000000..e28516853 --- /dev/null +++ b/.icons/pi.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/registry/coder-labs/.images/pi.png b/registry/coder-labs/.images/pi.png new file mode 100644 index 000000000..59b66a39a Binary files /dev/null and b/registry/coder-labs/.images/pi.png differ diff --git a/registry/coder-labs/modules/pi/README.md b/registry/coder-labs/modules/pi/README.md new file mode 100644 index 000000000..f1f0ace77 --- /dev/null +++ b/registry/coder-labs/modules/pi/README.md @@ -0,0 +1,386 @@ +--- +display_name: Pi +description: Install and configure the Pi coding agent CLI in your workspace. +icon: ../../../../.icons/pi.svg +verified: false +tags: [agent, pi, ai, ai-gateway] +--- + +# Pi + +Install and configure the [Pi](https://pi.dev/) coding agent CLI in your workspace. +Pi is a customizable terminal coding agent harness built by [earendil-works](https://github.com/earendil-works/pi). +The module installs and configures the CLI; starting Pi is left to a `coder_app`, an IDE launcher, or a custom `coder_script`. + +```tf +module "pi" { + source = "registry.coder.com/coder-labs/pi/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + enable_ai_gateway = true +} +``` + +![Pi interactive mode in a terminal](../../.images/pi.png) + +## Prerequisites + +The module installs Pi with `npm install -g @earendil-works/pi-coding-agent` into a user-owned prefix (`~/.coder-modules/coder-labs/pi/npm-global`), so it never needs `sudo` or a writable global npm prefix. +The workspace image must already have Node.js (>= 22.19.0), npm, and `jq`. +To skip the npm install entirely, see [Bring your own Pi binary](#bring-your-own-pi-binary). + +## Authentication + +Choose one of the following paths. +They are listed from most to least centrally managed. + +| Path | Raw keys in the template | Configure with | +| --------------------------------------------- | ------------------------ | -------------------------------------------------------------------- | +| [Coder AI Gateway](#ai-gateway) (recommended) | No | `enable_ai_gateway = true` | +| [Interactive `/login`](#interactive-login) | No | Nothing; users sign in from inside Pi | +| [Provider API keys](#provider-api-keys) | Via sensitive variables | `anthropic_api_key`, `openai_api_key`, `gemini_api_key`, `extra_env` | + +### Interactive login + +Leave every credential input unset and have users run `/login` inside Pi. +Pi can authenticate against a Claude Pro/Max, ChatGPT Plus/Pro, or GitHub Copilot subscription and stores the credential in `~/.pi/agent/auth.json` in the workspace, so no key ever appears in the template. + +```tf +module "pi" { + source = "registry.coder.com/coder-labs/pi/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +### Provider API keys + +Pi reads standard provider environment variables directly, so any combination can be set. +Every credential input is marked `sensitive = true`. +Pass values through a sensitive Terraform variable or a secret store rather than inline literals, so keys never land in template source. + +| Input | Environment variable | +| ------------------- | ---------------------------------------------------------------------------------------- | +| `anthropic_api_key` | `ANTHROPIC_API_KEY` | +| `openai_api_key` | `OPENAI_API_KEY` | +| `gemini_api_key` | `GEMINI_API_KEY` | +| `extra_env` | Any variable name, for other providers (Azure OpenAI, Mistral, Groq, DeepSeek, and more) | + +```tf +variable "anthropic_api_key" { + type = string + sensitive = true +} + +module "pi" { + source = "registry.coder.com/coder-labs/pi/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + anthropic_api_key = var.anthropic_api_key +} +``` + +## AI governance + +Coder can govern how Pi authenticates, where its model traffic goes, and which network destinations it can reach. + +### AI Gateway + +[AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) is a Premium Coder feature that provides centralized LLM proxy management, auditing, and attribution. +Requires Coder >= 2.30.0. + +Set `enable_ai_gateway = true` to route Pi's built-in `anthropic` and `openai` providers through your Coder deployment: + +- The install script writes provider `baseUrl` overrides to `~/.pi/agent/models.json`, pointing `anthropic` at `/api/v2/ai-gateway/anthropic` and `openai` at `/api/v2/ai-gateway/openai/v1`. + Other keys in `models.json` are preserved, and Pi's built-in model lists stay available. +- `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` are set to the workspace owner's Coder session token, which AI Gateway uses to authenticate the user. + +Coder then governs auth and routing centrally: developers never handle a provider key, the gateway injects the upstream credentials, and every request is attributed to the user and audited. + +```tf +module "pi" { + source = "registry.coder.com/coder-labs/pi/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + enable_ai_gateway = true +} +``` + +In Pi, run `/model` to pick an Anthropic or OpenAI model served by the gateway. + +> [!CAUTION] +> `enable_ai_gateway = true` is mutually exclusive with `anthropic_api_key` and `openai_api_key`. +> Setting either fails at plan time. +> A credential saved with `/login` in `auth.json` takes precedence over the gateway token, so run `/logout` for that provider if requests bypass the gateway. + +### Agent Firewall + +[Agent Firewall](https://coder.com/docs/ai-coder/agent-firewall) enforces a network egress allowlist around an agent so Pi can only reach approved destinations. +Install the [`agent-firewall`](https://registry.coder.com/modules/coder/agent-firewall) module and run `pi` through its wrapper to apply policy enforcement: + +```tf +module "pi" { + source = "registry.coder.com/coder-labs/pi/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + enable_ai_gateway = true +} + +module "agent-firewall" { + source = "registry.coder.com/coder/agent-firewall/coder" + version = "0.0.4" + agent_id = coder_agent.main.id +} + +resource "coder_app" "pi" { + agent_id = coder_agent.main.id + slug = "pi" + display_name = "Pi (Agent Firewall)" + icon = "/icon/pi.svg" + open_in = "slim-window" + command = <<-EOT + #!/usr/bin/env bash + set -e + cd /home/coder/project + exec "${module.agent-firewall.agent_firewall_wrapper_path}" \ + --config="${module.agent-firewall.agent_firewall_config_path}" -- pi + EOT +} +``` + +Add Pi's runtime endpoints from [Network access](#network-access-and-air-gapped-environments) to the Agent Firewall allowlist so requests are not blocked. + +## Dashboard entry point + +Add a `coder_app` to give developers a one-click launcher for Pi from the Coder dashboard. + +```tf +locals { + pi_workdir = "/home/coder/project" +} + +module "pi" { + source = "registry.coder.com/coder-labs/pi/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = local.pi_workdir + enable_ai_gateway = true +} + +resource "coder_app" "pi" { + agent_id = coder_agent.main.id + slug = "pi" + display_name = "Pi" + icon = "/icon/pi.svg" + open_in = "slim-window" + command = <<-EOT + #!/usr/bin/env bash + set -e + cd "${local.pi_workdir}" + pi --continue + EOT +} +``` + +`pi --continue` reopens the most recent Pi session for the working directory, or starts a new one if none exists. +Pi saves every session under `~/.pi/agent/sessions/`, so the conversation survives app relaunches and workspace restarts as long as the home directory persists. +Use `pi --resume` instead to pick from earlier sessions. + +## Session continuity + +The `coder_app` command re-executes on every reconnect, which starts a new Pi process. +To keep a single long-lived Pi process running across dashboard reconnects, run it inside a persistent `tmux` session and attach to it from the app: + +```tf +locals { + pi_workdir = "/home/coder/project" +} + +module "pi" { + source = "registry.coder.com/coder-labs/pi/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = local.pi_workdir + enable_ai_gateway = true +} + +resource "coder_app" "pi" { + agent_id = coder_agent.main.id + slug = "pi" + display_name = "Pi" + icon = "/icon/pi.svg" + open_in = "slim-window" + command = <<-EOT + #!/usr/bin/env bash + set -e + cd "${local.pi_workdir}" + exec tmux new-session -A -s pi 'pi --continue' + EOT +} +``` + +`tmux new-session -A -s pi` attaches to the running `pi` session if it exists, or starts it otherwise. +If the tmux session ends (for example after a workspace restart), `pi --continue` restores the previous conversation from disk. +The workspace image must include `tmux`. + +## Managed configuration + +The module manages Pi's user-level configuration in `~/.pi/agent/` on every workspace start and preserves keys it does not own: + +| File | Keys the module manages | Controlled by | +| --------------- | --------------------------------------------------------- | ----------------------- | +| `settings.json` | `defaultProjectTrust` | `default_project_trust` | +| `models.json` | `providers.anthropic.baseUrl`, `providers.openai.baseUrl` | `enable_ai_gateway` | + +### Project trust + +By default the module sets `defaultProjectTrust = "always"` so Pi does not prompt to trust a project folder on first run in the workspace. +Set `default_project_trust` to `"ask"` or `"never"` to change this behavior. + +```tf +module "pi" { + source = "registry.coder.com/coder-labs/pi/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + enable_ai_gateway = true + default_project_trust = "ask" +} +``` + +### Workdir + +`workdir` is optional. +When set, the module pre-creates the directory if it is missing. +Leave `workdir` unset if you only want the module to install and configure the CLI; users can `cd` into any project themselves. + +> [!NOTE] +> Pi does not include built-in MCP (Model Context Protocol) support. +> Extend Pi with [extensions, skills, and custom tools](https://github.com/earendil-works/pi) instead. + +## Version pinning and multiple providers + +```tf +module "pi" { + source = "registry.coder.com/coder-labs/pi/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + + pi_version = "0.87.1" # Pin to a specific Pi CLI version. + + anthropic_api_key = var.anthropic_api_key + openai_api_key = var.openai_api_key + gemini_api_key = var.gemini_api_key + + extra_env = { + MISTRAL_API_KEY = var.mistral_api_key + } +} +``` + +## Bring your own Pi binary + +Set `install_pi = false` when Pi is already baked into the workspace image. +The module then skips npm entirely, makes no network requests at install time, and only validates that `pi --version` runs before writing configuration. + +By default the module looks up `pi` on `PATH`. +If the binary lives somewhere else, set `pi_binary_path` to its absolute path; the module adds its directory to `PATH` and links it into the Coder script bin directory. + +```tf +module "pi" { + source = "registry.coder.com/coder-labs/pi/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + install_pi = false + pi_binary_path = "/opt/pi/bin/pi" + enable_ai_gateway = true +} +``` + +Workspace startup fails with a clear error in the install log if the binary is missing or not executable. + +## Network access and air-gapped environments + +The table lists every external endpoint the module and Pi contact, so you can pre-approve them in an allowlist or mirror them in a restricted network. + +| Phase | Endpoint | When | How to override | +| ------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| Install | npm registry (`https://registry.npmjs.org` by default) | `install_pi = true` (default) | `npm_registry_url`, or `install_pi = false` to skip entirely | +| Runtime | Coder deployment `access_url` (`/api/v2/ai-gateway/...`) | `enable_ai_gateway = true` | Already internal to your deployment | +| Runtime | `api.anthropic.com`, `api.openai.com`, `generativelanguage.googleapis.com`, and other provider APIs | Direct provider keys or `/login` | Use `enable_ai_gateway = true`, or a custom endpoint in `models.json` | +| Runtime | `pi.dev` | Latest-version check, model catalog refresh, and anonymous install reporting | `extra_env = { PI_OFFLINE = "1" }` disables all three | +| Runtime | `github.com` (`sharkdp/fd` and `BurntSushi/ripgrep` releases) | First run, when `fd` or `rg` is not on `PATH` | Install `fd` and `ripgrep` in the image, or set `PI_OFFLINE = "1"` | + +For restricted or air-gapped workspaces: + +- **Mirror the package.** Set `npm_registry_url` to an internal npm mirror (for example Artifactory or Nexus) that proxies `@earendil-works/pi-coding-agent`, or bake Pi into the image and set `install_pi = false`. +- **Keep model traffic internal.** Route requests through AI Gateway (`enable_ai_gateway = true`) instead of calling provider APIs directly. +- **Disable background network activity.** Set `PI_OFFLINE = "1"` through `extra_env` so Pi skips `pi.dev` requests and helper-tool downloads, and install `fd` and `ripgrep` in the image. +- **Enforce egress.** Combine with [Agent Firewall](#agent-firewall) to allow only the endpoints above. + +```tf +module "pi" { + source = "registry.coder.com/coder-labs/pi/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + npm_registry_url = "https://artifacts.internal.example.com/api/npm/npm-remote/" + enable_ai_gateway = true + + extra_env = { + PI_OFFLINE = "1" + } +} +``` + +## Serialize a downstream `coder_script` after the install pipeline + +The module exposes the `coder exp sync` name of each script it creates via the `scripts` output: an ordered list (`pre_install`, `install`, `post_install`) of names for scripts this module actually creates. +Scripts that were not configured are absent from the list. + +```tf +module "pi" { + source = "registry.coder.com/coder-labs/pi/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + enable_ai_gateway = true +} + +resource "coder_script" "post_pi" { + agent_id = coder_agent.main.id + display_name = "Run after Pi install" + run_on_start = true + script = <<-EOT + #!/usr/bin/env bash + set -euo pipefail + trap 'coder exp sync complete post-pi' EXIT + coder exp sync want post-pi ${join(" ", module.pi.scripts)} + coder exp sync start post-pi + + # Your work here runs after pi finishes installing. + pi --version + EOT +} +``` + +## Troubleshooting + +If you encounter any issues, check the log files in the `~/.coder-modules/coder-labs/pi/logs` directory within your workspace for detailed information. + +```bash +# Installation logs +cat ~/.coder-modules/coder-labs/pi/logs/install.log + +# Pre/post install script logs +cat ~/.coder-modules/coder-labs/pi/logs/pre_install.log +cat ~/.coder-modules/coder-labs/pi/logs/post_install.log +``` + +## References + +- [Pi documentation](https://github.com/earendil-works/pi) +- [AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) +- [Agent Firewall](https://coder.com/docs/ai-coder/agent-firewall) diff --git a/registry/coder-labs/modules/pi/main.test.ts b/registry/coder-labs/modules/pi/main.test.ts new file mode 100644 index 000000000..01a4f5424 --- /dev/null +++ b/registry/coder-labs/modules/pi/main.test.ts @@ -0,0 +1,470 @@ +import { + test, + afterEach, + describe, + setDefaultTimeout, + beforeAll, + expect, +} from "bun:test"; +import { + execContainer, + readFileContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + TerraformState, +} from "~test"; +import { + extractCoderEnvVars, + writeExecutable, +} from "../../../coder/modules/agentapi/test-util"; +import path from "path"; + +interface ModuleScripts { + pre_install?: string; + install: string; + post_install?: string; +} + +const SCRIPT_SUFFIXES = [ + "Pre-Install Script", + "Install Script", + "Post-Install Script", +] as const; + +const collectScripts = (state: TerraformState): ModuleScripts => { + const byDisplayName: Record = {}; + for (const resource of state.resources) { + if (resource.type !== "coder_script") continue; + for (const instance of resource.instances) { + const attrs = instance.attributes as Record; + const displayName = attrs.display_name as string | undefined; + const script = attrs.script as string | undefined; + if (displayName && script) { + byDisplayName[displayName] = script; + } + } + } + const scripts: Partial = {}; + for (const suffix of SCRIPT_SUFFIXES) { + const key = `Pi: ${suffix}`; + if (!(key in byDisplayName)) continue; + switch (suffix) { + case "Pre-Install Script": + scripts.pre_install = byDisplayName[key]; + break; + case "Install Script": + scripts.install = byDisplayName[key]; + break; + case "Post-Install Script": + scripts.post_install = byDisplayName[key]; + break; + } + } + if (!scripts.install) { + throw new Error("install script not found in terraform state"); + } + return scripts as ModuleScripts; +}; + +let cleanupFunctions: (() => Promise)[] = []; +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; +afterEach( + async () => { + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } + }, + // Removing a container after a real npm install can exceed the default + // 5s hook timeout on CI runners. + { timeout: 30 * 1000 }, +); + +interface SetupProps { + skipPiMock?: boolean; + moduleVariables?: Record; + terraformEnv?: Record; +} + +const setup = async ( + props?: SetupProps, +): Promise<{ + id: string; + coderEnvVars: Record; + scripts: ModuleScripts; +}> => { + const projectDir = "/home/coder/project"; + const moduleDir = path.resolve(import.meta.dir); + const state = await runTerraformApply( + moduleDir, + { + agent_id: "foo", + workdir: projectDir, + install_pi: "false", + ...props?.moduleVariables, + }, + props?.terraformEnv, + ); + const scripts = collectScripts(state); + const coderEnvVars = extractCoderEnvVars(state); + + const id = await runContainer("codercom/enterprise-node:latest"); + registerCleanup(async () => { + if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") { + console.log(`Not removing container ${id} in debug mode`); + return; + } + await removeContainer(id); + }); + + await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]); + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: "#!/bin/bash\nexit 0\n", + }); + if (!props?.skipPiMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/pi", + content: await Bun.file( + path.join(moduleDir, "testdata", "pi-mock.sh"), + ).text(), + }); + } + return { id, coderEnvVars, scripts }; +}; + +const runScripts = async ( + id: string, + scripts: ModuleScripts, + env?: Record, +) => { + const entries = env ? Object.entries(env) : []; + const envArgs = + entries.length > 0 + ? entries + .map( + ([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`, + ) + .join(" && ") + " && " + : ""; + const runRenderedScript = async (name: string, script: string) => { + const target = `/tmp/coder-utils-${name}.sh`; + await writeExecutable({ + containerId: id, + filePath: target, + content: script, + }); + return execContainer(id, ["bash", "-c", `${envArgs}${target}`]); + }; + const ordered: [string, string | undefined][] = [ + ["pre_install", scripts.pre_install], + ["install", scripts.install], + ["post_install", scripts.post_install], + ]; + for (const [name, script] of ordered) { + if (!script) continue; + const resp = await runRenderedScript(name, script); + if (resp.exitCode !== 0) { + console.log(`script ${name} failed:`); + console.log(resp.stdout); + console.log(resp.stderr); + throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`); + } + } +}; + +const runInstallScript = async ( + id: string, + script: string, + env?: Record, +) => { + const entries = env ? Object.entries(env) : []; + const envArgs = entries + .map(([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`) + .join(" && "); + const target = "/tmp/coder-utils-install.sh"; + await writeExecutable({ containerId: id, filePath: target, content: script }); + return execContainer(id, [ + "bash", + "-c", + `${envArgs ? `${envArgs} && ` : ""}${target}`, + ]); +}; + +const installLog = (id: string) => + readFileContainer( + id, + "/home/coder/.coder-modules/coder-labs/pi/logs/install.log", + ); + +setDefaultTimeout(60 * 1000); + +describe("pi", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + test("happy-path", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain("Skipping Pi installation"); + }); + + test("preinstalled-pi-is-required-when-installation-is-disabled", async () => { + const { id, scripts } = await setup({ skipPiMock: true }); + const result = await runInstallScript(id, scripts.install); + expect(result.exitCode).not.toBe(0); + const log = await installLog(id); + expect(log).toContain("Pi binary was not found or is not executable."); + }); + + test("install-requires-npm", async () => { + const { id, scripts } = await setup({ + skipPiMock: true, + moduleVariables: { install_pi: "true" }, + }); + // npm and its supporting files are root-owned on + // codercom/enterprise-node:latest, so removing it as the workspace user + // (as a plain `mv` would attempt) fails silently and leaves npm in + // place. Remove it as root to actually simulate npm being absent. + await execContainer( + id, + ["bash", "-c", "rm -f $(command -v npm) $(command -v npx)"], + ["-u", "root"], + ); + const result = await runInstallScript(id, scripts.install); + expect(result.exitCode).not.toBe(0); + const log = await installLog(id); + expect(log).toContain("npm was not found"); + }); + + test("install-does-not-require-writable-global-npm-prefix", async () => { + // codercom/enterprise-node:latest sets npm's default global prefix to + // /usr, which is root-owned. A plain `npm install -g` fails with EACCES + // for the unprivileged "coder" user; the module must install into a + // prefix it owns instead. + const { id, scripts } = await setup({ + skipPiMock: true, + moduleVariables: { install_pi: "true" }, + }); + const result = await runInstallScript(id, scripts.install); + expect(result.exitCode).toBe(0); + const log = await installLog(id); + expect(log).not.toContain("EACCES"); + expect(log).toContain("Installed Pi CLI"); + + const npmGlobalBin = + "/home/coder/.coder-modules/coder-labs/pi/npm-global/bin"; + const binExists = await execContainer(id, [ + "test", + "-x", + `${npmGlobalBin}/pi`, + ]); + expect(binExists.exitCode).toBe(0); + + const profile = await readFileContainer(id, "/home/coder/.bashrc"); + expect(profile).toContain(npmGlobalBin); + }); + + test("anthropic-api-key", async () => { + const apiKey = "test-api-key-123"; + const { id, coderEnvVars, scripts } = await setup({ + moduleVariables: { + anthropic_api_key: apiKey, + }, + }); + expect(coderEnvVars["ANTHROPIC_API_KEY"]).toBe(apiKey); + expect(scripts.install).not.toContain(apiKey); + }); + + test("extra-env", async () => { + const { coderEnvVars } = await setup({ + moduleVariables: { + extra_env: JSON.stringify({ MISTRAL_API_KEY: "test-mistral-key" }), + }, + }); + expect(coderEnvVars["MISTRAL_API_KEY"]).toBe("test-mistral-key"); + }); + + test("ai-gateway-configures-models-json", async () => { + const sessionToken = "test-session-token-123"; + const { id, coderEnvVars, scripts } = await setup({ + moduleVariables: { enable_ai_gateway: "true" }, + terraformEnv: { CODER_WORKSPACE_OWNER_SESSION_TOKEN: sessionToken }, + }); + expect(coderEnvVars["ANTHROPIC_API_KEY"]).toBe(sessionToken); + expect(coderEnvVars["OPENAI_API_KEY"]).toBe(sessionToken); + expect(scripts.install).not.toContain(sessionToken); + + await execContainer(id, [ + "bash", + "-c", + `mkdir -p /home/coder/.pi/agent && printf '%s' '{"providers":{"ollama":{"baseUrl":"http://localhost:11434/v1"}}}' > /home/coder/.pi/agent/models.json`, + ]); + await runScripts(id, scripts); + + const models = JSON.parse( + await readFileContainer(id, "/home/coder/.pi/agent/models.json"), + ); + expect(models.providers.anthropic.baseUrl).toMatch( + /\/api\/v2\/ai-gateway\/anthropic$/, + ); + expect(models.providers.openai.baseUrl).toMatch( + /\/api\/v2\/ai-gateway\/openai\/v1$/, + ); + expect(models.providers.anthropic.baseUrl).not.toContain("//api"); + expect(models.providers.ollama.baseUrl).toBe("http://localhost:11434/v1"); + }); + + test("models-json-untouched-without-ai-gateway", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const result = await execContainer(id, [ + "test", + "-e", + "/home/coder/.pi/agent/models.json", + ]); + expect(result.exitCode).not.toBe(0); + }); + + test("pi-binary-path-outside-path", async () => { + const binaryPath = "/opt/pi/bin/pi"; + const { id, scripts } = await setup({ + skipPiMock: true, + moduleVariables: { pi_binary_path: binaryPath }, + }); + await execContainer( + id, + ["bash", "-c", "mkdir -p /opt/pi/bin && chown coder:coder /opt/pi/bin"], + ["-u", "root"], + ); + await writeExecutable({ + containerId: id, + filePath: binaryPath, + content: await Bun.file( + path.join(import.meta.dir, "testdata", "pi-mock.sh"), + ).text(), + }); + const result = await runInstallScript(id, scripts.install); + expect(result.exitCode).toBe(0); + const log = await installLog(id); + expect(log).toContain("Validated existing Pi CLI: pi version v1.0.0"); + const profile = await readFileContainer(id, "/home/coder/.bashrc"); + expect(profile).toContain("/opt/pi/bin"); + }); + + test("pi-binary-path-missing", async () => { + const { id, scripts } = await setup({ + moduleVariables: { pi_binary_path: "/opt/pi/bin/pi" }, + }); + const result = await runInstallScript(id, scripts.install); + expect(result.exitCode).not.toBe(0); + const log = await installLog(id); + expect(log).toContain( + "pi_binary_path /opt/pi/bin/pi does not exist or is not executable.", + ); + }); + + test("install-from-npm-registry-url", async () => { + const registry = "https://registry.npmjs.org/"; + const { id, scripts } = await setup({ + skipPiMock: true, + moduleVariables: { install_pi: "true", npm_registry_url: registry }, + }); + const result = await runInstallScript(id, scripts.install); + expect(result.exitCode).toBe(0); + const log = await installLog(id); + expect(log).toContain(`Using npm registry: ${registry}`); + expect(log).toContain("Installed Pi CLI"); + }); + + test("unreachable-npm-registry-url-fails-install", async () => { + const { id, scripts } = await setup({ + skipPiMock: true, + moduleVariables: { + install_pi: "true", + npm_registry_url: "http://127.0.0.1:9/", + }, + }); + const result = await runInstallScript(id, scripts.install, { + npm_config_fetch_retries: "0", + }); + expect(result.exitCode).not.toBe(0); + const log = await installLog(id); + expect(log).toContain("Using npm registry: http://127.0.0.1:9/"); + expect(log).toContain("Pi installation failed."); + }); + + test("default-project-trust-written-to-settings", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + default_project_trust: "never", + }, + }); + await runScripts(id, scripts); + const settings = await readFileContainer( + id, + "/home/coder/.pi/agent/settings.json", + ); + expect(JSON.parse(settings).defaultProjectTrust).toBe("never"); + }); + + test("existing-settings-are-preserved", async () => { + const { id, scripts } = await setup(); + await execContainer(id, [ + "bash", + "-c", + "mkdir -p /home/coder/.pi/agent && printf '%s' '{\"theme\":\"dark\"}' > /home/coder/.pi/agent/settings.json", + ]); + await runScripts(id, scripts); + const settings = JSON.parse( + await readFileContainer(id, "/home/coder/.pi/agent/settings.json"), + ); + expect(settings.theme).toBe("dark"); + expect(settings.defaultProjectTrust).toBe("always"); + }); + + test("workdir-created-when-missing", async () => { + const workdir = "/home/coder/pi-test-folder"; + const { id, scripts } = await setup({ + moduleVariables: { workdir }, + }); + await runScripts(id, scripts); + const result = await execContainer(id, ["test", "-d", workdir]); + expect(result.exitCode).toBe(0); + }); + + test("pre-post-install-scripts", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + pre_install_script: "#!/bin/bash\necho 'pi-pre-install-script'", + post_install_script: "#!/bin/bash\necho 'pi-post-install-script'", + }, + }); + await runScripts(id, scripts); + + const preInstallLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder-labs/pi/logs/pre_install.log", + ); + expect(preInstallLog).toContain("pi-pre-install-script"); + + const postInstallLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder-labs/pi/logs/post_install.log", + ); + expect(postInstallLog).toContain("pi-post-install-script"); + }); +}); diff --git a/registry/coder-labs/modules/pi/main.tf b/registry/coder-labs/modules/pi/main.tf new file mode 100644 index 000000000..112f1b285 --- /dev/null +++ b/registry/coder-labs/modules/pi/main.tf @@ -0,0 +1,214 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.12" + } + } +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "icon" { + type = string + description = "The icon to use for the app." + default = "/icon/pi.svg" +} + +variable "workdir" { + type = string + description = "Optional project directory. When set, the module pre-creates it if missing." + default = null +} + +variable "pre_install_script" { + type = string + description = "Custom script to run before installing Pi. Can be used for dependency ordering between modules (e.g., waiting for git-clone to complete before Pi initialization)." + default = null +} + +variable "post_install_script" { + type = string + description = "Custom script to run after installing Pi." + default = null +} + +variable "install_pi" { + type = bool + description = "Whether to install the Pi coding agent CLI. Set to false when Pi is already baked into the workspace image." + default = true +} + +variable "pi_binary_path" { + type = string + description = "Absolute path to an existing Pi executable. Only used when install_pi is false; leave empty to look up pi on PATH." + default = "" + + validation { + condition = var.pi_binary_path == "" || startswith(var.pi_binary_path, "/") + error_message = "pi_binary_path must be an absolute path." + } + + validation { + condition = !(var.install_pi && var.pi_binary_path != "") + error_message = "pi_binary_path can only be set when install_pi is false." + } +} + +variable "npm_registry_url" { + type = string + description = "npm registry URL to install @earendil-works/pi-coding-agent from. Override to install from an internal mirror or artifact store (for example Artifactory or Nexus). Leave empty to use npm's configured registry." + default = "" + + validation { + condition = var.npm_registry_url == "" || can(regex("^https?://", var.npm_registry_url)) + error_message = "npm_registry_url must be an http(s) URL when set." + } +} + +variable "pi_version" { + type = string + description = "The npm version of @earendil-works/pi-coding-agent to install. Use 'latest' for the latest version or a specific version like '0.12.0'." + default = "latest" +} + +variable "enable_ai_gateway" { + type = bool + description = "Route Pi's Anthropic and OpenAI providers through Coder AI Gateway, authenticated with the workspace owner's Coder session token. https://coder.com/docs/ai-coder/ai-gateway" + default = false +} + +variable "anthropic_api_key" { + type = string + description = "API key passed to Pi via the ANTHROPIC_API_KEY env var." + sensitive = true + default = "" + + validation { + condition = !(var.enable_ai_gateway && var.anthropic_api_key != "") + error_message = "anthropic_api_key cannot be provided when enable_ai_gateway is true. AI Gateway authenticates Pi using Coder credentials." + } +} + +variable "openai_api_key" { + type = string + description = "API key passed to Pi via the OPENAI_API_KEY env var." + sensitive = true + default = "" + + validation { + condition = !(var.enable_ai_gateway && var.openai_api_key != "") + error_message = "openai_api_key cannot be provided when enable_ai_gateway is true. AI Gateway authenticates Pi using Coder credentials." + } +} + +variable "gemini_api_key" { + type = string + description = "API key passed to Pi via the GEMINI_API_KEY env var." + sensitive = true + default = "" +} + +variable "extra_env" { + type = map(string) + description = "Additional environment variables to pass to Pi, e.g. for other supported providers (Azure OpenAI, Mistral, Groq, DeepSeek, etc). Keys are used as-is as env var names." + default = {} + sensitive = true +} + +variable "default_project_trust" { + type = string + description = "Written to defaultProjectTrust in ~/.pi/agent/settings.json, controlling whether Pi prompts to trust a project folder on first run. One of: ask, always, never." + default = "always" + + validation { + condition = contains(["ask", "always", "never"], var.default_project_trust) + error_message = "default_project_trust must be one of: ask, always, never." + } +} + +resource "coder_env" "anthropic_api_key" { + count = var.anthropic_api_key != "" ? 1 : 0 + agent_id = var.agent_id + name = "ANTHROPIC_API_KEY" + value = var.anthropic_api_key +} + +resource "coder_env" "openai_api_key" { + count = var.openai_api_key != "" ? 1 : 0 + agent_id = var.agent_id + name = "OPENAI_API_KEY" + value = var.openai_api_key +} + +resource "coder_env" "gemini_api_key" { + count = var.gemini_api_key != "" ? 1 : 0 + agent_id = var.agent_id + name = "GEMINI_API_KEY" + value = var.gemini_api_key +} + +# Pi sends ANTHROPIC_API_KEY as X-Api-Key and OPENAI_API_KEY as a bearer +# token. AI Gateway accepts the Coder session token in either header. +resource "coder_env" "ai_gateway_anthropic_token" { + count = var.enable_ai_gateway ? 1 : 0 + agent_id = var.agent_id + name = "ANTHROPIC_API_KEY" + value = data.coder_workspace_owner.me.session_token +} + +resource "coder_env" "ai_gateway_openai_token" { + count = var.enable_ai_gateway ? 1 : 0 + agent_id = var.agent_id + name = "OPENAI_API_KEY" + value = data.coder_workspace_owner.me.session_token +} + +resource "coder_env" "extra_env" { + for_each = nonsensitive(toset(keys(var.extra_env))) + agent_id = var.agent_id + name = each.value + value = var.extra_env[each.value] +} + +locals { + workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" + ai_gateway_base_url = var.enable_ai_gateway ? "${trimsuffix(data.coder_workspace.me.access_url, "/")}/api/v2/ai-gateway" : "" + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL_PI = tostring(var.install_pi) + ARG_PI_VERSION = var.pi_version + ARG_PI_BINARY_PATH = var.pi_binary_path != "" ? base64encode(var.pi_binary_path) : "" + ARG_NPM_REGISTRY_URL = var.npm_registry_url + ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" + ARG_DEFAULT_PROJECT_TRUST = var.default_project_trust + ARG_AI_GATEWAY_BASE_URL = local.ai_gateway_base_url + }) + module_dir_name = ".coder-modules/coder-labs/pi" +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + + agent_id = var.agent_id + module_directory = "$HOME/${local.module_dir_name}" + display_name_prefix = "Pi" + icon = var.icon + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script +} + +output "scripts" { + description = "Ordered list of coder exp sync names for the coder_script resources this module actually creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list." + value = module.coder_utils.scripts +} diff --git a/registry/coder-labs/modules/pi/main.tftest.hcl b/registry/coder-labs/modules/pi/main.tftest.hcl new file mode 100644 index 000000000..05f699a9e --- /dev/null +++ b/registry/coder-labs/modules/pi/main.tftest.hcl @@ -0,0 +1,320 @@ +run "test_pi_basic" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = var.install_pi == true + error_message = "install_pi should default to true" + } + + assert { + condition = var.default_project_trust == "always" + error_message = "default_project_trust should default to always" + } +} + +run "test_pi_with_api_key" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + anthropic_api_key = "test-key" + } + + assert { + condition = coder_env.anthropic_api_key[0].value == "test-key" + error_message = "Anthropic API key should be set correctly" + } + + assert { + condition = !strcontains(local.install_script, nonsensitive(var.anthropic_api_key)) + error_message = "Anthropic API key should not be rendered into the install script" + } +} + +run "test_no_api_key_no_env" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = length(coder_env.anthropic_api_key) == 0 + error_message = "ANTHROPIC_API_KEY should not be created when no API key is provided" + } + + assert { + condition = length(coder_env.openai_api_key) == 0 + error_message = "OPENAI_API_KEY should not be created when no API key is provided" + } + + assert { + condition = length(coder_env.gemini_api_key) == 0 + error_message = "GEMINI_API_KEY should not be created when no API key is provided" + } +} + +run "test_extra_env" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + extra_env = { + MISTRAL_API_KEY = "test-mistral-key" + PI_OFFLINE = "1" + } + } + + assert { + condition = coder_env.extra_env["MISTRAL_API_KEY"].name == "MISTRAL_API_KEY" + error_message = "extra_env should create a coder_env resource named after the map key" + } + + assert { + condition = length(coder_env.extra_env) == 2 && coder_env.extra_env["PI_OFFLINE"].value == "1" + error_message = "extra_env should create one coder_env resource per map entry" + } + + assert { + condition = !strcontains(local.install_script, "test-mistral-key") + error_message = "extra_env values should not be rendered into the install script" + } +} + +run "test_default_project_trust_validation" { + command = plan + + variables { + agent_id = "test-agent" + default_project_trust = "invalid" + } + + expect_failures = [ + var.default_project_trust, + ] +} + +run "test_pi_custom_options" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project" + icon = "/icon/custom.svg" + pi_version = "0.12.0" + } + + assert { + condition = length(output.scripts) > 0 + error_message = "scripts output should be non-empty with custom options" + } +} + +run "test_workdir_optional" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = length(output.scripts) == 1 + error_message = "scripts output should have install script even without workdir" + } +} + +run "test_script_outputs_install_only" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = length(output.scripts) == 1 && output.scripts[0] == "coder-labs-pi-install_script" + error_message = "scripts output should list only the install script when pre/post are not configured" + } +} + +run "test_script_outputs_with_pre_and_post" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + pre_install_script = "echo pre" + post_install_script = "echo post" + } + + assert { + condition = output.scripts == ["coder-labs-pi-pre_install_script", "coder-labs-pi-install_script", "coder-labs-pi-post_install_script"] + error_message = "scripts output should list pre_install, install, post_install in run order" + } +} + +run "test_ai_gateway_enabled" { + command = plan + + variables { + agent_id = "test-agent" + enable_ai_gateway = true + } + + override_data { + target = data.coder_workspace.me + values = { + access_url = "https://coder.example.com/" + } + } + + override_data { + target = data.coder_workspace_owner.me + values = { + session_token = "mock-session-token" + } + } + + assert { + condition = coder_env.ai_gateway_anthropic_token[0].name == "ANTHROPIC_API_KEY" && coder_env.ai_gateway_anthropic_token[0].value == "mock-session-token" + error_message = "AI Gateway should authenticate the Anthropic provider with the workspace owner's session token" + } + + assert { + condition = coder_env.ai_gateway_openai_token[0].name == "OPENAI_API_KEY" && coder_env.ai_gateway_openai_token[0].value == "mock-session-token" + error_message = "AI Gateway should authenticate the OpenAI provider with the workspace owner's session token" + } + + assert { + condition = length(coder_env.anthropic_api_key) == 0 && length(coder_env.openai_api_key) == 0 + error_message = "Direct provider keys should not be set when AI Gateway is enabled" + } + + assert { + condition = local.ai_gateway_base_url == "https://coder.example.com/api/v2/ai-gateway" + error_message = "AI Gateway base URL should be derived from the access URL without a double slash" + } + + assert { + condition = strcontains(local.install_script, "ARG_AI_GATEWAY_BASE_URL='https://coder.example.com/api/v2/ai-gateway'") + error_message = "Install script should receive the AI Gateway base URL" + } + + assert { + condition = !strcontains(local.install_script, "mock-session-token") + error_message = "Session token should not be rendered into the install script" + } +} + +run "test_ai_gateway_disabled_by_default" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = length(coder_env.ai_gateway_anthropic_token) == 0 && length(coder_env.ai_gateway_openai_token) == 0 + error_message = "AI Gateway env vars should not be created by default" + } + + assert { + condition = strcontains(local.install_script, "ARG_AI_GATEWAY_BASE_URL=''") + error_message = "Install script should not configure AI Gateway by default" + } +} + +run "test_ai_gateway_rejects_anthropic_api_key" { + command = plan + + variables { + agent_id = "test-agent" + enable_ai_gateway = true + anthropic_api_key = "test-key" + } + + expect_failures = [var.anthropic_api_key] +} + +run "test_ai_gateway_rejects_openai_api_key" { + command = plan + + variables { + agent_id = "test-agent" + enable_ai_gateway = true + openai_api_key = "test-key" + } + + expect_failures = [var.openai_api_key] +} + +run "test_npm_registry_url" { + command = plan + + variables { + agent_id = "test-agent" + npm_registry_url = "https://npm.internal.example.com/" + } + + assert { + condition = strcontains(local.install_script, "ARG_NPM_REGISTRY_URL='https://npm.internal.example.com/'") + error_message = "Install script should receive the npm registry URL" + } +} + +run "test_npm_registry_url_validation" { + command = plan + + variables { + agent_id = "test-agent" + npm_registry_url = "npm.internal.example.com" + } + + expect_failures = [var.npm_registry_url] +} + +run "test_pi_binary_path" { + command = plan + + variables { + agent_id = "test-agent" + install_pi = false + pi_binary_path = "/opt/pi/bin/pi" + } + + assert { + condition = strcontains(local.install_script, base64encode("/opt/pi/bin/pi")) + error_message = "Install script should receive the encoded Pi binary path" + } +} + +run "test_pi_binary_path_must_be_absolute" { + command = plan + + variables { + agent_id = "test-agent" + install_pi = false + pi_binary_path = "bin/pi" + } + + expect_failures = [var.pi_binary_path] +} + +run "test_pi_binary_path_requires_install_disabled" { + command = plan + + variables { + agent_id = "test-agent" + pi_binary_path = "/opt/pi/bin/pi" + } + + expect_failures = [var.pi_binary_path] +} diff --git a/registry/coder-labs/modules/pi/scripts/install.sh.tftpl b/registry/coder-labs/modules/pi/scripts/install.sh.tftpl new file mode 100644 index 000000000..e030b3a16 --- /dev/null +++ b/registry/coder-labs/modules/pi/scripts/install.sh.tftpl @@ -0,0 +1,174 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BOLD='\033[0;1m' + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_INSTALL_PI='${ARG_INSTALL_PI}' +ARG_PI_VERSION='${ARG_PI_VERSION}' +ARG_PI_BINARY_PATH=$(echo -n '${ARG_PI_BINARY_PATH}' | base64 -d) +ARG_NPM_REGISTRY_URL='${ARG_NPM_REGISTRY_URL}' +ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) +ARG_DEFAULT_PROJECT_TRUST='${ARG_DEFAULT_PROJECT_TRUST}' +ARG_AI_GATEWAY_BASE_URL='${ARG_AI_GATEWAY_BASE_URL}' + +echo "--------------------------------" +printf "ARG_INSTALL_PI: %s\n" "$${ARG_INSTALL_PI}" +printf "ARG_PI_VERSION: %s\n" "$${ARG_PI_VERSION}" +printf "ARG_PI_BINARY_PATH: %s\n" "$${ARG_PI_BINARY_PATH}" +printf "ARG_NPM_REGISTRY_URL: %s\n" "$${ARG_NPM_REGISTRY_URL}" +printf "ARG_WORKDIR: %s\n" "$${ARG_WORKDIR}" +printf "ARG_DEFAULT_PROJECT_TRUST: %s\n" "$${ARG_DEFAULT_PROJECT_TRUST}" +printf "ARG_AI_GATEWAY_BASE_URL: %s\n" "$${ARG_AI_GATEWAY_BASE_URL}" +echo "--------------------------------" + +function add_path_to_shell_profiles() { + local path_dir="$1" + + for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do + if [ -f "$${profile}" ]; then + if ! grep -q "$${path_dir}" "$${profile}" 2> /dev/null; then + echo "export PATH=\"\$PATH:$${path_dir}\"" >> "$${profile}" + echo "Added $${path_dir} to $${profile}" + fi + fi + done + + local fish_config="$HOME/.config/fish/config.fish" + if [ -f "$${fish_config}" ]; then + if ! grep -q "$${path_dir}" "$${fish_config}" 2> /dev/null; then + echo "fish_add_path $${path_dir}" >> "$${fish_config}" + echo "Added $${path_dir} to $${fish_config}" + fi + fi +} + +function ensure_pi_in_path() { + local PI_BIN="" + if command_exists pi; then + PI_BIN=$(command -v pi) + fi + + if [ -z "$${PI_BIN}" ] || [ ! -x "$${PI_BIN}" ]; then + echo "Pi binary was not found or is not executable." >&2 + return 1 + fi + + local PI_DIR + PI_DIR=$(dirname "$${PI_BIN}") + + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/pi" ]; then + ln -s "$${PI_BIN}" "$${CODER_SCRIPT_BIN_DIR}/pi" + echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/pi -> $${PI_BIN}" + fi + + add_path_to_shell_profiles "$${PI_DIR}" +} + +function install_pi_cli() { + if [ "$${ARG_INSTALL_PI}" != "true" ]; then + echo "Skipping Pi installation as per configuration." + if [ -n "$${ARG_PI_BINARY_PATH}" ]; then + if [ ! -x "$${ARG_PI_BINARY_PATH}" ]; then + echo "pi_binary_path $${ARG_PI_BINARY_PATH} does not exist or is not executable." >&2 + return 1 + fi + local pi_binary_dir + pi_binary_dir=$(dirname "$${ARG_PI_BINARY_PATH}") + export PATH="$${pi_binary_dir}:$PATH" + fi + ensure_pi_in_path + printf "%s Validated existing Pi CLI: %s\n" "$${BOLD}" "$(pi --version)" + return + fi + + if ! command_exists npm; then + echo "Error: npm was not found. Pi is installed via npm and requires Node.js >= 22.19.0 and npm to already be present in the workspace." >&2 + return 1 + fi + + printf "%s Installing Pi coding agent CLI\n" "$${BOLD}" + + local package="@earendil-works/pi-coding-agent" + if [ "$${ARG_PI_VERSION}" != "latest" ]; then + package="$${package}@$${ARG_PI_VERSION}" + fi + + # npm's default global prefix is often root-owned (e.g. /usr on + # codercom/enterprise-node), which the workspace user cannot write to. + # Install into a prefix under the module's own data directory instead, so + # the install never depends on npm's default prefix being writable. + local npm_prefix="$HOME/.coder-modules/coder-labs/pi/npm-global" + mkdir -p "$${npm_prefix}" + + local npm_args=(install -g --ignore-scripts --prefix "$${npm_prefix}") + if [ -n "$${ARG_NPM_REGISTRY_URL}" ]; then + echo "Using npm registry: $${ARG_NPM_REGISTRY_URL}" + npm_args+=(--registry "$${ARG_NPM_REGISTRY_URL}") + fi + + if ! npm "$${npm_args[@]}" "$${package}"; then + echo "Pi installation failed." >&2 + return 1 + fi + + export PATH="$${npm_prefix}/bin:$PATH" + ensure_pi_in_path + printf "%s Installed Pi CLI: %s\n" "$${BOLD}" "$(pi --version)" +} + +function configure_pi_settings() { + local settings_dir="$HOME/.pi/agent" + local settings_path="$${settings_dir}/settings.json" + mkdir -p "$${settings_dir}" + + if [ -f "$${settings_path}" ]; then + jq --arg trust "$${ARG_DEFAULT_PROJECT_TRUST}" \ + '.defaultProjectTrust = $trust' \ + "$${settings_path}" > "$${settings_path}.tmp" && mv "$${settings_path}.tmp" "$${settings_path}" + else + jq -n --arg trust "$${ARG_DEFAULT_PROJECT_TRUST}" '{defaultProjectTrust: $trust}' > "$${settings_path}" + fi + + echo "Wrote Pi settings to $${settings_path}" +} + +# Pi ignores ANTHROPIC_BASE_URL and OPENAI_BASE_URL, so the gateway endpoints +# are set as provider baseUrl overrides in models.json. Overriding only +# baseUrl keeps Pi's built-in model lists for each provider. +function configure_ai_gateway() { + if [ -z "$${ARG_AI_GATEWAY_BASE_URL}" ]; then + return + fi + + local models_path="$HOME/.pi/agent/models.json" + local anthropic_url="$${ARG_AI_GATEWAY_BASE_URL}/anthropic" + local openai_url="$${ARG_AI_GATEWAY_BASE_URL}/openai/v1" + # shellcheck disable=SC2016 # $anthropic and $openai are jq variables. + local filter='.providers.anthropic.baseUrl = $anthropic | .providers.openai.baseUrl = $openai' + + if [ -f "$${models_path}" ]; then + jq --arg anthropic "$${anthropic_url}" --arg openai "$${openai_url}" "$${filter}" \ + "$${models_path}" > "$${models_path}.tmp" && mv "$${models_path}.tmp" "$${models_path}" + else + jq -n --arg anthropic "$${anthropic_url}" --arg openai "$${openai_url}" "$${filter}" > "$${models_path}" + fi + + echo "Configured Pi to use Coder AI Gateway in $${models_path}" +} + +function setup_workdir() { + if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then + echo "Creating workdir: $${ARG_WORKDIR}" + mkdir -p "$${ARG_WORKDIR}" + fi +} + +install_pi_cli +configure_pi_settings +configure_ai_gateway +setup_workdir diff --git a/registry/coder-labs/modules/pi/testdata/pi-mock.sh b/registry/coder-labs/modules/pi/testdata/pi-mock.sh new file mode 100644 index 000000000..aa50c81d0 --- /dev/null +++ b/registry/coder-labs/modules/pi/testdata/pi-mock.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +if [[ "$1" == "--version" ]]; then + echo "pi version v1.0.0" + exit 0 +fi + +echo "pi invoked with: $*" +exit 0