@litfamily/litopencode 1.0.29 → 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 (151) hide show
  1. package/CHANGELOG.md +18 -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/features.d.ts +27 -1
  38. package/dist/features.js +40 -8
  39. package/dist/host/server.d.ts +7 -0
  40. package/dist/host/server.js +12 -0
  41. package/dist/host/tui.d.ts +7 -0
  42. package/dist/host/tui.js +12 -0
  43. package/dist/server.js +9 -0
  44. package/dist/skill-renames.d.ts +1 -1
  45. package/dist/skills.d.ts +9 -2
  46. package/dist/skills.js +22 -8
  47. package/dist/tui/mission-source-v2.d.ts +51 -0
  48. package/dist/tui/mission-source-v2.js +52 -0
  49. package/dist/v2/agents.d.ts +105 -0
  50. package/dist/v2/agents.js +185 -0
  51. package/dist/v2/commands.d.ts +44 -0
  52. package/dist/v2/commands.js +65 -0
  53. package/dist/v2/context.d.ts +67 -0
  54. package/dist/v2/context.js +17 -0
  55. package/dist/v2/events.d.ts +55 -0
  56. package/dist/v2/events.js +193 -0
  57. package/dist/v2/hooks.d.ts +39 -0
  58. package/dist/v2/hooks.js +61 -0
  59. package/dist/v2/server.d.ts +2 -0
  60. package/dist/v2/server.js +68 -0
  61. package/dist/v2/session-hooks.d.ts +64 -0
  62. package/dist/v2/session-hooks.js +230 -0
  63. package/dist/v2/tools.d.ts +26 -0
  64. package/dist/v2/tools.js +49 -0
  65. package/dist/v2/tui.d.ts +63 -0
  66. package/dist/v2/tui.js +259 -0
  67. package/docs/assets/cover-motion-still.webp +0 -0
  68. package/docs/assets/cover-motion.webp +0 -0
  69. package/docs/assets/cover.webp +0 -0
  70. package/docs/assets/readme/badge-version.svg +1 -1
  71. package/docs/assets/readme/litfamily-machines.png +0 -0
  72. package/docs/migration.md +4 -3
  73. package/docs/reference-Ko-KR.md +109 -7
  74. package/docs/reference.md +122 -9
  75. package/package.json +10 -5
  76. package/skills/deep-interview/SKILL.md +1 -1
  77. package/skills/lit-brainstorm/SKILL.md +299 -0
  78. package/skills/lit-brainstorm/references/handoff-briefs.md +72 -0
  79. package/skills/lit-brainstorm/references/lenses.md +106 -0
  80. package/skills/lit-brainstorm/references/scoring-and-convergence.md +93 -0
  81. package/skills/lit-brainstorm/references/session-notes-and-lanes.md +122 -0
  82. package/skills/lit-docx/SKILL.md +1 -1
  83. package/skills/lit-docx/run.py +42 -4
  84. package/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -0
  85. package/skills/lit-docx/templates/latex/acs/template.tex +2 -0
  86. package/skills/lit-docx/templates/latex/elsevier/template.tex +2 -0
  87. package/skills/lit-docx/templates/latex/ieee/template.tex +2 -0
  88. package/skills/lit-docx/templates/latex/korean-generic/template.tex +2 -0
  89. package/skills/lit-docx/templates/latex/nature/template.tex +2 -0
  90. package/skills/lit-humanizer/scripts/detect.mjs +1 -1
  91. package/skills/lit-pptx/SKILL.md +1 -1
  92. package/skills/lit-pptx/command.mjs +94 -0
  93. package/skills/lit-pptx/run.mjs +16 -6
  94. package/skills/lit-pptx/scripts/compile-deck.js +2 -2
  95. package/skills/lit-typographic-motion/SKILL.md +5 -2
  96. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +3 -0
  97. package/skills/lit-typographic-motion/engine/browser.mjs +3 -9
  98. package/skills/lit-typographic-motion/engine/command.mjs +94 -0
  99. package/skills/lit-typographic-motion/engine/constants.mjs +29 -1
  100. package/skills/lit-typographic-motion/engine/cover/assets.mjs +91 -0
  101. package/skills/lit-typographic-motion/engine/cover/capture.mjs +173 -0
  102. package/skills/lit-typographic-motion/engine/cover/decode.mjs +173 -0
  103. package/skills/lit-typographic-motion/engine/cover/frame-worker.mjs +19 -0
  104. package/skills/lit-typographic-motion/engine/cover/gate.mjs +261 -0
  105. package/skills/lit-typographic-motion/engine/cover/image.mjs +138 -0
  106. package/skills/lit-typographic-motion/engine/cover/render.mjs +323 -0
  107. package/skills/lit-typographic-motion/engine/cover/sheet.mjs +65 -0
  108. package/skills/lit-typographic-motion/engine/cover/treatment.mjs +110 -0
  109. package/skills/lit-typographic-motion/engine/encode.mjs +1 -1
  110. package/skills/lit-typographic-motion/engine/look.mjs +113 -10
  111. package/skills/lit-typographic-motion/engine/sound.mjs +1 -1
  112. package/skills/lit-typographic-motion/engine/stage/capture.mjs +26 -18
  113. package/skills/lit-typographic-motion/engine/stage/cover-init.js +38 -0
  114. package/skills/lit-typographic-motion/engine/stage/loop-kit.js +102 -0
  115. package/skills/lit-typographic-motion/engine/stage/render.mjs +4 -4
  116. package/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
  117. package/skills/lit-typographic-motion/fonts/JetBrainsMono-OFL.txt +93 -0
  118. package/skills/lit-typographic-motion/fonts/JetBrainsMono-wght-latin.woff2 +0 -0
  119. package/skills/lit-typographic-motion/fonts/pins.json +3 -1
  120. package/skills/lit-typographic-motion/gate.mjs +34 -1
  121. package/skills/lit-typographic-motion/probe.mjs +8 -3
  122. package/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +16 -0
  123. package/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +35 -0
  124. package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +16 -0
  125. package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +29 -0
  126. package/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +17 -0
  127. package/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
  128. package/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +17 -0
  129. package/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +35 -0
  130. package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +62 -0
  131. package/skills/lit-typographic-motion/recipes/orbit-loop/index.html +100 -0
  132. package/skills/lit-typographic-motion/recipes/painted-light/expected.json +95 -0
  133. package/skills/lit-typographic-motion/recipes/painted-light/index.html +232 -0
  134. package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +62 -0
  135. package/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
  136. package/skills/lit-typographic-motion/references/cover.md +423 -0
  137. package/skills/lit-typographic-motion/references/craft-loop.md +2 -0
  138. package/skills/lit-typographic-motion/references/runtime.md +17 -2
  139. package/skills/lit-typographic-motion/references/stage.md +3 -0
  140. package/skills/lit-typographic-motion/references/treatment.md +4 -0
  141. package/skills/lit-typographic-motion/render.mjs +30 -6
  142. package/skills/lit-typographic-motion/runtime.mjs +12 -13
  143. package/skills/{durable-litgoal → litgoal}/SKILL.md +31 -12
  144. package/skills/managed-skill-manifest.json +173 -29
  145. package/skills/review-work/SKILL.md +27 -18
  146. package/skills/skill-rename-aliases.json +2 -1
  147. package/tools/check-pack-payload.mjs +4 -1
  148. package/tools/payload-substance-allowlist.json +1 -1
  149. package/tools/payload-substance-parity.json +6 -4
  150. package/tools/qa-cover-recipes.mjs +167 -0
  151. package/tools/version-manifests.json +6 -0
