@litfamily/litopencode 1.0.28 → 1.0.30

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.
Files changed (156) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README-Ko-KR.md +7 -7
  3. package/README.md +7 -7
  4. package/dist/activation-primary-prompts.js +15 -6
  5. package/dist/activation-prompt-utils.d.ts +2 -2
  6. package/dist/activation-prompt-utils.js +9 -3
  7. package/dist/activation-reentry.d.ts +29 -0
  8. package/dist/activation-reentry.js +71 -0
  9. package/dist/activation-routing.d.ts +1 -1
  10. package/dist/activation-routing.js +40 -7
  11. package/dist/activation-workflow-prompts.d.ts +2 -0
  12. package/dist/activation-workflow-prompts.js +32 -7
  13. package/dist/activation.d.ts +6 -1
  14. package/dist/activation.js +34 -7
  15. package/dist/cli/args.js +14 -0
  16. package/dist/cli/auto-update.js +2 -4
  17. package/dist/cli/command-plan.d.ts +40 -0
  18. package/dist/cli/command-plan.js +62 -0
  19. package/dist/cli/doctor.d.ts +4 -1
  20. package/dist/cli/doctor.js +65 -4
  21. package/dist/cli/host-detect.d.ts +58 -0
  22. package/dist/cli/host-detect.js +199 -0
  23. package/dist/cli/install-report.js +3 -1
  24. package/dist/cli/install.d.ts +9 -1
  25. package/dist/cli/install.js +134 -31
  26. package/dist/cli/managed-skill-assets.d.ts +1 -1
  27. package/dist/cli/managed-skill-assets.js +5 -3
  28. package/dist/cli/office-dependencies.d.ts +2 -0
  29. package/dist/cli/office-dependencies.js +7 -3
  30. package/dist/cli/scientific-visualization-dependencies.js +1 -1
  31. package/dist/cli/tui-config.d.ts +27 -2
  32. package/dist/cli/tui-config.js +77 -2
  33. package/dist/cli/types.d.ts +17 -0
  34. package/dist/cli.js +9 -6
  35. package/dist/commands.d.ts +10 -1
  36. package/dist/commands.js +17 -3
  37. package/dist/deliverable-hedge-guard.js +21 -9
  38. package/dist/features.d.ts +27 -1
  39. package/dist/features.js +40 -8
  40. package/dist/host/server.d.ts +7 -0
  41. package/dist/host/server.js +12 -0
  42. package/dist/host/tui.d.ts +7 -0
  43. package/dist/host/tui.js +12 -0
  44. package/dist/server.js +11 -1
  45. package/dist/skill-renames.d.ts +1 -1
  46. package/dist/skills.d.ts +9 -2
  47. package/dist/skills.js +22 -8
  48. package/dist/tui/mission-source-v2.d.ts +51 -0
  49. package/dist/tui/mission-source-v2.js +52 -0
  50. package/dist/v2/agents.d.ts +105 -0
  51. package/dist/v2/agents.js +185 -0
  52. package/dist/v2/commands.d.ts +44 -0
  53. package/dist/v2/commands.js +65 -0
  54. package/dist/v2/context.d.ts +67 -0
  55. package/dist/v2/context.js +17 -0
  56. package/dist/v2/events.d.ts +55 -0
  57. package/dist/v2/events.js +193 -0
  58. package/dist/v2/hooks.d.ts +39 -0
  59. package/dist/v2/hooks.js +61 -0
  60. package/dist/v2/server.d.ts +2 -0
  61. package/dist/v2/server.js +68 -0
  62. package/dist/v2/session-hooks.d.ts +64 -0
  63. package/dist/v2/session-hooks.js +230 -0
  64. package/dist/v2/tools.d.ts +26 -0
  65. package/dist/v2/tools.js +49 -0
  66. package/dist/v2/tui.d.ts +63 -0
  67. package/dist/v2/tui.js +259 -0
  68. package/docs/assets/cover-motion-still.webp +0 -0
  69. package/docs/assets/cover-motion.webp +0 -0
  70. package/docs/assets/cover.webp +0 -0
  71. package/docs/assets/readme/badge-version.svg +1 -1
  72. package/docs/assets/readme/litfamily-machines.png +0 -0
  73. package/docs/migration.md +4 -3
  74. package/docs/reference-Ko-KR.md +109 -7
  75. package/docs/reference.md +122 -9
  76. package/package.json +10 -5
  77. package/rules/bundled-rules/lit-humanizer.md +2 -0
  78. package/skills/deep-interview/SKILL.md +1 -1
  79. package/skills/lit-brainstorm/SKILL.md +299 -0
  80. package/skills/lit-brainstorm/references/handoff-briefs.md +72 -0
  81. package/skills/lit-brainstorm/references/lenses.md +106 -0
  82. package/skills/lit-brainstorm/references/scoring-and-convergence.md +93 -0
  83. package/skills/lit-brainstorm/references/session-notes-and-lanes.md +122 -0
  84. package/skills/lit-docx/SKILL.md +1 -1
  85. package/skills/lit-docx/run.py +42 -4
  86. package/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -0
  87. package/skills/lit-docx/templates/latex/acs/template.tex +2 -0
  88. package/skills/lit-docx/templates/latex/elsevier/template.tex +2 -0
  89. package/skills/lit-docx/templates/latex/ieee/template.tex +2 -0
  90. package/skills/lit-docx/templates/latex/korean-generic/template.tex +2 -0
  91. package/skills/lit-docx/templates/latex/nature/template.tex +2 -0
  92. package/skills/lit-humanizer/SKILL.md +4 -0
  93. package/skills/lit-humanizer/scripts/core.mjs +81 -1
  94. package/skills/lit-humanizer/scripts/detect.mjs +4 -3
  95. package/skills/lit-humanizer/scripts/guard-scan.mjs +12 -6
  96. package/skills/lit-pptx/SKILL.md +1 -1
  97. package/skills/lit-pptx/command.mjs +94 -0
  98. package/skills/lit-pptx/run.mjs +16 -6
  99. package/skills/lit-pptx/scripts/compile-deck.js +2 -2
  100. package/skills/lit-typographic-motion/SKILL.md +5 -2
  101. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +3 -0
  102. package/skills/lit-typographic-motion/engine/browser.mjs +3 -9
  103. package/skills/lit-typographic-motion/engine/command.mjs +94 -0
  104. package/skills/lit-typographic-motion/engine/constants.mjs +29 -1
  105. package/skills/lit-typographic-motion/engine/cover/assets.mjs +91 -0
  106. package/skills/lit-typographic-motion/engine/cover/capture.mjs +173 -0
  107. package/skills/lit-typographic-motion/engine/cover/decode.mjs +173 -0
  108. package/skills/lit-typographic-motion/engine/cover/frame-worker.mjs +19 -0
  109. package/skills/lit-typographic-motion/engine/cover/gate.mjs +261 -0
  110. package/skills/lit-typographic-motion/engine/cover/image.mjs +138 -0
  111. package/skills/lit-typographic-motion/engine/cover/render.mjs +323 -0
  112. package/skills/lit-typographic-motion/engine/cover/sheet.mjs +65 -0
  113. package/skills/lit-typographic-motion/engine/cover/treatment.mjs +110 -0
  114. package/skills/lit-typographic-motion/engine/encode.mjs +1 -1
  115. package/skills/lit-typographic-motion/engine/look.mjs +113 -10
  116. package/skills/lit-typographic-motion/engine/sound.mjs +1 -1
  117. package/skills/lit-typographic-motion/engine/stage/capture.mjs +26 -18
  118. package/skills/lit-typographic-motion/engine/stage/cover-init.js +38 -0
  119. package/skills/lit-typographic-motion/engine/stage/loop-kit.js +102 -0
  120. package/skills/lit-typographic-motion/engine/stage/render.mjs +4 -4
  121. package/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
  122. package/skills/lit-typographic-motion/fonts/JetBrainsMono-OFL.txt +93 -0
  123. package/skills/lit-typographic-motion/fonts/JetBrainsMono-wght-latin.woff2 +0 -0
  124. package/skills/lit-typographic-motion/fonts/pins.json +3 -1
  125. package/skills/lit-typographic-motion/gate.mjs +34 -1
  126. package/skills/lit-typographic-motion/probe.mjs +8 -3
  127. package/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +16 -0
  128. package/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +35 -0
  129. package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +16 -0
  130. package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +29 -0
  131. package/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +17 -0
  132. package/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
  133. package/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +17 -0
  134. package/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +35 -0
  135. package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +62 -0
  136. package/skills/lit-typographic-motion/recipes/orbit-loop/index.html +100 -0
  137. package/skills/lit-typographic-motion/recipes/painted-light/expected.json +95 -0
  138. package/skills/lit-typographic-motion/recipes/painted-light/index.html +232 -0
  139. package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +62 -0
  140. package/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
  141. package/skills/lit-typographic-motion/references/cover.md +423 -0
  142. package/skills/lit-typographic-motion/references/craft-loop.md +2 -0
  143. package/skills/lit-typographic-motion/references/runtime.md +17 -2
  144. package/skills/lit-typographic-motion/references/stage.md +3 -0
  145. package/skills/lit-typographic-motion/references/treatment.md +4 -0
  146. package/skills/lit-typographic-motion/render.mjs +30 -6
  147. package/skills/lit-typographic-motion/runtime.mjs +12 -13
  148. package/skills/{durable-litgoal → litgoal}/SKILL.md +31 -12
  149. package/skills/managed-skill-manifest.json +175 -31
  150. package/skills/review-work/SKILL.md +27 -18
  151. package/skills/skill-rename-aliases.json +2 -1
  152. package/tools/check-pack-payload.mjs +4 -1
  153. package/tools/payload-substance-allowlist.json +1 -1
  154. package/tools/payload-substance-parity.json +6 -4
  155. package/tools/qa-cover-recipes.mjs +167 -0
  156. package/tools/version-manifests.json +6 -0
