mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
feat(grok): add Grok Build (xAI) as a seventh CLI run mode
SessionMode gains 'grok', a first-class backend alongside Claude Code,
shell, OpenCode, Codex, Gemini, Antigravity and Pi: its own PTY, tmux
session, charcoal tab identity ('gk' badge), welcome button, run-mode
entry, cron agentType, Docker and remote-SSH command defaults, and
clone-repo Brain option. Flag surface verified live against grok 1.0.5.
Grok mixes two existing shapes and the wiring follows from that:
- Codex-shaped on permissions: the bypass switch is GrokConfig.alwaysApprove
(--always-approve, grok's bypassPermissions mode; config-level deny rules
still apply on top). The Run button sends it true, like runAntigravity(),
and clampExternalCliBypassForOwner() puts grok in the only-if-sent branch:
a bare grok spawn is grok's own ask-mode default, which is already safe,
so only a sent config needs the flag forced off. Cron needs nothing for
the same reason.
- OpenCode-shaped on rendering: grok is a fullscreen alternate-screen TUI
with mouse support, so it stays OUT of isAltScreenStripMode() and lands
on the narrow tmux-attach strip and the 'buffer' local-echo fallthrough
(unmeasured against an authenticated composer; documented fallback is the
'off' branch).
- Pi-shaped on resolution: 'grok' has npm squatters (@vibe-kit/grok-cli
also installs a grok bin), so grok-cli-resolver.ts version-probes every
candidate (grok --version, killSignal SIGKILL, VITEST-gated) and
GET /api/grok/status surfaces path AND version; GROK_VERSION_REGEX is
shared with the dependency registry so doctor and run mode cannot drift.
Env allowlist gains GROK_* plus the XAI_* vendor namespace (XAI_API_KEY is
grok's documented headless auth var), the same narrow-vendor reasoning as
GOOGLE_* for gemini. Resume is id-regexed on purpose: grok's own --resume
also matches session titles, which are arbitrary user strings that must
never reach the bash -c spawn line.
Docker: grok is not on npm, so the agent image installs it in its own step
(xAI's installer has no --dir override; the binary is copied to
/usr/local/bin and root's ~/.grok dropped in the same layer), and
credentials are seeded per-file (auth.json, config.toml, pager.toml; the
dir also holds sessions/, memory/ and the ~160MB binary). Remote SSH routes
through the login-shell wrapper like the other agent CLIs.
Verified end to end on an isolated CODEMAN_INSTANCE with grok 1.0.5
installed: /api/grok/status resolves and reports the probed version,
quick-start spawns a pane whose command line ends in 'grok
--always-approve', the real TUI renders (OAuth device screen on an
unauthenticated box), and grokConfig round-trips through state.json.
Docs: docs/grok-integration.md (user guide) + docs/grok-integration-plan.md
(decisions, verification record, follow-ups).
Tests: test/grok-mode.test.ts, test/grok-cli-resolver.test.ts, plus
extended clamp/system-routes/render-index-html/run-mode-ui/mobile-overview/
local-echo-gating coverage. npm test (the CI gate) green: 5910 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+106
-1
@@ -176,6 +176,7 @@ describe('Run launch synchronization', () => {
|
||||
'runGemini',
|
||||
'runAntigravity',
|
||||
'runPi',
|
||||
'runGrok',
|
||||
])
|
||||
);
|
||||
|
||||
@@ -365,12 +366,13 @@ describe('Codex quick start settings', () => {
|
||||
'welcomeAntigravityBtn',
|
||||
'welcomeGeminiBtn',
|
||||
'welcomePiBtn',
|
||||
'welcomeGrokBtn',
|
||||
'welcomeTunnelBtn',
|
||||
]) {
|
||||
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
|
||||
}
|
||||
const modeBtns: Record<string, { style: { display: string } }> = {};
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'shell']) {
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']) {
|
||||
modeBtns[mode] = { style: { display: 'PRISTINE' } };
|
||||
}
|
||||
const menu = {
|
||||
@@ -402,6 +404,7 @@ describe('Codex quick start settings', () => {
|
||||
gemini: false,
|
||||
antigravity: false,
|
||||
pi: false,
|
||||
grok: false,
|
||||
cloudflared: false,
|
||||
};
|
||||
|
||||
@@ -425,6 +428,13 @@ describe('Codex quick start settings', () => {
|
||||
const withPi = loadUi({ ...ALL_OFF, pi: true });
|
||||
withPi.app.applyWelcomeCliVisibility();
|
||||
expect(withPi.welcomeBtns.welcomePiBtn.style.display).toBe('flex');
|
||||
|
||||
// Grok is gated on `grok` like the rest; the resolver additionally
|
||||
// version-probes the binary, so a stray `grok` on PATH reports unavailable.
|
||||
const withGrok = loadUi({ ...ALL_OFF, grok: true });
|
||||
withGrok.app.applyWelcomeCliVisibility();
|
||||
expect(withGrok.welcomeBtns.welcomeGrokBtn.style.display).toBe('flex');
|
||||
expect(withGrok.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
|
||||
expect(withPi.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
|
||||
|
||||
// Antigravity is a first-class welcome action, gated on `agy` like the rest.
|
||||
@@ -457,6 +467,7 @@ describe('Codex quick start settings', () => {
|
||||
);
|
||||
expect(offered).toContain('antigravity');
|
||||
expect(offered).toContain('pi');
|
||||
expect(offered).toContain('grok');
|
||||
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) {'));
|
||||
@@ -993,3 +1004,97 @@ describe('Pi quick start', () => {
|
||||
expect(errors[0]).toContain('@earendil-works/pi-coding-agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grok quick start', () => {
|
||||
// Same envelope-unwrap regression guard as the blocks above, for runGrok(),
|
||||
// plus the rule that makes grok the OPPOSITE of pi: the Run button DOES send
|
||||
// `grokConfig: { alwaysApprove: true }` (grok's bypassPermissions mode), the
|
||||
// same product decision as runAntigravity's dangerouslySkipPermissions and
|
||||
// claude's --dangerously-skip-permissions. The multi-user clamp strips it
|
||||
// server-side for non-granted owners.
|
||||
it('drives runGrok() through the {success,data} envelope and sends alwaysApprove', async () => {
|
||||
const elements: Record<string, any> = {
|
||||
quickStartCase: { value: 'grok-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/grok/status')
|
||||
return {
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: { available: true, path: '/home/user/.grok/bin', version: '1.0.5' },
|
||||
}),
|
||||
};
|
||||
if (url === '/api/quick-start')
|
||||
return { json: async () => ({ success: true, data: { sessionId: 'sess-gk' } }) };
|
||||
if (url === '/api/sessions/sess-gk')
|
||||
return { json: async () => ({ success: true, data: { id: 'sess-gk', name: 'w1-grok-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.runGrok();
|
||||
|
||||
const body = requests.find((req) => req.url === '/api/quick-start')?.body;
|
||||
expect(body).toMatchObject({
|
||||
caseName: 'grok-case',
|
||||
mode: 'grok',
|
||||
grokConfig: { alwaysApprove: true },
|
||||
});
|
||||
expect(selected).toEqual(['sess-gk']);
|
||||
});
|
||||
|
||||
it('reports the install hint when the CLI is missing and starts nothing', async () => {
|
||||
const elements: Record<string, any> = { quickStartCase: { value: 'grok-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/grok/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.runGrok();
|
||||
|
||||
expect(requests).toEqual(['/api/grok/status']);
|
||||
expect(errors[0]).toContain('https://x.ai/cli/install.sh');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user