mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +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:
+106
-2
@@ -168,7 +168,15 @@ describe('Run launch synchronization', () => {
|
||||
// Fail loudly if the scan matched nothing: a silently empty scan would make
|
||||
// every assertion below vacuously true.
|
||||
expect([...bodies.keys()]).toEqual(
|
||||
expect.arrayContaining(['runClaude', 'runShell', 'runOpenCode', 'runCodex', 'runGemini', 'runAntigravity'])
|
||||
expect.arrayContaining([
|
||||
'runClaude',
|
||||
'runShell',
|
||||
'runOpenCode',
|
||||
'runCodex',
|
||||
'runGemini',
|
||||
'runAntigravity',
|
||||
'runPi',
|
||||
])
|
||||
);
|
||||
|
||||
for (const [name, body] of bodies) {
|
||||
@@ -356,12 +364,13 @@ describe('Codex quick start settings', () => {
|
||||
'welcomeOpencodeBtn',
|
||||
'welcomeAntigravityBtn',
|
||||
'welcomeGeminiBtn',
|
||||
'welcomePiBtn',
|
||||
'welcomeTunnelBtn',
|
||||
]) {
|
||||
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
|
||||
}
|
||||
const modeBtns: Record<string, { style: { display: string } }> = {};
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'shell']) {
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'shell']) {
|
||||
modeBtns[mode] = { style: { display: 'PRISTINE' } };
|
||||
}
|
||||
const menu = {
|
||||
@@ -392,6 +401,7 @@ describe('Codex quick start settings', () => {
|
||||
codex: false,
|
||||
gemini: false,
|
||||
antigravity: false,
|
||||
pi: false,
|
||||
cloudflared: false,
|
||||
};
|
||||
|
||||
@@ -410,6 +420,13 @@ describe('Codex quick start settings', () => {
|
||||
withTunnel.app.applyWelcomeCliVisibility();
|
||||
expect(withTunnel.welcomeBtns.welcomeTunnelBtn.style.display).toBe('flex');
|
||||
|
||||
// Pi is gated on `pi` like the rest; the resolver additionally version-probes
|
||||
// the binary, so a stray `pi` on PATH reports unavailable rather than broken.
|
||||
const withPi = loadUi({ ...ALL_OFF, pi: true });
|
||||
withPi.app.applyWelcomeCliVisibility();
|
||||
expect(withPi.welcomeBtns.welcomePiBtn.style.display).toBe('flex');
|
||||
expect(withPi.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
|
||||
|
||||
// Antigravity is a first-class welcome action, gated on `agy` like the rest.
|
||||
const withAgy = loadUi({ ...ALL_OFF, antigravity: true });
|
||||
withAgy.app.applyWelcomeCliVisibility();
|
||||
@@ -439,6 +456,7 @@ describe('Codex quick start settings', () => {
|
||||
(m) => m[1]
|
||||
);
|
||||
expect(offered).toContain('antigravity');
|
||||
expect(offered).toContain('pi');
|
||||
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
// Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
|
||||
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));
|
||||
@@ -889,3 +907,89 @@ describe('Antigravity quick start', () => {
|
||||
expect(selected).toEqual(['sess-ag']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pi quick start', () => {
|
||||
// Same envelope-unwrap regression guard as the blocks above, for runPi(), plus the
|
||||
// rule that makes pi different: it must send NO piConfig. Pi has no permission
|
||||
// prompts, and `approveProjectTrust` would opt the session into EXECUTING
|
||||
// repo-supplied TypeScript — never something a Run button decides silently.
|
||||
it('drives runPi() through the {success,data} envelope and sends no piConfig', async () => {
|
||||
const elements: Record<string, any> = {
|
||||
quickStartCase: { value: 'pi-case' },
|
||||
};
|
||||
const requests: Array<{ url: string; body?: any }> = [];
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: (id: string) => elements[id] ?? null },
|
||||
fetch: async (url: string, init?: { body?: string }) => {
|
||||
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
|
||||
if (url === '/api/pi/status')
|
||||
return {
|
||||
json: async () => ({ success: true, data: { available: true, path: '/usr/local/bin', version: '0.84.1' } }),
|
||||
};
|
||||
if (url === '/api/quick-start')
|
||||
return { json: async () => ({ success: true, data: { sessionId: 'sess-pi' } }) };
|
||||
if (url === '/api/sessions/sess-pi')
|
||||
return { json: async () => ({ success: true, data: { id: 'sess-pi', name: 'w1-pi-case' } }) };
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
},
|
||||
console,
|
||||
});
|
||||
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.getCaseSettings = () => ({});
|
||||
app.buildEnvOverrides = () => ({});
|
||||
app.sessions = new Map();
|
||||
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
|
||||
app._renderSessionTabsImmediate = vi.fn();
|
||||
const selected: string[] = [];
|
||||
app.selectSession = async (id: string) => {
|
||||
selected.push(id);
|
||||
};
|
||||
|
||||
await app.runPi();
|
||||
|
||||
const body = requests.find((req) => req.url === '/api/quick-start')?.body;
|
||||
expect(body).toMatchObject({ caseName: 'pi-case', mode: 'pi' });
|
||||
expect(body).not.toHaveProperty('piConfig');
|
||||
expect(selected).toEqual(['sess-pi']);
|
||||
});
|
||||
|
||||
it('reports the install hint when the CLI is missing and starts nothing', async () => {
|
||||
const elements: Record<string, any> = { quickStartCase: { value: 'pi-case' } };
|
||||
const requests: string[] = [];
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
document: { getElementById: (id: string) => elements[id] ?? null },
|
||||
fetch: async (url: string) => {
|
||||
requests.push(url);
|
||||
if (url === '/api/pi/status')
|
||||
return { json: async () => ({ success: true, data: { available: false, path: null, version: null } }) };
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
},
|
||||
console,
|
||||
});
|
||||
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
|
||||
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
|
||||
|
||||
const app = new (CodemanApp as any)();
|
||||
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
|
||||
const errors: string[] = [];
|
||||
app._reportSessionLaunchError = (_owns: boolean, msg: string) => errors.push(msg);
|
||||
|
||||
await app.runPi();
|
||||
|
||||
expect(requests).toEqual(['/api/pi/status']);
|
||||
expect(errors[0]).toContain('@earendil-works/pi-coding-agent');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user