@@ -7,6 +7,7 @@ import { autoHandoffSettingText } from "./auto-handoff.js";
7
7
  import { isReadOnlyWorkflowFamilyPlan } from "./workflow-families.js";
8
8
  import { queryKnowledge } from "./knowledge.js";
9
9
  import { activationDiscipline } from "./activation-probe.js";
10
+ import { decideReentry } from "./activation-reentry.js";
10
11
  import { micro, supportsMarkGlyphs } from "./lit-mark.js";
11
12
  // Prompt modules preserve minimum-first, single-task or few-task planning, avoidable custom code review,
12
13
  // and external-source safety; this module only coordinates their unchanged activation surfaces.
@@ -14,7 +15,7 @@ export { litHandoffPromptInjection, scientificVisualizationPromptInjection } fro
14
15
  export { litActivationBanner } from "./activation-prompt-utils.js";
15
16
  export { litLoopPromptInjection, litPlanPromptInjection } from "./activation-primary-prompts.js";
16
17
  export { containsStandaloneLitTrigger, detectChatActivationMode } from "./activation-routing.js";
17
- export { comprehendPromptInjection, debuggingPromptInjection, deepInterviewPromptInjection, gitMasterPromptInjection, litCruciblePromptInjection, initDeepPromptInjection, litGoalPromptInjection, litRecapPromptInjection, litResearchPromptInjection, lspPromptInjection, lspSetupPromptInjection, litCodePromptInjection, refactorPromptInjection, removeAiSlopsPromptInjection, browserDrivePromptInjection, structuralSearchPromptInjection, rulesPromptInjection, reviewWorkPromptInjection, startWorkChatPromptInjection, startWorkPromptInjection, litBurnoffFilePromptInjection, litFetchPromptInjection, litHumanizerPromptInjection } from "./activation-workflow-prompts.js";
18
+ export { comprehendPromptInjection, debuggingPromptInjection, deepInterviewPromptInjection, gitMasterPromptInjection, litBrainstormPromptInjection, litCruciblePromptInjection, initDeepPromptInjection, litGoalPromptInjection, litRecapPromptInjection, litResearchPromptInjection, lspPromptInjection, lspSetupPromptInjection, litCodePromptInjection, refactorPromptInjection, removeAiSlopsPromptInjection, browserDrivePromptInjection, structuralSearchPromptInjection, rulesPromptInjection, reviewModeForcedNote, reviewWorkPromptInjection, startWorkChatPromptInjection, startWorkPromptInjection, litBurnoffFilePromptInjection, litFetchPromptInjection, litHumanizerPromptInjection } from "./activation-workflow-prompts.js";
18
19
  export function showActivationToast(client, skillId, env = process.env) {
19
20
  const label = `🔥 LIT IGNITED · ${activationDiscipline(skillId)} 🔥`;
20
21
  const message = supportsMarkGlyphs(env)
@@ -134,7 +135,8 @@ export function createChatMessageActivationHook(projectRoot, options = {}) {
134
135
  if (mode === undefined || childSession)
135
136
  return;
136
137
  const trigger = mode === "lit-handoff" ? "handoff" : mode === "lit-scientific-visualization" ? mode : "lit";
137
- let activationText = promptForChatActivationMode(mode, nonEmptyTextParts.find((part) => detectChatActivationMode(part.text, agent) === mode)?.text);
138
+ const triggerText = nonEmptyTextParts.find((part) => detectChatActivationMode(part.text, agent) === mode)?.text;
139
+ let activationText = promptForChatActivationMode(mode, triggerText);
138
140
  if (mode === "start-work" && agent !== "lit-plan" && nonEmptyTextParts.length === 1) {
139
141
  const directive = parseStartWorkLifecycleDirective(nonEmptyTextParts[0].text, { chat: true });
140
142
  if (directive?.action === "resume") {
@@ -147,25 +149,50 @@ export function createChatMessageActivationHook(projectRoot, options = {}) {
147
149
  }
148
150
  }
149
151
  }
152
+ const reentry = options.activationMemory === undefined
153
+ ? { kind: "full" }
154
+ : await decideReentry({
155
+ memory: options.activationMemory,
156
+ projectRoot,
157
+ sessionID: input.sessionID,
158
+ mode,
159
+ text: triggerText ?? ""
160
+ });
161
+ const markMode = reentry.kind === "continue" ? reentry.mode : mode;
162
+ if (reentry.kind === "continue")
163
+ activationText = reentry.text;
164
+ else if (reentry.kind === "resume")
165
+ activationText = `${reentry.line}\n${activationText}`;
166
+ else if (reentry.kind === "confirm")
167
+ activationText = `${activationText}\n${reentry.line}`;
150
168
  output.parts.push({
151
- id: `prt_litopencode_${mode.replace("-", "_")}_${idSuffix(input.messageID ?? output.message.id)}`,
169
+ id: `prt_litopencode_${markMode.replace("-", "_")}_${idSuffix(input.messageID ?? output.message.id)}`,
152
170
  sessionID: input.sessionID,
153
171
  messageID: input.messageID ?? output.message.id,
154
172
  type: "text",
155
173
  text: activationText,
156
174
  metadata: {
157
175
  litopencode: {
158
- mode,
176
+ mode: markMode,
159
177
  trigger,
160
- source: "chat.message"
178
+ source: "chat.message",
179
+ ...(reentry.kind === "full" ? {} : { reentry: reentry.kind })
161
180
  }
162
181
  }
163
182
  });
164
- await options.onSkillActivation?.(input.sessionID, mode, nonEmptyTextParts.some((part) => endsWithStandaloneLitInvocation(part.text)));
183
+ const appendedLit = nonEmptyTextParts.some((part) => endsWithStandaloneLitInvocation(part.text));
184
+ if (reentry.kind === "continue") {
185
+ await options.onSkillContinue?.(input.sessionID, markMode, appendedLit);
186
+ return;
187
+ }
188
+ await options.onSkillActivation?.(input.sessionID, mode, appendedLit);
189
+ if (reentry.kind !== "confirm" && mode !== "lit-handoff" && mode !== "lit-scientific-visualization") {
190
+ options.activationMemory?.remember(input.sessionID, mode);
191
+ }
165
192
  if (mode === "lit-scientific-visualization") {
166
193
  options.onScientificVisualizationActivation?.(input.sessionID);
167
194
  }
168
- if (mode === "lit-task" || mode === "lit-recap" || isReadOnlyWorkflowFamilyPlan(mode))
195
+ if (mode === "lit-task" || mode === "lit-recap" || mode === "lit-brainstorm" || isReadOnlyWorkflowFamilyPlan(mode))
169
196
  return;
170
197
  try {
171
198
  await operations.init();
package/dist/cli/args.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
+ import { hostChoices, isHostChoice } from "./host-detect.js";
3
4
  import { installModelEfforts, isInstallModelEffort, resolveModelRoute } from "./model-catalog.js";
4
5
  export const loopCommands = [
5
6
  "create-goals",
@@ -54,6 +55,7 @@ export function parseArgs(argv) {
54
55
  let summary;
55
56
  let directive;
56
57
  let resolve;
58
+ let host = "auto";
57
59
  if (command === "--help" || command === "-h") {
58
60
  command = "help";
59
61
  }
@@ -244,6 +246,17 @@ export function parseArgs(argv) {
244
246
  index += 1;
245
247
  continue;
246
248
  }
249
+ if (arg === "--host") {
250
+ const value = argv[index + 1];
251
+ if (!value)
252
+ throw new Error("--host requires a value");
253
+ const cleaned = cleanOption(value, "--host") ?? "";
254
+ if (!isHostChoice(cleaned))
255
+ throw new Error("--host must be one of " + hostChoices.join(", "));
256
+ host = cleaned;
257
+ index += 1;
258
+ continue;
259
+ }
247
260
  if (arg === "--yes") {
248
261
  yes = true;
249
262
  continue;
@@ -302,6 +315,7 @@ export function parseArgs(argv) {
302
315
  modelPrompt: yes ? "never" : modelPrompt,
303
316
  outputStylePrompt: yes ? "never" : "auto",
304
317
  ...(tuiAction === undefined ? {} : { tuiAction }),
318
+ host,
305
319
  loop: {
306
320
  projectRoot,
307
321
  json: loopJson,
@@ -1,11 +1,10 @@
1
- import { execFile } from "node:child_process";
2
1
  import { createHash, randomUUID } from "node:crypto";
3
2
  import fs from "node:fs/promises";
4
3
  import os from "node:os";
5
4
  import path from "node:path";
6
- import { promisify } from "node:util";
7
5
  import { defaultOpenCodeConfigRoot } from "../state.js";
8
6
  import { compareStableVersions, parseStableVersion, requestRegistryLatestVersion } from "./update-notifier.js";
7
+ import { execCommand } from "./command-plan.js";
9
8
  import { readPackageMetadata } from "./json.js";
10
9
  export const autoUpdatePackageName = "@litfamily/litopencode";
11
10
  export const autoUpdateTimeoutMs = 30_000;
@@ -23,7 +22,6 @@ const journalFileName = "auto-update-journal.jsonl";
23
22
  const installLockFileName = "auto-update-install.lock";
24
23
  const checkFileName = "auto-update-check.json";
25
24
  const maxCommandOutputBytes = 4 * 1024 * 1024;
26
- const execFileAsync = promisify(execFile);
27
25
  /**
28
26
  * The staged CLI needs Node. Inside OpenCode the plugin runs on a Bun-compiled
29
27
  * host binary, and process.execPath names that binary rather than a Node
@@ -127,7 +125,7 @@ function failureKind(step, error) {
127
125
  };
128
126
  }
129
127
  async function defaultRunCommand(command, args, options) {
130
- return await execFileAsync(command, [...args], options);
128
+ return await execCommand(command, args, options);
131
129
  }
132
130
  async function bounded(operation, timeoutMs) {
133
131
  let timer;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * How to start a command so Windows accepts it. Node refuses to spawn a `.cmd` or `.bat` file
3
+ * without a shell (CVE-2024-27980, `spawn EINVAL`), and a bare `npm` or `opencode` is not found
4
+ * there because only the `.cmd` shim exists. POSIX runs the command as given.
5
+ */
6
+ export type CommandPlan = {
7
+ readonly command: string;
8
+ readonly args: readonly string[];
9
+ readonly shell: boolean;
10
+ };
11
+ export type CommandPlanContext = {
12
+ readonly platform?: NodeJS.Platform;
13
+ readonly env?: Readonly<Record<string, string | undefined>>;
14
+ readonly execPath?: string;
15
+ readonly versions?: Readonly<Record<string, string | undefined>>;
16
+ /** The path of a command on PATH, PATHEXT included. */
17
+ readonly find?: (command: string) => string | undefined;
18
+ readonly exists?: (file: string) => boolean;
19
+ };
20
+ /** One cmd.exe argument in double quotes. Refuses what quotes cannot protect, so a value never becomes shell syntax. */
21
+ export declare function quoteForCmd(value: string): string;
22
+ export declare function commandPlan(command: string, args: readonly string[], context?: CommandPlanContext): CommandPlan;
23
+ /**
24
+ * npm without a `.cmd` shim in the way. Inside OpenCode the plugin runs on a Bun host binary, so
25
+ * `process.execPath` is not Node: npm is found through its shim's own directory instead, and only
26
+ * then through a quoted shell.
27
+ */
28
+ export declare function npmPlan(args: readonly string[], context?: CommandPlanContext): CommandPlan;
29
+ type ExecOptions = {
30
+ readonly env?: NodeJS.ProcessEnv;
31
+ readonly cwd?: string;
32
+ readonly timeout?: number;
33
+ readonly maxBuffer?: number;
34
+ };
35
+ /** `execFile` through a CommandPlan; `npm` is resolved by `npmPlan`. A failure keeps the child's stderr and exit code. */
36
+ export declare function execCommand(command: string, args: readonly string[], options?: ExecOptions): Promise<{
37
+ stdout: string;
38
+ stderr: string;
39
+ }>;
40
+ export {};
@@ -0,0 +1,62 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { findCommandOnPath } from "./host-detect.js";
6
+ const shimPattern = /\.(?:cmd|bat)$/iu;
7
+ const unquotableCharacters = /["%\r\n\0]/u;
8
+ /** One cmd.exe argument in double quotes. Refuses what quotes cannot protect, so a value never becomes shell syntax. */
9
+ export function quoteForCmd(value) {
10
+ if (unquotableCharacters.test(value))
11
+ throw new Error("A Windows command argument may not contain a quote, a percent sign or a line break.");
12
+ return `"${value.replace(/(\\+)$/u, "$1$1")}"`;
13
+ }
14
+ function resolveCommand(command, context) {
15
+ if (command.includes("/") || command.includes("\\"))
16
+ return command;
17
+ const find = context.find ?? ((name) => findCommandOnPath(name, context.env ?? process.env, "win32"));
18
+ return find(command) ?? command;
19
+ }
20
+ export function commandPlan(command, args, context = {}) {
21
+ if ((context.platform ?? process.platform) !== "win32")
22
+ return { command, args, shell: false };
23
+ const resolved = resolveCommand(command, context);
24
+ if (!shimPattern.test(resolved))
25
+ return { command: resolved, args, shell: false };
26
+ return { command: quoteForCmd(resolved), args: args.map(quoteForCmd), shell: true };
27
+ }
28
+ /**
29
+ * npm without a `.cmd` shim in the way. Inside OpenCode the plugin runs on a Bun host binary, so
30
+ * `process.execPath` is not Node: npm is found through its shim's own directory instead, and only
31
+ * then through a quoted shell.
32
+ */
33
+ export function npmPlan(args, context = {}) {
34
+ if ((context.platform ?? process.platform) !== "win32")
35
+ return { command: "npm", args, shell: false };
36
+ const env = context.env ?? process.env;
37
+ const execPath = context.execPath ?? process.execPath;
38
+ const versions = context.versions ?? process.versions;
39
+ const exists = context.exists ?? ((file) => fs.existsSync(file));
40
+ const npmCli = env.npm_execpath;
41
+ if (npmCli !== undefined && /\.[cm]?js$/iu.test(npmCli) && versions.bun === undefined && /^node(?:\.exe)?$/iu.test(path.win32.basename(execPath))) {
42
+ return { command: execPath, args: [npmCli, ...args], shell: false };
43
+ }
44
+ const shim = resolveCommand("npm", context);
45
+ if (shimPattern.test(shim)) {
46
+ const directory = path.win32.dirname(shim);
47
+ const cli = path.win32.join(directory, "node_modules", "npm", "bin", "npm-cli.js");
48
+ if (exists(cli)) {
49
+ const sibling = path.win32.join(directory, "node.exe");
50
+ const node = exists(sibling) ? sibling : resolveCommand("node", context);
51
+ return { command: node, args: [cli, ...args], shell: false };
52
+ }
53
+ }
54
+ return commandPlan("npm", args, { ...context, platform: "win32" });
55
+ }
56
+ const execFileAsync = promisify(execFile);
57
+ /** `execFile` through a CommandPlan; `npm` is resolved by `npmPlan`. A failure keeps the child's stderr and exit code. */
58
+ export async function execCommand(command, args, options = {}) {
59
+ const context = { env: options.env ?? process.env };
60
+ const plan = command === "npm" ? npmPlan(args, context) : commandPlan(command, args, context);
61
+ return await execFileAsync(plan.command, [...plan.args], { ...options, encoding: "utf8", shell: plan.shell, windowsHide: true });
62
+ }
@@ -1,4 +1,5 @@
1
1
  import type { CliResult } from "./types.ts";
2
+ import { type HostChoice } from "./host-detect.ts";
2
3
  /**
3
4
  * Doctor reads the receipt and the record each OpenCode start leaves behind. It
4
5
  * cannot see inside a running OpenCode, so "running" means the last start seen.
@@ -13,4 +14,6 @@ export declare function inspectAutoUpdate(input: {
13
14
  * directory, and without one the directory itself.
14
15
  */
15
16
  export declare function doctorProjectRoot(cwd?: string): string;
16
- export declare function doctor(root: string): Promise<CliResult>;
17
+ /** OpenCode 2's update policy for plugins, read from the shared `autoupdate` setting and its opt-out variable. */
18
+ export declare function openCode2UpdatePolicy(config: Record<string, unknown> | null, env?: NodeJS.ProcessEnv): "auto" | "notify" | "off";
19
+ export declare function doctor(root: string, hostChoice?: HostChoice): Promise<CliResult>;
@@ -15,7 +15,9 @@ import { autoHandoffEnvEnabled, autoHandoffEnvPercent, createAutoHandoffSettings
15
15
  import { inspectLspCapability } from "./lsp-capability.js";
16
16
  import { probeMotionRuntime } from "./motion-runtime.js";
17
17
  import { isLitOpenCodeEntry } from "./plugin-mutation.js";
18
- import { inspectTuiPlugin } from "./tui-config.js";
18
+ import { inspectTuiPlugin, planCliJsonCleanup } from "./tui-config.js";
19
+ import { detectOpenCodeHosts, targetsV1, targetsV2 } from "./host-detect.js";
20
+ import { defaultOpenCodeConfigRoot } from "../state.js";
19
21
  import { autoUpdateCheckPath, autoUpdateInstallLockPath, autoUpdateJournalPath, autoUpdateReceiptPath, isAutoUpdateDisabled, readAutoUpdateCheck, readAutoUpdateReceipt } from "./auto-update.js";
20
22
  const updateNow = "npx @litfamily/litopencode@latest install";
21
23
  /** Each row says what a cause means and the one thing to do about it. */
@@ -162,7 +164,32 @@ export function doctorProjectRoot(cwd = process.cwd()) {
162
164
  return start;
163
165
  }
164
166
  }
165
- export async function doctor(root) {
167
+ /** OpenCode 2's update policy for plugins, read from the shared `autoupdate` setting and its opt-out variable. */
168
+ export function openCode2UpdatePolicy(config, env = process.env) {
169
+ const disabled = env.OPENCODE_DISABLE_AUTOUPDATE;
170
+ if (disabled !== undefined && disabled !== "" && disabled !== "0" && disabled.toLowerCase() !== "false")
171
+ return "off";
172
+ const value = config?.autoupdate;
173
+ if (value === false)
174
+ return "off";
175
+ if (value === "notify")
176
+ return "notify";
177
+ return "auto";
178
+ }
179
+ async function litEntryCount(root, config, packageName) {
180
+ const entries = [
181
+ ...(Array.isArray(config?.plugin) ? config.plugin : []),
182
+ ...(Array.isArray(config?.plugins) ? config.plugins : [])
183
+ ];
184
+ let count = 0;
185
+ for (const entry of entries) {
186
+ const id = typeof entry === "object" && entry !== null && !Array.isArray(entry) ? entry.package : pluginEntryId(entry);
187
+ if (isLitOpenCodeEntry(id) || (typeof id === "string" && (await isLocalPackageEntry(root, id, packageName))))
188
+ count += 1;
189
+ }
190
+ return count;
191
+ }
192
+ export async function doctor(root, hostChoice = "auto") {
166
193
  const metadata = await readPackageMetadata();
167
194
  const loaded = await loadConfig(root);
168
195
  const openCodeLitConfigPath = litOpenCodeConfigFileForOpenCodeRoot(root);
@@ -206,6 +233,25 @@ export async function doctor(root) {
206
233
  config: autoHandoffConfig.autoHandoff
207
234
  }).resolve();
208
235
  const hostCompaction = inspectHostCompactionPoint(hostConfig.config);
236
+ // Doctor never starts a host: it reads the npm package behind each command, and on a custom root only
237
+ // that root's config files.
238
+ const defaultRoot = path.resolve(root) === path.resolve(defaultOpenCodeConfigRoot());
239
+ const host = await detectOpenCodeHosts({
240
+ root,
241
+ override: hostChoice,
242
+ ...(defaultRoot ? {} : { resolvePackage: async () => undefined })
243
+ });
244
+ const forV1 = targetsV1(host.mode);
245
+ const forV2 = targetsV2(host.mode);
246
+ const pluginEntries = await litEntryCount(root, hostConfig.config, metadata.name);
247
+ const cliJson = await planCliJsonCleanup(root).then((plan) => ({ path: plan.path, stale: plan.removed }), (error) => ({ path: path.join(path.resolve(root), "cli.json"), stale: 0, error: error instanceof Error ? error.message : String(error) }));
248
+ const hostOnlyPresent = ["permissions", "policies"].filter((key) => hostConfig.config !== null && key in hostConfig.config);
249
+ const hostOnlyKeys = {
250
+ present: hostOnlyPresent,
251
+ breaksOpenCode1: forV1 && hostOnlyPresent.includes("permissions"),
252
+ note: "OpenCode 1 refuses to start when the shared opencode.json holds the OpenCode 2 `permissions` key. LitOpenCode never writes `permissions` or `policies`."
253
+ };
254
+ const openCode2Ok = !forV2 || (pluginEntries === 1 && cliJson.stale === 0);
209
255
  const autoHandoffWarnings = [...autoHandoff.warnings];
210
256
  if (autoHandoff.enabled && autoHandoff.percent !== null && hostCompaction.percent !== null && autoHandoff.percent >= hostCompaction.percent) {
211
257
  autoHandoffWarnings.push(`OpenCode compacts on its own at about ${hostCompaction.percent}% of the context window. Your ${autoHandoff.percent}% is at or above that, so the host compacts first and the handoff has no time to run. Choose a percent below ${hostCompaction.percent}.`);
@@ -216,7 +262,19 @@ export async function doctor(root) {
216
262
  package: metadata,
217
263
  capabilities: openCodeHostCapabilities(),
218
264
  hostLimits: { path: hostConfig.path, ...inspectHostLimits(hostConfig.config) },
219
- lsp: inspectLspCapability(hostConfig.config),
265
+ host: {
266
+ mode: host.mode,
267
+ binaries: host.binaries,
268
+ ...(host.unplaced === undefined ? {} : { unplaced: host.unplaced }),
269
+ evidence: host.evidence,
270
+ ...(host.override === undefined ? {} : { override: host.override }),
271
+ updatePolicy: forV2 ? openCode2UpdatePolicy(hostConfig.config) : null,
272
+ note: "OpenCode 1 runs as `opencode` and OpenCode 2 as `opencode2` when both are installed; they share this config folder."
273
+ },
274
+ lsp: {
275
+ ...inspectLspCapability(hostConfig.config),
276
+ ...(forV2 ? { openCode2: "OpenCode 2 accepts the lsp settings but does not run language servers, so it reports no diagnostics from them." } : {})
277
+ },
220
278
  autoHandoff: {
221
279
  enabled: autoHandoff.enabled,
222
280
  percent: autoHandoff.percent,
@@ -273,8 +331,11 @@ export async function doctor(root) {
273
331
  pinnedVersion: installPlugin.configured?.startsWith(`${metadata.name}@`) === true ? installPlugin.configured.slice(metadata.name.length + 1) : null
274
332
  }),
275
333
  install: {
276
- ok: installPlugin.ok && commandAliases.ok && nativeSkills.ok,
334
+ ok: installPlugin.ok && commandAliases.ok && nativeSkills.ok && openCode2Ok && !hostOnlyKeys.breaksOpenCode1,
277
335
  plugin: installPlugin,
336
+ pluginEntries,
337
+ cliJson,
338
+ hostOnlyKeys,
278
339
  commandAliases,
279
340
  nativeSkills,
280
341
  tui
@@ -0,0 +1,58 @@
1
+ export declare const hostChoices: readonly ["auto", "v1", "v2", "both"];
2
+ export type HostChoice = (typeof hostChoices)[number];
3
+ export type HostMode = "v1" | "v2" | "both" | "none";
4
+ export type HostMajor = 1 | 2;
5
+ export type HostCommand = "opencode" | "opencode2";
6
+ export type HostBinary = {
7
+ readonly command: HostCommand;
8
+ readonly major: HostMajor;
9
+ readonly version: string;
10
+ };
11
+ export type HostDetection = {
12
+ readonly mode: HostMode;
13
+ readonly binaries: readonly HostBinary[];
14
+ /** Commands on PATH with no npm package behind them, and the host each one was taken for. */
15
+ readonly unplaced?: readonly {
16
+ readonly command: HostCommand;
17
+ readonly major: HostMajor;
18
+ }[];
19
+ readonly evidence: {
20
+ readonly cliJson: boolean;
21
+ readonly tuiJson: boolean;
22
+ };
23
+ readonly v1Command?: string;
24
+ readonly v2Command?: string;
25
+ readonly override?: Exclude<HostChoice, "auto">;
26
+ };
27
+ export type PackageLike = {
28
+ readonly name: string;
29
+ readonly version: string;
30
+ };
31
+ export type HostDetectOptions = {
32
+ readonly root: string;
33
+ /** Injected package lookup; with it, PATH is only read through `findCommand`. */
34
+ readonly resolvePackage?: (command: string) => Promise<PackageLike | undefined>;
35
+ /** The real path of a command on PATH, or undefined. */
36
+ readonly findCommand?: (command: string) => string | undefined;
37
+ readonly override?: HostChoice;
38
+ };
39
+ type PathEnv = {
40
+ readonly PATH?: string;
41
+ readonly PATHEXT?: string;
42
+ };
43
+ export declare function isHostChoice(value: string): value is HostChoice;
44
+ /** A command on PATH. On Windows the PATHEXT extensions are tried too, so an npm `opencode.cmd` is found. */
45
+ export declare function findCommandOnPath(command: string, env?: PathEnv, platform?: NodeJS.Platform): string | undefined;
46
+ /** The npm package that provides a command on PATH, found by following its link to the package folder. */
47
+ export declare function resolveCommandPackage(command: string, env?: PathEnv, platform?: NodeJS.Platform): Promise<PackageLike | undefined>;
48
+ export declare function detectOpenCodeHosts(options: HostDetectOptions): Promise<HostDetection>;
49
+ /**
50
+ * Confirms an `opencode` that was only guessed to be OpenCode 1 (no npm package behind it) by asking its
51
+ * version, so `opencode plugin` never runs on OpenCode 2. A 2.x answer makes it OpenCode 2; no readable
52
+ * answer keeps the guess. Runs nothing when there is no guess to confirm.
53
+ */
54
+ export declare function confirmUnplacedOpenCode(detection: HostDetection, readVersion: (command: string) => Promise<string | undefined>): Promise<HostDetection>;
55
+ /** Whether a detection result means the OpenCode 1 or the OpenCode 2 surfaces must be installed. A host-less result keeps the OpenCode 1 behaviour. */
56
+ export declare function targetsV1(mode: HostMode): boolean;
57
+ export declare function targetsV2(mode: HostMode): boolean;
58
+ export {};
@@ -0,0 +1,199 @@
1
+ // Which OpenCode hosts are installed. OpenCode 1 ships as the npm package `opencode-ai` with the
2
+ // `opencode` command; OpenCode 2 ships as `@opencode/cli` with both `opencode` and `opencode2`. When both
3
+ // are kept, OpenCode 1 stays `opencode` and OpenCode 2 is run as `opencode2`; they share
4
+ // ~/.config/opencode. Detection never starts a host: it reads the npm package behind each command on
5
+ // PATH. Only OpenCode 2 ships `opencode2`, so any `opencode2` on PATH is OpenCode 2. An `opencode` it
6
+ // cannot place (the curl installer, Homebrew, mise, a Windows shim it cannot read) is taken for OpenCode 1,
7
+ // because OpenCode 1 keeps its install however it was installed, unless it is the same file as
8
+ // `opencode2`. That guess is confirmed by `confirmUnplacedOpenCode` (which runs `opencode --version`)
9
+ // before the installer runs `opencode plugin` on it. With no command on PATH, config files are the
10
+ // evidence: cli.json is written only by OpenCode 2, tui.json is OpenCode 1's TUI config. `--host`
11
+ // overrides the result.
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ export const hostChoices = ["auto", "v1", "v2", "both"];
15
+ const commands = ["opencode", "opencode2"];
16
+ const hostPackages = ["opencode-ai", "@opencode/cli"];
17
+ const shimLimitBytes = 64 * 1024;
18
+ export function isHostChoice(value) {
19
+ return hostChoices.includes(value);
20
+ }
21
+ function packageMajor(pkg) {
22
+ if (pkg.name === "opencode-ai")
23
+ return 1;
24
+ if (pkg.name === "@opencode/cli")
25
+ return 2;
26
+ return undefined;
27
+ }
28
+ /** A command on PATH. On Windows the PATHEXT extensions are tried too, so an npm `opencode.cmd` is found. */
29
+ export function findCommandOnPath(command, env = process.env, platform = process.platform) {
30
+ const windows = platform === "win32";
31
+ const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((ext) => ext !== "").map((ext) => ext.toLowerCase());
32
+ // npm leaves an extensionless sh script next to `x.cmd`; Windows never runs it, so a bare name matches only with an extension.
33
+ const names = windows
34
+ ? [...(extensions.some((ext) => command.toLowerCase().endsWith(ext)) ? [command] : []), ...extensions.map((ext) => command + ext)]
35
+ : [command];
36
+ for (const directory of (env.PATH ?? "").split(windows ? ";" : path.delimiter)) {
37
+ if (directory === "")
38
+ continue;
39
+ for (const name of names) {
40
+ const candidate = path.join(directory, name);
41
+ try {
42
+ fs.accessSync(candidate, windows ? fs.constants.F_OK : fs.constants.X_OK);
43
+ if (fs.statSync(candidate).isFile())
44
+ return candidate;
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ }
50
+ }
51
+ return undefined;
52
+ }
53
+ function readPackageJson(file) {
54
+ try {
55
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
56
+ if (typeof parsed === "object" && parsed !== null && typeof parsed.name === "string") {
57
+ const { name, version } = parsed;
58
+ return { name, version: typeof version === "string" ? version : "" };
59
+ }
60
+ }
61
+ catch {
62
+ // No readable package.json here.
63
+ }
64
+ return undefined;
65
+ }
66
+ /**
67
+ * npm on Windows puts a small `.cmd`/`.ps1` shim next to its `node_modules` instead of a link. The shim is
68
+ * read as text, never run, for the host package path it names.
69
+ */
70
+ function packageFromShim(file) {
71
+ let text;
72
+ try {
73
+ if (fs.statSync(file).size > shimLimitBytes)
74
+ return undefined;
75
+ text = fs.readFileSync(file, "utf8");
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ const normalized = text.replace(/\\/gu, "/");
81
+ for (const name of hostPackages) {
82
+ if (!normalized.includes(`node_modules/${name}/`))
83
+ continue;
84
+ return readPackageJson(path.join(path.dirname(file), "node_modules", ...name.split("/"), "package.json")) ?? { name, version: "" };
85
+ }
86
+ return undefined;
87
+ }
88
+ /** The npm package that provides a command on PATH, found by following its link to the package folder. */
89
+ export async function resolveCommandPackage(command, env = process.env, platform = process.platform) {
90
+ const found = findCommandOnPath(command, env, platform);
91
+ if (found === undefined)
92
+ return undefined;
93
+ let directory;
94
+ try {
95
+ directory = path.dirname(fs.realpathSync(found));
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ for (let depth = 0; depth < 4; depth += 1) {
101
+ // Only a host package counts: on Windows the walk starts in the npm folder, where an unrelated
102
+ // package.json further up must not hide the package the shim names.
103
+ const pkg = readPackageJson(path.join(directory, "package.json"));
104
+ if (pkg !== undefined && hostPackages.includes(pkg.name))
105
+ return pkg;
106
+ const parent = path.dirname(directory);
107
+ if (parent === directory)
108
+ break;
109
+ directory = parent;
110
+ }
111
+ return packageFromShim(found);
112
+ }
113
+ function realCommandPath(command) {
114
+ const found = findCommandOnPath(command);
115
+ if (found === undefined)
116
+ return undefined;
117
+ try {
118
+ return fs.realpathSync(found);
119
+ }
120
+ catch {
121
+ return found;
122
+ }
123
+ }
124
+ async function probe(command, options) {
125
+ const pkg = await (options.resolvePackage ?? resolveCommandPackage)(command);
126
+ const major = pkg === undefined ? undefined : packageMajor(pkg);
127
+ return pkg === undefined || major === undefined ? undefined : { command, major, version: pkg.version };
128
+ }
129
+ function modeOf(v1, v2) {
130
+ if (v1 && v2)
131
+ return "both";
132
+ if (v1)
133
+ return "v1";
134
+ if (v2)
135
+ return "v2";
136
+ return "none";
137
+ }
138
+ function summarize(binaries, unplaced, evidence, override) {
139
+ const hosts = [...binaries, ...unplaced];
140
+ const v1Command = hosts.find((host) => host.major === 1)?.command;
141
+ const v2Command = hosts.find((host) => host.major === 2)?.command;
142
+ const detected = hosts.length > 0 ? modeOf(v1Command !== undefined, v2Command !== undefined) : modeOf(evidence.tuiJson, evidence.cliJson);
143
+ return {
144
+ mode: override ?? detected,
145
+ binaries,
146
+ ...(unplaced.length === 0 ? {} : { unplaced }),
147
+ evidence,
148
+ ...(v1Command === undefined ? {} : { v1Command }),
149
+ ...(v2Command === undefined ? {} : { v2Command }),
150
+ ...(override === undefined ? {} : { override })
151
+ };
152
+ }
153
+ export async function detectOpenCodeHosts(options) {
154
+ const binaries = [];
155
+ for (const command of commands) {
156
+ const binary = await probe(command, options);
157
+ if (binary !== undefined)
158
+ binaries.push(binary);
159
+ }
160
+ const findCommand = options.findCommand ?? (options.resolvePackage === undefined ? realCommandPath : () => undefined);
161
+ const placed = (command) => binaries.some((binary) => binary.command === command);
162
+ const opencodeFile = placed("opencode") ? undefined : findCommand("opencode");
163
+ const opencode2File = placed("opencode2") ? undefined : findCommand("opencode2");
164
+ const unplaced = [];
165
+ if (opencodeFile !== undefined) {
166
+ const sameAsOpenCode2 = findCommand("opencode2") === opencodeFile;
167
+ unplaced.push({ command: "opencode", major: sameAsOpenCode2 ? 2 : 1 });
168
+ }
169
+ if (opencode2File !== undefined)
170
+ unplaced.push({ command: "opencode2", major: 2 });
171
+ const evidence = {
172
+ cliJson: fs.existsSync(path.join(options.root, "cli.json")),
173
+ tuiJson: fs.existsSync(path.join(options.root, "tui.json")) || fs.existsSync(path.join(options.root, "tui.jsonc"))
174
+ };
175
+ const override = options.override === undefined || options.override === "auto" ? undefined : options.override;
176
+ return summarize(binaries, unplaced, evidence, override);
177
+ }
178
+ /**
179
+ * Confirms an `opencode` that was only guessed to be OpenCode 1 (no npm package behind it) by asking its
180
+ * version, so `opencode plugin` never runs on OpenCode 2. A 2.x answer makes it OpenCode 2; no readable
181
+ * answer keeps the guess. Runs nothing when there is no guess to confirm.
182
+ */
183
+ export async function confirmUnplacedOpenCode(detection, readVersion) {
184
+ const guess = detection.unplaced?.find((entry) => entry.command === "opencode" && entry.major === 1);
185
+ if (guess === undefined)
186
+ return detection;
187
+ const major = /(\d+)\.\d+\.\d+/u.exec((await readVersion(guess.command)) ?? "")?.[1];
188
+ if (major !== "2")
189
+ return detection;
190
+ const unplaced = (detection.unplaced ?? []).map((entry) => (entry === guess ? { command: entry.command, major: 2 } : entry));
191
+ return summarize(detection.binaries, unplaced, detection.evidence, detection.override);
192
+ }
193
+ /** Whether a detection result means the OpenCode 1 or the OpenCode 2 surfaces must be installed. A host-less result keeps the OpenCode 1 behaviour. */
194
+ export function targetsV1(mode) {
195
+ return mode === "v1" || mode === "both" || mode === "none";
196
+ }
197
+ export function targetsV2(mode) {
198
+ return mode === "v2" || mode === "both";
199
+ }
@@ -1,3 +1,4 @@
1
+ import { targetsV1 } from "./host-detect.js";
1
2
  import { shippedDefaultsSummary } from "./model-catalog.js";
2
3
  const reset = "\u001b[0m";
3
4
  const green = "\u001b[38;5;82m";
@@ -191,7 +192,8 @@ export function renderInstallReport(report) {
191
192
  line("Native skills", report.nativeSkills.write.length + " managed / " + report.nativeSkills.preserve.length + " preserved"),
192
193
  ...nativeSkillsLinkLines(report),
193
194
  line("Plugin entries", report.plugin.currentCount + " -> " + report.plugin.resultCount),
194
- line("TUI config", report.tui.path),
195
+ // OpenCode 2 loads the TUI with the server plugin, so an OpenCode 2-only install has no TUI file.
196
+ ...(report.host === undefined || targetsV1(report.host.mode) ? [line("TUI config", report.tui.path)] : []),
195
197
  ...section("Progress"),
196
198
  progressLine("Resolve package"),
197
199
  progressLine("Read OpenCode config"),