portable-agent-layer 0.85.10 → 0.85.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/index.ts CHANGED
@@ -47,17 +47,14 @@ import {
47
47
  summarize,
48
48
  } from "../hooks/lib/import-merge";
49
49
  import { inference, previewInferenceRoute } from "../hooks/lib/inference";
50
- import { logDebug, recentHookErrors } from "../hooks/lib/log";
50
+ import { logDebug } from "../hooks/lib/log";
51
51
  import { ensureRegistered, writeRegistryEntry } from "../hooks/lib/machine";
52
- import { opencodeBackgroundModel } from "../hooks/lib/opencode-config";
53
52
  import { palHome, palPkg, paths, platform, toPath } from "../hooks/lib/paths";
54
- import { auditBindings, describeBindingIssue } from "../hooks/lib/projects";
55
53
  import { telosStatus } from "../hooks/lib/telos-topics";
56
- import { findBinaryOnPath } from "../hooks/lib/which";
57
54
  import { log } from "../targets/lib";
58
55
  import { builtinToolVerbs, runBuiltinTool } from "./builtin-tools";
59
- import { versionControlLines } from "./doctor-tools";
60
- import { checkPendingMigrations } from "./migrate";
56
+ import { type DoctorResult, detectAgents } from "./doctor/agents";
57
+ import { runDoctor } from "./doctor/run";
61
58
  import { findSessionAgent, NO_SESSION_AGENT_MESSAGE } from "./session-agent";
62
59
 
63
60
  const allArgs = process.argv.slice(2);