@@ -1,2 +1,10 @@
1
1
  import type { CliResult, InstallModelSelection, InstallPermissionMode, InstallProgress, ModelPromptMode, PermissionPromptMode } from "./types.ts";
2
- export declare function install(root: string, dryRun: boolean, modelSelection?: InstallModelSelection | undefined, modelPrompt?: ModelPromptMode, permissionMode?: InstallPermissionMode, permissionModeExplicit?: boolean, permissionPrompt?: PermissionPromptMode, progress?: InstallProgress, outputStylePrompt?: PermissionPromptMode): Promise<CliResult>;
2
+ import { type HostChoice } from "./host-detect.ts";
3
+ /**
4
+ * OpenCode 2 merges its own `plugins` key with the OpenCode 1 `plugin` list and refuses a second plugin
5
+ * with the same id, so a LitOpenCode entry under `plugins` is removed when OpenCode 2 is a target. The
6
+ * one registration stays in `plugin`, which both hosts read.
7
+ */
8
+ export declare function withoutV2PluginEntries(config: Record<string, unknown> | null): Record<string, unknown> | null;
9
+ export declare function assertNoHostOnlyKeysAdded(before: Record<string, unknown> | null, next: Record<string, unknown> | null): void;
10
+ export declare function install(root: string, dryRun: boolean, modelSelection?: InstallModelSelection | undefined, modelPrompt?: ModelPromptMode, permissionMode?: InstallPermissionMode, permissionModeExplicit?: boolean, permissionPrompt?: PermissionPromptMode, progress?: InstallProgress, outputStylePrompt?: PermissionPromptMode, host?: HostChoice): Promise<CliResult>;
@@ -1,10 +1,9 @@
1
- import { execFile } from "node:child_process";
2
1
  import fs from "node:fs/promises";
3
2
  import os from "node:os";
4
3
  import path from "node:path";
5
- import { promisify } from "node:util";
6
4
  import { createRuntimePaths } from "../state.js";
7
5
  import { describeCommandAliasMutation, ensureCommandAliases, inspectCommandAliases } from "./command-aliases.js";
6
+ import { execCommand } from "./command-plan.js";
8
7
  import { openCodeHostCapabilities } from "./host-capabilities.js";
9
8
  import { applyHostLimits, describeHostLimitMutation, ensureHostLimits } from "./host-limits.js";
10
9
  import { assertExistingModelRoutesSafe, migrateRetiredEfforts, describeInstallRoutes, describeLitConfigMutation, ensureLitConfig, OpenCodeConfigCommentError, readExistingOutputStyle, readInstallModelClassification, readInstallModelSelection, renderOpenCodeConfig, writeOpenCodeConfig } from "./install-config.js";
@@ -16,8 +15,8 @@ import { litOpenCodeConfigFileForOpenCodeRoot } from "../state.js";
16
15
  import { describeNativeSkillMutation, ensureNativeSkills, inspectNativeSkills } from "./native-skills.js";
17
16
  import { applyPluginMutation, describePluginMutation, isLitOpenCodeEntry } from "./plugin-mutation.js";
18
17
  import { prewarmMotionRuntime } from "./motion-runtime.js";
19
- import { applyTuiWrites, backupTuiFiles, inspectTuiPlugin, planTuiInstall } from "./tui-config.js";
20
- const execFileAsync = promisify(execFile);
18
+ import { applyCliJsonCleanup, applyTuiWrites, backupTuiFiles, inspectTuiPlugin, planCliJsonCleanup, planTuiInstall, skippedTuiPlan } from "./tui-config.js";
19
+ import { confirmUnplacedOpenCode, detectOpenCodeHosts, targetsV1, targetsV2 } from "./host-detect.js";
21
20
  function withProgress(progress, label, task) {
22
21
  return progress === undefined ? task() : progress.run(label, task);
23
22
  }
@@ -107,9 +106,18 @@ async function openCodeInstallTarget(metadata) {
107
106
  const spec = pluginSpec(metadata);
108
107
  return { value: spec, label: spec };
109
108
  }
