diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd9753..b9b15dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add repository commands for testing the current checkout either in a logged-out, automatically cleaned-up pi environment or with existing credentials and only Command Code models enabled. - Refresh display pricing for the current Command Code model catalog, remove expired Qwen promotional rates, add current free and discounted models, and require review when temporary prices expire. - Use the host-provided `pi-ai` and `pi-coding-agent` core packages instead of installing private runtime copies, including for local and out-of-store development checkouts. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a643e9c..da4d0a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,9 +21,27 @@ npm run test:models npm run test:oauth npm run test:abort npm run test:stream +npm run test:pi-isolated +npm run test:pi-authenticated npm run test:pi-local ``` +Start an isolated pi instance with only the current checkout installed and no existing Command Code credentials: + +```sh +npm run pi:isolated +``` + +Run `/login` inside pi. Temporary credentials, configuration, and sessions are deleted when pi exits. + +Start the current checkout with your existing pi credentials and only Command Code models in the model picker: + +```sh +npm run pi:authenticated +``` + +Both commands accept additional pi arguments after `--`, for example `npm run pi:authenticated -- --model claude-sonnet-4-6`. + Before opening a PR, run: ```sh diff --git a/README.md b/README.md index 799d833..d1c034c 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,22 @@ omp plugin uninstall pi-commandcode-provider ## Development +Start an isolated pi instance with only the current checkout installed and no existing Command Code credentials: + +```sh +npm run pi:isolated +``` + +Run `/login` inside pi. Temporary credentials, configuration, and sessions are deleted when pi exits. + +Start the current checkout with your existing pi credentials and only Command Code models in the model picker: + +```sh +npm run pi:authenticated +``` + +Both commands accept additional pi arguments after `--`, for example `npm run pi:authenticated -- --model claude-sonnet-4-6`. + See [CONTRIBUTING.md](CONTRIBUTING.md) for local setup and tests. See [RELEASE.md](RELEASE.md) for the release process. ## License diff --git a/index.ts b/index.ts index 70fb759..56ca606 100644 --- a/index.ts +++ b/index.ts @@ -16,10 +16,11 @@ import { AssistantMessageEventStream } from "@earendil-works/pi-ai" import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent" import { join } from "node:path" +import { getApiKey as getStoredApiKey } from "./src/converters.ts" import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" import { calculateCommandCodeCost } from "./src/cost.ts" import { DEFAULT_MODELS_URL, loadCommandCodeModels } from "./src/models.ts" -import { getApiKey, login, refreshToken } from "./src/oauth.ts" +import { getApiKey as getOAuthApiKey, login, refreshToken } from "./src/oauth.ts" import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts" const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE @@ -38,6 +39,7 @@ const streamCommandCode = createStreamCommandCode({ // --------------------------------------------------------------------------- export default async function (pi: ExtensionAPI) { + const storedApiKey = getStoredApiKey() const { models, warning } = await loadCommandCodeModels({ url: MODELS_URL, cachePath: MODELS_CACHE_PATH, @@ -48,7 +50,7 @@ export default async function (pi: ExtensionAPI) { pi.registerProvider("commandcode", { name: "Command Code", baseUrl: API_BASE, - apiKey: "$COMMANDCODE_API_KEY", + apiKey: storedApiKey, authHeader: true, api: "commandcode-custom", streamSimple: streamCommandCode, @@ -60,7 +62,7 @@ export default async function (pi: ExtensionAPI) { name: "Command Code", login, refreshToken, - getApiKey, + getApiKey: getOAuthApiKey, }, models: models.map((model) => ({ id: model.id, diff --git a/package.json b/package.json index f699e65..dac7cc8 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "files": [ "index.ts", "src/", + "scripts/", "README.md", "CHANGELOG.md", "CONTRIBUTING.md", @@ -28,10 +29,12 @@ "LICENSE" ], "scripts": { - "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", + "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", "typecheck": "tsc --noEmit", "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", "format": "prettier --write '**/*.{ts,mjs,json,md}'", + "pi:isolated": "node scripts/pi-isolated.mjs", + "pi:authenticated": "node scripts/pi-authenticated.mjs", "test:unit": "tsx tests/test-pure-functions.ts", "test:models": "tsx tests/test-models.ts", "test:pricing": "tsx tests/test-pricing.ts", @@ -39,6 +42,8 @@ "test:abort": "tsx tests/test-abort.ts", "test:stream": "tsx tests/test-stream.ts", "test:retry": "tsx tests/test-retry.ts", + "test:pi-isolated": "node tests/test-pi-isolated.mjs", + "test:pi-authenticated": "node tests/test-pi-authenticated.mjs", "test:pi-local": "node tests/test-pi-local.mjs", "test:smoke": "node tests/test-smoke.mjs", "test:cost": "tsx tests/test-cost.ts" diff --git a/scripts/pi-authenticated.mjs b/scripts/pi-authenticated.mjs new file mode 100644 index 0000000..b0fe10b --- /dev/null +++ b/scripts/pi-authenticated.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const extensionPath = resolve(repoRoot, "index.ts") + +const env = { + ...process.env, + PI_SKIP_VERSION_CHECK: "1", +} +delete env.COMMANDCODE_API_KEY + +const child = spawn( + "pi", + [ + "--no-extensions", + "--extension", + extensionPath, + "--provider", + "commandcode", + "--model", + "gpt-5.6-luna", + "--models", + "commandcode/*", + ...process.argv.slice(2), + ], + { + cwd: repoRoot, + env, + stdio: "inherit", + }, +) + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => child.kill(signal)) +} + +child.once("error", (error) => { + console.error(`Could not start pi: ${error.message}`) + process.exitCode = 1 +}) + +child.once("exit", (status, signal) => { + if (signal) process.kill(process.pid, signal) + process.exitCode = status ?? 1 +}) diff --git a/scripts/pi-isolated.mjs b/scripts/pi-isolated.mjs new file mode 100644 index 0000000..a725f4e --- /dev/null +++ b/scripts/pi-isolated.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process" +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const testRoot = await mkdtemp(join(tmpdir(), "pi-commandcode-isolated-")) +const agentDir = join(testRoot, "agent") +const sessionDir = join(testRoot, "sessions") + +await mkdir(agentDir, { mode: 0o700 }) +await mkdir(sessionDir, { mode: 0o700 }) + +const env = { + ...process.env, + HOME: testRoot, + USERPROFILE: testRoot, + PI_CODING_AGENT_DIR: agentDir, + PI_CODING_AGENT_SESSION_DIR: sessionDir, + PI_SKIP_VERSION_CHECK: "1", +} +delete env.COMMANDCODE_API_KEY + +let activeChild +let receivedSignal + +function forwardSignal(signal) { + receivedSignal = signal + activeChild?.kill(signal) +} + +const forwardSigint = () => forwardSignal("SIGINT") +const forwardSigterm = () => forwardSignal("SIGTERM") +process.on("SIGINT", forwardSigint) +process.on("SIGTERM", forwardSigterm) + +function runPi(args) { + return new Promise((resolveRun, rejectRun) => { + const child = spawn("pi", args, { cwd: repoRoot, env, stdio: "inherit" }) + activeChild = child + child.once("error", rejectRun) + child.once("exit", (status, signal) => { + activeChild = undefined + resolveRun({ status, signal }) + }) + }) +} + +let result +try { + console.error("Installing the current checkout into an isolated pi environment...") + const install = await runPi(["install", repoRoot, "--no-approve"]) + if (install.status !== 0 || install.signal) { + result = install + } else { + console.error("Starting pi. Temporary auth and sessions will be removed on exit.") + result = await runPi([ + "--no-approve", + "--provider", + "commandcode", + "--model", + "gpt-5.6-luna", + ...process.argv.slice(2), + ]) + } +} finally { + process.removeListener("SIGINT", forwardSigint) + process.removeListener("SIGTERM", forwardSigterm) + await rm(testRoot, { recursive: true, force: true }) + console.error("Removed the isolated pi environment.") +} + +const signal = receivedSignal ?? result?.signal +if (signal) process.kill(process.pid, signal) +process.exitCode = result?.status ?? 1 diff --git a/tests/test-pi-authenticated.mjs b/tests/test-pi-authenticated.mjs new file mode 100644 index 0000000..7f992b3 --- /dev/null +++ b/tests/test-pi-authenticated.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict" +import { spawnSync } from "node:child_process" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { delimiter, dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { describe, it } from "node:test" + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const launcher = join(repoRoot, "scripts", "pi-authenticated.mjs") + +function runLauncher() { + const fakeBin = mkdtempSync(join(tmpdir(), "pi-commandcode-fake-bin-")) + const logPath = join(fakeBin, "call.json") + const fakePi = join(fakeBin, "pi") + + writeFileSync( + fakePi, + `#!/bin/sh +node - "$@" <<'NODE' +const { writeFileSync } = require("node:fs") +writeFileSync(process.env.FAKE_PI_LOG, JSON.stringify({ + args: process.argv.slice(2), + agentDir: process.env.PI_CODING_AGENT_DIR ?? null, + apiKey: process.env.COMMANDCODE_API_KEY ?? null, + skipVersionCheck: process.env.PI_SKIP_VERSION_CHECK, +})) +NODE +`, + { mode: 0o700 }, + ) + + try { + const result = spawnSync(process.execPath, [launcher, "--thinking", "high"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + FAKE_PI_LOG: logPath, + PI_CODING_AGENT_DIR: "/existing/pi-agent", + COMMANDCODE_API_KEY: "existing-key", + }, + encoding: "utf8", + }) + return { result, call: JSON.parse(readFileSync(logPath, "utf8")) } + } finally { + rmSync(fakeBin, { recursive: true, force: true }) + } +} + +describe("authenticated pi launcher", () => { + it("loads only the checkout extension and leaves auth resolution to existing files", () => { + const { result, call } = runLauncher() + + assert.equal(result.status, 0) + assert.deepEqual(call.args, [ + "--no-extensions", + "--extension", + join(repoRoot, "index.ts"), + "--provider", + "commandcode", + "--model", + "gpt-5.6-luna", + "--models", + "commandcode/*", + "--thinking", + "high", + ]) + assert.equal(call.agentDir, "/existing/pi-agent") + assert.equal(call.apiKey, null) + assert.equal(call.skipVersionCheck, "1") + }) +}) diff --git a/tests/test-pi-isolated.mjs b/tests/test-pi-isolated.mjs new file mode 100644 index 0000000..0b809a6 --- /dev/null +++ b/tests/test-pi-isolated.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict" +import { spawnSync } from "node:child_process" +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { delimiter, dirname, join, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { describe, it } from "node:test" + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const launcher = join(repoRoot, "scripts", "pi-isolated.mjs") + +function runLauncher({ exitStatus = 0 } = {}) { + const fakeBin = mkdtempSync(join(tmpdir(), "pi-commandcode-fake-bin-")) + const logPath = join(fakeBin, "calls.jsonl") + const fakePi = join(fakeBin, "pi") + + writeFileSync( + fakePi, + `#!/bin/sh +node - "$@" <<'NODE' +const { appendFileSync } = require("node:fs") +appendFileSync(process.env.FAKE_PI_LOG, JSON.stringify({ + args: process.argv.slice(2), + agentDir: process.env.PI_CODING_AGENT_DIR, + sessionDir: process.env.PI_CODING_AGENT_SESSION_DIR, + skipVersionCheck: process.env.PI_SKIP_VERSION_CHECK, + home: process.env.HOME, + userProfile: process.env.USERPROFILE, + inheritedApiKey: process.env.COMMANDCODE_API_KEY ?? null, +}) + "\\n") +NODE +if [ "$1" = "install" ]; then exit 0; fi +exit ${exitStatus} +`, + { mode: 0o700 }, + ) + + try { + const result = spawnSync(process.execPath, [launcher, "--model", "claude-sonnet-5"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${fakeBin}${delimiter}${process.env.PATH ?? ""}`, + FAKE_PI_LOG: logPath, + COMMANDCODE_API_KEY: "must-not-leak", + }, + encoding: "utf8", + }) + const calls = readFileSync(logPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + return { result, calls } + } finally { + rmSync(fakeBin, { recursive: true, force: true }) + } +} + +describe("isolated pi launcher", () => { + it("installs the current checkout, forwards arguments, and removes its environment", () => { + const { result, calls } = runLauncher() + + assert.equal(result.status, 0) + assert.equal(calls.length, 2) + assert.deepEqual(calls[0].args, ["install", repoRoot, "--no-approve"]) + assert.deepEqual(calls[1].args, [ + "--no-approve", + "--provider", + "commandcode", + "--model", + "gpt-5.6-luna", + "--model", + "claude-sonnet-5", + ]) + + const [install, launch] = calls + assert.equal(install.agentDir, launch.agentDir) + assert.equal(install.sessionDir, launch.sessionDir) + assert.equal(launch.skipVersionCheck, "1") + assert.equal(launch.inheritedApiKey, null) + assert.ok(launch.agentDir.includes("pi-commandcode-isolated-")) + assert.equal(launch.home, dirname(launch.agentDir)) + assert.equal(launch.userProfile, dirname(launch.agentDir)) + assert.equal(dirname(launch.agentDir), dirname(launch.sessionDir)) + assert.equal(existsSync(dirname(launch.agentDir)), false) + assert.match(result.stderr, /Removed the isolated pi environment/) + }) + + it("returns the pi exit status and still removes its environment", () => { + const { result, calls } = runLauncher({ exitStatus: 7 }) + + assert.equal(result.status, 7) + assert.equal(calls.length, 2) + assert.equal(existsSync(dirname(calls[1].agentDir)), false) + }) +})