@litfamily/litopencode 1.0.17 → 1.0.19

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 (71) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README-Ko-KR.md +56 -86
  3. package/README.md +57 -86
  4. package/dist/activation-routing.js +3 -1
  5. package/dist/cli/args.js +5 -1
  6. package/dist/cli/auto-update.js +1 -1
  7. package/dist/cli/doctor.js +7 -5
  8. package/dist/cli/host-limits.js +2 -5
  9. package/dist/cli/install-config.d.ts +4 -0
  10. package/dist/cli/install-config.js +27 -1
  11. package/dist/cli/install-report.js +4 -1
  12. package/dist/cli/install.js +89 -28
  13. package/dist/cli/json.d.ts +1 -0
  14. package/dist/cli/json.js +10 -4
  15. package/dist/cli/jsonc.d.ts +17 -0
  16. package/dist/cli/jsonc.js +174 -0
  17. package/dist/cli/loop.d.ts +1 -1
  18. package/dist/cli/loop.js +81 -1
  19. package/dist/cli/motion-runtime.d.ts +13 -1
  20. package/dist/cli/motion-runtime.js +89 -2
  21. package/dist/cli/plugin-mutation.js +11 -6
  22. package/dist/cli/tui-config.d.ts +52 -0
  23. package/dist/cli/tui-config.js +198 -0
  24. package/dist/cli/types.d.ts +4 -0
  25. package/dist/cli.js +8 -3
  26. package/dist/deliverable-hedge-guard.js +5 -3
  27. package/dist/hooks.js +2 -1
  28. package/dist/tui/mission-band.d.ts +29 -0
  29. package/dist/tui/mission-band.js +116 -0
  30. package/dist/tui/mission-model.d.ts +139 -0
  31. package/dist/tui/mission-model.js +318 -0
  32. package/dist/tui/mission-motion.d.ts +66 -0
  33. package/dist/tui/mission-motion.js +147 -0
  34. package/dist/tui/mission-source.d.ts +56 -0
  35. package/dist/tui/mission-source.js +88 -0
  36. package/dist/tui/mission-text.d.ts +103 -0
  37. package/dist/tui/mission-text.js +613 -0
  38. package/dist/tui.d.ts +6 -0
  39. package/dist/tui.js +317 -0
  40. package/docs/assets/readme/badge-version.svg +1 -1
  41. package/docs/reference-Ko-KR.md +72 -1
  42. package/docs/reference.md +82 -1
  43. package/package.json +21 -2
  44. package/skills/lit-docx/scripts/docx_design.py +1 -1
  45. package/skills/lit-docx/templates/tonalities/brief.yaml +0 -1
  46. package/skills/lit-docx/templates/tonalities/journal.yaml +0 -1
  47. package/skills/lit-docx/templates/tonalities/manual.yaml +0 -1
  48. package/skills/lit-docx/templates/tonalities/memo.yaml +0 -1
  49. package/skills/lit-docx/templates/tonalities/proposal.yaml +0 -1
  50. package/skills/lit-docx/templates/tonalities/report.yaml +0 -1
  51. package/skills/lit-pptx/references/tonalities/atlas.md +3 -4
  52. package/skills/lit-pptx/references/tonalities/chalk.md +2 -2
  53. package/skills/lit-pptx/references/tonalities/gazette.md +3 -3
  54. package/skills/lit-pptx/references/tonalities/ledger.md +6 -7
  55. package/skills/lit-pptx/references/tonalities/night.md +3 -3
  56. package/skills/lit-pptx/references/tonalities/paper.md +4 -5
  57. package/skills/lit-pptx/references/tonalities/signal.md +3 -4
  58. package/skills/lit-pptx/references/tonalities/studio.md +3 -3
  59. package/skills/lit-pptx/scripts/lib/template-registry.js +1 -1
  60. package/skills/lit-pptx/scripts/validate_pptx.py +10 -4
  61. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +0 -12
  62. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +0 -12
  63. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +0 -12
  64. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +2 -14
  65. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +0 -12
  66. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +0 -12
  67. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +0 -12
  68. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +0 -12
  69. package/skills/managed-skill-manifest.json +25 -25
  70. package/tools/check-pack-payload.mjs +20 -5
  71. package/tools/version-manifests.json +18 -6
@@ -6,9 +6,9 @@ import { promisify } from "node:util";
6
6
  import { createRuntimePaths } from "../state.js";
7
7
  import { describeCommandAliasMutation, ensureCommandAliases, inspectCommandAliases } from "./command-aliases.js";
8
8
  import { openCodeHostCapabilities } from "./host-capabilities.js";
