mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19: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:
@@ -190,7 +190,7 @@ describe('_updateLocalEchoState mode gating', () => {
|
||||
expect(app._localEchoEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['claude', 'gemini', 'opencode'])('keeps the overlay enabled for %s sessions', (mode) => {
|
||||
it.each(['claude', 'gemini', 'opencode', 'pi'])('keeps the overlay enabled for %s sessions', (mode) => {
|
||||
const overlay = makeOverlay();
|
||||
const app = makeApp(mode, overlay);
|
||||
app._updateLocalEchoState();
|
||||
@@ -373,16 +373,19 @@ describe('_updateLocalEchoState echo policy', () => {
|
||||
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['claude', 'gemini', 'opencode'])("%s -> policy 'buffer' + overlay enabled (existing behavior)", (mode) => {
|
||||
const overlay = makeOverlay();
|
||||
const app = makeApp(mode, overlay) as PredictiveApp;
|
||||
app._predictiveEcho = makePredictor();
|
||||
app._updateLocalEchoState();
|
||||
expect(app._localEchoPolicy).toBe('buffer');
|
||||
expect(app._localEchoEnabled).toBe(true);
|
||||
expect(overlay.prompts.length).toBeGreaterThan(0); // setPrompt still called
|
||||
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled(); // not predict -> stray spans cleared
|
||||
});
|
||||
it.each(['claude', 'gemini', 'opencode', 'pi'])(
|
||||
"%s -> policy 'buffer' + overlay enabled (existing behavior)",
|
||||
(mode) => {
|
||||
const overlay = makeOverlay();
|
||||
const app = makeApp(mode, overlay) as PredictiveApp;
|
||||
app._predictiveEcho = makePredictor();
|
||||
app._updateLocalEchoState();
|
||||
expect(app._localEchoPolicy).toBe('buffer');
|
||||
expect(app._localEchoEnabled).toBe(true);
|
||||
expect(overlay.prompts.length).toBeGreaterThan(0); // setPrompt still called
|
||||
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled(); // not predict -> stray spans cleared
|
||||
}
|
||||
);
|
||||
|
||||
it('no active session -> policy off, no crash without a predictor instance', () => {
|
||||
const app = makeApp('codex') as PredictiveApp;
|
||||
|
||||
@@ -372,7 +372,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
|
||||
isCliAvailable: () => true,
|
||||
});
|
||||
const menu = app._buildMobileOverviewRunMenu();
|
||||
expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'shell']);
|
||||
expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'shell']);
|
||||
});
|
||||
|
||||
it('gates every mode the picker actually offers', () => {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
|
||||
import { buildSpawnCommand } from '../src/tmux-manager.js';
|
||||
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
|
||||
import { defaultRemoteCommandForMode } from '../src/remote-hosts.js';
|
||||
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
|
||||
|
||||
describe('Pi mode schemas', () => {
|
||||
it('accepts Pi session creation config', () => {
|
||||
const parsed = CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'pi',
|
||||
piConfig: {
|
||||
model: 'sonnet:high',
|
||||
provider: 'anthropic',
|
||||
thinking: 'high',
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.mode).toBe('pi');
|
||||
expect(parsed.piConfig).toEqual({
|
||||
model: 'sonnet:high',
|
||||
provider: 'anthropic',
|
||||
thinking: 'high',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts Pi quick-start config', () => {
|
||||
const parsed = QuickStartSchema.parse({
|
||||
caseName: 'pi-case',
|
||||
mode: 'pi',
|
||||
piConfig: { resumeSessionId: '0f9c2b14-aa10', continueSession: true },
|
||||
});
|
||||
|
||||
expect(parsed.mode).toBe('pi');
|
||||
expect(parsed.piConfig?.resumeSessionId).toBe('0f9c2b14-aa10');
|
||||
});
|
||||
|
||||
it('accepts a provider-qualified model (`openai/gpt-4o`)', () => {
|
||||
const parsed = CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'pi',
|
||||
piConfig: { model: 'openai/gpt-4o' },
|
||||
});
|
||||
expect(parsed.piConfig?.model).toBe('openai/gpt-4o');
|
||||
});
|
||||
|
||||
it('rejects unsafe Pi model strings', () => {
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'pi',
|
||||
piConfig: { model: 'pi; rm -rf /' },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects unsafe Pi provider strings', () => {
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'pi',
|
||||
piConfig: { provider: 'anthropic`whoami`' },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects unsafe Pi resumeSessionId values (ids only, never paths)', () => {
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'pi',
|
||||
piConfig: { resumeSessionId: '../../etc/passwd' },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects thinking levels outside pi’s enum', () => {
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'pi',
|
||||
piConfig: { thinking: 'ultra' },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('allows PI_* env overrides but NOT bare provider keys', () => {
|
||||
const parsed = CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'pi',
|
||||
envOverrides: { PI_OFFLINE: '1' },
|
||||
});
|
||||
expect(parsed.envOverrides).toEqual({ PI_OFFLINE: '1' });
|
||||
|
||||
// Pi's ~34 provider key vars share no prefix, and ALLOWED_ENV_PREFIXES is a single
|
||||
// GLOBAL list with no mode context — allowlisting them for pi would widen the
|
||||
// allowlist for every mode at once. They stay out; auth goes through pi's /login.
|
||||
expect(() =>
|
||||
CreateSessionSchema.parse({
|
||||
workingDir: '/tmp',
|
||||
mode: 'pi',
|
||||
envOverrides: { ANTHROPIC_API_KEY: 'sk-test' },
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pi spawn command', () => {
|
||||
it('builds a bare pi command when no config is sent (pi has no permission prompts)', () => {
|
||||
const cmd = buildSpawnCommand({ mode: 'pi', sessionId: 'abc12345' });
|
||||
expect(cmd).toBe('pi');
|
||||
});
|
||||
|
||||
it('maps model/provider/thinking to flags', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'pi',
|
||||
sessionId: 'abc12345',
|
||||
piConfig: { model: 'sonnet:high', provider: 'anthropic', thinking: 'xhigh' },
|
||||
});
|
||||
expect(cmd).toBe('pi --model sonnet:high --provider anthropic --thinking xhigh');
|
||||
});
|
||||
|
||||
it('emits --approve for true and --no-approve for false (tri-state project trust)', () => {
|
||||
expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { approveProjectTrust: true } })).toBe(
|
||||
'pi --approve'
|
||||
);
|
||||
expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { approveProjectTrust: false } })).toBe(
|
||||
'pi --no-approve'
|
||||
);
|
||||
// Absent = pi's own defaultProjectTrust; Codeman must not decide it.
|
||||
expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: {} })).toBe('pi');
|
||||
});
|
||||
|
||||
it('passes --session for resume and skips -c when both are present', () => {
|
||||
expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { resumeSessionId: '0f9c2b14' } })).toBe(
|
||||
'pi --session 0f9c2b14'
|
||||
);
|
||||
|
||||
expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { continueSession: true } })).toBe('pi -c');
|
||||
|
||||
// The two conflict upstream: a valid explicit session id wins.
|
||||
expect(
|
||||
buildSpawnCommand({
|
||||
mode: 'pi',
|
||||
sessionId: 'a',
|
||||
piConfig: { continueSession: true, resumeSessionId: '0f9c2b14' },
|
||||
})
|
||||
).toBe('pi --session 0f9c2b14');
|
||||
});
|
||||
|
||||
it('drops unsafe values rather than escaping them (the result lands in `bash -c "..."`)', () => {
|
||||
expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { model: 'a`b' } })).toBe('pi');
|
||||
expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { provider: 'x;id' } })).toBe('pi');
|
||||
expect(buildSpawnCommand({ mode: 'pi', sessionId: 'a', piConfig: { resumeSessionId: 'x; rm -rf /' } })).toBe('pi');
|
||||
// An out-of-enum thinking level never reaches the command line either.
|
||||
expect(
|
||||
buildSpawnCommand({
|
||||
mode: 'pi',
|
||||
sessionId: 'a',
|
||||
piConfig: { thinking: 'ultra' as unknown as 'high' },
|
||||
})
|
||||
).toBe('pi');
|
||||
});
|
||||
|
||||
it('never emits --api-key (a provider secret must not reach the spawn line)', () => {
|
||||
const cmd = buildSpawnCommand({
|
||||
mode: 'pi',
|
||||
sessionId: 'a',
|
||||
piConfig: { model: 'sonnet', provider: 'anthropic', approveProjectTrust: true },
|
||||
});
|
||||
expect(cmd).not.toContain('--api-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pi mode gates', () => {
|
||||
it('is an external CLI mode (readiness/ralph/respawn gating)', () => {
|
||||
expect(isExternalCliMode('pi')).toBe(true);
|
||||
});
|
||||
|
||||
it('is NOT an alt-screen strip mode (main-screen TUI + runtime-switchable fullscreen)', () => {
|
||||
expect(isAltScreenStripMode('pi')).toBe(false);
|
||||
});
|
||||
|
||||
it('has docker/remote default commands', () => {
|
||||
expect(defaultDockerCommandForMode('pi')).toBe('exec pi');
|
||||
// Routed through an interactive login shell so npm's global bin resolves —
|
||||
// same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
|
||||
expect(defaultRemoteCommandForMode('pi')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'pi\'');
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { isOpenCodeAvailable } from '../src/utils/opencode-cli-resolver.js';
|
||||
import { isCodexAvailable } from '../src/utils/codex-cli-resolver.js';
|
||||
import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js';
|
||||
import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
|
||||
import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
|
||||
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
|
||||
import { isGitAvailable } from '../src/git-clone.js';
|
||||
|
||||
@@ -43,6 +44,11 @@ vi.mock('../src/utils/antigravity-cli-resolver.js', () => ({
|
||||
isAntigravityAvailable: vi.fn(() => false),
|
||||
resolveAntigravityDir: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/pi-cli-resolver.js', () => ({
|
||||
isPiAvailable: vi.fn(() => false),
|
||||
resolvePiDir: vi.fn(() => null),
|
||||
getPiCliVersion: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
|
||||
isCloudflaredAvailable: vi.fn(() => false),
|
||||
resolveCloudflaredPath: vi.fn(() => null),
|
||||
@@ -131,6 +137,7 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
vi.mocked(isCodexAvailable).mockReturnValue(true);
|
||||
vi.mocked(isGeminiAvailable).mockReturnValue(false);
|
||||
vi.mocked(isAntigravityAvailable).mockReturnValue(false);
|
||||
vi.mocked(isPiAvailable).mockReturnValue(true);
|
||||
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
|
||||
vi.mocked(isGitAvailable).mockReturnValue(true);
|
||||
const { server } = makeServer({});
|
||||
@@ -144,6 +151,7 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
codex: true,
|
||||
gemini: false,
|
||||
antigravity: false,
|
||||
pi: true,
|
||||
cloudflared: true,
|
||||
git: true,
|
||||
});
|
||||
@@ -158,6 +166,7 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
isCodexAvailable,
|
||||
isGeminiAvailable,
|
||||
isAntigravityAvailable,
|
||||
isPiAvailable,
|
||||
isCloudflaredAvailable,
|
||||
isGitAvailable,
|
||||
]) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* First coverage for `clampExternalCliBypassForOwner` (session-routes.ts), the
|
||||
* multi-user §6.3 gate that keeps a NON-GRANTED owner from launching an external
|
||||
* CLI with its safety switches off. It backs both `POST /api/sessions` and
|
||||
* `POST /api/quick-start` and, until pi was added, had no tests at all.
|
||||
*
|
||||
* The helper has two shapes and the difference is the whole point:
|
||||
* - only-if-sent (codex, antigravity): an ABSENT config already spawns safe, so
|
||||
* only a sent config needs its flag forced off.
|
||||
* - MATERIALIZE (gemini, pi): the absent-config default is itself unsafe for a
|
||||
* non-granted owner (gemini's builder defaults to `yolo`; pi's default is an
|
||||
* interactive trust prompt the session user could just answer "yes" to), so
|
||||
* the clamp has to CREATE a config.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { _clampExternalCliBypassForOwner } from '../../src/web/routes/session-routes.js';
|
||||
import { createUser, invalidateUsersCache } from '../../src/user-store.js';
|
||||
|
||||
const PASSWORD = 'clamp-test-password';
|
||||
|
||||
describe('clampExternalCliBypassForOwner — single-user mode', () => {
|
||||
it('passes every config through untouched (the gate is a no-op)', async () => {
|
||||
const out = await _clampExternalCliBypassForOwner(
|
||||
undefined,
|
||||
{ dangerouslyBypassApprovals: true },
|
||||
{ approvalMode: 'yolo' },
|
||||
{ dangerouslySkipPermissions: true },
|
||||
{ approveProjectTrust: true }
|
||||
);
|
||||
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: true });
|
||||
expect(out.geminiConfig).toEqual({ approvalMode: 'yolo' });
|
||||
expect(out.antigravityConfig).toEqual({ dangerouslySkipPermissions: true });
|
||||
expect(out.piConfig).toEqual({ approveProjectTrust: true });
|
||||
});
|
||||
|
||||
it('leaves absent configs absent', async () => {
|
||||
const out = await _clampExternalCliBypassForOwner(undefined, undefined, undefined, undefined, undefined);
|
||||
expect(out.codexConfig).toBeUndefined();
|
||||
expect(out.geminiConfig).toBeUndefined();
|
||||
expect(out.antigravityConfig).toBeUndefined();
|
||||
expect(out.piConfig).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampExternalCliBypassForOwner — multi-user mode', () => {
|
||||
// The temp HOME from test/setup.ts is per-FILE, so users.json survives between
|
||||
// tests here — create the three accounts once.
|
||||
beforeAll(async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
invalidateUsersCache();
|
||||
await createUser({ username: 'boss', role: 'admin', password: PASSWORD });
|
||||
await createUser({ username: 'peon', role: 'user', password: PASSWORD });
|
||||
await createUser({ username: 'trusted', role: 'user', password: PASSWORD, canBypassPermissions: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
invalidateUsersCache();
|
||||
});
|
||||
|
||||
it('passes through for an admin owner', async () => {
|
||||
const out = await _clampExternalCliBypassForOwner(
|
||||
'boss',
|
||||
{ dangerouslyBypassApprovals: true },
|
||||
undefined,
|
||||
{ dangerouslySkipPermissions: true },
|
||||
{ approveProjectTrust: true }
|
||||
);
|
||||
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: true });
|
||||
expect(out.geminiConfig).toBeUndefined();
|
||||
expect(out.antigravityConfig).toEqual({ dangerouslySkipPermissions: true });
|
||||
expect(out.piConfig).toEqual({ approveProjectTrust: true });
|
||||
});
|
||||
|
||||
it('passes through for a user holding the bypass grant', async () => {
|
||||
const out = await _clampExternalCliBypassForOwner('trusted', undefined, undefined, undefined, {
|
||||
approveProjectTrust: true,
|
||||
});
|
||||
expect(out.piConfig).toEqual({ approveProjectTrust: true });
|
||||
});
|
||||
|
||||
it('forces codex/antigravity bypass off for a non-granted owner (only-if-sent branch)', async () => {
|
||||
const out = await _clampExternalCliBypassForOwner(
|
||||
'peon',
|
||||
{ dangerouslyBypassApprovals: true, model: 'gpt-5' },
|
||||
undefined,
|
||||
{ dangerouslySkipPermissions: true, model: 'gemini-3-pro' },
|
||||
undefined
|
||||
);
|
||||
expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, model: 'gpt-5' });
|
||||
expect(out.antigravityConfig).toEqual({ dangerouslySkipPermissions: false, model: 'gemini-3-pro' });
|
||||
});
|
||||
|
||||
it('leaves codex/antigravity absent when nothing was sent (they already spawn safe)', async () => {
|
||||
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined);
|
||||
expect(out.codexConfig).toBeUndefined();
|
||||
expect(out.antigravityConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('MATERIALIZES gemini to auto_edit even when no config was sent', async () => {
|
||||
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined);
|
||||
expect(out.geminiConfig).toEqual({ approvalMode: 'auto_edit' });
|
||||
});
|
||||
|
||||
it('MATERIALIZES pi to --no-approve even when no config was sent', async () => {
|
||||
// The load-bearing case: omitting --approve is NOT a clamp for pi, because
|
||||
// pi's own default is to ASK, and the session user can answer that prompt.
|
||||
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined);
|
||||
expect(out.piConfig).toEqual({ approveProjectTrust: false });
|
||||
});
|
||||
|
||||
it('forces a sent pi approveProjectTrust:true down to false, keeping other fields', async () => {
|
||||
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, {
|
||||
approveProjectTrust: true,
|
||||
model: 'sonnet:high',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
expect(out.piConfig).toEqual({
|
||||
approveProjectTrust: false,
|
||||
model: 'sonnet:high',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for an unknown/deleted owner', async () => {
|
||||
const out = await _clampExternalCliBypassForOwner('ghost', undefined, undefined, undefined, {
|
||||
approveProjectTrust: true,
|
||||
});
|
||||
expect(out.piConfig).toEqual({ approveProjectTrust: false });
|
||||
expect(out.geminiConfig).toEqual({ approvalMode: 'auto_edit' });
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,12 @@ vi.mock('../../src/utils/antigravity-cli-resolver.js', () => ({
|
||||
resolveAntigravityDir: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/utils/pi-cli-resolver.js', () => ({
|
||||
isPiAvailable: vi.fn(() => false),
|
||||
resolvePiDir: vi.fn(() => null),
|
||||
getPiCliVersion: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { subagentWatcher } from '../../src/subagent-watcher.js';
|
||||
@@ -93,6 +99,7 @@ import { getLifecycleLog } from '../../src/session-lifecycle-log.js';
|
||||
import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.js';
|
||||
import { isGeminiAvailable, resolveGeminiDir } from '../../src/utils/gemini-cli-resolver.js';
|
||||
import { isAntigravityAvailable, resolveAntigravityDir } from '../../src/utils/antigravity-cli-resolver.js';
|
||||
import { isPiAvailable, resolvePiDir, getPiCliVersion } from '../../src/utils/pi-cli-resolver.js';
|
||||
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
const mockedWriteFile = vi.mocked(fs.writeFile);
|
||||
@@ -106,6 +113,9 @@ const mockedIsGeminiAvailable = vi.mocked(isGeminiAvailable);
|
||||
const mockedResolveGeminiDir = vi.mocked(resolveGeminiDir);
|
||||
const mockedIsAntigravityAvailable = vi.mocked(isAntigravityAvailable);
|
||||
const mockedResolveAntigravityDir = vi.mocked(resolveAntigravityDir);
|
||||
const mockedIsPiAvailable = vi.mocked(isPiAvailable);
|
||||
const mockedResolvePiDir = vi.mocked(resolvePiDir);
|
||||
const mockedGetPiCliVersion = vi.mocked(getPiCliVersion);
|
||||
|
||||
describe('system-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
@@ -839,6 +849,38 @@ describe('system-routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/pi/status ==========
|
||||
|
||||
describe('GET /api/pi/status', () => {
|
||||
it('returns unavailable when pi is not installed', async () => {
|
||||
mockedIsPiAvailable.mockReturnValue(false);
|
||||
mockedResolvePiDir.mockReturnValue(null);
|
||||
mockedGetPiCliVersion.mockReturnValue(null);
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/pi/status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.available).toBe(false);
|
||||
expect(body.path).toBeNull();
|
||||
expect(body.version).toBeNull();
|
||||
});
|
||||
|
||||
it('returns available with path AND version when pi is installed', async () => {
|
||||
// `version` is pi-specific: `pi` is a generic binary name, so the resolver
|
||||
// version-probes it and this endpoint is where a misresolution shows up.
|
||||
mockedIsPiAvailable.mockReturnValue(true);
|
||||
mockedResolvePiDir.mockReturnValue('/home/user/.local/bin');
|
||||
mockedGetPiCliVersion.mockReturnValue('0.84.1');
|
||||
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/pi/status' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.available).toBe(true);
|
||||
expect(body.path).toBe('/home/user/.local/bin');
|
||||
expect(body.version).toBe('0.84.1');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/execution/model-config ==========
|
||||
|
||||
describe('GET /api/execution/model-config', () => {
|
||||
|
||||
+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