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
+}
+```
+
+
+
+## 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