Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/doctor-in-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---

Settings → System → Diagnostics runs `codeman doctor` on the server (`GET /api/doctor`) and lists which agent CLIs, tmux, Node and the optional office tools are installed, their versions, paths and install hints. The probe runs in a child process so a slow `--version` can never freeze the server; admin only in multi-user mode.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
## More Features

- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. CLIs are found the same way the Run menu finds them (including `~/.local/bin` and npm/nvm prefixes), so a service with a minimal `PATH` still reports them correctly. Admin only in multi-user mode.
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
Expand Down
1 change: 1 addition & 0 deletions config/test-suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const BROWSER_TEST_GLOBS = [
'test/shift-enter-keypress.browser.test.ts',
'test/key-tester.browser.test.ts',
'test/webhook-settings.browser.test.ts',
'test/doctor-settings.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
'test/mobile-ime-preview.browser.test.ts',
Expand Down
4 changes: 4 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,10 @@ Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Setting

Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL.

## Diagnostics

`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report.

## Voice dictation

Browser dictation transcribed through this server's Claude Code login, i.e. the
Expand Down
6 changes: 4 additions & 2 deletions docs/wiki/Settings-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,10 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
### System

`CLAUDE.md` template for new cases, default working directory, the image watcher, and
Cloudflare tunnel controls including the tunnel and upload URLs. In multi-user mode, the
**Users** administration entry is injected here.
Cloudflare tunnel controls including the tunnel and upload URLs. The **Diagnostics** group runs
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
tools with their versions and install hints (admin only in multi-user mode). In multi-user
mode, the **Users** administration entry is injected here.

## Session Options

Expand Down
17 changes: 17 additions & 0 deletions src/config/dependency-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* @module config/dependency-registry
*/

import { homedir } from 'node:os';
import { join } from 'node:path';
import { enabledClis } from './cli-registry/registry.js';
import { compileVersionRegex } from './cli-registry/patterns.js';

Expand All @@ -30,6 +32,20 @@ export interface PathResolver {
* there and a false "installed" contradicts the run mode's own resolver.
*/
requireVersionMatch?: boolean;
/**
* Absolute directories to probe (`<dir>/<bin>`) when `which` misses. A service (systemd,
* launchd) runs with a minimal PATH, so a CLI installed under `~/.local/bin` or an npm/nvm
* prefix is invisible to `which` while the run mode, which falls back to the registry's
* `discovery.searchDirs`, still finds it. Carries those dirs so the doctor agrees.
*/
searchDirs?: string[];
}

/** Expand a leading `~` (the only form registry `searchDirs` use). */
function expandSearchDir(dir: string): string {
if (dir === '~') return homedir();
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
return dir;
}

/** Resolve a Windows-installed app reachable from win32 or WSL. */
Expand Down Expand Up @@ -131,6 +147,7 @@ function cliDependencyEntries(): ToolDependency[] {
// (pi, grok, dsh): a bare `which` hit there is not evidence of the right
// program, so a version mismatch means MISSING rather than unknown-version.
requireVersionMatch: version?.requireVersionMatch,
searchDirs: cli.discovery.searchDirs.map(expandSearchDir),
},
},
],
Expand Down
18 changes: 15 additions & 3 deletions src/utils/dependency-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,23 @@ export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult {
if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` };

