diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js
index a2e9d3e1..1318350a 100644
--- a/src/web/public/session-ui.js
+++ b/src/web/public/session-ui.js
@@ -441,7 +441,7 @@ Object.assign(CodemanApp.prototype, {
// Load history sessions when menu opens
if (menu.classList.contains('active')) {
this._loadRunModeHistory();
- this._refreshRunModeAvailability();
+ this._refreshRunModeAvailability(menu);
const close = (ev) => {
if (!menu.contains(ev.target)) {
menu.classList.remove('active');
@@ -453,29 +453,22 @@ Object.assign(CodemanApp.prototype, {
},
/**
- * Hides run-mode dropdown entries for CLIs that aren't installed, so
+ * #201: hides run-mode dropdown entries for CLIs that aren't installed, so
* picking one doesn't spawn a session that immediately errors out.
- * Shell has no external CLI dependency and is never gated.
+ *
+ * Shell has no external CLI dependency and is never gated, which is also what
+ * guarantees the menu is never empty. Scoped to `menu` rather than the document:
+ * `.run-mode-option` is also the class the saved-dashboard rows and the history
+ * rows use, and a bare querySelector would find whichever came first in the DOM.
+ *
+ * Antigravity is in this list even though #201 predates it — it is a run mode
+ * like the rest, and `agy` is the LEAST likely of the five to be installed.
*/
- async _refreshRunModeAvailability() {
- const checks = [
- ['claude', '/api/claude/status'],
- ['opencode', '/api/opencode/status'],
- ['codex', '/api/codex/status'],
- ['gemini', '/api/gemini/status'],
- ];
- await Promise.all(checks.map(async ([mode, url]) => {
- const btn = document.querySelector(`.run-mode-option[data-mode="${mode}"]`);
- if (!btn) return;
- try {
- const res = await fetch(url);
- const env = await res.json();
- const status = env?.success === true ? env.data : env;
- btn.style.display = status?.available ? 'flex' : 'none';
- } catch {
- btn.style.display = 'none';
- }
- }));
+ _refreshRunModeAvailability(menu) {
+ for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity']) {
+ const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
+ if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
+ }
},
async _loadRunModeHistory() {
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index 2974faa9..4216a6bd 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -497,16 +497,21 @@ Object.assign(CodemanApp.prototype, {
* status effects) are passed to `codex` at launch, so on a box without codex
* the tab is a promise nothing can keep.
*
- * Availability comes from `window.__codemanCodexAvailable`, injected by
- * renderIndexHtml (same shape as the gesture flag) so the tab never flickers
+ * Availability comes from the injected `window.__codemanCliAvailable`, shared
+ * with the welcome buttons and the run-mode dropdown, so the tab never flickers
* in and back out. Only the tab BUTTON is toggled: the panel already carries
* `.modal-tab-content.hidden` unless it is the selected tab, and
* openAppSettings() always reopens on Display, so an unreachable button is
* enough to keep the panel unreachable.
+ *
+ * Note the inverted default versus the run buttons: an UNKNOWN flag hides this
+ * tab. Hiding a settings tab costs a user nothing (the values stay in the DOM
+ * and are still saved), whereas hiding a run button would leave a working
+ * install with nothing to click.
*/
_applyCodexSettingsVisibility() {
const btn = document.querySelector('#appSettingsModal .modal-tab-btn[data-tab="settings-codex"]');
- if (btn) btn.style.display = window.__codemanCodexAvailable ? '' : 'none';
+ if (btn) btn.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none';
},
switchSettingsTab(tabName) {
@@ -718,28 +723,39 @@ Object.assign(CodemanApp.prototype, {
this._updatePollTimer = setInterval(poll, 1500);
},
- async loadGeminiAvailability() {
- await this._loadCliAvailability('welcomeGeminiBtn', '/api/gemini/status');
+ /**
+ * Is `tool` installed on the server? Reads `window.__codemanCliAvailable`,
+ * injected by renderIndexHtml (see the comment there for why this is injected
+ * rather than fetched per surface).
+ *
+ * Unknown reads as AVAILABLE. A missing flag means the page was rendered by a
+ * build that predates the injection, or by a solo popup: hiding every run
+ * button on a doubt would leave nothing to click, and the pre-existing failure
+ * mode for a genuinely missing CLI is just an error toast.
+ */
+ isCliAvailable(tool) {
+ const flags = window.__codemanCliAvailable;
+ if (!flags || typeof flags !== 'object') return true;
+ return flags[tool] !== false;
},
- async loadClaudeAvailability() {
- await this._loadCliAvailability('welcomeClaudeBtn', '/api/claude/status');
- },
-
- async loadOpencodeAvailability() {
- await this._loadCliAvailability('welcomeOpencodeBtn', '/api/opencode/status');
- },
-
- async _loadCliAvailability(buttonId, statusUrl) {
- const btn = document.getElementById(buttonId);
- if (!btn) return;
- try {
- const res = await fetch(statusUrl);
- const env = await res.json();
- const status = env?.success === true ? env.data : env;
- btn.style.display = status?.available ? 'flex' : 'none';
- } catch {
- btn.style.display = 'none';
+ /**
+ * #200: show a welcome-screen button only where the thing it launches exists.
+ * The markup ships them hidden, so an old cached page can never flash a button
+ * for a tool this server does not have.
+ */
+ applyWelcomeCliVisibility() {
+ const buttons = [
+ ['welcomeClaudeBtn', 'claude'],
+ ['welcomeOpencodeBtn', 'opencode'],
+ ['welcomeGeminiBtn', 'gemini'],
+ // Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
+ // without cloudflared can only ever produce "cloudflared not found".
+ ['welcomeTunnelBtn', 'cloudflared'],
+ ];
+ for (const [id, tool] of buttons) {
+ const btn = document.getElementById(id);
+ if (btn) btn.style.display = this.isCliAvailable(tool) ? 'flex' : 'none';
}
},
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index 1449981c..a2b7b9af 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -1195,9 +1195,7 @@ Object.assign(CodemanApp.prototype, {
if (overlay) {
overlay.classList.add('visible');
this.loadTunnelStatus();
- this.loadClaudeAvailability();
- this.loadOpencodeAvailability();
- this.loadGeminiAvailability();
+ this.applyWelcomeCliVisibility();
this.loadHistorySessions();
this.initSearchPanel();
}
diff --git a/src/web/server.ts b/src/web/server.ts
index 7e916f8a..0e735499 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -1288,18 +1288,48 @@ export class WebServer extends EventEmitter {
// actual on/off. We expose `__codemanGestureAvailable` so the settings UI can
// show the toggle only when the feature is available, and inject the bundle
// (served same-origin from /gesture/, so 'self' covers it) only when enabled.
- // Codex CLI settings tab: the App Settings "Codex CLI" tab only means anything
- // when the codex binary is actually installed, so expose availability and let
- // settings-ui.js hide the tab otherwise (same shape as the gesture flag below).
- // Injected rather than fetched so the tab never flickers in and back out on
- // modal open. `isCodexAvailable()` memoizes its PATH probe, so this is cheap
- // per render; installing codex afterwards needs a server restart, exactly like
- // the /api/codex/status route that backs the Run menu.
+ // Tool availability (#200/#201): the welcome-screen run buttons, the run-mode
+ // dropdown entries and the App Settings "Codex CLI" tab are all offers that a
+ // box without the binary cannot keep — picking one spawns a session that
+ // errors out immediately. One object answers all three.
+ //
+ // INJECTED, not fetched per surface. The `/api//status` routes exist and
+ // stay (they mirror each other and are a fine API surface), but as the source
+ // for UI gating they buy nothing: every resolver memoizes its PATH probe on
+ // the server, so a fetch is exactly as stale as an injected value, while
+ // costing a round trip each time the dropdown opens and leaving the welcome
+ // buttons to flicker in after paint. Installing a CLI later needs a server
+ // restart either way. Memoized probes also make this cheap per render.
+ //
+ // Solo popups skip it: no settings modal, no welcome screen, no run menu.
if (!soloSessionId) {
- const { isCodexAvailable } = await import('../utils/codex-cli-resolver.js');
- if (isCodexAvailable()) {
- html = html.replace('', `\n`);
- }
+ const [
+ { isClaudeAvailable },
+ { isOpenCodeAvailable },
+ { isCodexAvailable },
+ { isGeminiAvailable },
+ { isAntigravityAvailable },
+ { isCloudflaredAvailable },
+ ] = await Promise.all([
+ import('../utils/claude-cli-resolver.js'),
+ import('../utils/opencode-cli-resolver.js'),
+ import('../utils/codex-cli-resolver.js'),
+ import('../utils/gemini-cli-resolver.js'),
+ import('../utils/antigravity-cli-resolver.js'),
+ import('../utils/cloudflared-resolver.js'),
+ ]);
+ const available = {
+ claude: isClaudeAvailable(),
+ opencode: isOpenCodeAvailable(),
+ codex: isCodexAvailable(),
+ gemini: isGeminiAvailable(),
+ antigravity: isAntigravityAvailable(),
+ cloudflared: isCloudflaredAvailable(),
+ };
+ html = html.replace(
+ '',
+ `\n`
+ );
}
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('', `\n`);
diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts
index 33b6246c..63569557 100644
--- a/test/render-index-html.test.ts
+++ b/test/render-index-html.test.ts
@@ -12,16 +12,40 @@
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
+import { isClaudeAvailable } from '../src/utils/claude-cli-resolver.js';
+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 { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
-// renderIndexHtml probes the real PATH for `codex`, which would make the codex
+// renderIndexHtml probes the real PATH for every CLI, which would make the
// assertions below depend on whatever happens to be installed on the machine
-// running the suite. Default to "not installed" so every OTHER test in this file
-// renders exactly as before, and opt in per test.
+// running the suite. Default them all to "not installed" and opt in per test.
+vi.mock('../src/utils/claude-cli-resolver.js', () => ({
+ isClaudeAvailable: vi.fn(() => false),
+ findClaudeDir: vi.fn(() => null),
+}));
+vi.mock('../src/utils/opencode-cli-resolver.js', () => ({
+ isOpenCodeAvailable: vi.fn(() => false),
+ resolveOpenCodeDir: vi.fn(() => null),
+}));
vi.mock('../src/utils/codex-cli-resolver.js', () => ({
isCodexAvailable: vi.fn(() => false),
resolveCodexDir: vi.fn(() => null),
}));
+vi.mock('../src/utils/gemini-cli-resolver.js', () => ({
+ isGeminiAvailable: vi.fn(() => false),
+ resolveGeminiDir: vi.fn(() => null),
+}));
+vi.mock('../src/utils/antigravity-cli-resolver.js', () => ({
+ isAntigravityAvailable: vi.fn(() => false),
+ resolveAntigravityDir: vi.fn(() => null),
+}));
+vi.mock('../src/utils/cloudflared-resolver.js', () => ({
+ isCloudflaredAvailable: vi.fn(() => false),
+ resolveCloudflaredPath: vi.fn(() => null),
+}));
const TEMPLATE = [
'',
@@ -96,25 +120,53 @@ describe('WebServer.renderIndexHtml', () => {
expect(html).toContain('gesture-codeman.js');
});
- it('exposes codex availability so the App Settings Codex tab can be gated', async () => {
+ it('reports every tool the welcome buttons, run menu and Codex tab gate on', async () => {
+ vi.mocked(isClaudeAvailable).mockReturnValue(true);
+ vi.mocked(isOpenCodeAvailable).mockReturnValue(false);
vi.mocked(isCodexAvailable).mockReturnValue(true);
+ vi.mocked(isGeminiAvailable).mockReturnValue(false);
+ vi.mocked(isAntigravityAvailable).mockReturnValue(false);
+ vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
const { server } = makeServer({});
const html = await render(server);
- expect(html).toContain('window.__codemanCodexAvailable=true');
+ const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
+ // Every key must be PRESENT, not merely truthy where installed: the client
+ // treats a missing key as available, so a dropped key silently un-gates.
+ expect(flags).toEqual({
+ claude: true,
+ opencode: false,
+ codex: true,
+ gemini: false,
+ antigravity: false,
+ cloudflared: true,
+ });
});
- it('omits the codex flag entirely when the codex binary does not resolve', async () => {
- vi.mocked(isCodexAvailable).mockReturnValue(false);
+ it('still emits the object when nothing at all is installed', async () => {
+ // The all-false case is the one that matters most and the easiest to get
+ // wrong by only injecting when something resolves.
+ for (const probe of [
+ isClaudeAvailable,
+ isOpenCodeAvailable,
+ isCodexAvailable,
+ isGeminiAvailable,
+ isAntigravityAvailable,
+ isCloudflaredAvailable,
+ ]) {
+ vi.mocked(probe).mockReturnValue(false);
+ }
const { server } = makeServer({});
const html = await render(server);
- expect(html).not.toContain('__codemanCodexAvailable');
+ expect(html).toContain('window.__codemanCliAvailable=');
+ const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
+ expect(Object.values(flags).every((v) => v === false)).toBe(true);
});
- it('skips the codex probe for a solo window, which has no settings modal', async () => {
+ it('skips the probe for a solo window, which has no welcome screen or run menu', async () => {
vi.mocked(isCodexAvailable).mockReturnValue(true);
const { server } = makeServer({});
const html = await render(server, 'sess-123');
- expect(html).not.toContain('__codemanCodexAvailable');
+ expect(html).not.toContain('__codemanCliAvailable');
});
it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => {
diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts
index 78485228..53f67ab2 100644
--- a/test/run-mode-ui.test.ts
+++ b/test/run-mode-ui.test.ts
@@ -296,7 +296,7 @@ describe('Codex quick start settings', () => {
describe('Codex CLI tab visibility', () => {
// Both settings on the tab are handed to `codex` at launch, so on an instance
// where the binary does not resolve the tab is a promise nothing can keep.
- // renderIndexHtml injects window.__codemanCodexAvailable; this pins the client
+ // renderIndexHtml injects window.__codemanCliAvailable; this pins the client
// half. Coupled test: it drives the REAL settings-ui.js against a stub button,
// so deleting the call in openAppSettings() is what it is meant to catch.
function loadSettingsUi(codexAvailable: boolean | undefined) {
@@ -313,7 +313,7 @@ describe('Codex quick start settings', () => {
console,
});
context.window = context;
- if (codexAvailable !== undefined) context.__codemanCodexAvailable = codexAvailable;
+ if (codexAvailable !== undefined) context.__codemanCliAvailable = { codex: codexAvailable };
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
return { app: new (CodemanApp as any)(), codexTabBtn };
@@ -345,6 +345,107 @@ describe('Codex quick start settings', () => {
});
});
+ describe('CLI availability gating (#200/#201)', () => {
+ // Drives the REAL settings-ui.js + session-ui.js against stub elements, so an
+ // added run mode that nobody wires up here is what these are meant to catch.
+ function loadUi(flags: Record | undefined) {
+ const CodemanApp = function CodemanApp(this: any) {};
+ const welcomeBtns: Record = {};
+ for (const id of ['welcomeClaudeBtn', 'welcomeOpencodeBtn', 'welcomeGeminiBtn', 'welcomeTunnelBtn']) {
+ welcomeBtns[id] = { style: { display: 'PRISTINE' } };
+ }
+ const modeBtns: Record = {};
+ for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'shell']) {
+ modeBtns[mode] = { style: { display: 'PRISTINE' } };
+ }
+ const menu = {
+ querySelector: (sel: string) => {
+ const m = sel.match(/data-mode="([^"]+)"/);
+ return m ? (modeBtns[m[1]] ?? null) : null;
+ },
+ };
+ const context: any = vm.createContext({
+ CodemanApp,
+ MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
+ localStorage: { getItem: () => null, setItem: () => {} },
+ document: { getElementById: (id: string) => welcomeBtns[id] ?? null, querySelector: () => null },
+ console,
+ });
+ context.window = context;
+ if (flags !== undefined) context.__codemanCliAvailable = flags;
+ for (const file of ['settings-ui.js', 'session-ui.js']) {
+ const src = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8');
+ vm.runInContext(src, context, { filename: file });
+ }
+ return { app: new (CodemanApp as any)(), welcomeBtns, modeBtns, menu };
+ }
+
+ const ALL_OFF = {
+ claude: false,
+ opencode: false,
+ codex: false,
+ gemini: false,
+ antigravity: false,
+ cloudflared: false,
+ };
+
+ it('hides each welcome button whose tool is missing, including the tunnel', () => {
+ const { app, welcomeBtns } = loadUi({ ...ALL_OFF, claude: true });
+ app.applyWelcomeCliVisibility();
+ expect(welcomeBtns.welcomeClaudeBtn.style.display).toBe('flex');
+ expect(welcomeBtns.welcomeOpencodeBtn.style.display).toBe('none');
+ expect(welcomeBtns.welcomeGeminiBtn.style.display).toBe('none');
+ // #200 originally DELETED the tunnel button and its QR outright; it is gated
+ // on cloudflared instead, so a box that has cloudflared keeps the feature.
+ expect(welcomeBtns.welcomeTunnelBtn.style.display).toBe('none');
+
+ const withTunnel = loadUi({ ...ALL_OFF, cloudflared: true });
+ withTunnel.app.applyWelcomeCliVisibility();
+ expect(withTunnel.welcomeBtns.welcomeTunnelBtn.style.display).toBe('flex');
+ });
+
+ it('gates every run mode in the dropdown, antigravity included, and never shell', () => {
+ const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true });
+ app._refreshRunModeAvailability(menu);
+ expect(modeBtns.claude.style.display).toBe('flex');
+ expect(modeBtns.antigravity.style.display).toBe('flex');
+ expect(modeBtns.opencode.style.display).toBe('none');
+ expect(modeBtns.codex.style.display).toBe('none');
+ expect(modeBtns.gemini.style.display).toBe('none');
+ // Shell needs no external CLI, and leaving it alone is what guarantees the
+ // menu is never empty on a box with nothing installed.
+ expect(modeBtns.shell.style.display).toBe('PRISTINE');
+ });
+
+ it('gates every mode the run-mode menu actually offers', () => {
+ // Catches a sixth run mode being added to index.html without being gated,
+ // which is exactly how antigravity slipped past #201.
+ const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
+ const menuHtml = html.slice(html.indexOf('id="runModeMenu"'));
+ const offered = [...menuHtml.slice(0, menuHtml.indexOf('
')).matchAll(/data-mode="([^"]+)"/g)].map(
+ (m) => m[1]
+ );
+ expect(offered).toContain('antigravity');
+ 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) {'));
+ const gated = fn.slice(0, fn.indexOf('\n },'));
+ for (const mode of offered.filter((m) => m !== 'shell')) {
+ expect(gated).toContain(`'${mode}'`);
+ }
+ });
+
+ it('shows everything when the flags were never injected', () => {
+ // A cached page from a build without the injection, or a solo popup. Hiding
+ // every run button on a doubt would leave a working install nothing to click.
+ const { app, welcomeBtns, modeBtns, menu } = loadUi(undefined);
+ app.applyWelcomeCliVisibility();
+ app._refreshRunModeAvailability(menu);
+ expect(welcomeBtns.welcomeClaudeBtn.style.display).toBe('flex');
+ expect(modeBtns.gemini.style.display).toBe('flex');
+ });
+ });
+
it('passes global Codex settings into quick-start config for new sessions', async () => {
const elements: Record
= {
quickStartCase: { value: 'codex-case' },
diff --git a/test/server-index-title.test.ts b/test/server-index-title.test.ts
index 5318056a..506e19bb 100644
--- a/test/server-index-title.test.ts
+++ b/test/server-index-title.test.ts
@@ -96,8 +96,16 @@ describe('WebServer index.html templating (#82)', () => {
it('only substitutes the tag — the rest of the template is identical (modulo asset cache-busting)', async () => {
// renderIndexHtml also appends ?v= cache-bust params to same-origin
- // .js/.css refs; strip them so the title remains the only other change.
- const html = (await render('laptop')).replace(/(\.(?:js|css))\?v=[^"]*/g, '$1');
+ // .js/.css refs, and injects the CLI-availability flags before ; strip
+ // both so the title remains the only other change.
+ //
+ // The flag strip is what keeps this test environment-independent. It used to
+ // pass here by luck: the availability script was injected only where a CLI
+ // resolved, so the assertion held on a machine with none installed and would
+ // have failed on a developer's box that had them.
+ const html = (await render('laptop'))
+ .replace(/(\.(?:js|css))\?v=[^"]*/g, '$1')
+ .replace(/