mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
feat(pi): add Pi (pi.dev) as a sixth CLI run mode (#206)
SessionMode gains 'pi', a first-class backend alongside Claude Code, OpenCode, Codex, Gemini and Antigravity: its own PTY, tmux session, rose tab identity, welcome button, run-mode entry, cron agentType, Docker and remote-SSH command defaults, and clone-repo Brain option. Pi is a different shape of CLI from the other four, and three decisions follow from that: - It has NO permission prompts and no sandbox, so there is no --dangerously-skip-permissions analog and none was invented. The privilege-shaped knob is the tri-state approveProjectTrust, which makes pi load and EXECUTE repo-local .pi/extensions TypeScript and install missing project packages. clampExternalCliBypassForOwner() therefore puts pi in the MATERIALIZE branch: a non-granted multi-user owner gets --no-approve even when no config was sent, because pi's own default is a prompt the session user could answer themselves. That helper had zero test coverage; it now has coverage for all four CLIs. - Only the PI_ prefix joins the env allowlist. Pi's ~34 provider key vars share no prefix and ALLOWED_ENV_PREFIXES is one global list with no mode context, so admitting them would widen the allowlist for every mode at once. Auth goes through pi's /login or the server's own environment. --api-key is deliberately never wired: it would put a provider secret on the spawn command line. - pi stays OUT of isAltScreenStripMode(). Its default TUI renders into the main screen with terminal-owned scrollback, and its 0.84.0 fullscreen mode is runtime-switchable via /settings; that flip was measured to put the pane into the alt screen, which the strip would have corrupted. pi-cli-resolver.ts additionally sanity-probes `pi --version` and requires semver-shaped output, because `pi` is a short generic name a stray binary can shadow; GET /api/pi/status surfaces path and version so a misresolution is diagnosable rather than presenting as a broken mode. Docker installs pi in its own --ignore-scripts step so that flag cannot affect the other four CLIs, and seeds its credentials per-file rather than whole-dir (~/.pi/agent also holds sessions, extensions and package trees). Verified end to end against pi 0.84.1 on an isolated instance: resolver search-dir fallback, flag construction, piConfig persistence across a full server restart, the trust prompt and its --no-approve suppression, the rose Run button on the default daylight-blue skin (the nested skin block eats per-mode gradients unless the rule lives inside it), and the buffer local-echo policy, which pi tolerates where codex did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @fileoverview Resolve the Pi CLI (`pi`) binary across common install paths.
|
||||
*
|
||||
* Mirrors antigravity-cli-resolver.ts, with one addition the other external-CLI
|
||||
* resolvers do not need: `pi` is a SHORT, GENERIC name (Raspberry Pi tooling,
|
||||
* personal scripts, `$PATH` accidents), so a `which pi` hit is not by itself
|
||||
* evidence that the coding agent is installed. Every candidate is therefore
|
||||
* sanity-probed with `pi --version` and required to print a semver-shaped
|
||||
* string; a binary that fails the probe is treated as absent and the rejected
|
||||
* path is logged so a misresolution is diagnosable.
|
||||
*
|
||||
* Pi ships as the npm package `@earendil-works/pi-coding-agent`, so the search
|
||||
* dirs are the usual global-bin locations (npm/bun/manual installs).
|
||||
*
|
||||
* @module utils/pi-cli-resolver
|
||||
*/
|
||||
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
|
||||
/** Common directories where the Pi CLI binary may be installed */
|
||||
const PI_SEARCH_DIRS = [
|
||||
join(homedir(), '.local', 'bin'),
|
||||
'/usr/local/bin',
|
||||
join(homedir(), '.bun', 'bin'),
|
||||
join(homedir(), '.npm-global', 'bin'),
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/** A real `pi --version` prints a semver-shaped string (e.g. `0.84.1`). */
|
||||
const PI_VERSION_PATTERN = /^\d+\.\d+\.\d+/;
|
||||
|
||||
/** Cached directory containing the pi binary (empty string = searched but not found) */
|
||||
let _piDir: string | null = null;
|
||||
/** Cached version string reported by the resolved binary (empty string = probed, unusable) */
|
||||
let _piVersion: string | null = null;
|
||||
|
||||
/**
|
||||
* Run `pi --version` on a candidate path and return the trimmed version when it
|
||||
* looks like the coding agent. Returns null for anything else — a missing
|
||||
* binary, a non-zero exit, a hang (timeout), or output that is not semver-shaped
|
||||
* (which is how an unrelated `pi` on PATH gets rejected).
|
||||
*
|
||||
* Never runs under vitest: the suites must stay hermetic and must not depend on
|
||||
* whether the dev box happens to have pi installed.
|
||||
*/
|
||||
function probePiVersion(binPath: string): string | null {
|
||||
if (process.env.VITEST) return null;
|
||||
try {
|
||||
const out = execFileSync(binPath, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
// Upstream prints a bare version today; tolerate a `pi 0.84.1` style prefix too.
|
||||
const candidate = out.split(/\s+/).find((token) => PI_VERSION_PATTERN.test(token));
|
||||
if (candidate) return candidate;
|
||||
console.warn(`[PiResolver] Ignoring ${binPath}: "pi --version" printed ${JSON.stringify(out.slice(0, 80))}`);
|
||||
} catch (err) {
|
||||
console.warn(`[PiResolver] Ignoring ${binPath}: "pi --version" failed (${(err as Error).message})`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the directory containing a verified `pi` binary.
|
||||
* Checks `which pi` first, then falls back to common install locations. Every
|
||||
* candidate must pass the `pi --version` sanity probe (§2.6 of the integration
|
||||
* plan) before it is accepted.
|
||||
*
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolvePiDir(): string | null {
|
||||
if (_piDir !== null) return _piDir || null;
|
||||
|
||||
const accept = (binPath: string): string | null => {
|
||||
// Under vitest the probe never runs, so existence alone decides (keeps the
|
||||
// suites hermetic and matches how the sibling resolvers behave there).
|
||||
if (process.env.VITEST) {
|
||||
_piDir = dirname(binPath);
|
||||
_piVersion = '';
|
||||
return _piDir;
|
||||
}
|
||||
const version = probePiVersion(binPath);
|
||||
if (!version) return null;
|
||||
_piDir = dirname(binPath);
|
||||
_piVersion = version;
|
||||
return _piDir;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = execSync('which pi', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
const dir = accept(result);
|
||||
if (dir) return dir;
|
||||
}
|
||||
} catch {
|
||||
// pi not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of PI_SEARCH_DIRS) {
|
||||
const binPath = join(dir, 'pi');
|
||||
if (!existsSync(binPath)) continue;
|
||||
const accepted = accept(binPath);
|
||||
if (accepted) return accepted;
|
||||
}
|
||||
|
||||
_piDir = '';
|
||||
_piVersion = '';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Pi CLI is available on the system.
|
||||
*/
|
||||
export function isPiAvailable(): boolean {
|
||||
return resolvePiDir() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version reported by the resolved `pi` binary, or null when pi is unavailable
|
||||
* (or when the probe was skipped, i.e. under vitest). Surfaced through
|
||||
* `GET /api/pi/status` so a misresolution is diagnosable from the UI.
|
||||
*/
|
||||
export function getPiCliVersion(): string | null {
|
||||
resolvePiDir();
|
||||
return _piVersion || null;
|
||||
}
|
||||
Reference in New Issue
Block a user