if (spec.resolver.kind === 'path') {
const { bins, versionArg, versionRegex, requireVersionMatch } = spec.resolver;
const { bins, versionArg, versionRegex, requireVersionMatch, searchDirs } = spec.resolver;
for (const bin of bins) {
const resolved = host.which(bin);
// `which` first (the PATH), then the registry's search dirs: under a service the PATH is
// minimal and the run mode finds the CLI through those dirs, so the doctor must too.
let resolved = host.which(bin);
if (!resolved && searchDirs) {
for (const dir of searchDirs) {
const candidate = `${dir.replace(/\/+$/, '')}/${bin}`;
if (host.fileExists(candidate)) {
resolved = candidate;
break;
}
}
}
if (resolved) {
const out = host.runVersion(bin, [versionArg ?? '--version']);
// Run the RESOLVED path: a bare name would miss the same binary `which` just missed.
const out = host.runVersion(resolved, [versionArg ?? '--version']);
const version = out ? extractVersion(out, versionRegex) : undefined;
// A generic binary name that prints the wrong thing is some OTHER program (see
// PathResolver.requireVersionMatch). Keep looking, then report MISSING; the
Expand Down
14 changes: 14 additions & 0 deletions src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2829,6 +2829,20 @@ <h2>System</h2>
</div>
<p class="set-section-blurb">Paths, automation and remote access. Set once, rarely touched.</p>

<div class="set-group" id="doctorGroup">
<div class="set-group-head"><h4>Diagnostics</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="diagnostics doctor dependencies tmux node claude codex check install">
<div class="set-row-text">
<span class="set-row-label">Check this machine</span>
<span class="set-row-desc">Runs <code>codeman doctor</code> on the server: which agent CLIs, tmux, Node and the optional office tools are installed, their versions, and how to install what is missing.</span>
</div>
<button class="btn-toolbar btn-sm" id="doctorRunBtn" onclick="app.runDoctor()">Run checks</button>
</div>
<div id="doctorResult" class="set-note" style="display:none" data-i18n-skip></div>
</div>
</div>

<div class="set-group">
<div class="set-group-head"><h4>Paths</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
Expand Down
75 changes: 75 additions & 0 deletions src/web/public/settings-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ Object.assign(CodemanApp.prototype, {
this._mcpSyncSavedOn = settings.mcpSyncEnabled === true;
document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn;
this.applyMcpSyncVisibility();
this._applyDoctorAdminGate();
this.loadWebhook();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
Expand Down Expand Up @@ -1192,6 +1193,18 @@ Object.assign(CodemanApp.prototype, {
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
},

/**
* GET /api/doctor is admin-only in multi-user mode (it names install paths on the host), so a
* non-admin gets no Diagnostics group instead of a button that can only answer 403. Also
* wired to `codeman:me` for the same late-resolving role as the groups above.
*/
_applyDoctorAdminGate() {
const group = document.getElementById('doctorGroup');
if (!group) return;
const me = window.__codemanUser || {};
group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
},

/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
async mcpSync(apply) {
const out = this.$('mcpSyncResult');
Expand Down Expand Up @@ -1364,6 +1377,67 @@ Object.assign(CodemanApp.prototype, {
}
},

/**
* Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list
* each tool. Built with DOM nodes and textContent: paths and versions come from the host.
*/
async runDoctor() {
const out = document.getElementById('doctorResult');
const btn = document.getElementById('doctorRunBtn');
if (!out) return;
const say = (text) => {
out.replaceChildren(document.createTextNode(text));
out.style.display = 'block';
};
if (btn) btn.disabled = true;
say('Checking…');
try {
const res = await this._api('/api/doctor');
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
say(body?.error || 'The check failed.');
return;
}
const { tools, summary, platform } = body.data;
const glyph = { ok: '✓', missing: '✗', outdated: '!', error: '!', skipped: '–' };
const list = document.createElement('ul');
list.style.margin = '0';
list.style.paddingLeft = '1.2em';
for (const t of tools) {
const li = document.createElement('li');
const strong = document.createElement('b');
strong.textContent = `${glyph[t.status] || '?'} ${t.label}`;
li.append(strong);
const bits = [t.status];
if (t.version) bits.push(t.version);
if (t.status !== 'ok' && t.status !== 'skipped') bits.push(t.required ? 'required' : 'optional');
if (t.reason) bits.push(t.reason);
li.append(document.createTextNode(` ${bits.join(' · ')}`));
if (t.path) {
const p = document.createElement('div');
p.className = 'mono';
p.textContent = t.path;
li.append(p);
}
if (t.status === 'missing' && t.installHint) {
const h = document.createElement('div');
h.textContent = `Install: ${t.installHint}`;
li.append(h);
}
list.append(li);
}
const head = document.createElement('p');
head.textContent =
`${summary.ok} ok · ${summary.requiredMissing} required missing · ${summary.optionalMissing} optional missing` +
` (${platform.environment})`;
out.replaceChildren(head, list);
out.style.display = 'block';
} finally {
if (btn) btn.disabled = false;
}
},

_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
Expand Down Expand Up @@ -4373,4 +4447,5 @@ document.addEventListener?.('codeman:me', () => {
window.app?._applyCustomModelAdminGate?.();
window.app?._applyCliManagementAdminGate?.();
window.app?._applyMcpSyncAdminGate?.();
window.app?._applyDoctorAdminGate?.();
});
100 changes: 100 additions & 0 deletions src/web/routes/doctor-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* @fileoverview `GET /api/doctor` — the `codeman doctor` dependency report (Node, the agent CLIs,
* tmux, LibreOffice, MS Office) for Settings → System → Diagnostics.
*
* The probe engine is synchronous (`which` + `<bin> --version` per tool, each up to its own
* timeout), so it must never run on the server's event loop: a handful of slow probes would
* freeze every request and every SSE client, with the process still alive. The default runner
* therefore runs `codeman doctor --json` in a CHILD PROCESS of this same entry script and
* parses its output; the runner is injected so tests never spawn anything.
*
* Read-only, but the report names install paths and versions on the host, so in multi-user
* mode it is admin only (the same bar as the other host-introspection routes).
*/

import { execFile } from 'node:child_process';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { isAdmin } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { TOOL_CATEGORIES } from '../../config/dependency-registry.js';
import type { DependencyReportJson } from '../../utils/dependency-report.js';

export type DoctorRunner = (category?: string) => Promise<DependencyReportJson>;

const DOCTOR_TIMEOUT_MS = 30_000;

function isReport(v: unknown): v is DependencyReportJson {
const r = v as Partial<DependencyReportJson> | null;
return !!r && Array.isArray(r.tools) && typeof r.summary === 'object' && r.summary !== null;
}

/**
* Run `doctor --json` out of process. The CLI exits non-zero when a required tool is missing,
* and still prints the report, so a non-zero exit with parseable stdout is a normal result.
*/
export const defaultDoctorRunner: DoctorRunner = (category) =>
new Promise((resolve, reject) => {
const args = [
...process.execArgv,
process.argv[1],
'doctor',
'--json',
...(category ? ['--category', category] : []),
];
execFile(
process.execPath,
args,
{ timeout: DOCTOR_TIMEOUT_MS, maxBuffer: 1024 * 1024, env: process.env },
(err, stdout) => {
if (err && (err as { killed?: boolean }).killed) {
return reject(new Error(`timed out after ${DOCTOR_TIMEOUT_MS / 1000} s`));
}
try {
const parsed: unknown = JSON.parse(stdout);
if (isReport(parsed)) return resolve(parsed);
} catch {
/* fall through to the error below */
}
reject(err ?? new Error('doctor produced no report'));
}
);
});

export function registerDoctorRoutes(app: FastifyInstance, runner: DoctorRunner = defaultDoctorRunner): void {
// Each run forks a full Node process, so two tabs or a script must not stack them: callers
// asking for the same category while one is in flight share its promise.
const inFlight = new Map<string, Promise<DependencyReportJson>>();
const runShared = (category?: string): Promise<DependencyReportJson> => {
const key = category ?? '';
let running = inFlight.get(key);
if (!running) {
running = runner(category).finally(() => inFlight.delete(key));
inFlight.set(key, running);
}
return running;
};
app.get(
'/api/doctor',
async (req: FastifyRequest, reply: FastifyReply): Promise<ApiResponse<DependencyReportJson>> => {
if (isMultiUserMode() && !isAdmin(req)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
const { category } = req.query as { category?: string };
if (category !== undefined && !(TOOL_CATEGORIES as readonly string[]).includes(category)) {
reply.code(400);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Unknown category "${category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`
);
}
try {
return { success: true, data: await runShared(category) };
} catch (err) {
reply.code(500);
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `doctor failed: ${getErrorMessage(err)}`);
}
}
);
}
1 change: 1 addition & 0 deletions src/web/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-rout
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
export { registerWebhookRoutes } from './webhook-routes.js';
export { registerDoctorRoutes } from './doctor-routes.js';
export {
registerCustomModelRoutes,
refreshAllCustomModelHosts,
Expand Down
2 changes: 2 additions & 0 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ import {
registerTabLayoutRoutes,
registerMcpSyncRoutes,
registerWebhookRoutes,
registerDoctorRoutes,
registerCustomModelRoutes,
refreshAllCustomModelHosts,
readCustomModelEndpointsEnabled,
Expand Down Expand Up @@ -1143,6 +1144,7 @@ export class WebServer extends EventEmitter {
configDir: getDataDir(),
hostTitle: () => this.windowTitle,
});
registerDoctorRoutes(this.app);
registerCustomModelRoutes(this.app);
registerCliRegistryRoutes(this.app);

Expand Down
Loading
Loading