@@ -75,53 +72,6 @@ if (allArgs[0] === "cli") {
75
72
 
76
73
  // ── Session: pal [args] ──
77
74
 
78
- interface ToolCheck {
79
- name: string;
80
- available: boolean;
81
- version?: string;
82
- }
83
-
84
- function checkTool(cmd: string, versionArgs: string[] = ["--version"]): ToolCheck {
85
- try {
86
- const result = spawnSync(cmd, versionArgs, {
87
- stdio: ["ignore", "pipe", "pipe"],
88
- shell: true,
89
- timeout: 5000,
90
- });
91
- if (result.status === 0) {
92
- const version = (result.stdout?.toString() || "").trim().split("\n")[0];
93
- return { name: cmd, available: true, version };
94
- }
95
- } catch {
96
- // not found
97
- }
98
- return { name: cmd, available: false };
99
- }
100
-
101
- /**
102
- * Copilot ships as a VS Code extension as well as a CLI, and the extension puts
103
- * no `copilot` binary on PATH — but both read hooks, skills and agents out of
104
- * ~/.copilot. Fall back to that directory so PAL's copilot checks still run.
105
- */
106
- function checkCopilot(): ToolCheck {
107
- const cli = checkTool("copilot", ["version"]);
108
- if (cli.available) return cli;
109
- if (existsSync(platform.copilotDir())) {
110
- return { name: "copilot", available: true, version: "~/.copilot (no CLI on PATH)" };
111
- }
112
- return cli;
113
- }
114
-
115
- /**
116
- * The Cursor CLI installs `cursor-agent` (and `agent`) and no `cursor` command;
117
- * `cursor` is the editor's shell command. Both read ~/.cursor, so either counts.
118
- */
119
- function checkCursor(): ToolCheck {
120
- const cli = checkTool("cursor-agent");
121
- if (cli.available) return cli;
122
- return checkTool("cursor");
123
- }
124
-
125
75
  async function session(sessionArgs: string[]) {
126
76
  const agent = findSessionAgent();
127
77
  if (!agent) {
@@ -223,10 +173,11 @@ async function runCli(command: string | undefined, args: string[]) {
223
173
  await status();
224
174
  break;
225
175
  case "doctor": {
226
- doctor();
176
+ const exitCode = runDoctor(args);
227
177
  if (args.includes("--probe-inference") || args.includes("--probe")) {
228
178
  await probeInference();
229
179
  }
180
+ process.exit(exitCode);
230
181
  break;
231
182
  }
232
183
  case "migrate": {
@@ -346,7 +297,8 @@ function showHelp() {
346
297
  pal cli export [path] [--dry-run] Export state to zip
347
298
  pal cli import [path] [--dry-run] Merge state from zip (--overwrite to replace)
348
299
  pal cli status Show PAL configuration
349
- pal cli doctor [--probe-inference] Check prerequisites and health (--probe fires real inference per route)
300
+ pal cli doctor [--verbose] [--json] Find what is wrong and how to fix it; exits 1 on any failure
301
+ [--probe-inference] Also fire real inference per route
350
302
  pal cli migrate [--list] [--dry-run] Run pending data migrations
351
303
  pal cli <tool> [args] Run a built-in agent tool ('<tool> --help' for its flags):
352
304
  ${builtinToolVerbs.join(" · ")}
@@ -419,7 +371,7 @@ function parseTargets(args: string[]): Targets {
419
371
  /** Resolve targets against available agents. Errors if explicitly requested but missing. */
420
372
  function resolveTargets(args: string[], health?: DoctorResult): Targets {
421
373
  const requested = parseTargets(args);
422
- const h = health || doctor(true);
374
+ const h = health || detectAgents();
423
375
  const explicit = args.some(
424
376
  (a) =>
425
377
  a === "--claude" ||
@@ -472,140 +424,6 @@ function resolveTargets(args: string[], health?: DoctorResult): Targets {
472
424
  return targets;
473
425
  }
474
426
 
475
- // ── Hook health ──
476
-
477
- function checkClaudeHooksRegistered(): boolean {
478
- const settingsPath = resolve(platform.claudeDir(), "settings.json");
479
- if (!existsSync(settingsPath)) return false;
480
- try {
481
- const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
482
- const groups = settings?.hooks?.SessionStart;
483
- if (!Array.isArray(groups)) return false;
484
- return groups.some((g: { hooks?: { command?: string }[] }) =>
485
- g?.hooks?.some((h) => h?.command?.includes("LoadContext"))
486
- );
487
- } catch {
488
- return false;
489
- }
490
- }
491
-
492
- function checkCursorHooksRegistered(): boolean {
493
- const hooksPath = resolve(platform.cursorDir(), "hooks.json");
494
- if (!existsSync(hooksPath)) return false;
495
- try {
496
- const data = JSON.parse(readFileSync(hooksPath, "utf-8"));
497
- const hooks = data?.hooks?.sessionStart;
498
- if (!Array.isArray(hooks)) return false;
499
- return hooks.some((h: { command?: string }) => h?.command?.includes("LoadContext"));
500
- } catch {
501
- return false;
502
- }
503
- }
504
-
505
- function checkOpencodePluginInstalled(): boolean {
506
- return existsSync(resolve(platform.opencodeDir(), "plugins", "pal-plugin.ts"));
507
- }
508
-
509
- function checkCopilotHooksRegistered(): boolean {
510
- return existsSync(resolve(platform.copilotDir(), "hooks", "pal-hooks.json"));
511
- }
512
-
513
- function checkCodexHooksRegistered(): boolean {
514
- const hooksPath = resolve(platform.codexDir(), "hooks.json");
515
- if (!existsSync(hooksPath)) return false;
516
- try {
517
- const data = JSON.parse(readFileSync(hooksPath, "utf-8"));
518
- const entries = data?.hooks?.SessionStart;
519
- if (!Array.isArray(entries)) return false;
520
- return entries.some((entry: { command?: string; hooks?: { command?: string }[] }) => {
521
- if (entry?.command?.includes("LoadContext")) return true;
522
- return entry?.hooks?.some((h) => h?.command?.includes("LoadContext")) ?? false;
523
- });
524
- } catch {
525
- return false;
526
- }
527
- }
528
-
529
- function checkCopilotInstructionsPresent(): boolean {
530
- const instructionsDir = resolve(platform.copilotDir(), "instructions");
531
- if (!existsSync(instructionsDir)) return false;
532
- return readdirSync(instructionsDir).some(
533
- (f) => f.startsWith("pal-") && f.endsWith(".instructions.md")
534
- );
535
- }
536
-
537
- // ── Install integrity (Tier 2 doctor checks) ──
538
-
539
- /** Hook-config keys that carry a shell command: cross-platform and per-shell variants. */
540
- function isHookCommandField(key: string): boolean {
541
- return key === "command" || key === "bash" || key === "powershell";
542
- }
543
-
544
- /** Recursively collect every command-carrying field value in a hook-config JSON. */
545
- function extractAllHookCommands(obj: unknown, out: string[] = []): string[] {
546
- if (Array.isArray(obj)) {
547
- for (const item of obj) extractAllHookCommands(item, out);
548
- } else if (obj && typeof obj === "object") {
549
- for (const [k, v] of Object.entries(obj)) {
550
- if (isHookCommandField(k) && typeof v === "string") {
551
- out.push(v);
552
- } else {
553
- extractAllHookCommands(v, out);
554
- }
555
- }
556
- }
557
- return out;
558
- }
559
-
560
- interface HookPrefixCheck {
561
- ok: boolean;
562
- total: number;
563
- missing: number;
564
- firstMissing?: string;
565
- }
566
-
567
- /**
568
- * True when a hook command names its agent, by env prefix or by argv flag.
569
- *
570
- * An env prefix only parses in one shell family, so hook configs whose host
571
- * shell is unknown declare the agent with a shell-agnostic `--agent=` flag.
572
- */
573
- function declaresAgent(cmd: string, agentName: string): boolean {
574
- return (
575
- cmd.startsWith(`PAL_AGENT=${agentName} `) ||
576
- cmd.startsWith(`$env:PAL_AGENT='${agentName}'; `) ||
577
- cmd.includes(`--agent=${agentName}`)
578
- );
579
- }
580
-
581
- /** Verify every command in an installed hook file names `<agent>` as its agent. */
582
- function checkAgentHookPrefix(filePath: string, agentName: string): HookPrefixCheck {
583
- if (!existsSync(filePath)) return { ok: false, total: 0, missing: 0 };
584
- try {
585
- const data = JSON.parse(readFileSync(filePath, "utf-8"));
586
- const commands = extractAllHookCommands(data.hooks ?? data);
587
- const missing = commands.filter((c) => !declaresAgent(c, agentName));
588
- return {
589
- ok: commands.length > 0 && missing.length === 0,
590
- total: commands.length,
591
- missing: missing.length,
592
- firstMissing: missing[0]?.slice(0, 80),
593
- };
594
- } catch {
595
- return { ok: false, total: 0, missing: 0 };
596
- }
597
- }
598
-
599
- interface FreshnessCheck {
600
- ok: boolean;
601
- reason?: string;
602
- }
603
-
604
- /**
605
- * Verify the installed opencode plugin is at least as new as the source. Catches
606
- * the "stale install" failure mode we hit live — plugin file is a copy, not a
607
- * symlink, so source edits don't reach the running opencode until reinstall.
608
- */
609
427
  /**
610
428
  * Probe every supported agent route with a tiny real inference call.
611
429
  * Sequential (concurrent multi-CLI spawns trigger the empty-abort race we
@@ -662,438 +480,6 @@ async function probeInference(): Promise<void> {
662
480
  }
663
481
  }
664
482
 
665
- function checkOpencodePluginFresh(): FreshnessCheck {
666
- const installedPath = resolve(platform.opencodeDir(), "plugins", "pal-plugin.ts");
667
- const sourcePath = resolve(palPkg(), "src", "targets", "opencode", "plugin.ts");
668
- if (!existsSync(installedPath))
669
- return { ok: false, reason: "installed plugin missing" };
670
- if (!existsSync(sourcePath)) return { ok: true }; // can't compare; assume installed is fine
671
- try {
672
- const installedMtime = statSync(installedPath).mtimeMs;
673
- const sourceMtime = statSync(sourcePath).mtimeMs;
674
- if (installedMtime >= sourceMtime) return { ok: true };
675
- const ageMin = Math.round((sourceMtime - installedMtime) / 60000);
676
- return {
677
- ok: false,
678
- reason: `source is ${ageMin}m newer than installed — run 'pal cli install --opencode'`,
679
- };
680
- } catch {
681
- return { ok: true };
682
- }
683
- }
684
-
685
- function playwrightBrowsersPath(): string {
686
- if (process.env.PLAYWRIGHT_BROWSERS_PATH) return process.env.PLAYWRIGHT_BROWSERS_PATH;
687
- const home = homedir();
688
- if (process.platform === "darwin") return resolve(home, "Library/Caches/ms-playwright");
689
- if (process.platform === "win32") return resolve(home, "AppData/Local/ms-playwright");
690
- return resolve(home, ".cache/ms-playwright");
691
- }
692
-
693
- function checkPlaywrightChromium(): boolean {
694
- const base = playwrightBrowsersPath();
695
- if (!existsSync(base)) return false;
696
- try {
697
- return readdirSync(base).some((f) => f.startsWith("chromium-"));
698
- } catch {
699
- return false;
700
- }
701
- }
702
-
703
- function rtkInstallHint(): string {
704
- if (process.platform === "win32")
705
- return "download rtk.exe from https://github.com/rtk-ai/rtk/releases and add it to PATH";
706
- if (process.platform === "darwin") return "`brew install rtk`";
707
- return "`curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh`";
708
- }
709
-
710
- // ── Doctor ──
711
-
712
- interface DoctorResult {
713
- bun: ToolCheck;
714
- claude: ToolCheck;
715
- opencode: ToolCheck;
716
- cursor: ToolCheck;
717
- copilot: ToolCheck;
718
- codex: ToolCheck;
719
- rtk: ToolCheck;
720
- hasAgent: boolean;
721
- }
722
-
723
- function doctor(silent = false): DoctorResult {
724
- // Allow CI/tests to skip agent detection
725
- if (process.env.PAL_SKIP_DOCTOR === "1") {
726
- return {
727
- bun: { name: "bun", available: true, version: Bun.version },
728
- claude: { name: "claude", available: true },
729
- opencode: { name: "opencode", available: true },
730
- cursor: { name: "cursor", available: true },
731
- copilot: { name: "copilot", available: true },
732
- codex: { name: "codex", available: true },
733
- rtk: { name: "rtk", available: true },
734
- hasAgent: true,
735
- };
736
- }
737
-
738
- const bun = { name: "bun", available: true, version: Bun.version };
739
- const claude = checkTool("claude");
740
- const opencode = checkTool("opencode");
741
- const cursor = checkCursor();
742
- const copilot = checkCopilot();
743
- const codex = checkTool("codex");
744
- const rtk = checkTool("rtk");
745
- const hasAgent =
746
- claude.available ||
747
- opencode.available ||
748
- cursor.available ||
749
- copilot.available ||
750
- codex.available;
751
-
752
- const home = palHome();
753
- const telosCount = (() => {
754
- try {
755
- return readdirSync(resolve(home, "telos")).filter((f) => f.endsWith(".md")).length;
756
- } catch {
757
- return 0;
758
- }
759
- })();
760
-
761
- if (!silent) {
762
- const ok = (msg: string) => console.log(` \x1b[32m\u2713\x1b[0m ${msg}`);
763
- const warn = (msg: string) => console.log(` \x1b[33m\u26A0\x1b[0m ${msg}`);
764
- const fail = (msg: string) => console.log(` \x1b[31m\u2717\x1b[0m ${msg}`);
765
- const info = (msg: string) => console.log(` \x1b[90m\u00B7\x1b[0m ${msg}`);
766
-
767
- console.log("");
768
- log.info("Prerequisites");
769
- ok(`Bun ${bun.version}`);
770
- const palBin = findBinaryOnPath("pal");
771
- palBin
772
- ? ok(`pal on PATH — ${palBin}`)
773
- : fail(
774
- "pal — not on PATH; skills and docs invoke tools as 'pal cli ...', which will not resolve. Install globally: bun add -g portable-agent-layer"
775
- );
776
- claude.available
777
- ? ok(`Claude Code ${claude.version || ""}`.trim())
778
- : fail("Claude Code — not found");
779
- opencode.available
780
- ? ok(`opencode ${opencode.version || ""}`.trim())
781
- : fail("opencode — not found");
782
- cursor.available
783
- ? ok(`Cursor ${cursor.version || ""}`.trim())
784
- : fail("Cursor — not found");
785
- copilot.available
786
- ? ok(`Copilot ${copilot.version || ""}`.trim())
787
- : fail("Copilot — not found");
788
- codex.available
789
- ? ok(`Codex ${codex.version || ""}`.trim())
790
- : fail("Codex — not found");
791
- checkPlaywrightChromium()
792
- ? ok("Playwright Chromium installed")
793
- : fail(
794
- "Playwright Chromium — not found (run 'pal cli install' or 'bunx playwright install chromium')"
795
- );
796
- rtk.available
797
- ? ok(rtk.version || "rtk")
798
- : info(
799
- `rtk — not installed (optional; enables Bash output compression — ${rtkInstallHint()})`
800
- );
801
- const print = { ok, warn, info };
802
- for (const line of versionControlLines()) print[line.level](line.text);
803
-
804
- console.log("");
805
- log.info("PAL state");
806
- ok(`PAL home: ${home}`);
807
- telosCount > 0 ? ok(`TELOS: ${telosCount} files`) : fail("TELOS: not scaffolded");
808
-
809
- // Identity
810
- const palSettingsPath = resolve(home, "memory", "pal-settings.json");
811
- if (existsSync(palSettingsPath)) {
812
- try {
813
- const s = JSON.parse(readFileSync(palSettingsPath, "utf-8"));
814
- const hasIdentity = s?.identity?.principal?.name && s?.identity?.ai?.name;
815
- hasIdentity
816
- ? ok("Identity configured")
817
- : warn("Identity — incomplete (run 'pal cli install')");
818
- } catch {
819
- warn("Identity — could not read pal-settings.json");
820
- }
821
- } else {
822
- warn("Identity — pal-settings.json missing (run 'pal cli install')");
823
- }
824
-
825
- // AGENTS.md
826
- const agentsMdPath = resolve(platform.opencodeDir(), "AGENTS.md");
827
- existsSync(agentsMdPath)
828
- ? ok("AGENTS.md present")
829
- : fail("AGENTS.md — missing (run 'pal cli install')");
830
-
831
- if (claude.available) {
832
- const claudeMdPath = resolve(platform.claudeDir(), "CLAUDE.md");
833
- existsSync(claudeMdPath)
834
- ? ok("CLAUDE.md present")
835
- : fail("CLAUDE.md — missing (run 'pal cli install --claude')");
836
- }
837
-
838
- // An empty TELOS is a normal state, not a broken install: the onboarding
839
- // skill fills it whenever the user is ready, which may be months from now.
840
- {
841
- const unanswered = telosStatus(home)
842
- .filter((topic) => topic.priority && !topic.answered)
843
- .map((topic) => topic.key);
844
- unanswered.length === 0
845
- ? ok("TELOS answered")
846
- : warn(
847
- `TELOS unanswered — ${unanswered.join(", ")} (ask your agent to onboard you)`
848
- );
849
- }
850
-
851
- // Project bindings — where each project lives on THIS machine
852
- {
853
- const issues = auditBindings();
854
- if (issues.length === 0) {
855
- ok("Project bindings healthy");
856
- } else {
857
- for (const issue of issues) warn(`Binding: ${describeBindingIssue(issue)}`);
858
- }
859
- }
860
-
861
- // Dependencies (PAL's own npm packages)
862
- const nodeModulesPath = resolve(palPkg(), "node_modules");
863
- existsSync(nodeModulesPath)
864
- ? ok("Dependencies installed")
865
- : fail("Dependencies missing — run 'pal cli install'");
866
-
867
- // Skills (per installed agent)
868
- console.log("");
869
- log.info("Skills");
870
- const countSkillsIn = (dir: string) =>
871
- existsSync(dir)
872
- ? readdirSync(dir).filter((f) => existsSync(resolve(dir, f, "SKILL.md"))).length
873
- : 0;
874
- if (claude.available) {
875
- const n = countSkillsIn(resolve(platform.claudeDir(), "skills"));
876
- n > 0
877
- ? ok(`Claude Code skills: ${n}`)
878
- : warn("Claude Code skills — none found (run 'pal cli install --claude')");
879
- }
880
- if (opencode.available) {
881
- const n = countSkillsIn(resolve(platform.agentsDir(), "skills"));
882
- n > 0
883
- ? ok(`opencode skills: ${n}`)
884
- : warn("opencode skills — none found (run 'pal cli install --opencode')");
885
- }
886
- if (cursor.available) {
887
- const n = countSkillsIn(resolve(platform.cursorDir(), "skills"));
888
- n > 0
889
- ? ok(`Cursor skills: ${n}`)
890
- : warn("Cursor skills — none found (run 'pal cli install --cursor')");
891
- }
892
- if (copilot.available) {
893
- const n = countSkillsIn(resolve(platform.copilotDir(), "skills"));
894
- n > 0
895
- ? ok(`Copilot skills: ${n}`)
896
- : warn("Copilot skills — none found (run 'pal cli install --copilot')");
897
- }
898
- if (codex.available) {
899
- const n = countSkillsIn(resolve(platform.codexDir(), "skills"));
900
- n > 0
901
- ? ok(`Codex skills: ${n}`)
902
- : warn("Codex skills — none found (run 'pal cli install --codex')");
903
- }
904
-
905
- // Hook registration (per installed agent)
906
- console.log("");
907
- log.info("Hooks");
908
- if (claude.available) {
909
- checkClaudeHooksRegistered()
910
- ? ok("Claude Code hooks registered")
911
- : fail("Claude Code hooks — not registered (run 'pal cli install --claude')");
912
- }
913
- if (opencode.available) {
914
- checkOpencodePluginInstalled()
915
- ? ok("opencode plugin installed")
916
- : fail("opencode plugin — not installed (run 'pal cli install --opencode')");
917
- }
918
- if (cursor.available) {
919
- checkCursorHooksRegistered()
920
- ? ok("Cursor hooks registered")
921
- : fail("Cursor hooks — not registered (run 'pal cli install --cursor')");
922
- }
923
- if (copilot.available) {
924
- checkCopilotHooksRegistered()
925
- ? ok("Copilot hooks registered")
926
- : fail("Copilot hooks — not registered (run 'pal cli install --copilot')");
927
- checkCopilotInstructionsPresent()
928
- ? ok("Copilot instructions present")
929
- : warn("Copilot instructions missing (written at first session stop)");
930
- }
931
- if (codex.available) {
932
- checkCodexHooksRegistered()
933
- ? ok("Codex hooks registered")
934
- : fail("Codex hooks — not registered (run 'pal cli install --codex')");
935
- }
936
-
937
- // Install integrity — verify PAL_AGENT prefix on every command in installed
938
- // hook files. Catches stale installs after template changes (the exact bug
939
- // we hit live for opencode + copilot). opencode uses a plugin file instead
940
- // of a hooks.json, so it gets a separate freshness check.
941
- console.log("");
942
- log.info("Install integrity");
943
- const prefixCheck = (
944
- filePath: string,
945
- agentName: string,
946
- installCmd: string
947
- ): void => {
948
- const r = checkAgentHookPrefix(filePath, agentName);
949
- if (r.ok) {
950
- ok(`${agentName}: declared on all ${r.total} hook commands`);
951
- } else if (r.total === 0) {
952
- fail(`${agentName}: hook file missing or unreadable at ${filePath}`);
953
- } else {
954
- fail(
955
- `${agentName}: ${r.missing}/${r.total} hook commands do not declare ${agentName} (run '${installCmd}')`
956
- );
957
- if (r.firstMissing) {
958
- log.warn(` First offender: ${r.firstMissing}…`);
959
- }
960
- }
961
- };
962
- if (claude.available) {
963
- prefixCheck(
964
- resolve(platform.claudeDir(), "settings.json"),
965
- "claude",
966
- "pal cli install --claude"
967
- );
968
- }
969
- if (cursor.available) {
970
- prefixCheck(
971
- resolve(platform.cursorDir(), "hooks.json"),
972
- "cursor",
973
- "pal cli install --cursor"
974
- );
975
- }
976
- if (copilot.available) {
977
- prefixCheck(
978
- resolve(platform.copilotDir(), "hooks", "pal-hooks.json"),
979
- "copilot",
980
- "pal cli install --copilot"
981
- );
982
- }
983
- if (codex.available) {
984
- prefixCheck(
985
- resolve(platform.codexDir(), "hooks.json"),
986
- "codex",
987
- "pal cli install --codex"
988
- );
989
- }
990
- if (opencode.available) {
991
- const fresh = checkOpencodePluginFresh();
992
- fresh.ok
993
- ? ok("opencode plugin: source-and-installed in sync")
994
- : fail(`opencode plugin: ${fresh.reason}`);
995
- }
996
-
997
- // Inference routing preview — what `inference()` would do RIGHT NOW
998
- console.log("");
999
- log.info("Inference");
1000
- {
1001
- const preview = previewInferenceRoute();
1002
- console.log(` → Active agent: ${preview.agent}`);
1003
- if (preview.route === "none") {
1004
- fail(`Would route to: NONE — ${preview.reason}`);
1005
- } else if (preview.route === "disabled") {
1006
- warn(`Would route to: DISABLED — ${preview.reason}`);
1007
- } else if (preview.route.endsWith("-api")) {
1008
- warn(`Would route to: ${preview.route} (${preview.reason})`);
1009
- } else {
1010
- ok(`Would route to: ${preview.route} (${preview.reason})`);
1011
- }
1012
- }
1013
- if (opencode.available) {
1014
- const model = opencodeBackgroundModel();
1015
- model
1016
- ? ok(`opencode background model: ${model}`)
1017
- : warn(
1018
- `opencode background model: not pinned — background inference uses the model last picked in the TUI. Pin one with "model" in ${resolve(platform.opencodeDir(), "config.json")}`
1019
- );
1020
- }
1021
- if (process.env.PAL_INFERENCE_DISABLED === "1") {
1022
- warn(
1023
- "PAL_INFERENCE_DISABLED=1 — test kill-switch leaked into prod env; every inference call will return failure"
1024
- );
1025
- } else {
1026
- ok("PAL_INFERENCE_DISABLED is not set (production-safe)");
1027
- }
1028
-
1029
- // Spawn-guard env vars should never appear in the user's shell — they're
1030
- // set ONLY in PAL-spawned subprocesses. Leaks into the user shell cause
1031
- // every inference call to short-circuit silently.
1032
- if (process.env.PAL_SPAWNED_INFERENCE) {
1033
- fail(
1034
- `PAL_SPAWNED_INFERENCE=${process.env.PAL_SPAWNED_INFERENCE} leaked into shell — every inference call will refuse. Unset it.`
1035
- );
1036
- } else {
1037
- ok("PAL_SPAWNED_INFERENCE not leaked (recursion guard clean)");
1038
- }
1039
- if (process.env.PAL_INFERENCE_DEPTH) {
1040
- fail(
1041
- `PAL_INFERENCE_DEPTH=${process.env.PAL_INFERENCE_DEPTH} leaked into shell — depth circuit-breaker will fire. Unset it.`
1042
- );
1043
- } else {
1044
- ok("PAL_INFERENCE_DEPTH not leaked (depth counter clean)");
1045
- }
1046
-
1047
- // API key checks — both are optional safety-net fallbacks. Unset is normal
1048
- // for CLI-only setups; only matters if your native CLI breaks.
1049
- process.env.PAL_ANTHROPIC_API_KEY
1050
- ? ok("PAL_ANTHROPIC_API_KEY set (anthropic-api fallback available)")
1051
- : info("PAL_ANTHROPIC_API_KEY unset (optional — anthropic-api fallback off)");
1052
- process.env.PAL_OPENAI_API_KEY
1053
- ? ok("PAL_OPENAI_API_KEY set (openai-api fallback for codex available)")
1054
- : info("PAL_OPENAI_API_KEY unset (optional — openai-api fallback off)");
1055
- process.env.PAL_GEMINI_API_KEY
1056
- ? ok("PAL_GEMINI_API_KEY is set")
1057
- : warn("PAL_GEMINI_API_KEY — not set (optional, for YouTube analysis)");
1058
- process.env.PAL_XAI_API_KEY
1059
- ? ok("PAL_XAI_API_KEY is set")
1060
- : warn("PAL_XAI_API_KEY — not set (optional, for Grok researcher)");
1061
- process.env.PAL_PERPLEXITY_API_KEY
1062
- ? ok("PAL_PERPLEXITY_API_KEY is set")
1063
- : warn("PAL_PERPLEXITY_API_KEY — not set (optional, for Perplexity researcher)");
1064
-
1065
- // Hook health from debug.log
1066
- const hookHealth = recentHookErrors();
1067
- if (hookHealth.totalErrors === 0) {
1068
- ok("Hooks: no recent errors");
1069
- } else {
1070
- fail(`Hooks: ${hookHealth.totalErrors} error(s) in last 24h`);
1071
- if (hookHealth.lastError) {
1072
- log.warn(` Last: ${hookHealth.lastError}`);
1073
- }
1074
- }
1075
-
1076
- // Pending migrations
1077
- const pendingMigrations = checkPendingMigrations();
1078
- if (pendingMigrations.length > 0) {
1079
- for (const m of pendingMigrations) {
1080
- const detail = m.detail ? ` (${m.detail})` : "";
1081
- warn(
1082
- `Migration pending: ${m.id} — ${m.description}${detail} → run 'pal cli migrate'`
1083
- );
1084
- }
1085
- }
1086
-
1087
- if (!hasAgent) {
1088
- console.log("");
1089
- log.error(NO_SESSION_AGENT_MESSAGE);
1090
- }
1091
- console.log("");
1092
- }
1093
-
1094
- return { bun, claude, opencode, cursor, copilot, codex, rtk, hasAgent };
1095
- }
1096
-
1097
483
  // ── Commands ──
1098
484
 
1099
485
  async function init(args: string[]) {
@@ -1101,8 +487,8 @@ async function init(args: string[]) {
1101
487
 
1102
488
  banner();
1103
489
 
1104
- // Run doctor first — abort if no agents available
1105
- const health = doctor(false);
490
+ const health = detectAgents();
491
+ runDoctor([], health);
1106
492
  if (!health.hasAgent) {
1107
493
  process.exit(1);
1108
494
  }