110
- async function installWithOpenCode(target) {
109
+ async function openCodeVersion(command) {
111
110
  try {
112
- await execFileAsync("opencode", ["plugin", target.value, "--global", "--force"], {
111
+ const { stdout } = await execCommand(command, ["--version"], { timeout: 15_000, maxBuffer: 64 * 1024 });
112
+ return String(stdout);
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
118
+ async function installWithOpenCode(target, command = "opencode") {
119
+ try {
120
+ await execCommand(command, ["plugin", target.value, "--global", "--force"], {
113
121
  timeout: 120_000,
114
122
  maxBuffer: 1024 * 1024 * 4
115
123
  });
@@ -147,14 +155,41 @@ async function normalizeOpenCodeEntries(root, target) {
147
155
  await writeOpenCodeConfig(configFile, { ...config, plugin: nextPlugin });
148
156
  }
149
157
  }
158
+ function v2PluginEntryId(value) {
159
+ if (typeof value === "object" && value !== null && !Array.isArray(value))
160
+ return value.package;
161
+ return value;
162
+ }
163
+ /**
164
+ * OpenCode 2 merges its own `plugins` key with the OpenCode 1 `plugin` list and refuses a second plugin
165
+ * with the same id, so a LitOpenCode entry under `plugins` is removed when OpenCode 2 is a target. The
166
+ * one registration stays in `plugin`, which both hosts read.
167
+ */
168
+ export function withoutV2PluginEntries(config) {
169
+ if (config === null || !Array.isArray(config.plugins))
170
+ return config;
171
+ const kept = config.plugins.filter((entry) => !isLitOpenCodeEntry(v2PluginEntryId(entry)));
172
+ return kept.length === config.plugins.length ? config : { ...config, plugins: kept };
173
+ }
174
+ // OpenCode 1 refuses to start when the shared config holds the OpenCode 2 `permissions` key, and OpenCode 2
175
+ // keeps `policies` there too. LitOpenCode never writes either; this check makes that a hard rule.
176
+ const hostOnlyKeys = ["permissions", "policies"];
177
+ export function assertNoHostOnlyKeysAdded(before, next) {
178
+ for (const key of hostOnlyKeys) {
179
+ if (next !== null && key in next && JSON.stringify(next[key]) !== JSON.stringify(before?.[key])) {
180
+ throw new Error(`Refusing to write the OpenCode 2 \`${key}\` key into the shared config.`);
181
+ }
182
+ }
183
+ }
150
184
  // Works out every OpenCode config edit the install will make before anything is written, and returns
151
185
  // the refusal when one of them would delete a user comment. On the default root OpenCode registers the
152
186
  // plugin itself; LitOpenCode then only cleans up stale entries and sets host limits.
153
- async function commentLossRefusal(root, configFile, target, before, mutation, reconfigureHostLimits, useOpenCodeInstaller) {
187
+ async function commentLossRefusal(root, configFile, target, before, mutation, reconfigureHostLimits, useOpenCodeInstaller, stripV2Plugins = false) {
154
188
  const planned = [];
189
+ const v2 = (next) => (stripV2Plugins ? withoutV2PluginEntries(next) : next);
155
190
  if (!useOpenCodeInstaller) {
156
191
  const next = applyPluginMutation(before, mutation, target.value);
157
- planned.push({ file: configFile, next: reconfigureHostLimits ? applyHostLimits(next) : next });
192
+ planned.push({ file: configFile, next: v2(reconfigureHostLimits ? applyHostLimits(next) : next) });
158
193
  }
159
194
  else {
160
195
  for (const file of openCodeConfigCandidates(root)) {
@@ -170,7 +205,7 @@ async function commentLossRefusal(root, configFile, target, before, mutation, re
170
205
  if (JSON.stringify(normalized) !== JSON.stringify(registered))
171
206
  next = { ...config, plugin: normalized };
172
207
  }
173
- planned.push({ file, next: file === configFile && reconfigureHostLimits ? applyHostLimits(next) : next });
208
+ planned.push({ file, next: v2(file === configFile && reconfigureHostLimits ? applyHostLimits(next) : next) });
174
209
  }
175
210
  }
176
211
  try {
@@ -186,7 +221,7 @@ async function commentLossRefusal(root, configFile, target, before, mutation, re
186
221
  }
187
222
  return undefined;
188
223
  }
189
- async function verifyInstalledSurfaces(root, configFile, target, metadata, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle) {
224
+ async function verifyInstalledSurfaces(root, configFile, target, metadata, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle, host = { mode: "v1", binaries: [], evidence: { cliJson: false, tuiJson: false } }) {
190
225
  const config = await readJsonObjectIfPresent(configFile);
191
226
  const pluginEntries = Array.isArray(config?.plugin) ? config.plugin : [];
192
227
  const pluginIds = pluginEntries.map((entry) => typeof entry === "string" ? entry : Array.isArray(entry) && typeof entry[0] === "string" ? entry[0] : "");
@@ -195,12 +230,20 @@ async function verifyInstalledSurfaces(root, configFile, target, metadata, selec
195
230
  const litConfig = await describeLitConfigMutation(root, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle);
196
231
  const commandAliases = await inspectCommandAliases(root);
197
232
  const nativeSkills = await inspectNativeSkills(root, metadata);
198
- const tui = await inspectTuiPlugin(root, target.value);
199
233
  const failures = [];
200
234
  if (targetCount !== 1 || staleLitEntries.length > 0)
201
235
  failures.push("plugin registration");
202
- if (!tui.ok)
203
- failures.push("TUI plugin registration in " + tui.path);
236
+ if (targetsV1(host.mode)) {
237
+ const tui = await inspectTuiPlugin(root, target.value);
238
+ if (!tui.ok)
239
+ failures.push("TUI plugin registration in " + tui.path);
240
+ }
241
+ if (targetsV2(host.mode)) {
242
+ if (withoutV2PluginEntries(config) !== config)
243
+ failures.push("duplicate LitOpenCode entry under plugins");
244
+ if ((await planCliJsonCleanup(root)).removed > 0)
245
+ failures.push("LitOpenCode entry left in cli.json");
246
+ }
204
247
  if (litConfig.changed)
205
248
  failures.push("LitOpenCode routes");
206
249
  if (!commandAliases.ok)
@@ -214,7 +257,7 @@ async function verifyInstalledSurfaces(root, configFile, target, metadata, selec
214
257
  if (failures.length > 0)
215
258
  throw new Error("Install verification failed: " + failures.join("; "));
216
259
  }
217
- export async function install(root, dryRun, modelSelection = undefined, modelPrompt = "auto", permissionMode = "safe", permissionModeExplicit = false, permissionPrompt = "auto", progress, outputStylePrompt = "auto") {
260
+ export async function install(root, dryRun, modelSelection = undefined, modelPrompt = "auto", permissionMode = "safe", permissionModeExplicit = false, permissionPrompt = "auto", progress, outputStylePrompt = "auto", host = "auto") {
218
261
  const retiredNotice = await migrateRetiredEfforts(root, dryRun);
219
262
  if (retiredNotice !== undefined)
220
263
  process.stdout.write(retiredNotice + "\n");
@@ -233,33 +276,66 @@ export async function install(root, dryRun, modelSelection = undefined, modelPro
233
276
  await activeProgress.start(pluginSpec(metadata));
234
277
  }
235
278
  const spec = pluginSpec(metadata);
236
- const resolved = await withProgress(activeProgress, "Resolve package", async () => {
279
+ const located = await withProgress(activeProgress, "Resolve package", async () => {
237
280
  const paths = createRuntimePaths(root);
238
281
  const useOpenCodeInstaller = shouldUseOpenCodeInstaller(root);
239
282
  const target = useOpenCodeInstaller ? await openCodeInstallTarget(metadata) : { value: spec, label: spec };
240
- return { paths, useOpenCodeInstaller, target };
283
+ // A custom root says nothing about which host reads it, so only its config files count there.
284
+ const detection = await detectOpenCodeHosts({
285
+ root,
286
+ override: host,
287
+ ...(useOpenCodeInstaller ? {} : { resolvePackage: async () => undefined })
288
+ });
289
+ return { paths, useOpenCodeInstaller, target, detection };
241
290
  });
242
- const inspected = await withProgress(activeProgress, "Read OpenCode config", async () => {
243
- const configFile = resolved.useOpenCodeInstaller
291
+ const resolveFor = (detection) => {
292
+ const forV1 = targetsV1(detection.mode);
293
+ const forV2 = targetsV2(detection.mode);
294
+ // `opencode plugin` is the OpenCode 1 command; OpenCode 2 installs the package itself on first load.
295
+ const runInstaller = located.useOpenCodeInstaller && forV1;
296
+ const opencodeIsV2 = [...detection.binaries, ...(detection.unplaced ?? [])].some((found) => found.command === "opencode" && found.major === 2);
297
+ if (runInstaller && detection.v1Command === undefined && opencodeIsV2) {
298
+ throw new Error("Found no OpenCode 1 command: `opencode` is OpenCode 2, and `opencode plugin` would change OpenCode 2's config. Install for OpenCode 2 with --host v2, or put OpenCode 1 on PATH as `opencode`.");
299
+ }
300
+ return { ...located, detection, runInstaller, forV1, forV2 };
301
+ };
302
+ const inspect = async (current) => {
303
+ const configFile = current.useOpenCodeInstaller
244
304
  ? await openCodeManagedConfigFile(root)
245
- : resolved.paths.opencodeConfigFile;
305
+ : current.paths.opencodeConfigFile;
246
306
  const before = await readJsonObjectIfPresent(configFile);
247
307
  return {
248
308
  configFile,
249
309
  before,
250
- mutation: describePluginMutation(before, resolved.target.value),
310
+ mutation: describePluginMutation(before, current.target.value),
251
311
  litopencodeConfig: await describeLitConfigMutation(root, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle),
252
312
  commandAliases: await describeCommandAliasMutation(root),
253
313
  nativeSkills: await describeNativeSkillMutation(root, metadata),
254
314
  hostLimits: describeHostLimitMutation(before, selectedModel !== undefined),
255
- tui: await planTuiInstall(root, resolved.target.value),
315
+ tui: current.forV1 ? await planTuiInstall(root, current.target.value) : skippedTuiPlan(root),
316
+ cliJson: current.forV2 ? await planCliJsonCleanup(root) : { path: path.join(path.resolve(root), "cli.json"), removed: 0 },
256
317
  routes: dryRun
257
318
  ? await describeInstallRoutes(root, before, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle)
258
319
  : undefined
259
320
  };
260
- });
261
- const { configFile, before, mutation, litopencodeConfig, commandAliases, nativeSkills, hostLimits, routes, tui } = inspected;
262
- const blocked = (await commentLossRefusal(root, configFile, resolved.target, before, mutation, hostLimits.reconfigure, resolved.useOpenCodeInstaller)) ?? tui.blocked;
321
+ };
322
+ const refusalFor = async (current, plan) => (await commentLossRefusal(root, plan.configFile, current.target, plan.before, plan.mutation, plan.hostLimits.reconfigure, current.runInstaller, current.forV2)) ?? plan.tui.blocked ?? plan.cliJson.blocked;
323
+ let resolved = resolveFor(located.detection);
324
+ let inspected = await withProgress(activeProgress, "Read OpenCode config", async () => inspect(resolved));
325
+ let blocked = await refusalFor(resolved, inspected);
326
+ // A real install that passed every refusal is about to run `opencode plugin`. An `opencode` only guessed
327
+ // to be OpenCode 1 answers `--version` first, so an OpenCode 2 installed outside npm gets the OpenCode 2
328
+ // setup instead. A dry run and a refused install start nothing.
329
+ if (blocked === undefined && !dryRun && resolved.runInstaller) {
330
+ const confirmed = await confirmUnplacedOpenCode(resolved.detection, openCodeVersion);
331
+ if (confirmed !== resolved.detection) {
332
+ resolved = resolveFor(confirmed);
333
+ inspected = await inspect(resolved);
334
+ blocked = await refusalFor(resolved, inspected);
335
+ }
336
+ }
337
+ const { configFile, before, mutation, litopencodeConfig, commandAliases, nativeSkills, hostLimits, routes, tui, cliJson } = inspected;
338
+ const v2PluginsChanged = resolved.forV2 && withoutV2PluginEntries(before) !== before;
263
339
  const report = {
264
340
  dryRun,
265
341
  path: configFile,
@@ -269,12 +345,20 @@ export async function install(root, dryRun, modelSelection = undefined, modelPro
269
345
  nativeSkills,
270
346
  tui: tui.report,
271
347
  patch: mutation.patch,
272
- changed: mutation.changed || litopencodeConfig.changed || commandAliases.changed || nativeSkills.changed || hostLimits.changed || tui.report.changed,
348
+ changed: mutation.changed || litopencodeConfig.changed || commandAliases.changed || nativeSkills.changed || hostLimits.changed || tui.report.changed || cliJson.removed > 0 || v2PluginsChanged,
273
349
  ...(blocked === undefined ? {} : { blocked }),
274
350
  routes,
275
351
  hostLimits: { path: configFile, ...hostLimits },
276
352
  capabilities: openCodeHostCapabilities(),
277
- package: { name: metadata.name, version: metadata.version }
353
+ package: { name: metadata.name, version: metadata.version },
354
+ host: {
355
+ mode: resolved.detection.mode,
356
+ binaries: resolved.detection.binaries,
357
+ ...(resolved.detection.unplaced === undefined ? {} : { unplaced: resolved.detection.unplaced }),
358
+ evidence: resolved.detection.evidence,
359
+ ...(resolved.detection.override === undefined ? {} : { override: resolved.detection.override })
360
+ },
361
+ cliJson: { path: cliJson.path, removed: cliJson.removed, changed: cliJson.removed > 0 }
278
362
  };
279
363
  if (dryRun) {
280
364
  return {
@@ -295,30 +379,49 @@ export async function install(root, dryRun, modelSelection = undefined, modelPro
295
379
  await ensureCommandAliases(root);
296
380
  });
297
381
  await withProgress(activeProgress, "Register plugin", async () => {
298
- if (resolved.useOpenCodeInstaller) {
382
+ if (resolved.runInstaller) {
299
383
  // The package's `./tui` export makes `opencode plugin` add the TUI entry itself, so back up
300
384
  // first, then normalize whatever it wrote without a second backup.
301
385
  await backupTuiFiles(tui);
302
- await installWithOpenCode(resolved.target);
386
+ // Both config candidates as they were, so an OpenCode 2 key added to either one is caught.
387
+ const candidates = openCodeConfigCandidates(root);
388
+ const snapshots = new Map(await Promise.all(candidates.map(async (file) => [file, await readJsonObjectIfPresent(file)])));
389
+ await installWithOpenCode(resolved.target, resolved.detection.v1Command ?? "opencode");
303
390
  const installed = await readJsonObjectIfPresent(configFile);
304
391
  if (!Array.isArray(installed?.plugin) || !installed.plugin.some((entry) => (Array.isArray(entry) ? entry[0] : entry) === resolved.target.value))
305
392
  throw new Error("OpenCode did not register the requested package target");
306
393
  await normalizeOpenCodeEntries(root, resolved.target);
394
+ if (resolved.forV2) {
395
+ for (const file of candidates) {
396
+ const current = await readJsonObjectIfPresent(file);
397
+ const stripped = withoutV2PluginEntries(current);
398
+ if (stripped === current || stripped === null)
399
+ continue;
400
+ assertNoHostOnlyKeysAdded(snapshots.get(file) ?? null, stripped);
401
+ await writeOpenCodeConfig(file, stripped);
402
+ }
403
+ }
307
404
  await ensureHostLimits(configFile, await readJsonObjectIfPresent(configFile), hostLimits.reconfigure);
308
405
  const settled = await planTuiInstall(root, resolved.target.value);
309
406
  if (settled.blocked !== undefined)
310
407
  throw new OpenCodeConfigCommentError(settled.blocked);
311
408
  await applyTuiWrites(settled.writes, { backup: false });
409
+ for (const file of candidates)
410
+ assertNoHostOnlyKeysAdded(snapshots.get(file) ?? null, await readJsonObjectIfPresent(file));
312
411
  return;
313
412
  }
314
413
  await applyTuiWrites(tui.writes);
315
414
  const withPlugin = applyPluginMutation(before, mutation, resolved.target.value);
316
- const next = hostLimits.reconfigure ? applyHostLimits(withPlugin) : withPlugin;
317
- if (JSON.stringify(next) === JSON.stringify(before))
415
+ const limited = hostLimits.reconfigure ? applyHostLimits(withPlugin) : withPlugin;
416
+ const next = resolved.forV2 ? withoutV2PluginEntries(limited) : limited;
417
+ assertNoHostOnlyKeysAdded(before, next);
418
+ if (next === null || JSON.stringify(next) === JSON.stringify(before))
318
419
  return;
319
420
  await writeOpenCodeConfig(configFile, next);
320
421
  });
321
- await withProgress(activeProgress, "Verify install", async () => verifyInstalledSurfaces(root, configFile, resolved.target, metadata, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle));
422
+ if (resolved.forV2)
423
+ await applyCliJsonCleanup(cliJson);
424
+ await withProgress(activeProgress, "Verify install", async () => verifyInstalledSurfaces(root, configFile, resolved.target, metadata, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle, resolved.detection));
322
425
  await activeProgress?.complete();
323
426
  // MO-A-42: the installer attempts the motion pre-warm; a failure never fails the install.
324
427
  const motionReceipt = wrapReceiptLine(prewarmMotionRuntime(metadata.packageRoot));
@@ -1,5 +1,5 @@
1
1
  import type { LitOpenCodeRuntimeSkillId } from "../skills.ts";
2
- export type ManagedSkillId = "lit-comprehend" | "lit-handoff" | "lit-scientific-visualization" | "lit-diagram-drawer" | "lit-pptx" | "lit-typographic-motion" | "lit-docx" | "readme-studio" | "frontend-ui-ux" | "visual-qa" | "lit-plan" | "browser-drive" | "lit-humanizer" | "autoresearch" | "autoconference" | "wikify";
2
+ export type ManagedSkillId = "lit-brainstorm" | "lit-comprehend" | "lit-handoff" | "lit-scientific-visualization" | "lit-diagram-drawer" | "lit-pptx" | "lit-typographic-motion" | "lit-docx" | "readme-studio" | "frontend-ui-ux" | "visual-qa" | "lit-plan" | "browser-drive" | "lit-humanizer" | "autoresearch" | "autoconference" | "wikify";
3
3
  export type CanonicalManagedAsset = {
4
4
  readonly path: string;
5
5
  readonly sha256: string;
@@ -6,7 +6,8 @@ if (managedSkillManifest.schemaVersion !== 1) {
6
6
  throw new Error(`Unsupported managed-skill manifest schema: ${String(managedSkillManifest.schemaVersion)}`);
7
7
  }
8
8
  export function managedSkillDefinition(id) {
9
- if (id !== "lit-comprehend" &&
9
+ if (id !== "lit-brainstorm" &&
10
+ id !== "lit-comprehend" &&
10
11
  id !== "lit-handoff" &&
11
12
  id !== "lit-scientific-visualization" &&
12
13
  id !== "lit-diagram-drawer" &&
@@ -79,7 +80,7 @@ export function isRecursivelyManagedSkill(id) {
79
80
  // id without writing its trigger is a compile error, not a silent fallback.
80
81
  const skillDiscoveryDescriptions = Object.freeze({
81
82
  "workflow-loop": "Trigger when the user wants a multi-step LitOpenCode work loop started, resumed, or kept running — bare `lit`, `/lit`, `/litwork`, \"keep going\", \"continue the loop\" — or when a request needs goal binding, ordered execution, and ledger-backed progress across turns instead of one answer. Also trigger when one request hands over a whole feature, fix, or ticket to carry through to done — build it, add tests, verify against each acceptance condition and on the real user-facing path, clean up temporary artifacts, then report what changed (\"start to finish\", \"end to end\", \"see it through\", \"처음부터 끝까지\", \"끝까지 해줘\"). Do not trigger for a question, an explanation, or a one-line snippet.",
82
- "durable-litgoal": "Trigger when work needs an objective that outlives the current turn: binding a goal with checkable criteria, reading or appending the `.litopencode/litgoal` ledger, resuming a schema-3 bounded-authority work record, or answering \"what was I working on\" from durable state rather than chat memory.",
83
+ litgoal: "Trigger when work needs a finish line that outlives the current turn — litgoal sets and keeps it, lit-loop and start-work run toward it: binding a goal with checkable criteria, reading or appending the `.litopencode/litgoal` ledger, resuming a schema-3 bounded-authority work record, or answering \"what was I working on\" from durable state rather than chat memory.",
83
84
  "agent-roster": "Trigger when deciding which OpenCode agent should do the work, and again the moment work is about to be split across several lanes at once — before delegating through the task tool, when a request fans out into planning, implementation, review, research, or verification lanes, or when the user asks which agents exist and what each is permitted to touch. Delegation here is depth-one, so lanes need non-overlapping slices, explicit per-lane state, and a completion claim that aggregates every lane's evidence rather than summarising it.",
84
85
  "lit-plan": "Trigger when the user asks for a plan, checklist, approach, or design before implementation, or when a request is large or risky enough that execution must not start until scope, non-goals, ordered action/output/verification items, and a falsifiable DoneClaim are agreed. Do not trigger to perform the work itself.",
85
86
  "start-work": "Trigger when an approved plan or an explicit go-ahead already exists and implementation should begin or resume — \"start work\", \"execute the plan\", \"pick up where we left off\" — so the loop runs in verifiable slices with delegation lanes, evidence, and an independent verifier before any completion claim.",
@@ -92,6 +93,7 @@ const skillDiscoveryDescriptions = Object.freeze({
92
93
  "lit-fetch": "Trigger when a public HTTP(S) page, README, raw file, or API response must actually be fetched, so retrieval runs through the SSRF guards and returns a typed verdict, and so authentication, paywall, rate-limit, consent, and challenge boundaries become stop conditions instead of improvised workarounds.",
93
94
  "release-guardrails": "Trigger before any release-shaped action — version bump, tag, publish, changelog, \"is this ready to ship\" — so vocabulary scanning, version lockstep, and a real dry-pack manifest run before the claim is made and publication stays behind explicit user approval.",
94
95
  "lit-init": "Trigger when a repository needs agent-facing guidance created or refreshed: \"set up AGENTS.md\", \"document this repo for agents\", onboarding an unfamiliar codebase, or a restructure that left directory guidance stale and misleading.",
96
+ "lit-brainstorm": "Trigger when the question is still open and the user wants options before a decision — \"what could we call it\", \"any ideas for\", \"how else could we do this\", \"brainstorm\", \"ideation\", `lit idea`, `lit brainstorm` — so many different options are generated, clustered, scored against stated criteria, and narrowed to a short list with trade-offs before one hand-off to deep-interview or lit-plan. Do not trigger to plan a chosen thing, to interview toward one spec, or to implement.",
95
97
  "lit-crucible": "Trigger before planning when the approach itself is uncertain, contested, or expensive to reverse — competing designs, hidden coupling, a request to pressure-test assumptions or find what will go wrong — so independent read-only lanes attack the idea and only surviving evidence-backed insights reach lit-plan.",
96
98
  refactor: "Trigger when code should change shape without changing behavior: extracting, renaming, deduplicating, splitting an overgrown module, or paying down structure the user calls messy or hard to follow — so the behavior boundary is pinned by before/after checks and feature work is not smuggled into the diff.",
97
99
  "lit-burnoff": "Trigger when text or code reads as machine-written and should not — comments restating the code, hedging filler, decorative headings, invented emphasis — or when the user asks to \"clean this up\", \"make it less AI\", or strip generated padding, while facts, APIs, tests, and accessibility behavior stay intact.",
@@ -113,7 +115,7 @@ const skillDiscoveryDescriptions = Object.freeze({
113
115
  "lit-scientific-visualization": "Trigger only for explicit `/lit-scientific-visualization`, an exact bare `lit-scientific-visualization` message, or native selection by the exact `lit-scientific-visualization` id.",
114
116
  "lit-diagram-drawer": "Select this skill from OpenCode's native skill picker when the user needs an architecture map, workflow, schema, decision path, schedule, or conceptual chart. Route product pages and interface layouts to frontend-ui-ux; route measured scientific data and statistical figures to lit-scientific-visualization. Do not claim a slash command or bare-chat route.",
115
117
  "lit-pptx": "Select for a PowerPoint deck or presentation, including a bounded lit request for slides. Keep editable Markdown and PPTX together, run the QA gate, and inspect the render.",
116
- "lit-typographic-motion": "Select for a new film or video. Write the treatment first, then render on the stage path (a captured page) or the type path (words alone), gate it and look at the frames; editing existing footage or embedding a video in a page is outside this route.",
118
+ "lit-typographic-motion": "Select for a new film or video. Write the treatment first, then render on the stage path (a captured page) or the type path (words alone), gate it and look at the frames; a README cover loop takes cover mode. Editing existing footage or embedding a video in a page is outside this route.",
117
119
  "lit-docx": "Select for a Word report, proposal, or document, including a bounded lit report request. Keep editable Markdown and DOCX together; select publisher profiles when specified and inspect rendered pages.",
118
120
  "readme-studio": "Select by native skill name for factual README writing and local cover composition. Inspect actual session image capability; never infer generation support or authorize publication from selection.",
119
121
  "frontend-ui-ux": "Trigger the moment interface work is described, not after it starts — \"design a settings page\", \"redesign the dashboard\", \"build a login screen\", or any making verb aimed at a UI, screen, layout, component, or stylesheet — so compact direction and existing tokens shape the authorized build. Evolve the contract with implementation and inspect renders; review and plan intent remains read-only. Also on the exact `frontend-ui-ux` id. The SKILL.md reference router names which of the sixteen shipped reference documents answers which question.",
@@ -1,3 +1,5 @@
1
+ /** A venv keeps its interpreter in `Scripts` on Windows and in `bin` elsewhere. */
2
+ export declare const venvPythonMarker: (platform?: NodeJS.Platform) => string;
1
3
  export declare function inspectOfficeDependencies(): {
2
4
  pptx: {
3
5
  nodeReady: boolean;
@@ -3,21 +3,25 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { findCommandOnPath } from "./host-detect.js";
6
7
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
7
8
  const cacheRoot = path.join(process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), ".cache"), "litopencode", "office");
8
9
  function cached(label, lock, marker) {
9
10
  const digest = createHash("sha256").update(readFileSync(path.join(packageRoot, lock))).digest("hex").slice(0, 16);
10
11
  return existsSync(path.join(cacheRoot, `${label}-${digest}`, marker));
11
12
  }
13
+ /** A venv keeps its interpreter in `Scripts` on Windows and in `bin` elsewhere. */
14
+ export const venvPythonMarker = (platform = process.platform) => (platform === "win32" ? "Scripts/python.exe" : "bin/python");
12
15
  export function inspectOfficeDependencies() {
13
- const onPath = (name) => (process.env.PATH ?? "").split(path.delimiter).some((dir) => existsSync(path.join(dir, name)));
16
+ const onPath = (name) => findCommandOnPath(name) !== undefined;
17
+ const python = venvPythonMarker();
14
18
  return {
15
19
  pptx: {
16
20
  nodeReady: cached("node", "skills/lit-pptx/package-lock.json", "node_modules/pptxgenjs"),
17
- pythonReady: cached("python-pptx", "skills/lit-pptx/requirements.lock", "bin/python")
21
+ pythonReady: cached("python-pptx", "skills/lit-pptx/requirements.lock", python)
18
22
  },
19
23
  docx: {
20
- pythonReady: cached("python-docx", "skills/lit-docx/requirements.lock", "bin/python")
24
+ pythonReady: cached("python-docx", "skills/lit-docx/requirements.lock", python)
21
25
  },
22
26
  optional: { pandoc: onPath("pandoc"), xelatex: onPath("xelatex"), soffice: onPath("soffice") }
23
27
  };
@@ -15,7 +15,7 @@ const optionalModules = Object.freeze([
15
15
  ]);
16
16
  function selectedInterpreter() {
17
17
  const explicit = process.env.LITOPENCODE_SCIENTIFIC_PYTHON?.trim();
18
- return explicit && explicit.length > 0 ? explicit : "python3";
18
+ return explicit && explicit.length > 0 ? explicit : process.platform === "win32" ? "python" : "python3";
19
19
  }
20
20
  function unavailableReport(command) {
21
21
  return {
@@ -37,8 +37,6 @@ export declare function applyTuiWrites(writes: readonly PlannedWrite[], options?
37
37
  }): Promise<void>;
38
38
  /** Backs up the TUI files a plan is about to change, for writers outside LitOpenCode such as `opencode plugin`. */
39
39
  export declare function backupTuiFiles(plan: TuiInstallPlan): Promise<void>;
40
- /** Removes LitOpenCode's own TUI entries, and only those, from tui.json and tui.jsonc. */
41
- export declare function removeTuiPlugin(root: string, dryRun: boolean): Promise<TuiRemovalReport>;
42
40
  export type TuiInspection = {
43
41
  readonly path: string;
44
42
  readonly expected: string;
@@ -49,4 +47,31 @@ export type TuiInspection = {
49
47
  readonly error?: string;
50
48
  };
51
49
  export declare function inspectTuiPlugin(root: string, expected: string): Promise<TuiInspection>;
50
+ export declare const cliConfigFile = "cli.json";
51
+ export type CliJsonCleanupPlan = {
52
+ readonly path: string;
53
+ readonly removed: number;
54
+ readonly write?: PlannedWrite;
55
+ readonly blocked?: string;
56
+ };
57
+ /** Plans the removal of LitOpenCode's own entries from the `plugins` list of cli.json, without writing. */
58
+ export declare function planCliJsonCleanup(root: string): Promise<CliJsonCleanupPlan>;
59
+ /** Writes a cli.json cleanup plan, backing the file up first. Returns the backup path. */
60
+ export declare function applyCliJsonCleanup(plan: CliJsonCleanupPlan): Promise<string | undefined>;
61
+ /** The TUI part of an install that targets OpenCode 2 only: nothing to write in tui.json. */
62
+ export declare function skippedTuiPlan(root: string): TuiInstallPlan;
63
+ /**
64
+ * `tui remove`: takes LitOpenCode's own entries out of tui.json/tui.jsonc and, unless only OpenCode 1 is
65
+ * targeted, out of cli.json, each with a backup. Both files are read and planned first, so a malformed or
66
+ * comment-losing file refuses before anything is written. A dry run only counts.
67
+ */
68
+ export declare function removeTuiEntries(root: string, dryRun: boolean, options: {
69
+ readonly cliJson: boolean;
70
+ }): Promise<TuiRemovalReport & {
71
+ readonly cliJson?: {
72
+ readonly path: string;
73
+ readonly removed: number;
74
+ readonly backup?: string;
75
+ };
76
+ }>;
52
77
  export {};
@@ -132,8 +132,8 @@ export async function backupTuiFiles(plan) {
132
132
  for (const write of plan.writes)
133
133
  await backup(write);
134
134
  }
135
- /** Removes LitOpenCode's own TUI entries, and only those, from tui.json and tui.jsonc. */
136
- export async function removeTuiPlugin(root, dryRun) {
135
+ /** Plans the removal of LitOpenCode's own TUI entries, and only those, from tui.json and tui.jsonc, without writing. */
136
+ async function planTuiRemoval(root) {
137
137
  const files = await Promise.all(tuiConfigCandidates(root).map(readTuiFile));
138
138
  const writes = [];
139
139
  for (const file of files) {
@@ -146,6 +146,9 @@ export async function removeTuiPlugin(root, dryRun) {
146
146
  const kept = plugins.filter((_value, index) => !owned.includes(index));
147
147
  writes.push({ write: render(file, { ...file.config, plugin: kept }), removed: owned.length });
148
148
  }
149
+ return writes;
150
+ }
151
+ async function applyTuiRemoval(writes, dryRun) {
149
152
  const reports = [];
150
153
  for (const { write, removed } of writes) {
151
154
  if (dryRun) {
@@ -196,3 +199,75 @@ export async function inspectTuiPlugin(root, expected) {
196
199
  ok: present && entries === 1
197
200
  };
198
201
  }
202
+ // OpenCode 2 keeps its terminal settings in one global cli.json, and its first TUI start copies the
203
+ // tui.json plugin list there. A LitOpenCode entry in cli.json is loaded after the TUI the server plugin
204
+ // brings and wins by id, so a stale pin would shadow the current TUI. OpenCode 2 loads LitOpenCode's TUI
205
+ // from the server plugin on its own; the installer and `tui remove` therefore take LitOpenCode out of
206
+ // cli.json, keeping every other entry, the comments and a backup.
207
+ export const cliConfigFile = "cli.json";
208
+ function cliEntryId(value) {
209
+ if (typeof value === "string")
210
+ return value;
211
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
212
+ const pkg = value.package;
213
+ return typeof pkg === "string" ? pkg : "";
214
+ }
215
+ return "";
216
+ }
217
+ /** Plans the removal of LitOpenCode's own entries from the `plugins` list of cli.json, without writing. */
218
+ export async function planCliJsonCleanup(root) {
219
+ const file = path.join(path.resolve(root), cliConfigFile);
220
+ const current = await readTuiFile(file);
221
+ const plugins = Array.isArray(current.config?.plugins) ? current.config.plugins : [];
222
+ const kept = [];
223
+ for (const value of plugins) {
224
+ const id = cliEntryId(value);
225
+ if (id !== "" && (isLitOpenCodeEntry(id) || (await isLitOpenCodePathEntry(root, id))))
226
+ continue;
227
+ kept.push(value);
228
+ }
229
+ const removed = plugins.length - kept.length;
230
+ if (removed === 0 || current.config === null)
231
+ return { path: file, removed: 0 };
232
+ try {
233
+ return { path: file, removed, write: render(current, { ...current.config, plugins: kept }) };
234
+ }
235
+ catch (error) {
236
+ if (!(error instanceof OpenCodeConfigCommentError))
237
+ throw error;
238
+ return { path: file, removed, blocked: error.message };
239
+ }
240
+ }
241
+ /** Writes a cli.json cleanup plan, backing the file up first. Returns the backup path. */
242
+ export async function applyCliJsonCleanup(plan) {
243
+ if (plan.write === undefined)
244
+ return undefined;
245
+ const backupPath = await backup(plan.write);
246
+ await writeTextAtomically(plan.write.path, plan.write.text);
247
+ return backupPath;
248
+ }
249
+ /** The TUI part of an install that targets OpenCode 2 only: nothing to write in tui.json. */
250
+ export function skippedTuiPlan(root) {
251
+ return {
252
+ report: { path: tuiConfigCandidates(root)[0], alreadyPresent: false, changed: false, add: [], currentCount: 0, resultCount: 0, cleaned: [] },
253
+ writes: []
254
+ };
255
+ }
256
+ /**
257
+ * `tui remove`: takes LitOpenCode's own entries out of tui.json/tui.jsonc and, unless only OpenCode 1 is
258
+ * targeted, out of cli.json, each with a backup. Both files are read and planned first, so a malformed or
259
+ * comment-losing file refuses before anything is written. A dry run only counts.
260
+ */
261
+ export async function removeTuiEntries(root, dryRun, options) {
262
+ const tuiPlan = await planTuiRemoval(root);
263
+ const cliPlan = options.cliJson ? await planCliJsonCleanup(root) : undefined;
264
+ if (cliPlan?.blocked !== undefined)
265
+ throw new OpenCodeConfigCommentError(cliPlan.blocked);
266
+ const tui = await applyTuiRemoval(tuiPlan, dryRun);
267
+ if (cliPlan === undefined)
268
+ return tui;
269
+ if (dryRun || cliPlan.write === undefined)
270
+ return { ...tui, cliJson: { path: cliPlan.path, removed: cliPlan.removed } };
271
+ const backupPath = await applyCliJsonCleanup(cliPlan);
272
+ return { ...tui, cliJson: { path: cliPlan.path, removed: cliPlan.removed, ...(backupPath === undefined ? {} : { backup: backupPath }) } };
273
+ }
@@ -10,6 +10,7 @@ export type PackageMetadata = {
10
10
  readonly version: string;
11
11
  readonly packageRoot: string;
12
12
  };
13
+ import type { HostBinary, HostChoice, HostDetection, HostMode } from "./host-detect.ts";
13
14
  export type ParsedArgs = {
14
15
  readonly command?: string;
15
16
  readonly root: string;
@@ -27,6 +28,7 @@ export type ParsedArgs = {
27
28
  readonly outputStylePrompt: PermissionPromptMode;
28
29
  readonly loop: LoopArgs;
29
30
  readonly tuiAction?: string;
31
+ readonly host: HostChoice;
30
32
  };
31
33
  export type LoopArgs = {
32
34
  readonly projectRoot: string;
@@ -190,4 +192,19 @@ export type InstallReport = {
190
192
  readonly routes?: InstallRouteReport;
191
193
  readonly capabilities: OpenCodeHostCapabilities;
192
194
  readonly package: Pick<PackageMetadata, "name" | "version">;
195
+ readonly host?: {
196
+ readonly mode: HostMode;
197
+ readonly binaries: readonly HostBinary[];
198
+ readonly unplaced?: HostDetection["unplaced"];
199
+ readonly evidence: {
200
+ readonly cliJson: boolean;
201
+ readonly tuiJson: boolean;
202
+ };
203
+ readonly override?: string;
204
+ };
205
+ readonly cliJson?: {
206
+ readonly path: string;
207
+ readonly removed: number;
208
+ readonly changed: boolean;
209
+ };
193
210
  };
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import { defaultOpenAiRoutes, legacyFastAliasHelp, modelEffortHelp, openaiModelH
10
10
  import { fetchPublicSource } from "./lit-fetch.js";
11
11
  import { readPackageMetadata } from "./cli/json.js";
12
12
  import { runMotionRuntime } from "./cli/motion-runtime.js";
13
- import { removeTuiPlugin } from "./cli/tui-config.js";
13
+ import { removeTuiEntries } from "./cli/tui-config.js";
14
14
  import path from "node:path";
15
15
  import { pathToFileURL } from "node:url";
16
16
  function helpText() {
@@ -86,7 +86,8 @@ function helpText() {
86
86
  " ships a per-channel capture playbook, and keeps blocked codes separate from fail codes.",
87
87
  " lit-diagram-drawer creates conceptual, editorial, and technical diagrams with packaged type guides,",
88
88
  " local verifiers, bounded importers, and exports that never install tools.",
89
- " lit-typographic-motion directs films from a treatment: a captured stage page or the type engine.",
89
+ " lit-typographic-motion directs films from a treatment: a captured stage page or the type engine,",
90
+ " and seamless README cover loops gated on the decoded WebP.",
90
91
  " Typographic-motion engine adapted from mexicat/pdoom-video (MIT, Giacomo Magnanini), commit `ca251e3`.",
91
92
  " Each skill resolves its own helpers inside its own installed tree; these skills add no command,",
92
93
  " MCP, agent, authentication, or write authority.",
@@ -138,9 +139,11 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
138
139
  if (isLoopCommand(parsed.command))
139
140
  return await runLoopCommand(parsed.command, parsed.loop, parsed.dryRun);
140
141
  if (parsed.command === "doctor")
141
- return await doctor(parsed.root);
142
- if (parsed.command === "tui")
143
- return { exitCode: 0, stdout: JSON.stringify(await removeTuiPlugin(parsed.root, parsed.dryRun), null, 2) };
142
+ return await doctor(parsed.root, parsed.host);
143
+ if (parsed.command === "tui") {
144
+ const removed = await removeTuiEntries(parsed.root, parsed.dryRun, { cliJson: parsed.host !== "v1" });
145
+ return { exitCode: 0, stdout: JSON.stringify(removed, null, 2) };
146
+ }
144
147
  if (parsed.command === "fetch-public") {
145
148
  if (!parsed.fetchPublicUrl)
146
149
  return { exitCode: 2, stderr: "fetch-public requires a URL" };
@@ -154,7 +157,7 @@ export async function runCli(argv = process.argv.slice(2), options = {}) {
154
157
  return { exitCode: output.ok ? 0 : 1, stdout: renderFetchPublicText(output) };
155
158
  }
156
159
  if (parsed.command === "install") {
157
- return await install(parsed.root, parsed.dryRun, parsed.modelSelection, parsed.modelPrompt, parsed.permissionMode, parsed.permissionModeExplicit, parsed.permissionPrompt, options.installProgress, parsed.outputStylePrompt);
160
+ return await install(parsed.root, parsed.dryRun, parsed.modelSelection, parsed.modelPrompt, parsed.permissionMode, parsed.permissionModeExplicit, parsed.permissionPrompt, options.installProgress, parsed.outputStylePrompt, parsed.host);
158
161
  }
159
162
  return { exitCode: 2, stderr: `Unknown command: ${parsed.command}` };
160
163
  }