9
- import { describeHostLimitMutation, ensureHostLimits } from "./host-limits.js";
10
- import { assertExistingModelRoutesSafe, describeInstallRoutes, describeLitConfigMutation, ensureLitConfig, readExistingOutputStyle, readInstallModelClassification, readInstallModelSelection, writeTextAtomically } from "./install-config.js";
11
- import { readJsonObjectIfPresent, readPackageMetadata } from "./json.js";
9
+ import { applyHostLimits, describeHostLimitMutation, ensureHostLimits } from "./host-limits.js";
10
+ import { assertExistingModelRoutesSafe, describeInstallRoutes, describeLitConfigMutation, ensureLitConfig, OpenCodeConfigCommentError, readExistingOutputStyle, readInstallModelClassification, readInstallModelSelection, renderOpenCodeConfig, writeOpenCodeConfig } from "./install-config.js";
11
+ import { readJsonObjectIfPresent, readPackageMetadata, readTextIfPresent } from "./json.js";
12
12
  import { renderInstallReport, wrapReceiptLine } from "./install-report.js";
13
13
  import { renderLitOpenCodeWordmark } from "./install-tui.js";
14
14
  import { promptModelSelection, promptOutputStyle, promptPermissionMode } from "./model-routing.js";
@@ -16,6 +16,7 @@ import { litOpenCodeConfigFileForOpenCodeRoot } from "../state.js";
16
16
  import { describeNativeSkillMutation, ensureNativeSkills, inspectNativeSkills } from "./native-skills.js";
17
17
  import { applyPluginMutation, describePluginMutation, isLitOpenCodeEntry } from "./plugin-mutation.js";
18
18
  import { prewarmMotionRuntime } from "./motion-runtime.js";
19
+ import { applyTuiWrites, backupTuiFiles, inspectTuiPlugin, planTuiInstall } from "./tui-config.js";
19
20
  const execFileAsync = promisify(execFile);
20
21
  function withProgress(progress, label, task) {
21
22
  return progress === undefined ? task() : progress.run(label, task);
@@ -118,29 +119,72 @@ async function installWithOpenCode(target) {
118
119
  throw new Error("OpenCode plugin install failed for " + target.label + (detail ? ":\n" + detail : ""));
119
120
  }
120
121
  }
122
+ async function normalizedPluginEntries(root, target, pluginValue) {
123
+ const nextPlugin = [];
124
+ let registered = false;
125
+ for (const entry of pluginValue) {
126
+ const id = Array.isArray(entry) ? entry[0] : entry;
127
+ if (id === target.value || isLitOpenCodeEntry(entry) || (await isLitOpenCodePathEntry(root, id))) {
128
+ if (!registered)
129
+ nextPlugin.push(Array.isArray(entry) ? [target.value, ...entry.slice(1)] : target.value);
130
+ registered = true;
131
+ }
132
+ else {
133
+ nextPlugin.push(entry);
134
+ }
135
+ }
136
+ return nextPlugin;
137
+ }
121
138
  async function normalizeOpenCodeEntries(root, target) {
122
139
  for (const configFile of openCodeConfigCandidates(root)) {
123
140
  const config = await readJsonObjectIfPresent(configFile);
124
141
  const pluginValue = config?.plugin;
125
142
  if (!Array.isArray(pluginValue))
126
143
  continue;
127
- const nextPlugin = [];
128
- let registered = false;
129
- for (const entry of pluginValue) {
130
- const id = Array.isArray(entry) ? entry[0] : entry;
131
- if (id === target.value || isLitOpenCodeEntry(entry) || (await isLitOpenCodePathEntry(root, id))) {
132
- if (!registered)
133
- nextPlugin.push(Array.isArray(entry) ? [target.value, ...entry.slice(1)] : target.value);
134
- registered = true;
135
- }
136
- else {
137
- nextPlugin.push(entry);
138
- }
139
- }
144
+ const nextPlugin = await normalizedPluginEntries(root, target, pluginValue);
140
145
  if (JSON.stringify(nextPlugin) === JSON.stringify(pluginValue))
141
146
  continue;
142
- await writeTextAtomically(configFile, JSON.stringify({ ...config, plugin: nextPlugin }, null, 2) + "\n");
147
+ await writeOpenCodeConfig(configFile, { ...config, plugin: nextPlugin });
148
+ }
149
+ }
150
+ // Works out every OpenCode config edit the install will make before anything is written, and returns
151
+ // the refusal when one of them would delete a user comment. On the default root OpenCode registers the
152
+ // plugin itself; LitOpenCode then only cleans up stale entries and sets host limits.
153
+ async function commentLossRefusal(root, configFile, target, before, mutation, reconfigureHostLimits, useOpenCodeInstaller) {
154
+ const planned = [];
155
+ if (!useOpenCodeInstaller) {
156
+ const next = applyPluginMutation(before, mutation, target.value);
157
+ planned.push({ file: configFile, next: reconfigureHostLimits ? applyHostLimits(next) : next });
158
+ }
159
+ else {
160
+ for (const file of openCodeConfigCandidates(root)) {
161
+ const config = file === configFile ? before : await readJsonObjectIfPresent(file);
162
+ let next = config;
163
+ const pluginValue = config?.plugin;
164
+ if (config !== null && Array.isArray(pluginValue)) {
165
+ const registered = file === configFile &&
166
+ !pluginValue.some((entry) => (Array.isArray(entry) ? entry[0] : entry) === target.value)
167
+ ? [...pluginValue, target.value]
168
+ : pluginValue;
169
+ const normalized = await normalizedPluginEntries(root, target, registered);
170
+ if (JSON.stringify(normalized) !== JSON.stringify(registered))
171
+ next = { ...config, plugin: normalized };
172
+ }
173
+ planned.push({ file, next: file === configFile && reconfigureHostLimits ? applyHostLimits(next) : next });
174
+ }
175
+ }
176
+ try {
177
+ for (const { file, next } of planned) {
178
+ if (next !== null)
179
+ renderOpenCodeConfig(file, await readTextIfPresent(file), next);
180
+ }
143
181
  }
182
+ catch (error) {
183
+ if (error instanceof OpenCodeConfigCommentError)
184
+ return error.message;
185
+ throw error;
186
+ }
187
+ return undefined;
144
188
  }
