doctor.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* doctor.mjs — Setup validation for career-ops
* Checks all prerequisites and prints a pass/fail checklist.
*/
import { accessSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs';
import { execFileSync } from 'child_process';
import { homedir } from 'os';
import { delimiter, join, dirname, isAbsolute, relative, resolve, sep } from 'path';
import { fileURLToPath } from 'url';
import { findTitleFilterConflicts } from './lib/title-filter-conflicts.mjs';
import { sectionState, scanHeadings, SECTIONS } from './lib/personalization.mjs';
import { discoverPlugins, pluginRoots, pluginStatus, resolveSuccessorIds } from './plugins/_engine.mjs';
import { getCareerOpsRoot, getProfilePath, hasProfileOverride, isPluginInstallCopy } from './path-resolver.mjs';
import { parseConfigByExtension } from './jsonc-parse.mjs';
import { validateFlags, flagValue } from './lib/cli-flags.mjs';
import { geminiNodeFloor } from './lib/gemini-node-floor.mjs';
import { SKILL_ENTRYPOINTS } from './scaffolder/bin/skill-entrypoints.mjs';
import { nodeFloor } from './lib/node-floor.mjs';
import { classifyChromiumLaunchError } from './lib/chromium-launch-error.mjs';
import { findExperienceSections, parseCompanyHeading, EXPERIENCE_HEADING_NAMES } from './lib/cv-markdown.mjs';
import { LINKEDIN_DOCTOR_LABEL, linkedInPluginFix } from './lib/linkedin-import.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
// path-resolver.mjs refuses an explicit data root inside a plugin install copy,
// and some modules loaded below resolve the root as they load. Ask once, first,
// so the refusal reaches the user as a report instead of a stack trace.
try {
getCareerOpsRoot();
} catch (err) {
if (err?.code !== 'CAREER_OPS_UNSAFE_DATA_ROOT') throw err;
if (argv.includes('--json')) {
// Same keys as a normal --json run, so an agent reading `warnings` sees it;
// onboardingNeeded stays true so nothing proceeds on this root.
console.log(JSON.stringify({
onboardingNeeded: true,
missing: [],
unpersonalized: [],
warnings: [err.message],
autoCopied: [],
dataRootError: { code: err.code, source: err.source, root: err.root },
}));
} else {
console.log(`\ncareer-ops doctor\n================\n\n✗ ${err.message}\n`);
}
process.exit(1);
}
// Modules that import an npm package (directly or through their own imports)
// load here, after the fact, instead of as static imports. A static import of
// a missing package fails while Node links the module graph, before a single
// line of this file runs, so a copy with no node_modules (a ZIP download, a
// copy made without `npm install`) died with a raw ERR_MODULE_NOT_FOUND stack
// from the very check that exists to report missing dependencies. Loading them
// dynamically lets that failure be caught and reported with the fix.
let hasYamlContent, loadYaml, dotenv, AGENT_CLIS, validateProfile, EXAMPLE_PATH;
let resolveExtractorMode, auditUserTemplates, browserLaunchOptions, resolveBrowserExecutable, snapshotBrowserProcessEnv;
try {
[
{ hasYamlContent, loadYaml },
{ default: dotenv },
{ AGENT_CLIS },
{ validateProfile, EXAMPLE_PATH },
{ resolveExtractorMode },
{ auditUserTemplates },
{ browserLaunchOptions, resolveBrowserExecutable, snapshotBrowserProcessEnv },
] = await Promise.all([
import('./lib/yaml-document.mjs'),
import('dotenv'),
import('./lib/agent-cli.mjs'),
import('./validate-profile.mjs'),
import('./browser-extract.mjs'),
import('./cv-templates.mjs'),
import('./lib/browser-launch.mjs'),
]);
} catch (err) {
let missing = [];
try { missing = findMissingDependencies(__dirname); } catch { /* no readable package.json: the error still names the package */ }
const name = /Cannot find package '((?:@[^/'\\:]+\/)?[^/'\\:]+)'/.exec(err?.message)?.[1];
if (name && !missing.includes(name)) missing.unshift(name);
if (err?.code !== 'ERR_MODULE_NOT_FOUND' || missing.length === 0) throw err;
console.error(`career-ops dependencies are not installed in ${__dirname} (missing: ${missing.join(', ')}), so doctor cannot run its checks.\nFix: run \`npm install\` in ${__dirname}, then run doctor again.`);
process.exit(1);
}
// CLIs the doctor recognises.
// --help ran the full diagnostic and printed the report at exit 0 (#2856), so
// a mistyped flag was indistinguishable from a clean run — and --targe
// silently diagnosed THIS checkout instead of the one asked for. Handled via
// lib/cli-flags.mjs's validateFlags() (#2775), which rejects unrecognized
// flags before --help so `--help --bogus` still errors.
const KNOWN_FLAGS = ['--target', '--json', '--init-templates', '--strict', '--cli', '--help', '-h'];
// Both take a value, as the next argv token or as `--flag=value`. validateFlags
// accepts either form, so both are read with flagValue(): an indexOf() lookup
// cannot see `--target=<path>` and would diagnose this checkout instead.
const VALUE_FLAGS = ['--target', '--cli'];
const USAGE = `Usage:
node doctor.mjs # run the setup diagnostic
node doctor.mjs --json # read-only machine-readable onboarding state
node doctor.mjs --json --init-templates # create missing personalization files for onboarding
node doctor.mjs --strict # also probe portals.yml entries (network)
node doctor.mjs --target <path> # diagnose another career-ops checkout
node doctor.mjs --cli <name> # check a specific CLI's integration
node doctor.mjs --help # show this message
CLIs: ${AGENT_CLIS.join(', ')}`;
// requireOperand: without it, `--target --json` reads --json as the target
// path (flagValue() below takes the next token as-is), and the
// doctor silently diagnoses a directory literally named "--json" at exit 0
// (#3087) — this is the onboarding entrypoint, so that's a user told to
// create files that already exist. Nothing more specific to say than the
// shared message.
validateFlags(argv, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true });
const explicitTarget = flagValue(argv, '--target') || null;
const projectRoot = explicitTarget || getCareerOpsRoot();
// --target diagnoses a whole other checkout, so that checkout's profile is the
// one that counts; otherwise CAREER_OPS_PROFILE applies as it does everywhere.
// With no override the path is the root the doctor started from: the human run
// loads the root's whole .env later, and a CAREER_OPS_ROOT in it must not move
// this one check to another root.
const profilePathFor = (root) => (explicitTarget || !hasProfileOverride() ? getProfilePath(root) : getProfilePath());
// Only scan.mjs, the evaluators, openai-tailor.mjs and openrouter-runner load a .env; most readers
// of the profile see the process environment alone. Doctor reports what they see,
// so an override set only in the root's .env is looked up for the advice below
// but never counted as a found profile.
const profileFromEnvFile = (root) => {
try {
const value = dotenv.parse(readFileSync(join(root, '.env'))).CAREER_OPS_PROFILE;
return value?.trim() ? value.trim() : null;
} catch {
return null; // no .env, or unreadable
}
};
// node_modules and .git belong to the CODE checkout, not the resolved data
// root — under a split checkout (CAREER_OPS_ROOT/CAREER_OPS_DATA_DIR or the
// .career-ops-data marker) those are two different directories, and neither
// ever holds the other's artifacts (career-ops#3867 finding 6). --target is
// the one case that means "diagnose this whole other checkout" — code layer
// included — so it keeps pointing both roots at the same place, matching how
// tests/doctor-tracked-bak-files.test.mjs already exercises it.
const codeRoot = explicitTarget || __dirname;
const JSON_OUT = argv.includes('--json');
const INIT_TEMPLATES = argv.includes('--init-templates');
if (INIT_TEMPLATES && !JSON_OUT) {
console.error('Error: --init-templates requires --json');
process.exit(1);
}
// --strict adds a live reachability probe of every portals.yml entry (network).
// Opt-in so the default `npm run doctor` stays fast and fully offline.
const STRICT = argv.includes('--strict');
const cliFlag = flagValue(argv, '--cli') ?? null;
// ANSI colors (only on TTY)
const isTTY = process.stdout.isTTY;
const green = (s) => isTTY ? `\x1b[32m${s}\x1b[0m` : s;
const red = (s) => isTTY ? `\x1b[31m${s}\x1b[0m` : s;
const yellow = (s) => isTTY ? `\x1b[33m${s}\x1b[0m` : s;
const dim = (s) => isTTY ? `\x1b[2m${s}\x1b[0m` : s;
function checkNodeVersion() {
return nodeFloor(process.versions.node);
}
// El check mas frecuente de la comunidad, medido: 8 personas en 4 semanas
// preguntando por cuota y coste, y la causa mas repetida es esta. Aviso, nunca
// fallo: tener una clave puesta es una eleccion legitima, lo que no es legitimo
// es que el usuario no sepa que la esta usando en vez del plan que ya paga.
function checkBillingSource() {
const key = process.env.ANTHROPIC_API_KEY;
const authToken = process.env.ANTHROPIC_AUTH_TOKEN;
// Enabled means SET TO A TRUTHY VALUE, not merely present. These switches are
// documented as `=1`, so `CLAUDE_CODE_USE_BEDROCK=0` is how someone turns one
// off — and mere presence would then report "requests bill to your cloud
// account" at exactly the user who just said they don't. A billing check that
// misreads an explicit opt-out causes the confusion it exists to remove.
// Matches the repo's own env-flag convention (=== '1' in merge-tracker.mjs
// and update-system.mjs), while also accepting `true` since these are
// third-party switches users copy from assorted docs.
const cloud = ['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY']
.filter((v) => /^(1|true|yes|on)$/i.test(String(process.env[v] ?? '').trim()));
if (cloud.length) {
return {
warn: true,
label: `${cloud[0]} is set, so requests bill to your cloud account, not to a Claude subscription.`,
fix: [
'Intentional? Nothing to do.',
`Not intentional: unset ${cloud[0]} and restart your terminal.`,
],
};
}
const which = key ? 'ANTHROPIC_API_KEY' : (authToken ? 'ANTHROPIC_AUTH_TOKEN' : null);
if (!which) {
return { pass: true, label: 'Billing source: no API key in the environment (a Claude subscription will be used if you are logged in)' };
}
return {
warn: true,
label: `${which} is set, so it takes precedence over any Claude subscription: this session bills per token even if you pay for Pro or Max.`,
fix: [
'Intentional (you meant to use API credits)? Nothing to do.',
`Not intentional: remove the export of ${which} from ~/.zshrc, ~/.bashrc, ~/.profile or a project .env, restart your terminal, and run /login.`,
'Batch runs are the exception: `claude -p` workers do not use the interactive login, so they need `claude setup-token` exported as CLAUDE_CODE_OAUTH_TOKEN.',
'Details: docs/RUNNING_ON_A_BUDGET.md section 2b.',
],
};
}
// Whether each package.json dependency resolves from the code checkout the way
// Node will at run time — not whether a node_modules directory exists. That
// was wrong both ways: a git worktree with no node_modules of its own resolves
// through the main checkout's (Node walks up parent directories), and a
// node_modules installed before a dependency was added (undici, #4445) still
// exists while the import fails. Every script loads its dependencies with ESM
// `import`, whose bare-specifier lookup is node_modules in the code root and
// each ancestor directory — and nothing else: unlike require(), it ignores
// NODE_PATH and the global folders, so a package found only there would pass
// here and still fail to import. A package counts as installed when one of
// those node_modules holds it. Checking for its package.json, rather than
// resolving the package, never consults an `exports` map that refuses the
// lookup and never loads the package — doctor runs on every session's first
// message.
function findMissingDependencies(root) {
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
const lookupDirs = [];
for (let dir = resolve(root); ; dir = dirname(dir)) {
lookupDirs.push(join(dir, 'node_modules'));
if (dirname(dir) === dir) break;
}
return Object.keys(manifest.dependencies || {}).filter((name) =>
!lookupDirs.some((dir) => existsSync(join(dir, name, 'package.json'))));
}
function checkDependencies() {
let missing;
try {
missing = findMissingDependencies(codeRoot);
} catch (err) {
return {
pass: false,
label: `Dependencies could not be checked: package.json unreadable (${err.message})`,
fix: 'Run doctor from a career-ops checkout, or pass --target <checkout>',
};
}
if (missing.length === 0) {
return { pass: true, label: 'Dependencies installed' };
}
return {
pass: false,
label: `Dependencies missing: ${missing.join(', ')}`,
fix: isPluginInstallCopy(codeRoot)
? 'Start a new Claude Code session: the plugin\'s SessionStart hook installs them and reports any npm error'
: 'Run: npm install',
};
}
// A Claude Code plugin install runs from a copy under ~/.claude/plugins/ that
// Claude Code replaces on every plugin update, so the data root must live
// outside it. Null for a checkout, which is not a copy.
function checkPluginInstall(root) {
if (explicitTarget || !isPluginInstallCopy(codeRoot)) return null;
if (isPluginInstallCopy(root)) {
return {
pass: false,
label: `Data root ${root} is inside the Claude Code plugin install, which is replaced on every plugin update`,
fix: 'Set CAREER_OPS_ROOT to an absolute path outside ~/.claude/plugins, or unset it to use ~/career-ops-data',
};
}
return { pass: true, label: `Claude Code plugin install: your files live in ${root}` };
}
// The web UI is optional, so missing web/ dependencies are a warning: only
// `npm run ui` needs them, and a CLI-only install never does.
function checkWebDependencies() {
const webDir = join(codeRoot, 'web');
if (!existsSync(join(webDir, 'package.json'))) return null;
let missing;
try {
missing = findMissingDependencies(webDir);
} catch (err) {
return { warn: true, label: `Web UI dependencies could not be checked: web/package.json unreadable (${err.message})` };
}
if (missing.length === 0) return { pass: true, label: 'Web UI dependencies installed' };
return {
warn: true,
label: `Web UI dependencies are not installed (${missing.length} missing, including ${missing[0]}), so npm run ui cannot start`,
fix: 'Run: npm --prefix web ci',
};
}
// update-system.mjs versions before #2857 could commit cascading .bak backups
// when a checkout write failed. #2857 stops that for NEW installs, but an
// install that already `git add`ed one is stuck: nothing in the update path
// untracks a path git already has, so the .bak files persist forever and the
// next update looks like it silently did nothing (career-ops#2881) — nothing
// points at .bak unless something checks for it. Kept to a single cheap
// `git ls-files` call so it costs nothing on the common case of zero matches.
function checkTrackedBakFiles(root) {
let raw;
try {
raw = execFileSync('git', ['ls-files', '-z', '--', '*.bak*'], {
cwd: root,
encoding: 'utf-8',
timeout: 5000,
// The non-checkout classification below reads Git's diagnostic. Keep
// this subprocess deterministic without changing the user's locale.
env: { ...process.env, LC_ALL: 'C', LANGUAGE: 'C' },
// stderr PIPED, not inherited. execFileSync's default hands the child our
// own stderr, so outside a checkout git printed
// fatal: not a git repository (or any of the parent directories): .git
// before this catch ever ran — ahead of the JSON on `doctor --json`, which
// AGENTS.md has every agent run on the first message of every session. The
// catch below already handles that case; git's own message added nothing
// but the appearance of something being broken.
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (err) {
// Piping means the reason is ours to read rather than the terminal's. Not
// being a checkout is the expected case and stays a clean skip; anything
// else — git missing, a permissions problem, a timeout — is reported, since
// silently skipping those would claim a check ran that did not.
const stderr = String(err?.stderr ?? '');
if (/not a git repository/i.test(stderr) || err?.code === 'ENOENT') {
return { pass: true, label: 'Tracked .bak files: skipped (not a git checkout)' };
}
const detail = stderr.trim().split('\n')[0] || err?.message || 'unknown error';
return { warn: true, label: `Tracked .bak files: check could not run (${detail})` };
}
const paths = raw.split('\0').filter(Boolean);
if (paths.length === 0) {
return { pass: true, label: 'No tracked .bak backup files' };
}
const preview = paths.slice(0, 8);
const rest = paths.length - preview.length;
return {
warn: true,
label: `${paths.length} tracked .bak backup file${paths.length === 1 ? '' : 's'} found — an old update-system.mjs bug committed these, and no update untracks them on its own`,
fix: [
...preview,
...(rest > 0 ? [`...and ${rest} more`] : []),
"git ls-files '*.bak*' # find them",
'git rm --cached <each path> # untrack, leaving the file on disk',
'git commit -m "chore: untrack .bak backups"',
],
};
}
// A checkout made without symlink support (Windows without Developer Mode,
// core.symlinks=false) writes each per-CLI skill entrypoint as a regular file
// holding only the symlink target text, so that CLI loads a ~43-byte skill with
// no router in it (career-ops#4589). update-system.mjs apply repairs these via
// ensureSkillEntrypoints, but apply returns early on an install that is already
// up to date, so a fresh clone never reaches it and nothing else says why
// /career-ops does nothing. Read-only on purpose (doctor must not write): it
// names the stubs and the one command that materializes them.
// One script for every shell, deliberately free of quote characters so each
// shell can wrap it in its own: backticks for the one string, and both paths
// arrive as separate argv words, never interpolated into the script. (Read back
// with argv.at(-n): tests/main-guard-convention.test.mjs bans the literal
// entry-path index in source files, printed strings included.)
const MATERIALIZE_SCRIPT = 'import(require(`url`).pathToFileURL(process.argv.at(-2)).href).then(m=>console.log(m.materializeSkillEntrypoints(process.argv.at(-1))))';
// --target accepts any path, so what is printed has to be literal when pasted.
// Each shell expands something different inside the quoting it prefers:
// sh single quotes are fully literal; a quote is closed, escaped, reopened
// PowerShell single quotes are literal ($(...), $HOME stay text); a quote is
// escaped by doubling it, and PowerShell also treats the curly
// and low quotes as quote characters, so those double too
// cmd double quotes are the only form, and %VAR% is expanded inside
// them with no escape on an interactive line (a Windows path may
// contain %, and a double quote cannot occur in one)
const quoteSh = (v) => `'${v.replace(/'/g, `'\\''`)}'`;
const quotePowerShell = (v) => `'${v.replace(/['\u2018\u2019\u201A\u201B]/g, (c) => c + c)}'`;
const quoteCmd = (v) => `"${v}"`;
function repairCommands(root) {
const mod = join(root, 'scaffolder', 'bin', 'skill-entrypoints.mjs');
const updater = join(root, 'update-system.mjs');
const forms = (quote) => ({
materialize: `node -e ${quote(MATERIALIZE_SCRIPT)} ${quote(mod)} ${quote(root)}`,
update: `node ${quote(updater)} apply --confirm`,
});
const lines = [];
const section = (heading, f) => lines.push(heading.materialize, f.materialize, heading.update, f.update);
const heading = {
materialize: 'Repair them now, no update needed:',
update: 'Or update (this only repairs them when an update is actually applied):',
};
if (process.platform !== 'win32') {
section(heading, forms(quoteSh));
return lines;
}
const label = (shell) => ({
materialize: `${heading.materialize} (${shell})`,
update: `${heading.update} (${shell})`,
});
section(label('PowerShell'), forms(quotePowerShell));
if (root.includes('%')) {
lines.push('cmd.exe form not shown: this path contains %, which cmd always expands. Use the PowerShell commands above.');
} else {
section(label('cmd.exe'), forms(quoteCmd));
}
return lines;
}
function checkSkillEntrypoints(root) {
const stubs = [];
for (const entry of SKILL_ENTRYPOINTS) {
const entryPath = join(root, ...entry.path.split('/'));
try {
const stat = lstatSync(entryPath);
if (stat.isSymbolicLink() || !stat.isFile()) continue;
if (readFileSync(entryPath, 'utf-8').trim() === entry.pointer) stubs.push(entry.path);
} catch {
// Missing or unreadable: not a stub. A CLI the user never installed is
// not worth a warning, and ensureSkillEntrypoints creates absent ones.
}
}
if (stubs.length === 0) {
return { pass: true, label: 'CLI skill entrypoints are real files or symlinks' };
}
return {
warn: true,
label: `${stubs.length} CLI skill entrypoint${stubs.length === 1 ? ' is' : 's are'} a symlink-target stub, not the skill — this checkout has no symlink support, so that CLI loads an empty /career-ops`,
// Materializing first: it is the repair that works on the clone this
// warning is most likely for, one that is already up to date, where apply
// returns before reaching ensureSkillEntrypoints. Both are built from the
// root the check just inspected, so they act on that checkout from whatever
// directory they are pasted into.
fix: [
...stubs,
...repairCommands(root),
],
};
}
// The browsers `npx playwright install` downloads are dynamically linked against
// FHS library paths, so on NixOS they fail to start (libglib-2.0.so.0 not
// found). The flake dev shell points Playwright at nixpkgs' patched browsers
// instead, and is the only fix that works there.
function playwrightInstallFix() {
if (existsSync('/etc/NIXOS')) {
return 'On NixOS, run career-ops inside the flake dev shell (`nix develop`, or `direnv allow`) and re-enter it after `npm install`. Do not run `npx playwright install`: NixOS cannot run the browsers it downloads. See docs/NIXOS.md.';
}
return 'Run: npx playwright install chromium';
}
// Missing shared libraries are a host problem, not a download problem: the
// browser is there and `npx playwright install` skips it. Playwright's own
// remedy is install-deps, which installs system packages and so needs root.
// It only knows apt (#4223), so on any other Linux it would fail with
// "apt-get: command not found"; there the distro's own package manager is the
// fix.
function isDebianLike() {
try {
const osRelease = readFileSync('/etc/os-release', 'utf-8');
return /^(?:ID|ID_LIKE)=.*\b(?:debian|ubuntu)\b/m.test(osRelease);
} catch {
return false;
}
}
function playwrightDepsFix() {
if (existsSync('/etc/NIXOS')) return playwrightInstallFix();
// On Windows the missing pieces are Media Foundation DLLs. install-deps
// installs them through Playwright's install_media_pack.ps1, which enables a
// Windows Server feature and so needs an administrator shell; Windows N
// editions lack them by design and need Microsoft's Media Feature Pack.
if (process.platform === 'win32') {
return [
'Run in an administrator terminal: npx playwright install-deps chromium',
'On a Windows N edition, install the Media Feature Pack from Microsoft instead (Settings → Apps → Optional features).',
];
}
if (process.platform === 'linux' && !isDebianLike()) {
return 'Install the packages that provide these libraries with your distribution\'s package manager. `npx playwright install-deps` uses apt-get, so it only works on Debian and Ubuntu.';
}
const sudo = process.platform !== 'win32' && process.getuid?.() ? 'sudo ' : '';
return `Run: ${sudo}npx playwright install-deps chromium`;
}
async function checkPlaywright(browserProcessEnv) {
let chromium;
try {
({ chromium } = await import('playwright'));
} catch (err) {
// The package itself is missing or broken, so there is no browser to
// install yet: `npx playwright install` would fetch a CLI whose version
// need not match the one package.json pins.
const notFound = err?.code === 'ERR_MODULE_NOT_FOUND';
return {
pass: false,
label: notFound
? 'Playwright chromium could not be checked: the playwright package is not installed'
: `Playwright chromium could not be checked: the playwright package failed to load (${String(err?.message ?? err).split('\n')[0]})`,
fix: 'Run: npm install',
};
}
// Validate by launching — chromium.executablePath() points at Chrome for Testing
// (full binary) but chromium.launch() may use the headless-shell binary, which
// lives at a different path and requires a separate install. Launching directly
// tests the exact binary the runtime uses and catches stub-installs (directory
// present but no binary — just ABOUT + LICENSE files).
let browser;
// The configured browser, so the check tests what the rest of the repo will
// actually launch rather than always probing the bundled Chromium.
const override = resolveBrowserExecutable({ projectRoot, processEnv: browserProcessEnv });
try {
browser = await chromium.launch(browserLaunchOptions(
{ headless: true },
{ projectRoot, processEnv: browserProcessEnv },
));
return {
pass: true,
label: override
? `Playwright chromium installed (using the ${override.source === 'env' ? 'configured' : 'auto-detected'} browser at ${override.path})`
: 'Playwright chromium installed',
};
} catch (err) {
// Only a missing binary is fixed by installing it. Everything else means
// the binary is present and failed to start, so the advice follows the
// error Playwright reported instead of repeating a no-op install.
const cause = classifyChromiumLaunchError(err);
// Either cause can be sidestepped by pointing career-ops at a
// Chromium-family browser the machine already has.
const overrideHint = override
? ` The browser it tried is the one configured for career-ops (${override.path}); clear that setting to fall back to the bundled Chromium.`
: ' Or, to use a browser this machine already has instead of the bundled'
+ ' Chromium (e.g. on an OS too old for it), set'
+ ' PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/browser, or'
+ ' CAREER_OPS_BROWSER_AUTODETECT=true to detect an installed'
+ ' Brave/Chrome/Edge, in your shell or the data-root .env.';
if (cause.kind === 'missing-executable') {
return {
pass: false,
label: 'Playwright chromium not installed',
fix: playwrightInstallFix() + overrideHint,
};
}
if (cause.kind === 'missing-libraries') {
const which = cause.libraries.length ? ` (${cause.libraries.join(', ')})` : '';
return {
pass: false,
label: `Playwright chromium is installed but cannot start: the system libraries it needs are missing${which}`,
fix: playwrightDepsFix(),
};
}
return {
pass: false,
label: `Playwright chromium is installed but failed to launch: ${cause.detail}`,
fix: 'Reinstalling will not fix this, because the browser is already installed. If doctor runs inside a sandbox or container, check that it allows a browser process to start.' + overrideHint,
};
} finally {
try { await browser?.close(); } catch { /* ignore */ }
}
}
// Per-CLI MCP config registry. `plugins: true` marks a CLI whose MCP servers
// can also arrive from outside the project root: from .claude.json or from an
// installed plugin (see isPlaywrightMcpFromClaudeJson, isPlaywrightMcpFromPlugin).
// `userFiles` marks a CLI whose USER-level config can also carry servers
// (see opencodeUserConfigFiles).
const MCP_CONFIGS = [
{ cli: 'claude', files: ['.mcp.json', '.claude/settings.json', '.claude/settings.local.json'], plugins: true },
// opencode.jsonc is JSONC: OpenCode accepts comments and trailing commas
// there, and JSON.parse throwing on them used to read as "no MCP server
// configured" (#2252).
{ cli: 'opencode', files: ['opencode.json', 'opencode.jsonc'], userFiles: () => opencodeUserConfigFiles() },
{ cli: 'omp', files: ['.omp/mcp.json', '.omp/.mcp.json', 'mcp.json', '.mcp.json'] },
];
// Server qualifies if its definition references the @playwright/mcp package,
// or the `mcp-server-playwright` binary that package installs. nixpkgs ships
// the binary on its own (pkgs.playwright-mcp, in the flake dev shell), so a
// NixOS config names the command and never mentions the npm package.
function isPlaywrightServer(server, { allowDisabled = false } = {}) {
if (!server || typeof server !== 'object') return false;
if (!allowDisabled && (server.enabled === false || server.enabled === 'false')) return false;
const blob = JSON.stringify(server).toLowerCase();
return blob.includes('@playwright/mcp') || blob.includes('mcp-server-playwright');
}
// Any bucket shape that can hold a server map. A plugin's own .mcp.json is a
// BARE map ({ "playwright": {...} }) rather than the mcpServers/mcp wrapper the
// project-root configs use, so `cfg` itself is a candidate bucket (#2752).
function hasPlaywrightIn(cfg, { bare = false } = {}) {
if (!cfg || typeof cfg !== 'object') return false;
const buckets = [cfg.mcpServers, cfg.mcp, ...(bare ? [cfg] : [])]
.filter((b) => b && typeof b === 'object');
return buckets.some((servers) => Object.values(servers).some(isPlaywrightServer));
}
// Missing or malformed file reads as "not configured", never as a crash -
// matches the pre-existing swallow-and-continue behavior of the project scan.
function readConfigIfPresent(file) {
if (!existsSync(file)) return null;
try {
return parseConfigByExtension(file, readFileSync(file, 'utf8')) ?? null;
} catch {
return null;
}
}
// Claude Code's user config dir. CLAUDE_CONFIG_DIR is the documented override;
// the tests point it at a tmpdir so this never reads the real machine.
function claudeConfigDir() {
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
}
// OpenCode's USER-level config sources, in ASCENDING precedence order
// (opencode.ai/docs/config#precedence-order: global → $OPENCODE_CONFIG →
// project): ~/.config/opencode/opencode.json[c] (XDG_CONFIG_HOME honored),
// then $OPENCODE_CONFIG_DIR as a second config-directory candidate, then the
// $OPENCODE_CONFIG file. Config sources MERGE — the custom directory is
// loaded after the global config and overrides it, it never replaces it — so
// setting $OPENCODE_CONFIG_DIR must not take the default global dir out of
// the scan. Same reasoning as isPlaywrightMcpFromClaudeJson: a server
// installed at user level is installed and working, so a project-only scan
// would report a machine that has Playwright MCP as unconfigured. Missing or
// malformed files read as null.
function opencodeUserConfigFiles() {
const names = ['opencode.json', 'opencode.jsonc'];
const files = [];
const globalDir = process.env.XDG_CONFIG_HOME
? join(process.env.XDG_CONFIG_HOME, 'opencode')
: join(homedir(), '.config', 'opencode');
for (const name of names) files.push(join(globalDir, name));
const customDir = process.env.OPENCODE_CONFIG_DIR;
if (customDir && customDir !== globalDir) {
for (const name of names) files.push(join(customDir, name));
}
if (process.env.OPENCODE_CONFIG) files.push(process.env.OPENCODE_CONFIG);
return files;
}
// The MCP server map one OpenCode config source contributes: the documented
// flat shape (`mcp: { name: {...} }`), the `mcp: { servers: {...} }` wrapper
// seen in the wild, and — for configs copied between CLIs — Claude's
// `mcpServers` bucket.
function opencodeServerMap(cfg) {
if (!cfg || typeof cfg !== 'object') return {};
const mcp = (cfg.mcp && typeof cfg.mcp === 'object') ? cfg.mcp : {};
const { servers, ...flat } = mcp;
return {
...(cfg.mcpServers && typeof cfg.mcpServers === 'object' ? cfg.mcpServers : {}),
...(servers && typeof servers === 'object' ? servers : {}),
...flat,
};
}
// OpenCode MERGES config sources — "Configuration files are merged together,
// not replaced", later sources overriding earlier ones per SERVER NAME, with
// `enabled: false` keeping a server from starting (opencode.ai/docs/config,
// opencode.ai/docs/mcp-servers). So an any-file-contains scan is wrong in
// both directions: a project file that redefines the inherited `playwright`
// name — as a disable or as a replacement — must win over a user-level
// server, and a user-level server must still count when the project file
// never mentions it. Configs arrive in ASCENDING precedence order; the
// answer is about the EFFECTIVE entry.
function hasPlaywrightInMergedOpenCode(configs) {
const effective = {};
for (const cfg of configs) Object.assign(effective, opencodeServerMap(cfg));
return Object.values(effective)
.some((def) => def && def.enabled !== false && isPlaywrightServer(def));
}
// Resolve OMP's active agent directory using supported selectors:
// named profile ($OMP_PROFILE / $PI_PROFILE), explicit $PI_CODING_AGENT_DIR
// override, or the default ~/.omp/agent directory (docs/settings.md).
// Honors $PI_CONFIG_DIR when set, and treats 'default' as the default profile.
function ompAgentDir() {
const profile = (process.env.OMP_PROFILE || process.env.PI_PROFILE || '').trim();
const configDirName = process.env.PI_CONFIG_DIR || '.omp';
if (profile && profile !== 'default') {
return join(homedir(), configDirName, 'profiles', profile, 'agent');
}
if (process.env.PI_CODING_AGENT_DIR) {
return process.env.PI_CODING_AGENT_DIR;
}
return join(homedir(), configDirName, 'agent');
}
// OMP's USER-level config sources for settings (docs/settings.md):
// active agent directory (config.yml > config.yaml > settings.json).
function ompUserConfigFiles() {
const dir = ompAgentDir();
const configYml = join(dir, 'config.yml');
const configYaml = join(dir, 'config.yaml');
if (existsSync(configYml)) return [configYml];
if (existsSync(configYaml)) return [configYaml];
return [join(dir, 'settings.json')];
}
function ompConfigEnableProjectState(cfg) {
if (!cfg || typeof cfg !== 'object') return undefined;
if (isExplicitlyDisabled(cfg['mcp.enableProjectConfig'])) return false;
if (isExplicitlyEnabled(cfg['mcp.enableProjectConfig'])) return true;
if (cfg.mcp && typeof cfg.mcp === 'object') {
if (isExplicitlyDisabled(cfg.mcp.enableProjectConfig)) return false;
if (isExplicitlyEnabled(cfg.mcp.enableProjectConfig)) return true;
}
return undefined;
}
// OMP allows disabling project MCP configuration via mcp.enableProjectConfig: false
// in project settings (.omp/config.yml, .omp/settings.json) or user settings
// (~/.omp/agent/config.yml, ~/.omp/settings.json). When false, project-level MCP
// Active project-level settings files for OMP (docs/settings.md).
// Excludes inactive variants like .omp/config.yaml that OMP does not load.
function ompProjectConfigFiles(root) {
if (!root) return [];
return [
join(root, '.omp', 'config.yml'),
join(root, '.omp', 'settings.json'),
];
}
// OMP honors mcp.enableProjectConfig (boolean)
// in project settings (.omp/config.yml, .omp/settings.json) or user settings
// (~/.omp/agent/config.yml, ~/.omp/settings.json). When false, project-level MCP
// sources are excluded from discovery (docs/mcp-config.md). Defaults to true.
function isOmpProjectMcpEnabled(root) {
for (const file of ompProjectConfigFiles(root)) {
const state = ompConfigEnableProjectState(readOmpConfigIfPresent(file));
if (state !== undefined) return state;
}
for (const file of ompUserConfigFiles()) {
const state = ompConfigEnableProjectState(readOmpConfigIfPresent(file));
if (state !== undefined) return state;
}
return true;
}
// OMP gates user-level Claude Code MCP discovery behind enabledProviders ('claude'),
// but setting CLAUDE_CONFIG_DIR implicitly enables it unless disabledProviders lists it.
function isOmpClaudeUserProviderEnabled(root) {
const configs = [
...ompProjectConfigFiles(root),
...ompUserConfigFiles(),
].map(readOmpConfigIfPresent).filter(Boolean);
const listFor = (key) => {
const cfg = configs.find((entry) => Array.isArray(entry[key]));
return cfg ? cfg[key].map((p) => String(p).toLowerCase()) : [];
};
if (listFor('disabledProviders').includes('claude')) return false;
if (listFor('enabledProviders').includes('claude')) return true;
return Boolean(process.env.CLAUDE_CONFIG_DIR);
}
// OMP merges MCP definitions across user agent directory and project files.
// Project definitions override/shadow user definitions with the same name.
// User-level disabledServers acts as an explicit denylist across all sources (docs/mcp-config.md).
// User-level enabledServers force-enables a server definition unless disabledServers also lists it.
function getOmpEffectivePlaywright(root) {
const projectEnabled = isOmpProjectMcpEnabled(root);
const claudeUserEnabled = isOmpClaudeUserProviderEnabled(root);
const claudeHome = process.env.CLAUDE_CONFIG_DIR ? process.env.CLAUDE_CONFIG_DIR : homedir();
const sources = [
// 1. Imported Claude user configs (gated on effective Claude user provider)
...(claudeUserEnabled ? [
{ file: join(claudeConfigDir(), 'mcp.json'), source: 'the Claude user mcp.json' },
{ file: join(claudeHome, '.claude.json'), source: 'the Claude user .claude.json' },
] : []),
// 2. Project root fallback files (ascending precedence: .mcp.json, then mcp.json)
...(projectEnabled && root ? [
{ file: join(root, '.mcp.json'), source: '.mcp.json' },
{ file: join(root, 'mcp.json'), source: 'mcp.json' },
] : (projectEnabled ? [
{ file: '.mcp.json', source: '.mcp.json' },
{ file: 'mcp.json', source: 'mcp.json' },
] : [])),
// 3. Imported Claude project configs (.claude/.mcp.json, then .claude/mcp.json)
...(projectEnabled && root ? [
{ file: join(root, '.claude', '.mcp.json'), source: '.claude/.mcp.json' },
{ file: join(root, '.claude', 'mcp.json'), source: '.claude/mcp.json' },
] : []),
// 4. Native OMP user files (ascending precedence: .mcp.json, then mcp.json)
{ file: join(ompAgentDir(), '.mcp.json'), source: 'the OMP user mcp.json' },
{ file: join(ompAgentDir(), 'mcp.json'), source: 'the OMP user mcp.json', userOverrides: true },
// 5. Native OMP project files (ascending precedence: .omp/.mcp.json, then .omp/mcp.json)
...(projectEnabled && root ? [
{ file: join(root, '.omp', '.mcp.json'), source: '.omp/.mcp.json' },
{ file: join(root, '.omp', 'mcp.json'), source: '.omp/mcp.json' },
] : (projectEnabled ? [
{ file: join('.omp', '.mcp.json'), source: '.omp/.mcp.json' },
{ file: join('.omp', 'mcp.json'), source: '.omp/mcp.json' },
] : [])),
];
const disabledServers = new Set();
const forcedEnabled = new Set();
const effectiveServers = new Map();
for (const { file, cfg: directCfg, source, userOverrides } of sources) {
const cfg = directCfg ?? (file ? readConfigIfPresent(file) : null);
if (!cfg || typeof cfg !== 'object') continue;
if (userOverrides && Array.isArray(cfg.disabledServers)) {
for (const s of cfg.disabledServers) {
if (typeof s === 'string' && s.trim()) {
disabledServers.add(s.trim().toLowerCase());
}
}
}
if (userOverrides && Array.isArray(cfg.enabledServers)) {
for (const s of cfg.enabledServers) {
if (typeof s === 'string' && s.trim()) {
forcedEnabled.add(s.trim().toLowerCase());
}
}
}
const buckets = [cfg.mcpServers, cfg.mcp].filter((b) => b && typeof b === 'object');
for (const bucket of buckets) {
for (const [name, def] of Object.entries(bucket)) {
if (def && typeof def === 'object') {
effectiveServers.set(name.toLowerCase(), { name, def, source });
}
}
}
}
for (const [name, entry] of effectiveServers.entries()) {
if (disabledServers.has(name)) continue;
const forced = forcedEnabled.has(name);
if (!forced && (entry.def.enabled === false || entry.def.enabled === 'false')) continue;
if (isPlaywrightServer(entry.def, { allowDisabled: forced })) {
return entry;
}
}
return null;
}
function readOmpConfigIfPresent(file) {
if (!existsSync(file)) return null;
try {
const content = readFileSync(file, 'utf8');
if (file.endsWith('.json') || file.endsWith('.jsonc')) {
try {
return parseConfigByExtension(file, content);
} catch {
return loadYaml(content);
}
}
return loadYaml(content) ?? null;
} catch {
return null;
}
}
function isExplicitlyDisabled(val) {
if (val === false) return true;
if (typeof val === 'string') {
const s = val.trim().toLowerCase();
return s === 'false' || s === 'off' || s === 'disabled' || s === 'no';
}
return false;
}
function isExplicitlyEnabled(val) {
if (val === true) return true;
if (typeof val === 'string') {
const s = val.trim().toLowerCase();
return s === 'true' || s === 'on' || s === 'enabled' || s === 'yes';
}
return false;
}
// Inspect a parsed OMP config object for its native browser setting.
// Returns false if browser is explicitly disabled, true if explicitly enabled,
// or undefined if unspecified.
function ompConfigBrowserState(cfg) {
if (!cfg || typeof cfg !== 'object') return undefined;
if (isExplicitlyDisabled(cfg.browser)) return false;
if (isExplicitlyEnabled(cfg.browser)) return true;
if (isExplicitlyDisabled(cfg['browser.enabled'])) return false;
if (isExplicitlyEnabled(cfg['browser.enabled'])) return true;
if (cfg.browser && typeof cfg.browser === 'object') {
if (isExplicitlyDisabled(cfg.browser.enabled)) return false;
if (isExplicitlyEnabled(cfg.browser.enabled)) return true;
}
return undefined;
}
// OMP drops recognized browser MCP servers (playwright, puppeteer, etc.) by
// default when its native browser prelude is active (browser.enabled defaults
// to true). A Playwright MCP definition is only usable if the native browser
// is explicitly disabled in project settings (.omp/config.yml, .omp/settings.json)
// or user settings (~/.omp/agent/config.yml, ~/.omp/settings.json).
function isOmpBrowserDisabled(root) {
for (const file of ompProjectConfigFiles(root)) {
const state = ompConfigBrowserState(readOmpConfigIfPresent(file));
if (state !== undefined) return state === false;
}
for (const file of ompUserConfigFiles()) {
const state = ompConfigBrowserState(readOmpConfigIfPresent(file));
if (state !== undefined) return state === false;
}
return false;
}
// A Claude Code plugin declares its MCP servers in its own .mcp.json, which
// lives under the user's config dir - never in the project root. The
// project-root scan below therefore reports "not detected" on a machine where
// Playwright MCP is installed and working, which reads as though the MANDATORY
// offer-liveness verification in AGENTS.md cannot be met (#2752).
//
// Only ENABLED plugins count: an installed-but-disabled plugin still ships its
// manifest on disk, but registers no server. Claude Code merges enabledPlugins
// across user config (~/.claude/settings.json), project config
// (.claude/settings.json), and project-local config
// (.claude/settings.local.json), with project settings taking precedence over
// user settings (#3698). Enumeration is driven by the manifests rather than by
// walking plugins/cache, so a large cache costs nothing and disabled plugins
// are never read.
function isPlaywrightMcpFromPlugin(root) {
const configDir = claudeConfigDir();
const enabled = [
readConfigIfPresent(join(configDir, 'settings.json'))?.enabledPlugins,
root ? readConfigIfPresent(join(root, '.claude', 'settings.json'))?.enabledPlugins : null,
root ? readConfigIfPresent(join(root, '.claude', 'settings.local.json'))?.enabledPlugins : null,
].filter((e) => e && typeof e === 'object')
.reduce((acc, e) => Object.assign(acc, e), {});
if (!Object.keys(enabled).length) return false;
const installed = readConfigIfPresent(join(configDir, 'plugins', 'installed_plugins.json'))?.plugins;
if (!installed || typeof installed !== 'object') return false;
return Object.entries(enabled).some(([key, on]) => {
if (on !== true) return false;
const entries = Array.isArray(installed[key]) ? installed[key] : [];
// Read installPath off the entry rather than destructuring it: the `= {}`
// default only covers `undefined`, so a manifest carrying a literal `null`
// entry - valid JSON, and what a half-written install leaves behind - threw
// a TypeError here. That is worse than the miss this function was added to
// fix (#2752): doctor dies before printing, so every other check vanishes
// with it and the output looks like a broken install rather than one
// unconfigured plugin.
return entries.some((entry) => {
const installPath = entry?.installPath;
if (typeof installPath !== 'string' || !installPath) return false;
return hasPlaywrightIn(readConfigIfPresent(join(installPath, '.mcp.json')), { bare: true });
});
});
}
// `claude mcp add` writes to .claude.json, not to any file in the checkout
// (#4392): user scope under the top-level `mcpServers`, local scope (the
// default) under `projects[<dir>].mcpServers`. The file lives in
// CLAUDE_CONFIG_DIR when that is set, otherwise directly in the home dir -
// beside ~/.claude/, not inside it.
//
// Claude Code on Windows can store one directory under both C:\... and C:/...
// with the server under only one of them, while process.cwd() returns the
// backslash form. So keys are compared with slashes and case folded, and every
// matching key counts, not just the first.
function isPlaywrightMcpFromClaudeJson(root) {
const dir = process.env.CLAUDE_CONFIG_DIR || homedir();
const cfg = readConfigIfPresent(join(dir, '.claude.json'));
if (!cfg || typeof cfg !== 'object') return false;
if (hasPlaywrightIn(cfg)) return true;
if (!root || !cfg.projects || typeof cfg.projects !== 'object') return false;
const norm = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
const target = norm(root);
return Object.entries(cfg.projects)
.some(([key, project]) => norm(key) === target && hasPlaywrightIn(project));
}
function isPlaywrightMcpConfigured(root, activeCli) {
const entry = MCP_CONFIGS.find((c) => c.cli === activeCli);
if (!entry) return false; // known CLI but no MCP file mapping; caller warns
// OpenCode reads every source below (user-level configs, then the project)
// and MERGES them, so the question is about the effective server map — not
// whether any one file mentions the package (hasPlaywrightInMergedOpenCode).
if (entry.userFiles) {
const configs = [
...entry.userFiles(),
...entry.files.map((rel) => join(root, ...rel.split('/'))),
].map(readConfigIfPresent).filter(Boolean);
return hasPlaywrightInMergedOpenCode(configs);
}
if (activeCli === 'omp') {
const effective = getOmpEffectivePlaywright(root);
return effective !== null && isOmpBrowserDisabled(root);
}
const inProject = entry.files.some((rel) => {
const file = join(root, ...rel.split('/'));
return hasPlaywrightIn(readConfigIfPresent(file));
});
if (inProject) return true;
// Gated behind the project scan, so an already-configured project pays no
// extra I/O and non-plugin CLIs never touch the user config dir.
return entry.plugins === true
&& (isPlaywrightMcpFromClaudeJson(root) || isPlaywrightMcpFromPlugin(root));
}
// CLI resolution: --cli flag > $CAREER_OPS_CLI > .env (CAREER_OPS_CLI=...) >
// runtime marker (OPENCODE=1 → opencode) > checkout hint (an opencode.json
// with no Claude MCP config beside it) > one other CLI executable on PATH >
// several other CLIs on PATH (unset, so the user picks) > default ('claude').
// An unknown value at ANY explicit level returns the sentinel 'unknown' and
// produces no MCP output — CLI-dependent checks are silently skipped. .env
// parsing is best-effort: missing file is normal, malformed values are caught
// per call below.
//
// The binary name is the id, except Antigravity, which installs as `agy`.
// Same mapping the web UI uses. A new CLI in AGENT_CLIS is found under its id.
const CLI_BIN = Object.fromEntries(AGENT_CLIS.map((id) => [id, id === 'antigravity' ? 'agy' : id]));
// Executable file on PATH. stat + X_OK, never a --version spawn: doctor stays
// offline, and a CLI that prints to the terminal on startup must not run just
// because setup is being checked. A directory, or a file without the execute
// bit, is not an install.
function cliExecutableOnPath(bin) {
const pathEnv = process.env.PATH;
// Unset or empty PATH means nothing is installed. A hermetic run keeps the
// claude default. A set PATH can still contain an empty component.
if (!pathEnv) return false;
const exts = process.platform === 'win32'
? ['', ...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').map((ext) => ext.trim()).filter(Boolean)]
: [''];
for (const dir of pathEnv.split(delimiter)) {
// POSIX searches the current directory for an empty component (a leading,
// trailing, or doubled separator). Windows does not.
const searchDir = dir || (process.platform === 'win32' ? '' : '.');
if (!searchDir) continue;
for (const ext of exts) {
const candidate = join(searchDir, bin + ext);
try {
if (!statSync(candidate).isFile()) continue;
accessSync(candidate, constants.X_OK);
return true;
} catch {
// Absent, or present but not executable.
}
}
}
return false;
}
// PATH is only consulted when nothing else named a CLI. claude on PATH, or no
// known CLI at all, keeps the historical default so a hermetic run (CI, a
// machine with nothing installed) does not change. One other CLI is unambiguous.
// Two or more, with claude absent, is a guess — report them and ask for
// remember-cli instead of checking Claude's plugin.
function resolveCliFromPath() {
const present = AGENT_CLIS.filter((id) => cliExecutableOnPath(CLI_BIN[id]));
if (present.includes('claude') || present.length === 0) {
return { cli: 'claude', source: 'default' };
}
if (present.length === 1) {
return { cli: present[0], source: 'path' };
}
return {
cli: 'unset',
source: 'path',
warning: `Several agent CLIs are on PATH (${present.join(', ')}) and none is selected. Pick one with \`node remember-cli.mjs <cli>\`.`,
};
}
function resolveActiveCli() {
if (cliFlag !== undefined && cliFlag !== null) {
if (!AGENT_CLIS.includes(cliFlag)) {
return { cli: 'unknown', source: 'flag', warning: `Unknown --cli "${cliFlag}". Valid: ${AGENT_CLIS.join(', ')}.` };
}
return { cli: cliFlag, source: 'flag' };
}
if (process.env.CAREER_OPS_CLI) {
if (!AGENT_CLIS.includes(process.env.CAREER_OPS_CLI)) {
return { cli: 'unknown', source: 'env', warning: `CAREER_OPS_CLI="${process.env.CAREER_OPS_CLI}" is not a recognized CLI. Valid: ${AGENT_CLIS.join(', ')}.` };
}
return { cli: process.env.CAREER_OPS_CLI, source: 'env' };
}
// .env is best-effort: missing file → fall through to default. dotenv does
// not throw on a missing path when `quiet: true`, so no try/catch is needed.
// The load must not make a .env-only CAREER_OPS_PROFILE look like a shell one.
const hadProfileOverride = hasProfileOverride();
dotenv.config({ path: join(projectRoot, '.env'), quiet: true });
if (!hadProfileOverride) delete process.env.CAREER_OPS_PROFILE;
if (process.env.CAREER_OPS_CLI) {
if (!AGENT_CLIS.includes(process.env.CAREER_OPS_CLI)) {
return { cli: 'unknown', source: '.env', warning: `CAREER_OPS_CLI in .env is not a recognized CLI. Valid: ${AGENT_CLIS.join(', ')}.` };
}
return { cli: process.env.CAREER_OPS_CLI, source: '.env' };
}
// Runtime marker: OpenCode stamps OPENCODE=1 on every process it runs.
// Without this, doctor started from inside OpenCode falls through to the
// claude default and then scans Claude's MCP configs — reporting "Playwright
// MCP not detected" on a machine where it is configured in opencode.json.
// Exact match: a user who sets OPENCODE=0 to opt out keeps the default.
if (process.env.OPENCODE === '1') {
return { cli: 'opencode', source: 'auto' };
}
// Checkout hint: an opencode.json/opencode.jsonc in the launch directory
// with NO Claude MCP config beside it means this checkout is driven from
// OpenCode (shell runs inside OpenCode are already caught by the marker
// above; a checkout carrying .mcp.json/.claude config stays on the claude
// default, so dual-config checkouts behave exactly as before).
const cwd = process.cwd();
const hasOpenCodeCfg = ['opencode.json', 'opencode.jsonc'].some((f) => existsSync(join(cwd, f)));
const hasClaudeCfg = ['.mcp.json', '.claude/settings.json', '.claude/settings.local.json']
.some((f) => existsSync(join(cwd, f)));
if (hasOpenCodeCfg && !hasClaudeCfg) {
return { cli: 'opencode', source: 'auto' };
}
return resolveCliFromPath();
}
function checkPlaywrightMcp(root, activeCli) {
// Unknown CLI (typo / not in AGENT_CLIS), or several CLIs on PATH and no
// selection yet. Neither has a config to scan, and neither should be handed
// another CLI's install command.
if (activeCli === 'unknown' || activeCli === 'unset') return null;
// Known CLI without an MCP file mapping.
const entry = MCP_CONFIGS.find((c) => c.cli === activeCli);
if (!entry) {
return {
warn: true,
label: `Playwright MCP check skipped for CLI: ${activeCli}`,
fix: [
`doctor doesn't scan MCP configs for "${activeCli}". Verify your Playwright MCP setup manually for that CLI.`,
`CLIs with scanning today: ${MCP_CONFIGS.map((c) => c.cli).join(', ')}.`,
],
};
}
if (isPlaywrightMcpConfigured(root, activeCli)) {
return { pass: true, label: `Playwright MCP server configured (${activeCli})` };
}
// Active CLI is known (flag/env/.env) but its MCP isn't configured or is filtered.
if (activeCli === 'omp') {
const effective = getOmpEffectivePlaywright(root);
if (effective) {
return {
warn: true,
label: `Playwright MCP server defined in ${effective.source}, but filtered by OMP native browser`,
fix: [
'OMP drops browser MCP servers by default when its native browser is active.',
'To use Playwright MCP in OMP, disable the built-in browser by setting "browser": { "enabled": false } in .omp/settings.json or .omp/config.yml (or run "omp config set browser.enabled false").',
],
};
}
}
return {
warn: true,
label: `Playwright MCP tools not detected (active CLI: ${activeCli})`,
fix: [
entry.plugins
? `No project-level MCP config, no server in .claude.json (~/.claude.json, or $CLAUDE_CONFIG_DIR/.claude.json when set), and no enabled plugin providing one, was detected for ${activeCli}.`
: `No project-level MCP config was detected for ${activeCli}.`,
activeCli === 'opencode'
? 'Add the Playwright MCP server to opencode.json (project; see opencode.example.json) or to the global ~/.config/opencode/opencode.json[c] (or $OPENCODE_CONFIG), or pass --cli <name> if you actually run a different CLI.'
: activeCli === 'omp'
? 'Add the Playwright MCP server to .omp/mcp.json, or pass --cli <name> if you actually run a different CLI.'
: `Add the Playwright MCP server to your ${activeCli} config, or install a plugin that provides it (e.g. /plugin install playwright@claude-plugins-official).`,
],
};
}
// Report which scan/JD extractor is active (config/profile.yml → scan.extractor).
// `mcp` (default) uses the browser MCP; `cli` uses browser-extract.mjs. When cli
// is selected but the helper is missing, the modes fall back to MCP — surface
// that as a warning, never a failure.
function checkScanExtractor(root) {
const mode = resolveExtractorMode(profilePathFor(root));
if (mode === 'cli') {
if (existsSync(join(__dirname, 'browser-extract.mjs'))) {
return { pass: true, label: 'Scan extractor: cli (browser-extract.mjs)' };
}
return {
warn: true,
label: 'Scan extractor: cli set, but browser-extract.mjs is missing — falls back to MCP',
fix: ['Restore browser-extract.mjs, or set `scan.extractor: mcp` in config/profile.yml.'],
};
}
return { pass: true, label: 'Scan extractor: mcp (default)' };
}
// Single source of truth for the four user-layer prerequisites (the list
// AGENTS.md "First Run" documents). BOTH the human checklist (`checkPrereq`)
// and the machine-readable cold-start state (`onboardingState`) derive from
// THIS array, so they cannot drift. Paths use "/" and are split for join().
const USER_LAYER_PREREQS = [
{
path: 'cv.md',
blocksExit: true,
fix: [
'Create cv.md in the project root with your CV in markdown',
'See examples/cv-example.md for the format',
],
},
{
path: 'config/profile.yml',
blocksExit: true,
emptyImpact: 'evaluations and scans have none of your targeting',
fix: [
'Run: node onboard-write.mjs --answers <answers.json> [--target <data-root>]',
'Run: cp config/profile.example.yml config/profile.yml',
'Then edit it with your details',
],
},
{
path: 'modes/_profile.md',
fix: [
'Run: cp modes/_profile.template.md modes/_profile.md',
'Then customize your archetypes / targeting narrative',
],
},
{
path: 'portals.yml',
env: 'CAREER_OPS_PORTALS',
emptyImpact: 'scans have no companies or filters',
fix: [
'Run: cp templates/portals.example.yml portals.yml',
'Then customize with your target companies',
],
},
];
const CLI_NAMES = 'claude, codex, opencode, qwen, agy or grok';
// The lane variables (CAREER_OPS_PORTALS, CAREER_OPS_PIPELINE) move one file
// out of the data root, and the scanner reads them (path-resolver.mjs), so
// doctor has to look where a scan would.
const PORTALS_PREREQ = USER_LAYER_PREREQS.find(({ path }) => path === 'portals.yml');
function prereqFile(root, { path, env }) {
if (path === 'config/profile.yml') return profilePathFor(root);
return (env && process.env[env]) || join(root, ...path.split('/'));
}
function prereqPresent(root, prereq) {
return existsSync(prereqFile(root, prereq));
}
const EMPTY_YAML_REASON = 'has no settings (empty or only comments)';
function emptyYamlPrereqs(root) {
return USER_LAYER_PREREQS.filter((prereq) => {
if (!prereq.emptyImpact || !prereqPresent(root, prereq)) return false;
try {
return !hasYamlContent(readFileSync(prereqFile(root, prereq), 'utf-8'));
} catch {
return false;
}
});
}
// The root's .env names a profile. scan.mjs, the evaluators, openai-tailor.mjs and
// openrouter-runner.mjs load a .env, and all four follow CAREER_OPS_PROFILE once
// it is loaded (scan.mjs from the data root, the evaluators and openai-tailor.mjs
// from a working-directory config(), openrouter-runner.mjs from the code root).
// The other readers load none. That is a split whether or not a data-root profile exists, so it is
// reported either way. A shell value shadows the .env, so it is not a split then.
function checkEnvFileProfile(root) {
const fromEnvFile = explicitTarget || hasProfileOverride() ? null : profileFromEnvFile(root);
if (!fromEnvFile) return null;
return {
warn: true,
label: `.env sets CAREER_OPS_PROFILE=${fromEnvFile}, but of the profile readers, only scan.mjs, the evaluators, openai-tailor.mjs and openrouter-runner.mjs load .env; cv-sync-check, generate-pdf, upskill and the other readers use ${profilePathFor(root)}`,
fix: [`Export CAREER_OPS_PROFILE=${fromEnvFile} in your shell so every command reads the same file, or remove it from .env`],
};
}
function checkPrereq(prereq) {
const { path, fix } = prereq;
if (emptyYamlPrereqs(projectRoot).includes(prereq)) {
return { warn: true, label: `${path} ${EMPTY_YAML_REASON}; ${prereq.emptyImpact}`, fix };
}
if (prereqPresent(projectRoot, prereq)) {
return { pass: true, label: `${path} found` };
}
if (path === 'config/profile.yml' && !explicitTarget && hasProfileOverride()) {
const named = profilePathFor(projectRoot);
return {
pass: false,
label: `${path} not found (CAREER_OPS_PROFILE names ${named})`,
fix: [`Create ${named}, or unset CAREER_OPS_PROFILE to use the profile in the data root`],
};
}
const fromEnvFile = path === 'config/profile.yml' && !explicitTarget ? profileFromEnvFile(projectRoot) : null;
if (fromEnvFile) {
return {
pass: false,
label: `${path} not found (.env sets CAREER_OPS_PROFILE=${fromEnvFile}, but most commands never load .env)`,
fix: [`Export CAREER_OPS_PROFILE=${fromEnvFile} in your shell so every command sees it, or remove it from .env and put the profile at ${profilePathFor(projectRoot)}`],
};
}
// cv.md and config/profile.yml are the files a run cannot score without.
// The other two stay warnings: a checkout can be healthy and still unfinished.
if (prereq.blocksExit) {
return { pass: false, label: `${path} not found (user setup required)`, fix };
}
return { warn: true, label: `${path} not found (user setup required)`, fix };
}
function checkFonts() {
const fontsDir = join(__dirname, 'fonts');
if (!existsSync(fontsDir)) {
return {
pass: false,
label: 'fonts/ directory not found',
fix: 'The fonts/ directory is required for PDF generation',
};
}
try {
const files = readdirSync(fontsDir);
if (files.length === 0) {
return {
pass: false,
label: 'fonts/ directory is empty',
fix: 'The fonts/ directory must contain font files for PDF generation',
};
}
} catch {
return {
pass: false,
label: 'fonts/ directory not readable',
fix: 'Check permissions on the fonts/ directory',
};
}
return { pass: true, label: 'Fonts directory ready' };
}
function checkAutoDir(name) {
const dirPath = join(projectRoot, name);
if (existsSync(dirPath)) {
return { pass: true, label: `${name}/ directory ready` };
}
try {
mkdirSync(dirPath, { recursive: true });
return { pass: true, label: `${name}/ directory ready (auto-created)` };
} catch {
return {
pass: false,
label: `${name}/ directory could not be created`,
fix: `Run: mkdir ${name}`,
};
}
}
// --strict only: probe every portals.yml entry (tracked_companies and job_boards)
// so a typo'd slug or a dead board — either of which 404s silently on scans —
// surfaces here. Skipped gracefully when portals.yml is absent. Delegates to
// verify-portals.mjs so there is one probing implementation. Network-bound,
// hence opt-in.
async function checkPortalSlugs(root) {
const portalsPath = prereqFile(root, PORTALS_PREREQ);
if (!existsSync(portalsPath)) {
return { pass: true, label: 'Portal entries: no portals.yml yet (skipped)' };
}
try {
const { verifyPortalsFile } = await import('./verify-portals.mjs');
const { loadProviders } = await import('./providers/_registry.mjs');
const { mergeProviderPlugins } = await import('./plugins/_engine.mjs');
const { makeHttpCtx } = await import('./providers/_http.mjs');
// Load the same provider plugins the scanner uses so tier 2 runs: without
// them every non-ATS entry (all job_boards, plus any Workday/Avature/…
// tracked_companies) resolves to an un-actionable "skipped" and a broken
// one would never reach "missing" — a false pass.
const providers = await loadProviders(join(__dirname, 'providers'));
// verify-portals.mjs's own CLI folds in enabled provider plugins (#4026);
// this caller builds the map itself, so it must too, or a plugin-provider
// entry is probed as unclaimed. No-op when config/plugins.yml is absent.
await mergeProviderPlugins(providers, { root: __dirname, dataRoot: root });
const { results } = await verifyPortalsFile(portalsPath, {
providers,
httpCtx: makeHttpCtx(),
});
// Only 'missing' is a failure — a live probe that 404'd. 'skipped' (no
// provider claimed the entry) is left informational here, matching
// `verify-portals --strict`, which also fails only on 'missing'; an entry
// that resolves solely through a local parser is skipped by this path by
// design and must not fail the check.
const unresolved = results.filter((r) => r.status === 'missing');
if (unresolved.length === 0) {
return { pass: true, label: 'All portals.yml entries resolve' };
}
return {
pass: false,
label: `${unresolved.length} portals.yml entr${unresolved.length === 1 ? 'y' : 'ies'} do not resolve`,
fix: [
...unresolved.map((r) => {
const src = r.ats ? `${r.ats}/${r.slug}` : (r.provider || '?');
let line = `${r.name}: ${src} — ${r.reason || 'unresolved'}`;
if (r.suggested) line += ` → try ${r.suggested.ats}/${r.suggested.slug}`;
return line;
}),
'Probe variants with: node verify-portals.mjs --add "<company>"',
],
};
} catch (err) {
return { warn: true, label: `Portal entry check skipped: ${err.message}` };
}
}
const PIPELINE_SKELETON = `# Pipeline — Pending URLs
Paste job URLs below as \`- [ ] {url}\` then run \`/career-ops pipeline\`.
## Pending
## Processed
`;
function checkPipelineFile() {
const filePath = process.env.CAREER_OPS_PIPELINE || join(projectRoot, 'data', 'pipeline.md');
const shown = process.env.CAREER_OPS_PIPELINE ? filePath : 'data/pipeline.md';
if (existsSync(filePath)) {
return { pass: true, label: `${shown} ready` };
}
try {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, PIPELINE_SKELETON, 'utf-8');
return { pass: true, label: `${shown} ready (auto-created)` };
} catch {
return {
pass: false,
label: `${shown} could not be created`,
fix: process.env.CAREER_OPS_PIPELINE
? `Run: mkdir -p "$(dirname "$CAREER_OPS_PIPELINE")" && touch "$CAREER_OPS_PIPELINE"`
: 'Run: mkdir -p data && touch data/pipeline.md',
};
}
}
// Discover plugins + their non-secret config block, synchronously. Used by both
// the human check and the --json onboarding state.
// A parse failure is REPORTED, not folded into {}. An unreadable config and a
// config with nothing enabled produced the same empty object, so doctor — the
// one tool whose job is to say what is wrong — answered "off" for every plugin
// the user had actually switched on, and said nothing about why.
//
// Returns the config plus the parse error, so callers can tell the two apart.
function readPluginConfigSync(root) {
const cfgPath = join(root, 'config', 'plugins.yml');
if (!existsSync(cfgPath)) return { cfg: {}, error: null };
try {
return { cfg: loadYaml(readFileSync(cfgPath, 'utf8')) || {}, error: null };
} catch (err) {
return { cfg: {}, error: String(err.message).split('\n')[0] };
}
}
// Plugin layer health: list discovered plugins + whether each enabled one's keys
// are present. WARN-not-FAIL so a half-configured plugin never blocks setup.
// plugins.mjs loads the data root .env before it checks plugin keys. Doctor
// skips that load when --cli or CAREER_OPS_CLI picks the CLI, so the file is
// read here without touching process.env.
function pluginState(manifest, cfg) {
const s = pluginStatus(manifest, cfg);
let fileEnv = {};
try { fileEnv = dotenv.parse(readFileSync(join(projectRoot, '.env'))); } catch { /* no .env */ }
const missingEnv = s.missingEnv.filter((name) => !fileEnv[name]);
return { ...s, missingEnv, enabled: s.configured && missingEnv.length === 0 };
}
function activePlugins(root) {
return discoverPlugins(pluginRoots(root), resolveSuccessorIds(root));
}
function checkPlugins(root) {
let manifests;
// Plugin files live in the code checkout. config/plugins.yml lives in the
// data root. Reading plugins from the data root reports "none installed"
// while `node plugins.mjs list` on the same install lists them. --target
// sets codeRoot to the directory it was given.
try { manifests = activePlugins(codeRoot); } catch { return { pass: true, label: 'Plugins: none' }; }
if (manifests.length === 0) return { pass: true, label: 'Plugins: none installed' };
const { cfg, error: cfgError } = readPluginConfigSync(root);
// Reported before the per-plugin lines, because when the config did not parse
// every one of those lines is derived from an empty object and says "off"
// regardless of what the user configured.
if (cfgError) {
return {
warn: true,
label: `Plugins: config/plugins.yml did not parse (${cfgError}) — every plugin below reads as off`,
fix: ['Fix the YAML in config/plugins.yml. Until then `plugins.mjs enable/disable` will refuse to write to it.'],
};
}
const lines = [];
const fixes = [];
for (const m of manifests) {
const s = pluginState(m, cfg);
lines.push(`${m.id} (${s.enabled ? 'enabled' : s.configured ? `missing ${s.missingEnv.join(', ')}` : 'off'})`);
if (s.configured && s.missingEnv.length) {
const paste = linkedInPluginFix(m.id);
if (paste) fixes.push(paste);
fixes.push(`${m.id}: add ${s.missingEnv.join(', ')} to .env`);
}
}
const label = `Plugins: ${lines.join(', ')}`;
return fixes.length ? { warn: true, label, fix: fixes } : { pass: true, label };
}
// profile.yml steers scoring targets, output language, spend tier, CV format and
// location policy — and the existence check above is all that ever looked at it.
// Every reader does `profile?.language?.output` and takes the fallback when the
// key is missing, which is indistinguishable from the key being MISSPELLED. So
// `langauge: {output: ja}` produces English output and no signal anywhere.
//
// WARN, never FAIL, like the plugin check below it: an unknown key is a typo,
// not a broken install, and refusing to run would be a worse answer than naming
// it.
// cv.md exists but in a shape the CV checks cannot read (#4879). The prereq
// check only proves the file is there; cv-title-check.mjs and
// verify-cv-structure.mjs locate jobs through lib/cv-markdown.mjs, and a cv.md
// whose Experience section they cannot find makes both run against nothing.
// They do say so per run, but only once a tailored CV is already being built;
// this says it at setup time. A warning, never a failure: cv.md is the user's
// file and career-ops still works with it.
function checkCvShape(root) {
const cvPath = join(root, 'cv.md');
if (!existsSync(cvPath)) return null; // the prereq check owns "absent"
let text;
try {
text = readFileSync(cvPath, 'utf-8');
} catch (err) {
return { warn: true, label: `cv.md could not be read (${err.message})` };
}
const sections = findExperienceSections(text);
// Same entry rule verify-cv-structure.mjs parses with, so a stray
// "### Notes" under Experience is not counted as a job.
const entries = sections.flat().filter((line) => parseCompanyHeading(line) !== null).length;
if (entries > 0) {
return { pass: true, label: `cv.md: Experience section recognized (${entries} entr${entries === 1 ? 'y' : 'ies'})` };
}
return {
warn: true,
label: sections.length === 0
? 'cv.md: no Experience section the CV checks recognize — title and structure checks will not run'
: 'cv.md: Experience section has no "### Company — Location" entries — title and structure checks will not run',
fix: [
...(sections.length === 0 ? [`Name the section ${EXPERIENCE_HEADING_NAMES.map((n) => `"## ${n}"`).join(', ')}`] : []),
'Start each job with "### Company — Location", then a bold **Title** line, then a dates line (see examples/cv-example.md)',
],
};
}
// The user's own templates under data/templates/ (#5777). Advisory: a template
// that stopped resolving, or lost a slot every shipped variant keeps, is named
// here instead of surfacing at render time.
function checkUserTemplates(root) {
let findings;
try {
// The shipped baseline is the checkout being diagnosed, when it has one. A
// --target that holds only user data has no templates/, so the executing
// checkout's own default stays the baseline there.
const shipped = join(codeRoot, 'templates');
findings = auditUserTemplates({
userDir: join(root, 'data', 'templates'),
...(existsSync(join(shipped, 'cv-template.html')) ? { dir: shipped } : {}),
});
} catch (err) {
return { warn: true, label: `data/templates: could not be audited (${err.message})` };
}
if (findings.length === 0) return null;
return {
warn: true,
label: `data/templates: ${findings.length} template${findings.length === 1 ? '' : 's'} need${findings.length === 1 ? 's' : ''} attention`,
fix: findings.flatMap((f) => f.problems.map((p) => {
const file = f.path.split(/[\\/]/).pop();
return p.type === 'invalid' || p.type === 'name'
? `${file}: ${p.detail}`
: `${file}: lacks ${p.missing.map((m) => `{{${m}}}`).join(', ')}, which every shipped ${f.kind === 'cv' ? 'CV' : 'cover-letter'} variant keeps`;
})),
};
}
function checkProfileShape(root) {
const profilePath = profilePathFor(root);
if (!existsSync(profilePath)) return null; // the prereq check owns "absent"
// Name the file actually read. With --target that is the target's
// profile, not the CAREER_OPS_PROFILE path the default label comes from.
// A CAREER_OPS_PROFILE outside the root keeps its absolute path, since a
// ../../ path says less than the one the user exported.
const rel = relative(root, profilePath);
const label = rel && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) ? rel.split(sep).join('/') : profilePath;
let findings;
try {
const example = existsSync(EXAMPLE_PATH) ? readFileSync(EXAMPLE_PATH, 'utf-8') : '';
findings = validateProfile(readFileSync(profilePath, 'utf-8'), example, label).findings;
} catch (err) {
return { warn: true, label: `${label} could not be read (${err.message})` };
}
const actionable = findings.filter((f) => f.level !== 'info');
if (actionable.length === 0) return { pass: true, label: `${label}: shape OK` };
// A bad value sits under a key the readers know. Calling it an unrecognized
// key would send the user to delete a setting they only need to correct.
const badValues = actionable.some((f) => f.code === 'bad-value');
const otherIssues = actionable.some((f) => f.code !== 'bad-value');
const why = badValues && otherIssues
? 'unrecognized keys and invalid values have no effect'
: badValues
? 'known settings hold values the readers ignore'
: 'settings under an unrecognized key have no effect';
return {
warn: true,
label: `${label}: ${actionable.length} issue${actionable.length === 1 ? '' : 's'} — ${why}`,
fix: actionable.map((f) => f.message),
};
}
async function main() {
console.log('\ncareer-ops doctor');
console.log('================\n');
const browserProcessEnv = snapshotBrowserProcessEnv();
const { cli: activeCli, source: cliSource, warning: cliWarning } = resolveActiveCli();
// A data root inside the plugin copy is deleted on the next plugin update,
// so the checks that create User Layer folders and files skip it.
const pluginInstall = checkPluginInstall(projectRoot);
const userLayerWritable = !pluginInstall || pluginInstall.pass;
const checks = [
checkNodeVersion(),
// Devuelve null salvo que el CLI activo sea Gemini: el filter(Boolean) de
// abajo lo descarta, así que ningún otro usuario ve un check que no le toca.
geminiNodeFloor(activeCli, process.versions.node),
checkBillingSource(),
checkDependencies(),
pluginInstall,
checkWebDependencies(),
checkTrackedBakFiles(codeRoot),
checkSkillEntrypoints(codeRoot),
await checkPlaywright(browserProcessEnv),
checkPlaywrightMcp(process.cwd(), activeCli),
checkScanExtractor(projectRoot),
...USER_LAYER_PREREQS.map(checkPrereq),
checkFonts(),
checkPersonalization(projectRoot),
checkProfileShape(projectRoot),
checkTitleFilterConflicts(projectRoot),
checkCvShape(projectRoot),
checkUserTemplates(projectRoot),
...(userLayerWritable
? [checkAutoDir('data'), checkPipelineFile(), checkAutoDir('output'), checkAutoDir('reports')]
: []),
checkPlugins(projectRoot),
{ pass: true, label: LINKEDIN_DOCTOR_LABEL },
// checkPrereq already explains the .env case when the profile is missing.
prereqPresent(projectRoot, { path: 'config/profile.yml' }) ? checkEnvFileProfile(projectRoot) : null,
].filter(Boolean);
// Network-bound portals.yml reachability probe — only under --strict.
if (STRICT) {
checks.push(await checkPortalSlugs(projectRoot));
}
let failures = 0;
let warnings = 0;
if (cliWarning) {
warnings++;
console.log(`${yellow('⚠')} ${cliWarning}`);
}
for (const result of checks) {
const fixes = Array.isArray(result.fix) ? result.fix : result.fix ? [result.fix] : [];
if (result.warn) {
warnings++;
console.log(`${yellow('⚠')} ${result.label}`);
for (const hint of fixes) {
console.log(` ${dim('→ ' + hint)}`);
}
} else if (result.pass) {
console.log(`${green('✓')} ${result.label}`);
} else {
failures++;
console.log(`${red('✗')} ${result.label}`);
for (const hint of fixes) {
console.log(` ${dim('→ ' + hint)}`);
}
}
}
console.log('');
const setupMissing = USER_LAYER_PREREQS.filter((prereq) => !prereqPresent(projectRoot, prereq));
const setupNote = setupMissing.length > 0
? `Setup is not finished (${setupMissing.length} file${setupMissing.length === 1 ? '' : 's'} missing: ${setupMissing.map(({ path }) => path).join(', ')})`
: null;
// --target forces codeRoot and projectRoot to the same path, so equality
// alone would drop --target and let onboard-write follow getCareerOpsRoot().
const quoteOnboard = process.platform === 'win32' ? quotePowerShell : quoteSh;
const onboardCmd = !explicitTarget && projectRoot === codeRoot
? 'node onboard-write.mjs --answers <answers.json>'
: `node ${quoteOnboard(join(codeRoot, 'onboard-write.mjs'))} --answers <answers.json> --target ${quoteOnboard(projectRoot)}`;
// onboard-write only writes config/profile.yml and portals.yml, so cv.md
// gets its own step instead of a command that would not create it.
const setupSteps = [];
if (setupMissing.some(({ path }) => path === 'config/profile.yml' || path === 'portals.yml')) {
setupSteps.push(`Run \`${onboardCmd}\` from the career-ops checkout.`);
}
if (setupMissing.some(({ path }) => path === 'cv.md')) {
setupSteps.push(`Add your CV as cv.md in ${projectRoot} (examples/cv-example.md shows the format).`);
}
setupSteps.push(`Open your AI CLI in the checkout (${CLI_NAMES}) and it walks you through setup by chatting.`);
const setupHint = setupSteps.join(' ');
if (failures > 0) {
console.log(`Result: ${failures} issue${failures === 1 ? '' : 's'} found. Fix them and run \`npm run doctor\` again.`);
if (setupNote) {
console.log(`${setupNote}.`);
console.log(setupHint);
}
process.exit(1);
} else {
const warnNote = warnings > 0 ? ` (${warnings} warning${warnings === 1 ? '' : 's'} — see above)` : '';
if (setupNote) {
console.log(`Result: ${setupNote}${warnNote}.`);
console.log(setupHint);
} else {
console.log(`Result: All checks passed${warnNote}. You're ready to go! Open your AI CLI in this folder (${CLI_NAMES}) to start.`);
}
console.log('');
console.log('Join the community: https://discord.gg/8pRpHETxa4');
process.exit(0);
}
}
// Personalization files that silently degrade output while still passing the
// existence check. `modes/_custom.md` is deliberately absent: it holds optional
// procedural house rules, so shipping it unedited is a valid end state. These
// two are not —
// _profile.md unedited feeds the TEMPLATE AUTHOR's archetypes and North Star
// into every A-F evaluation, so offers are scored against a stranger.
// _brief.md unedited hands the triage first pass literal `{placeholders}`
// instead of the candidate's archetypes, comp floor and hard DQ criteria.
// Explicit onboarding copies both from their templates, so existence alone
// tells us nothing about personalization — only the CONTENT does.
const PERSONALIZATION_FILES = [
{
path: 'modes/_profile.md',
template: 'modes/_profile.template.md',
impact: 'evaluations score against the template author\'s targeting, not yours',
},
{
path: 'modes/_brief.md',
template: 'modes/_brief.template.md',
impact: 'triage reads literal {placeholders} instead of your archetypes',
},
];
// Placeholder tokens the template itself ships, e.g. `{Your Name}`. Comparing
// against the template's own set (rather than any `{...}` run) keeps braces the
// user legitimately wrote — a code snippet, a JSON example — from false-firing.
function templatePlaceholders(text) {
return new Set(text.match(/\{[^{}\n]{2,60}\}/g) || []);
}
// Returns [{ path, reason }] for personalization files still carrying template
// content. Missing files are NOT reported here — that is `missing`'s job.
function unpersonalizedFiles(root) {
const out = [];
for (const { path, template, impact } of PERSONALIZATION_FILES) {
const targetPath = join(root, ...path.split('/'));
// The checkout's template first, as the web reads it, so doctor and the web
// agree on which sections are still template text even when a separate data
// root holds an older copy. The data root's copy only covers a checkout
// that ships none (#5340).
const checkoutTemplatePath = join(__dirname, ...template.split('/'));
const templatePath = existsSync(checkoutTemplatePath) ? checkoutTemplatePath : join(root, ...template.split('/'));
if (!existsSync(targetPath) || !existsSync(templatePath)) continue;
let target, tpl;
try {
target = readFileSync(targetPath, 'utf-8');
tpl = readFileSync(templatePath, 'utf-8');
} catch {
continue; // unreadable → let the existence checks speak
}
// An empty file is neither identical to the template nor carries a
// placeholder, so it would pass as personalized while the web asks for
// every section (#5340).
if (!target.trim()) {
out.push({ path, reason: 'is empty', impact });
continue;
}
if (path === 'modes/_profile.md') {
// A _profile.md written in the user's own structure, without any of the
// eight ## Your … headings, is quiet here rather than reported as missing
// every section (#5126). Missing prerequisites speak for absent files.
const userHeadings = new Set(scanHeadings(target).heads.filter((h) => h.level === 2).map((h) => h.heading));
const hasAnyTemplateHeading = SECTIONS.some((s) => userHeadings.has(s.heading));
if (!hasAnyTemplateHeading) {
const left = [...templatePlaceholders(tpl)].filter((p) => target.includes(p));
if (left.length > 0) {
out.push({
path,
reason: `still has ${left.length} unfilled placeholder${left.length === 1 ? '' : 's'} (e.g. ${left[0]})`,
impact,
});
}
continue;
}
const states = sectionState(target, tpl);
const unfilled = states.filter((s) => s.state !== 'filled');
if (unfilled.length > 0) {
const n = unfilled.length;
const templateCount = unfilled.filter((s) => s.state === 'template').length;
const missingCount = unfilled.filter((s) => s.state === 'missing').length;
const ids = unfilled.map((s) => s.id).join(', ');
out.push({
path,
reason: `still has ${n} unfilled section${n === 1 ? '' : 's'}: ${templateCount} on template, ${missingCount} missing (${ids})`,
impact,
});
}
continue;
}
if (target === tpl) {
out.push({ path, reason: 'still identical to the shipped template', impact });
continue;
}
const left = [...templatePlaceholders(tpl)].filter((p) => target.includes(p));
if (left.length > 0) {
out.push({
path,
reason: `still has ${left.length} unfilled placeholder${left.length === 1 ? '' : 's'} (e.g. ${left[0]})`,
impact,
});
}
}
return out;
}
function checkPersonalization(root) {
const absent = PERSONALIZATION_FILES.filter(({ path }) => !existsSync(join(root, ...path.split('/'))));
if (absent.length === PERSONALIZATION_FILES.length) return null;
const stale = unpersonalizedFiles(root);
if (stale.length === 0) {
return { label: 'Personalization files customized', pass: true };
}
return {
label: `Personalization incomplete: ${stale.map((s) => s.path).join(', ')}`,
warn: true,
fix: stale.flatMap((s) => [`${s.path} — ${s.reason}; ${s.impact}`,
` ask your agent: "personalize ${s.path} from my CV"`]),
};
}
// Single source of truth for the cold-start state: the same four user-layer
// prerequisites that AGENTS.md "First Run" lists. `--json` turns the trigger into
// a deterministic mechanism the agent runs (instead of re-deriving it from prose),
// and `--target <dir>` lets the test suite point it at a simulated virgin env.
function titleFilterConflicts(root) {
// Same override the scanner honours (scan.mjs PORTALS_PATH), so a diagnosis
// describes the file a scan would actually read, not the default one.
const portalsPath = prereqFile(root, PORTALS_PREREQ);
if (!existsSync(portalsPath)) return null;
let config;
try {
config = loadYaml(readFileSync(portalsPath, 'utf-8'));
} catch {
// A portals.yml that does not parse is a different check's problem; this
// one only has something to say about a file it could actually read.
return null;
}
const { conflicts } = findTitleFilterConflicts(config?.title_filter);
return conflicts.length > 0 ? conflicts : null;
}
// `main()` feeds the ordinary human-readable run; onboardingState() feeds
// `--json`. Both need this check, and it is non-blocking in both: a positive
// its own negatives veto is a configuration smell to fix, not a reason to
// refuse to start, so it reports as a warning rather than a failure.
function checkTitleFilterConflicts(root) {
const conflicts = titleFilterConflicts(root);
if (!conflicts) return { label: 'title_filter positives all reachable', pass: true };
const n = conflicts.length;
return {
label: `title_filter: ${n} positive${n === 1 ? '' : 's'} never keeps a title`,
warn: true,
fix: conflicts.flatMap((c) => [
`"${c.positive}" stands for "${c.title}", which negative "${c.negative}" vetoes`,
' ask your agent: "which title_filter entries contradict each other?"',
]),
};
}
function onboardingState(root) {
const autoCopied = [];
// Same rule as main(): never seed User Layer files inside a plugin copy.
const pluginInstall = checkPluginInstall(root);
const userLayerWritable = !pluginInstall || pluginInstall.pass;
const templates = [
{ target: 'modes/_profile.md', template: 'modes/_profile.template.md' },
{ target: 'modes/_custom.md', template: 'modes/_custom.template.md' },
{ target: 'modes/_brief.md', template: 'modes/_brief.template.md' },
{ target: 'voice-dna.md', template: 'voice-dna.template.md' },
];
for (const { target, template } of templates) {
const targetPath = join(root, ...target.split('/'));
const rootTemplatePath = join(root, ...template.split('/'));
const templatePath = existsSync(rootTemplatePath) ? rootTemplatePath : join(__dirname, ...template.split('/'));
// Diagnosis must not create user files. Copy only during explicit onboarding.
if (INIT_TEMPLATES && userLayerWritable && !existsSync(targetPath) && existsSync(templatePath)) {
try {
mkdirSync(dirname(targetPath), { recursive: true });
copyFileSync(templatePath, targetPath, constants.COPYFILE_EXCL);
autoCopied.push(target);
} catch {
// Gracefully handle read-only filesystems (e.g., CI/CD or containerized environments)
// by leaving the file uncopied and letting onboardingNeeded/prereq checks handle it.
}
}
}
const missing = USER_LAYER_PREREQS
.filter((prereq) => !prereqPresent(root, prereq))
.map(({ path }) => path);
const { cli: activeCli, source: cliSource, warning: cliWarning } = resolveActiveCli();
// MCP project configuration belongs to the launch checkout. `root` is the
// user-data layer and may point elsewhere under split-checkout installs.
const mcpCheck = checkPlaywrightMcp(process.cwd(), activeCli);
const unpersonalized = [
...unpersonalizedFiles(root),
...emptyYamlPrereqs(root).map(({ path, emptyImpact }) => ({ path, reason: EMPTY_YAML_REASON, impact: emptyImpact })),
];
const titleFilterIssues = titleFilterConflicts(root);
// Every other check in this function is data-layer and correctly uses this
// function's own `root` parameter. The tracked-.bak check is the one
// code-layer exception (#3867 finding 6) — it must read the module-level
// codeRoot (the code checkout), which only differs from `root` when a real
// split-checkout data root is in play and no --target was given.
const bakCheck = checkTrackedBakFiles(codeRoot);
const skillCheck = checkSkillEntrypoints(codeRoot);
const cvShape = checkCvShape(root);
const userTemplates = checkUserTemplates(root);
const envFileProfile = checkEnvFileProfile(root);
const warnings = [
...(cliWarning ? [cliWarning] : []),
...(pluginInstall && !pluginInstall.pass ? [`${pluginInstall.label}\n→ ${pluginInstall.fix}`] : []),
...(mcpCheck?.warn ? [`${mcpCheck.label}\n→ ${[].concat(mcpCheck.fix || []).join('\n ')}`] : []),
...(bakCheck.warn ? [`${bakCheck.label}\n→ ${[].concat(bakCheck.fix || []).join('\n ')}`] : []),
...(skillCheck.warn ? [`${skillCheck.label}\n→ ${[].concat(skillCheck.fix || []).join('\n ')}`] : []),
...(cvShape?.warn ? [`${cvShape.label}\n→ ${[].concat(cvShape.fix || []).join('\n ')}`] : []),
...(userTemplates?.warn ? [`${userTemplates.label}\n→ ${[].concat(userTemplates.fix || []).join('\n ')}`] : []),
...(envFileProfile ? [`${envFileProfile.label}\n→ ${envFileProfile.fix.join('\n ')}`] : []),
...unpersonalized.map((u) => `${u.path} ${u.reason} — ${u.impact}\n→ Personalize it from cv.md before running evaluations.`),
];
const playwrightMcp = activeCli !== 'unknown' && activeCli !== 'unset' && MCP_CONFIGS.find((c) => c.cli === activeCli)
? { [activeCli]: mcpCheck?.pass === true }
: {};
let plugins = [];
let pluginConfigError = null;
try {
const { cfg, error } = readPluginConfigSync(root);
// Travels in the JSON so a consumer can tell "nothing enabled" from "the
// config did not parse" — the two used to be the same empty list.
pluginConfigError = error;
plugins = activePlugins(codeRoot).map((m) => {
const s = pluginState(m, cfg);
return { id: m.id, hooks: m.hooks, enabled: s.enabled, missingEnv: s.missingEnv };
});
} catch { plugins = []; }
return {
onboardingNeeded: missing.length > 0,
missing,
// Non-blocking by design: career-ops is meant to work out of the box, so an
// unedited personalization file must not gate the whole system. It DOES have
// to be visible — surfaced as its own field the agent can branch on rather
// than a string it has to pattern-match out of `warnings`.
unpersonalized,
// Present only when it fired: a coherent title_filter adds no key.
...(titleFilterIssues ? { titleFilterConflicts: titleFilterIssues } : {}),
warnings,
autoCopied,
plugins,
// Only present when it happened, so existing consumers see no new key on a
// healthy run and a broken config is impossible to read as "none enabled".
...(pluginConfigError ? { pluginConfigError } : {}),
playwright_mcp: playwrightMcp,
active_cli: activeCli,
cli_source: cliSource,
};
}
if (JSON_OUT) {
console.log(JSON.stringify(onboardingState(projectRoot)));
process.exit(0);
} else {
main().catch((err) => {
console.error('doctor.mjs failed:', err.message);
process.exit(1);
});
}