145
189
  async function verifyInstalledSurfaces(root, configFile, target, metadata, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle) {
146
190
  const config = await readJsonObjectIfPresent(configFile);
@@ -151,9 +195,12 @@ async function verifyInstalledSurfaces(root, configFile, target, metadata, selec
151
195
  const litConfig = await describeLitConfigMutation(root, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle);
152
196
  const commandAliases = await inspectCommandAliases(root);
153
197
  const nativeSkills = await inspectNativeSkills(root, metadata);
198
+ const tui = await inspectTuiPlugin(root, target.value);
154
199
  const failures = [];
155
200
  if (targetCount !== 1 || staleLitEntries.length > 0)
156
201
  failures.push("plugin registration");
202
+ if (!tui.ok)
203
+ failures.push("TUI plugin registration in " + tui.path);
157
204
  if (litConfig.changed)
158
205
  failures.push("LitOpenCode routes");
159
206
  if (!commandAliases.ok)
@@ -202,12 +249,14 @@ export async function install(root, dryRun, modelSelection = undefined, modelPro
202
249
  commandAliases: await describeCommandAliasMutation(root),
203
250
  nativeSkills: await describeNativeSkillMutation(root, metadata),
204
251
  hostLimits: describeHostLimitMutation(before, selectedModel !== undefined),
252
+ tui: await planTuiInstall(root, resolved.target.value),
205
253
  routes: dryRun
206
254
  ? await describeInstallRoutes(root, before, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle)
207
255
  : undefined
208
256
  };
209
257
  });
210
- const { configFile, before, mutation, litopencodeConfig, commandAliases, nativeSkills, hostLimits, routes } = inspected;
258
+ const { configFile, before, mutation, litopencodeConfig, commandAliases, nativeSkills, hostLimits, routes, tui } = inspected;
259
+ const blocked = (await commentLossRefusal(root, configFile, resolved.target, before, mutation, hostLimits.reconfigure, resolved.useOpenCodeInstaller)) ?? tui.blocked;
211
260
  const report = {
212
261
  dryRun,
213
262
  path: configFile,
@@ -215,20 +264,28 @@ export async function install(root, dryRun, modelSelection = undefined, modelPro
215
264
  plugin: mutation.plugin,
216
265
  commandAliases,
217
266
  nativeSkills,
267
+ tui: tui.report,
218
268
  patch: mutation.patch,
219
- changed: mutation.changed || litopencodeConfig.changed || commandAliases.changed || nativeSkills.changed || hostLimits.changed,
269
+ changed: mutation.changed || litopencodeConfig.changed || commandAliases.changed || nativeSkills.changed || hostLimits.changed || tui.report.changed,
270
+ ...(blocked === undefined ? {} : { blocked }),
220
271
  routes,
221
272
  hostLimits: { path: configFile, ...hostLimits },
222
273
  capabilities: openCodeHostCapabilities(),
223
274
  package: { name: metadata.name, version: metadata.version }
224
275
  };
225
276
  if (dryRun) {
226
- return { exitCode: 0, stdout: JSON.stringify(report, null, 2) };
277
+ return {
278
+ exitCode: blocked === undefined ? 0 : 1,
279
+ stdout: JSON.stringify(report, null, 2),
280
+ ...(blocked === undefined ? {} : { stderr: "ERROR: " + blocked })
281
+ };
227
282
  }
228
283
  if (nativeSkills.collisions.length > 0) {
229
284
  throw new Error("Native skill collision: refusing to overwrite pre-existing roots without managed entrypoints: " +
230
285
  nativeSkills.collisions.join(", "));
231
286
  }
287
+ if (blocked !== undefined)
288
+ throw new OpenCodeConfigCommentError(blocked);
232
289
  await withProgress(activeProgress, "Write litopencode.json", async () => {
233
290
  await ensureLitConfig(root, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle);
234
291
  await ensureNativeSkills(root, metadata);
@@ -236,23 +293,27 @@ export async function install(root, dryRun, modelSelection = undefined, modelPro
236
293
  });
237
294
  await withProgress(activeProgress, "Register plugin", async () => {
238
295
  if (resolved.useOpenCodeInstaller) {
296
+ // The package's `./tui` export makes `opencode plugin` add the TUI entry itself, so back up
297
+ // first, then normalize whatever it wrote without a second backup.
298
+ await backupTuiFiles(tui);
239
299
  await installWithOpenCode(resolved.target);
240
300
  const installed = await readJsonObjectIfPresent(configFile);
241
301
  if (!Array.isArray(installed?.plugin) || !installed.plugin.some((entry) => (Array.isArray(entry) ? entry[0] : entry) === resolved.target.value))
242
302
  throw new Error("OpenCode did not register the requested package target");
243
303
  await normalizeOpenCodeEntries(root, resolved.target);
244
304
  await ensureHostLimits(configFile, await readJsonObjectIfPresent(configFile), hostLimits.reconfigure);
305
+ const settled = await planTuiInstall(root, resolved.target.value);
306
+ if (settled.blocked !== undefined)
307
+ throw new OpenCodeConfigCommentError(settled.blocked);
308
+ await applyTuiWrites(settled.writes, { backup: false });
245
309
  return;
246
310
  }
247
- const next = applyPluginMutation(before, mutation, resolved.target.value);
248
- if (hostLimits.reconfigure) {
249
- await ensureHostLimits(configFile, next, true);
250
- return;
251
- }
252
- if (!mutation.changed)
311
+ await applyTuiWrites(tui.writes);
312
+ const withPlugin = applyPluginMutation(before, mutation, resolved.target.value);
313
+ const next = hostLimits.reconfigure ? applyHostLimits(withPlugin) : withPlugin;
314
+ if (JSON.stringify(next) === JSON.stringify(before))
253
315
  return;
254
- await fs.mkdir(path.dirname(configFile), { recursive: true });
255
- await writeTextAtomically(configFile, JSON.stringify(next, null, 2) + "\n");
316
+ await writeOpenCodeConfig(configFile, next);
256
317
  });
257
318
  await withProgress(activeProgress, "Verify install", async () => verifyInstalledSurfaces(root, configFile, resolved.target, metadata, selectedModel, selectedPermissionMode, selectedPermissionExplicit, selectedOutputStyle));
258
319
  await activeProgress?.complete();
@@ -1,4 +1,5 @@
1
1
  import type { PackageMetadata } from "./types.ts";
2
2
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
3
3
  export declare function readPackageMetadata(): Promise<PackageMetadata>;
4
+ export declare function readTextIfPresent(filePath: string): Promise<string | null>;
4
5
  export declare function readJsonObjectIfPresent(filePath: string): Promise<Record<string, unknown> | null>;
package/dist/cli/json.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { parseJsonc } from "./jsonc.js";
4
5
  export function isRecord(value) {
5
6
  return typeof value === "object" && value !== null && !Array.isArray(value);
6
7
  }
@@ -13,17 +14,22 @@ export async function readPackageMetadata() {
13
14
  }
14
15
  return { name: parsed.name, version: parsed.version, packageRoot };
15
16
  }
16
- export async function readJsonObjectIfPresent(filePath) {
17
- let raw;
17
+ export async function readTextIfPresent(filePath) {
18
18
  try {
19
- raw = await fs.readFile(filePath, "utf8");
19
+ return await fs.readFile(filePath, "utf8");
20
20
  }
21
21
  catch (error) {
22
22
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
23
23
  return null;
24
24
  throw error;
25
25
  }
26
- const parsed = JSON.parse(raw);
26
+ }
27
+ // OpenCode configs are JSONC, so every reader here accepts comments and trailing commas.
28
+ export async function readJsonObjectIfPresent(filePath) {
29
+ const raw = await readTextIfPresent(filePath);
30
+ if (raw === null)
31
+ return null;
32
+ const parsed = parseJsonc(raw);
27
33
  if (!isRecord(parsed)) {
28
34
  throw new Error(`Malformed JSON at ${filePath}: expected an object.`);
29
35
  }
@@ -0,0 +1,17 @@
1
+ export type JsoncEdit = {
2
+ readonly ok: true;
3
+ readonly text: string;
4
+ } | {
5
+ readonly ok: false;
6
+ readonly key: string;
7
+ };
8
+ export declare function lineEnding(text: string): string;
9
+ export declare function parseJsonc(text: string): unknown;
10
+ export declare function hasJsoncComments(text: string): boolean;
11
+ export declare function formatJsoncMember(key: string, value: unknown, eol?: string): string;
12
+ /**
13
+ * Rewrites only the top-level entries of `text` whose value differs from `next`, leaving every other
14
+ * byte (comments, a leading BOM and the line ending included) in place. Refuses, naming the entry,
15
+ * when an edited value holds a comment or when an entry would have to be removed.
16
+ */
17
+ export declare function editJsoncObject(text: string, next: Record<string, unknown>): JsoncEdit;
@@ -0,0 +1,174 @@
1
+ // OpenCode reads its config as JSONC (line and block comments, trailing commas). Comments are blanked
2
+ // in place, keeping every offset, so structure found in the blanked copy addresses the original text.
3
+ function stringEnd(text, start) {
4
+ let index = start + 1;
5
+ while (index < text.length) {
6
+ if (text[index] === "\\")
7
+ index += 2;
8
+ else if (text[index] === '"')
9
+ return index + 1;
10
+ else
11
+ index += 1;
12
+ }
13
+ return text.length;
14
+ }
15
+ function blankComments(text) {
16
+ let out = "";
17
+ let index = 0;
18
+ while (index < text.length) {
19
+ if (text[index] === '"') {
20
+ const end = stringEnd(text, index);
21
+ out += text.slice(index, end);
22
+ index = end;
23
+ }
24
+ else if (text.startsWith("//", index)) {
25
+ const newline = text.indexOf("\n", index);
26
+ const end = newline === -1 ? text.length : newline;
27
+ out += " ".repeat(end - index);
28
+ index = end;
29
+ }
30
+ else if (text.startsWith("/*", index)) {
31
+ const close = text.indexOf("*/", index + 2);
32
+ if (close === -1) {
33
+ throw new SyntaxError(`Unterminated block comment in JSONC starting at line ${text.slice(0, index).split("\n").length}`);
34
+ }
35
+ const end = close + 2;
36
+ out += text.slice(index, end).replace(/[^\r\n]/gu, " ");
37
+ index = end;
38
+ }
39
+ else {
40
+ out += text[index];
41
+ index += 1;
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+ function blankTrailingCommas(text) {
47
+ let out = "";
48
+ let index = 0;
49
+ while (index < text.length) {
50
+ if (text[index] === '"') {
51
+ const end = stringEnd(text, index);
52
+ out += text.slice(index, end);
53
+ index = end;
54
+ continue;
55
+ }
56
+ if (text[index] === "," && /^\s*[\]}]/u.test(text.slice(index + 1)))
57
+ out += " ";
58
+ else
59
+ out += text[index];
60
+ index += 1;
61
+ }
62
+ return out;
63
+ }
64
+ const bom = "\uFEFF";
65
+ function withoutBom(text) {
66
+ return text.startsWith(bom) ? text.slice(1) : text;
67
+ }
68
+ export function lineEnding(text) {
69
+ return text.includes("\r\n") ? "\r\n" : "\n";
70
+ }
71
+ export function parseJsonc(text) {
72
+ return JSON.parse(blankTrailingCommas(blankComments(withoutBom(text))));
73
+ }
74
+ export function hasJsoncComments(text) {
75
+ return blankComments(text) !== text;
76
+ }
77
+ function skipWhitespace(text, index) {
78
+ while (index < text.length && /\s/u.test(text[index]))
79
+ index += 1;
80
+ return index;
81
+ }
82
+ function valueEnd(text, start) {
83
+ if (text[start] === '"')
84
+ return stringEnd(text, start);
85
+ if (text[start] !== "{" && text[start] !== "[") {
86
+ let index = start;
87
+ while (index < text.length && !/[\s,\]}]/u.test(text[index]))
88
+ index += 1;
89
+ return index;
90
+ }
91
+ let depth = 0;
92
+ let index = start;
93
+ while (index < text.length) {
94
+ const char = text[index];
95
+ if (char === '"') {
96
+ index = stringEnd(text, index);
97
+ continue;
98
+ }
99
+ if (char === "{" || char === "[")
100
+ depth += 1;
101
+ if (char === "}" || char === "]")
102
+ depth -= 1;
103
+ index += 1;
104
+ if (depth === 0)
105
+ return index;
106
+ }
107
+ return index;
108
+ }
109
+ // `blanked` is text that already parsed as a JSONC object, with its comments blanked.
110
+ function topLevelMembers(blanked) {
111
+ const members = [];
112
+ let index = skipWhitespace(blanked, 0) + 1;
113
+ for (;;) {
114
+ index = skipWhitespace(blanked, index);
115
+ if (blanked[index] === ",")
116
+ index = skipWhitespace(blanked, index + 1);
117
+ if (blanked[index] === "}" || index >= blanked.length)
118
+ return { close: index, members };
119
+ const keyEnd = stringEnd(blanked, index);
120
+ const key = JSON.parse(blanked.slice(index, keyEnd));
121
+ const valueStart = skipWhitespace(blanked, skipWhitespace(blanked, keyEnd) + 1);
122
+ const end = valueEnd(blanked, valueStart);
123
+ members.push({ key, valueStart, valueEnd: end });
124
+ index = end;
125
+ }
126
+ }
127
+ function formatValue(value, eol = "\n") {
128
+ return JSON.stringify(value, null, 2).replace(/\n/gu, `${eol} `);
129
+ }
130
+ export function formatJsoncMember(key, value, eol = "\n") {
131
+ return `${JSON.stringify(key)}: ${formatValue(value, eol)}`;
132
+ }
133
+ /**
134
+ * Rewrites only the top-level entries of `text` whose value differs from `next`, leaving every other
135
+ * byte (comments, a leading BOM and the line ending included) in place. Refuses, naming the entry,
136
+ * when an edited value holds a comment or when an entry would have to be removed.
137
+ */
138
+ export function editJsoncObject(text, next) {
139
+ if (text.startsWith(bom)) {
140
+ const edit = editJsoncObject(text.slice(1), next);
141
+ return edit.ok ? { ok: true, text: bom + edit.text } : edit;
142
+ }
143
+ const eol = lineEnding(text);
144
+ const current = parseJsonc(text);
145
+ const removed = Object.keys(current).find((key) => !(key in next));
146
+ if (removed !== undefined)
147
+ return { ok: false, key: removed };
148
+ let edited = text;
149
+ for (const [key, value] of Object.entries(next)) {
150
+ if (JSON.stringify(current[key]) === JSON.stringify(value))
151
+ continue;
152
+ const blanked = blankComments(edited);
153
+ const { close, members } = topLevelMembers(blanked);
154
+ const member = [...members].reverse().find((candidate) => candidate.key === key);
155
+ if (member !== undefined) {
156
+ if (blanked.slice(member.valueStart, member.valueEnd) !== edited.slice(member.valueStart, member.valueEnd)) {
157
+ return { ok: false, key };
158
+ }
159
+ edited = edited.slice(0, member.valueStart) + formatValue(value, eol) + edited.slice(member.valueEnd);
160
+ continue;
161
+ }
162
+ let lineStart = close;
163
+ while (lineStart > 0 && (edited[lineStart - 1] === " " || edited[lineStart - 1] === "\t"))
164
+ lineStart -= 1;
165
+ const ownLine = lineStart > 0 && edited[lineStart - 1] === "\n";
166
+ const at = ownLine ? lineStart : close;
167
+ edited = edited.slice(0, at) + (ownLine ? "" : eol) + " " + formatJsoncMember(key, value, eol) + eol + edited.slice(at);
168
+ const last = members.at(-1);
169
+ if (last !== undefined && !blanked.slice(last.valueEnd, close).includes(",")) {
170
+ edited = edited.slice(0, last.valueEnd) + "," + edited.slice(last.valueEnd);
171
+ }
172
+ }
173
+ return { ok: true, text: edited };
174
+ }
@@ -1,3 +1,3 @@
1
1
  import type { CliResult, LoopArgs } from "./types.ts";
2
2
  import type { LoopCommand } from "./args.ts";
3
- export declare function runLoopCommand(command: LoopCommand, loop: LoopArgs): Promise<CliResult>;
3
+ export declare function runLoopCommand(command: LoopCommand, loop: LoopArgs, dryRun?: boolean): Promise<CliResult>;
package/dist/cli/loop.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { completeGoals, createGoals, EvidenceLedgerError, evaluateGoalGate, readEvidenceLedgerStatus, recordCheckpoint, recordEvidence, recordReviewBlocker, recordSteering, resolveReviewBlocker } from "../ledger.js";
2
+ import { createHash } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
2
6
  import { createRuntimePaths } from "../state.js";
3
7
  // Exit codes are distinct so a caller can tell a refusal from a usage error: 2 is a malformed
4
8
  // invocation, 3 is a deliberate refusal, 1 is a gate that did not pass.
@@ -38,7 +42,83 @@ function describeGoal(goal) {
38
42
  lines.push(` - ${reason}`);
39
43
  return lines.join("\n");
40
44
  }
41
- export async function runLoopCommand(command, loop) {
45
+ async function snapshotTree(dir) {
46
+ const files = new Map();
47
+ const walk = async (current) => {
48
+ let entries;
49
+ try {
50
+ entries = await fs.readdir(current, { withFileTypes: true });
51
+ }
52
+ catch (error) {
53
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
54
+ return;
55
+ throw error;
56
+ }
57
+ for (const entry of entries) {
58
+ const full = path.join(current, entry.name);
59
+ if (entry.isDirectory())
60
+ await walk(full);
61
+ else
62
+ files.set(path.relative(dir, full), createHash("sha256").update(await fs.readFile(full)).digest("hex"));
63
+ }
64
+ };
65
+ await walk(dir);
66
+ return files;
67
+ }
68
+ // A dry run executes the verb against a private copy of the project's .litopencode state, so the
69
+ // same validation, refusals and gates apply, then reports which files the real run would change.
70
+ // The copy dereferences symlinks, so a write in the copy can never reach a linked target.
71
+ async function dryRunLoopCommand(command, loop) {
72
+ const realRuntimeDir = createRuntimePaths(loop.projectRoot).runtimeDir;
73
+ const shadowRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "litopencode-dry-run-shadow-")));
74
+ try {
75
+ const shadowRuntimeDir = createRuntimePaths(shadowRoot).runtimeDir;
76
+ try {
77
+ await fs.cp(realRuntimeDir, shadowRuntimeDir, { recursive: true, dereference: true });
78
+ }
79
+ catch (error) {
80
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
81
+ throw error;
82
+ }
83
+ const before = await snapshotTree(shadowRuntimeDir);
84
+ const result = await runLoopCommandAt(command, { ...loop, projectRoot: shadowRoot });
85
+ const after = await snapshotTree(shadowRuntimeDir);
86
+ const writes = [];
87
+ for (const [file, hash] of after) {
88
+ const previous = before.get(file);
89
+ if (previous === undefined)
90
+ writes.push({ path: path.join(realRuntimeDir, file), change: "create" });
91
+ else if (previous !== hash)
92
+ writes.push({ path: path.join(realRuntimeDir, file), change: "update" });
93
+ }
94
+ for (const file of before.keys())
95
+ if (!after.has(file))
96
+ writes.push({ path: path.join(realRuntimeDir, file), change: "delete" });
97
+ writes.sort((left, right) => left.path.localeCompare(right.path));
98
+ const realProjectRoot = path.resolve(loop.projectRoot);
99
+ const relabel = (text) => text?.split(shadowRoot).join(realProjectRoot);
100
+ if (loop.json && result.stdout !== undefined) {
101
+ const payload = { dryRun: true, command, wouldWrite: writes, result: JSON.parse(relabel(result.stdout) ?? "null") };
102
+ return { exitCode: result.exitCode, stdout: JSON.stringify(payload, null, 2), stderr: relabel(result.stderr) };
103
+ }
104
+ const plan = writes.length === 0
105
+ ? `DRY RUN: nothing was written. ${command} would change no files.`
106
+ : [`DRY RUN: nothing was written. ${command} would change:`, ...writes.map((entry) => ` ${entry.change} ${entry.path}`)].join("\n");
107
+ const preview = relabel(result.stdout);
108
+ const stdout = preview === undefined || preview === "" ? plan : `${plan}\nThe real run would report:\n${preview}`;
109
+ return { exitCode: result.exitCode, stdout, stderr: relabel(result.stderr) };
110
+ }
111
+ finally {
112
+ await fs.rm(shadowRoot, { recursive: true, force: true });
113
+ }
114
+ }
115
+ // status only reads, so --dry-run changes nothing about it; every other verb writes the ledger.
116
+ export async function runLoopCommand(command, loop, dryRun = false) {
117
+ if (dryRun && command !== "status")
118
+ return await dryRunLoopCommand(command, loop);
119
+ return await runLoopCommandAt(command, loop);
120
+ }
121
+ async function runLoopCommandAt(command, loop) {
42
122
  const root = loop.projectRoot;
43
123
  try {
44
124
  if (command === "create-goals") {
@@ -1,6 +1,18 @@
1
1
  import type { CliResult } from "./types.ts";
2
2
  export declare const motionInstallCommand = "litopencode motion-runtime install";
3
- export declare function runMotionRuntime(packageRoot: string, args: readonly string[]): CliResult;
3
+ export type MotionRuntimeArgs = {
4
+ readonly command: "help";
5
+ } | {
6
+ readonly command: "install";
7
+ readonly audio: boolean;
8
+ readonly wordTiming: boolean;
9
+ readonly dryRun: boolean;
10
+ } | {
11
+ readonly command: "status";
12
+ readonly json: boolean;
13
+ };
14
+ export declare function parseMotionRuntimeArgs(args: readonly string[]): MotionRuntimeArgs;
15
+ export declare function runMotionRuntime(packageRoot: string, args: readonly string[]): Promise<CliResult>;
4
16
  export declare function motionPrewarmSkipped(env?: NodeJS.ProcessEnv): string | undefined;
5
17
  export declare function prewarmMotionRuntime(packageRoot: string, env?: NodeJS.ProcessEnv): string;
6
18
  export declare function probeMotionRuntime(packageRoot: string): unknown;
@@ -1,5 +1,14 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
1
9
  import { spawnSync } from "node:child_process";
2
10
  import path from "node:path";
11
+ import { pathToFileURL } from "node:url";
3
12
  // lit-typographic-motion's runtime lives in the managed skill (runtime.mjs, probe.mjs). The CLI verb
4
13
  // `litopencode motion-runtime install|status` runs it under this same Node binary; the installer
5
14
  // attempts the same pre-warm and reduces the outcome to one receipt line.
@@ -7,8 +16,86 @@ function motionScript(packageRoot, file) {
7
16
  return path.join(packageRoot, "skills", "lit-typographic-motion", file);
8
17
  }
9
18
  export const motionInstallCommand = "litopencode motion-runtime install";
10
- export function runMotionRuntime(packageRoot, args) {
11
- const result = spawnSync(process.execPath, [motionScript(packageRoot, "runtime.mjs"), ...args], { stdio: "inherit" });
19
+ const motionUsage = "Usage: litopencode motion-runtime install [--audio] [--word-timing] [--dry-run] | status [--json]";
20
+ // Every flag is checked before the runtime script runs, because install downloads and writes the
21
+ // cache: an unknown or misspelled flag must stop the command, never be ignored.
22
+ export function parseMotionRuntimeArgs(args) {
23
+ const [command, ...flags] = args;
24
+ const refuse = (reason) => {
25
+ throw new Error(`motion-runtime: ${reason}\n${motionUsage}`);
26
+ };
27
+ if (command === undefined || command === "help" || command === "--help" || command === "-h") {
28
+ if (flags.length > 0)
29
+ refuse(`unexpected argument after ${command ?? "motion-runtime"}: ${flags[0]}`);
30
+ return { command: "help" };
31
+ }
32
+ if (command === "install") {
33
+ let audio = false;
34
+ let wordTiming = false;
35
+ let dryRun = false;
36
+ for (const flag of flags) {
37
+ if (flag === "--audio")
38
+ audio = true;
39
+ else if (flag === "--word-timing")
40
+ wordTiming = true;
41
+ else if (flag === "--dry-run" || flag === "--dry-run=true")
42
+ dryRun = true;
43
+ else
44
+ refuse(`unknown install flag ${flag}`);
45
+ }
46
+ return { command: "install", audio, wordTiming, dryRun };
47
+ }
48
+ if (command === "status") {
49
+ let json = false;
50
+ for (const flag of flags) {
51
+ if (flag === "--json")
52
+ json = true;
53
+ else if (flag === "--dry-run" || flag.startsWith("--dry-run="))
54
+ refuse("status only reads the cache, so --dry-run does not apply");
55
+ else
56
+ refuse(`unknown status flag ${flag}`);
57
+ }
58
+ return { command: "status", json };
59
+ }
60
+ return refuse(`unknown command ${command}`);
61
+ }
62
+ // Reads the same pins and cache state the install uses and reports each step it would take.
63
+ // Nothing here downloads, creates the cache, or takes the install lock.
64
+ async function describeMotionInstall(packageRoot, options) {
65
+ const runtime = await import(__rewriteRelativeImportExtension(pathToFileURL(motionScript(packageRoot, "runtime.mjs")).href));
66
+ const paths = runtime.runtimePaths(process.env);
67
+ const state = runtime.inspectWarmState(process.env);
68
+ const lines = ["DRY RUN: nothing was downloaded or written.", `motion runtime cache: ${paths.base}`];
69
+ if (options.wordTiming) {
70
+ lines.push(runtime.describeWordTiming());
71
+ if (!runtime.inspectWordTiming().pinned) {
72
+ lines.push("would stop: no licence-verified Korean alignment model is pinned, so the real install exits 14 (BLOCKED_DEPS_NOT_PREWARMED) before downloading anything.");
73
+ return lines.join("\n");
74
+ }
75
+ }
76
+ const fonts = [...state.missingFonts, ...state.mismatchedFonts];
77
+ lines.push(fonts.length === 0 ? "fonts: all pinned fonts are present" : `would fetch and verify ${fonts.length} pinned font file(s) into ${paths.fonts}: ${fonts.join(", ")}`);
78
+ lines.push(state.deps.length === 0 ? "engine dependencies: already installed" : `would run npm ci for ${state.deps.join(", ")} into ${paths.node}`);
79
+ if (options.audio)
80
+ lines.push(state.audio.ready ? "audio venv: already ready" : `would create the pinned librosa audio venv in ${paths.audio}`);
81
+ lines.push("Run the same command without --dry-run to install.");
82
+ return lines.join("\n");
83
+ }
84
+ export async function runMotionRuntime(packageRoot, args) {
85
+ let parsed;
86
+ try {
87
+ parsed = parseMotionRuntimeArgs(args);
88
+ }
89
+ catch (error) {
90
+ return { exitCode: 2, stderr: error instanceof Error ? error.message : String(error) };
91
+ }
92
+ if (parsed.command === "install" && parsed.dryRun) {
93
+ return { exitCode: 0, stdout: await describeMotionInstall(packageRoot, parsed) };
94
+ }
95
+ const forwarded = parsed.command === "help" ? ["--help"]
96
+ : parsed.command === "status" ? ["status", ...(parsed.json ? ["--json"] : [])]
97
+ : ["install", ...(parsed.audio ? ["--audio"] : []), ...(parsed.wordTiming ? ["--word-timing"] : [])];
98
+ const result = spawnSync(process.execPath, [motionScript(packageRoot, "runtime.mjs"), ...forwarded], { stdio: "inherit" });
12
99
  return { exitCode: typeof result.status === "number" ? result.status : 1 };
13
100
  }
14
101
  // A test run never reaches the network or the real cache from the installer: node --test marks its