@slkiser/opencode-quota 3.11.1 → 4.0.0

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 (208) hide show
  1. package/README.md +103 -57
  2. package/dist/bin/opencode-quota.d.ts.map +1 -1
  3. package/dist/bin/opencode-quota.js +24 -7
  4. package/dist/bin/opencode-quota.js.map +1 -1
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/anthropic.d.ts.map +1 -1
  10. package/dist/lib/anthropic.js +106 -3
  11. package/dist/lib/anthropic.js.map +1 -1
  12. package/dist/lib/atomic-json.d.ts +1 -0
  13. package/dist/lib/atomic-json.d.ts.map +1 -1
  14. package/dist/lib/atomic-json.js +5 -2
  15. package/dist/lib/atomic-json.js.map +1 -1
  16. package/dist/lib/cli-show.d.ts.map +1 -1
  17. package/dist/lib/cli-show.js +11 -7
  18. package/dist/lib/cli-show.js.map +1 -1
  19. package/dist/lib/command-surfaces.d.ts +10 -0
  20. package/dist/lib/command-surfaces.d.ts.map +1 -0
  21. package/dist/lib/command-surfaces.js +9 -0
  22. package/dist/lib/command-surfaces.js.map +1 -0
  23. package/dist/lib/config-file-utils.d.ts +5 -0
  24. package/dist/lib/config-file-utils.d.ts.map +1 -1
  25. package/dist/lib/config-file-utils.js +20 -3
  26. package/dist/lib/config-file-utils.js.map +1 -1
  27. package/dist/lib/config.d.ts +5 -3
  28. package/dist/lib/config.d.ts.map +1 -1
  29. package/dist/lib/config.js +124 -32
  30. package/dist/lib/config.js.map +1 -1
  31. package/dist/lib/copilot.d.ts +19 -12
  32. package/dist/lib/copilot.d.ts.map +1 -1
  33. package/dist/lib/copilot.js +462 -566
  34. package/dist/lib/copilot.js.map +1 -1
  35. package/dist/lib/display-sanitize.d.ts.map +1 -1
  36. package/dist/lib/display-sanitize.js +13 -0
  37. package/dist/lib/display-sanitize.js.map +1 -1
  38. package/dist/lib/entries.d.ts +53 -4
  39. package/dist/lib/entries.d.ts.map +1 -1
  40. package/dist/lib/entries.js.map +1 -1
  41. package/dist/lib/init-installer.d.ts +11 -1
  42. package/dist/lib/init-installer.d.ts.map +1 -1
  43. package/dist/lib/init-installer.js +542 -154
  44. package/dist/lib/init-installer.js.map +1 -1
  45. package/dist/lib/openai.d.ts +9 -12
  46. package/dist/lib/openai.d.ts.map +1 -1
  47. package/dist/lib/openai.js +69 -40
  48. package/dist/lib/openai.js.map +1 -1
  49. package/dist/lib/opencode-config-editor.d.ts +53 -0
  50. package/dist/lib/opencode-config-editor.d.ts.map +1 -0
  51. package/dist/lib/opencode-config-editor.js +274 -0
  52. package/dist/lib/opencode-config-editor.js.map +1 -0
  53. package/dist/lib/opencode-config-providers.d.ts +18 -0
  54. package/dist/lib/opencode-config-providers.d.ts.map +1 -1
  55. package/dist/lib/opencode-config-providers.js +75 -4
  56. package/dist/lib/opencode-config-providers.js.map +1 -1
  57. package/dist/lib/opencode-runtime-paths.d.ts.map +1 -1
  58. package/dist/lib/opencode-runtime-paths.js +10 -3
  59. package/dist/lib/opencode-runtime-paths.js.map +1 -1
  60. package/dist/lib/opencode-storage.d.ts +10 -0
  61. package/dist/lib/opencode-storage.d.ts.map +1 -1
  62. package/dist/lib/opencode-storage.js +75 -0
  63. package/dist/lib/opencode-storage.js.map +1 -1
  64. package/dist/lib/provider-add-command.d.ts +35 -0
  65. package/dist/lib/provider-add-command.d.ts.map +1 -0
  66. package/dist/lib/provider-add-command.js +185 -0
  67. package/dist/lib/provider-add-command.js.map +1 -0
  68. package/dist/lib/provider-add.d.ts +21 -0
  69. package/dist/lib/provider-add.d.ts.map +1 -0
  70. package/dist/lib/provider-add.js +210 -0
  71. package/dist/lib/provider-add.js.map +1 -0
  72. package/dist/lib/provider-metadata.d.ts +1 -1
  73. package/dist/lib/provider-metadata.d.ts.map +1 -1
  74. package/dist/lib/provider-metadata.js +11 -5
  75. package/dist/lib/provider-metadata.js.map +1 -1
  76. package/dist/lib/quota-command-format.d.ts +2 -0
  77. package/dist/lib/quota-command-format.d.ts.map +1 -1
  78. package/dist/lib/quota-command-format.js +94 -26
  79. package/dist/lib/quota-command-format.js.map +1 -1
  80. package/dist/lib/quota-dialog-commands.d.ts.map +1 -1
  81. package/dist/lib/quota-dialog-commands.js +8 -4
  82. package/dist/lib/quota-dialog-commands.js.map +1 -1
  83. package/dist/lib/quota-export-types.d.ts +48 -27
  84. package/dist/lib/quota-export-types.d.ts.map +1 -1
  85. package/dist/lib/quota-export-types.js +0 -7
  86. package/dist/lib/quota-export-types.js.map +1 -1
  87. package/dist/lib/quota-export.d.ts.map +1 -1
  88. package/dist/lib/quota-export.js +63 -16
  89. package/dist/lib/quota-export.js.map +1 -1
  90. package/dist/lib/quota-providers-local.d.ts +55 -0
  91. package/dist/lib/quota-providers-local.d.ts.map +1 -0
  92. package/dist/lib/quota-providers-local.js +313 -0
  93. package/dist/lib/quota-providers-local.js.map +1 -0
  94. package/dist/lib/quota-providers-remote.d.ts +23 -0
  95. package/dist/lib/quota-providers-remote.d.ts.map +1 -0
  96. package/dist/lib/quota-providers-remote.js +318 -0
  97. package/dist/lib/quota-providers-remote.js.map +1 -0
  98. package/dist/lib/quota-providers.d.ts +77 -0
  99. package/dist/lib/quota-providers.d.ts.map +1 -0
  100. package/dist/lib/quota-providers.js +630 -0
  101. package/dist/lib/quota-providers.js.map +1 -0
  102. package/dist/lib/quota-render-data.d.ts +1 -0
  103. package/dist/lib/quota-render-data.d.ts.map +1 -1
  104. package/dist/lib/quota-render-data.js +35 -16
  105. package/dist/lib/quota-render-data.js.map +1 -1
  106. package/dist/lib/quota-runtime-context.d.ts.map +1 -1
  107. package/dist/lib/quota-runtime-context.js +3 -4
  108. package/dist/lib/quota-runtime-context.js.map +1 -1
  109. package/dist/lib/quota-state.d.ts +5 -2
  110. package/dist/lib/quota-state.d.ts.map +1 -1
  111. package/dist/lib/quota-state.js +268 -51
  112. package/dist/lib/quota-state.js.map +1 -1
  113. package/dist/lib/quota-status.d.ts +2 -1
  114. package/dist/lib/quota-status.d.ts.map +1 -1
  115. package/dist/lib/quota-status.js +247 -69
  116. package/dist/lib/quota-status.js.map +1 -1
  117. package/dist/lib/qwen-local-quota.d.ts +12 -11
  118. package/dist/lib/qwen-local-quota.d.ts.map +1 -1
  119. package/dist/lib/qwen-local-quota.js +82 -87
  120. package/dist/lib/qwen-local-quota.js.map +1 -1
  121. package/dist/lib/scoped-update.d.ts +63 -0
  122. package/dist/lib/scoped-update.d.ts.map +1 -0
  123. package/dist/lib/scoped-update.js +311 -0
  124. package/dist/lib/scoped-update.js.map +1 -0
  125. package/dist/lib/tui-runtime.d.ts +2 -0
  126. package/dist/lib/tui-runtime.d.ts.map +1 -1
  127. package/dist/lib/tui-runtime.js +33 -9
  128. package/dist/lib/tui-runtime.js.map +1 -1
  129. package/dist/lib/types.d.ts +51 -15
  130. package/dist/lib/types.d.ts.map +1 -1
  131. package/dist/lib/types.js +2 -1
  132. package/dist/lib/types.js.map +1 -1
  133. package/dist/plugin.d.ts.map +1 -1
  134. package/dist/plugin.js +75 -100
  135. package/dist/plugin.js.map +1 -1
  136. package/dist/providers/alibaba-coding-plan.d.ts.map +1 -1
  137. package/dist/providers/alibaba-coding-plan.js +35 -4
  138. package/dist/providers/alibaba-coding-plan.js.map +1 -1
  139. package/dist/providers/anthropic.d.ts.map +1 -1
  140. package/dist/providers/anthropic.js +13 -1
  141. package/dist/providers/anthropic.js.map +1 -1
  142. package/dist/providers/chutes.d.ts.map +1 -1
  143. package/dist/providers/chutes.js +6 -0
  144. package/dist/providers/chutes.js.map +1 -1
  145. package/dist/providers/copilot.d.ts +2 -1
  146. package/dist/providers/copilot.d.ts.map +1 -1
  147. package/dist/providers/copilot.js +143 -60
  148. package/dist/providers/copilot.js.map +1 -1
  149. package/dist/providers/cursor.d.ts.map +1 -1
  150. package/dist/providers/cursor.js +30 -1
  151. package/dist/providers/cursor.js.map +1 -1
  152. package/dist/providers/deepseek.d.ts.map +1 -1
  153. package/dist/providers/deepseek.js +13 -1
  154. package/dist/providers/deepseek.js.map +1 -1
  155. package/dist/providers/google-agy.d.ts.map +1 -1
  156. package/dist/providers/google-agy.js +7 -1
  157. package/dist/providers/google-agy.js.map +1 -1
  158. package/dist/providers/google-antigravity.d.ts.map +1 -1
  159. package/dist/providers/google-antigravity.js +10 -2
  160. package/dist/providers/google-antigravity.js.map +1 -1
  161. package/dist/providers/google-gemini-cli.d.ts.map +1 -1
  162. package/dist/providers/google-gemini-cli.js +8 -2
  163. package/dist/providers/google-gemini-cli.js.map +1 -1
  164. package/dist/providers/kimi-code.d.ts.map +1 -1
  165. package/dist/providers/kimi-code.js +6 -0
  166. package/dist/providers/kimi-code.js.map +1 -1
  167. package/dist/providers/minimax-coding-plan.d.ts.map +1 -1
  168. package/dist/providers/minimax-coding-plan.js +12 -4
  169. package/dist/providers/minimax-coding-plan.js.map +1 -1
  170. package/dist/providers/nanogpt.d.ts.map +1 -1
  171. package/dist/providers/nanogpt.js +19 -1
  172. package/dist/providers/nanogpt.js.map +1 -1
  173. package/dist/providers/ollama-cloud.d.ts.map +1 -1
  174. package/dist/providers/ollama-cloud.js +12 -0
  175. package/dist/providers/ollama-cloud.js.map +1 -1
  176. package/dist/providers/openai.d.ts.map +1 -1
  177. package/dist/providers/openai.js +7 -0
  178. package/dist/providers/openai.js.map +1 -1
  179. package/dist/providers/opencode-go.d.ts.map +1 -1
  180. package/dist/providers/opencode-go.js +9 -1
  181. package/dist/providers/opencode-go.js.map +1 -1
  182. package/dist/providers/quota-providers.d.ts +6 -0
  183. package/dist/providers/quota-providers.d.ts.map +1 -0
  184. package/dist/providers/quota-providers.js +276 -0
  185. package/dist/providers/quota-providers.js.map +1 -0
  186. package/dist/providers/qwen-code.d.ts.map +1 -1
  187. package/dist/providers/qwen-code.js +29 -3
  188. package/dist/providers/qwen-code.js.map +1 -1
  189. package/dist/providers/registry.d.ts.map +1 -1
  190. package/dist/providers/registry.js +2 -0
  191. package/dist/providers/registry.js.map +1 -1
  192. package/dist/providers/result-helpers.d.ts +2 -1
  193. package/dist/providers/result-helpers.d.ts.map +1 -1
  194. package/dist/providers/result-helpers.js +6 -1
  195. package/dist/providers/result-helpers.js.map +1 -1
  196. package/dist/providers/synthetic.d.ts.map +1 -1
  197. package/dist/providers/synthetic.js +7 -1
  198. package/dist/providers/synthetic.js.map +1 -1
  199. package/dist/providers/zai.d.ts.map +1 -1
  200. package/dist/providers/zai.js +7 -1
  201. package/dist/providers/zai.js.map +1 -1
  202. package/dist/providers/zhipu.d.ts.map +1 -1
  203. package/dist/providers/zhipu.js +7 -1
  204. package/dist/providers/zhipu.js.map +1 -1
  205. package/dist/tui.d.ts.map +1 -1
  206. package/dist/tui.js +148 -122
  207. package/dist/tui.tsx +122 -114
  208. package/package.json +29 -21
@@ -1,18 +1,21 @@
1
1
  import { existsSync } from "fs";
2
2
  import { readFile } from "fs/promises";
3
- import { basename } from "path";
3
+ import { basename, join } from "path";
4
4
  import { writeJsonAtomic } from "./atomic-json.js";
5
+ import { applyConfigDocumentEdit, planConfigDocumentEdit, validateConfigDocumentEdit, } from "./opencode-config-editor.js";
5
6
  import { dedupeNonEmptyStrings, extractPluginSpecsFromParsedConfig, getPluginSpecFromEntry, isQuotaPluginSpec, resolveEditableConfigPath, findGitWorktreeRoot, } from "./config-file-utils.js";
6
7
  import { parseJsonOrJsonc } from "./jsonc.js";
8
+ import { QUOTA_PROVIDERS_AGGREGATE_ID } from "./quota-providers.js";
7
9
  import { getOpencodeRuntimeDirCandidates } from "./opencode-runtime-paths.js";
8
10
  import { QUOTA_PROVIDER_SHAPES, getQuotaProviderDisplayLabel, normalizeQuotaProviderId, } from "./provider-metadata.js";
9
11
  import { getQuotaFormatStyleLabel, isQuotaFormatStyle, resolveQuotaFormatStyle, } from "./quota-format-style.js";
10
- import { getQuotaToastConfigPath, QUOTA_TOAST_CONFIG_RELATIVE_PATH } from "./config.js";
11
- const QUOTA_PLUGIN_SPEC = "@slkiser/opencode-quota";
12
+ import { getQuotaToastConfigPath, QUOTA_TOAST_CONFIG_RELATIVE_PATH, QUOTA_TOAST_CONFIG_RELATIVE_PATHS, } from "./config.js";
13
+ const QUOTA_PLUGIN_SPEC = "@slkiser/opencode-quota@latest";
12
14
  const OPENCODE_SCHEMA_URL = "https://opencode.ai/config.json";
13
15
  const TUI_SCHEMA_URL = "https://opencode.ai/tui.json";
14
16
  const GITHUB_REPO_URL = "https://github.com/slkiser/opencode-quota";
15
17
  const GITHUB_STAR_NOTE = `if this helps, stars are appreciated: ${GITHUB_REPO_URL}`;
18
+ const TUI_COMMAND_DISPLAY_COMMENT = "// OpenCode Quota: tuiCommandDisplay chooses whether native TUI command output appears in the session transcript or a local popup dialog.";
16
19
  export class InitInstallerError extends Error {
17
20
  details;
18
21
  constructor(message, details) {
@@ -30,48 +33,37 @@ function hasOwnKey(value, key) {
30
33
  function jsonEqual(left, right) {
31
34
  return JSON.stringify(left) === JSON.stringify(right);
32
35
  }
33
- const QUOTA_UI_CHOICE_ORDER = [
34
- "toast",
35
- "sidebar",
36
- "compact_status",
37
- "none",
38
- ];
36
+ const QUOTA_UI_CHOICE_ORDER = ["sidebar", "toast", "compact_status", "none"];
39
37
  function normalizeQuotaUiIntent(selections) {
40
- const legacySelections = selections;
41
- const quotaUi = legacySelections.quotaUi ?? [];
42
- const rawChoices = Array.isArray(quotaUi)
43
- ? quotaUi
44
- : quotaUi === "toast_sidebar"
45
- ? ["toast", "sidebar"]
46
- : [quotaUi];
38
+ const rawSelections = selections;
39
+ if (hasOwnKey(rawSelections, "tuiCompactStatus")) {
40
+ throw new InitInstallerError("Unsupported installer selection: tuiCompactStatus");
41
+ }
42
+ const quotaUi = rawSelections.quotaUi;
43
+ if (!Array.isArray(quotaUi)) {
44
+ throw new InitInstallerError("Quota UI selections must be an array.");
45
+ }
46
+ if (quotaUi.length === 0) {
47
+ throw new InitInstallerError("Quota UI selections must not be empty.");
48
+ }
47
49
  const seen = new Set();
48
- for (const rawChoice of rawChoices) {
49
- if (typeof rawChoice !== "string" || !QUOTA_UI_CHOICE_ORDER.includes(rawChoice)) {
50
+ for (const rawChoice of quotaUi) {
51
+ if (typeof rawChoice !== "string" ||
52
+ !QUOTA_UI_CHOICE_ORDER.includes(rawChoice)) {
50
53
  throw new InitInstallerError(`Unknown Quota UI option: ${String(rawChoice)}`);
51
54
  }
52
55
  seen.add(rawChoice);
53
56
  }
54
- const legacyCompactMode = legacySelections.tuiCompactStatus;
55
- if (legacyCompactMode !== undefined && legacyCompactMode !== "off") {
56
- if (legacyCompactMode !== "home_bottom" && legacyCompactMode !== "home_bottom_session_prompt") {
57
- throw new InitInstallerError(`Unknown Compact TUI status: ${String(legacyCompactMode)}`);
58
- }
59
- seen.delete("none");
60
- seen.add("compact_status");
61
- }
62
- let choices = QUOTA_UI_CHOICE_ORDER.filter((choice) => seen.has(choice));
63
- if (choices.length === 0) {
64
- choices = ["none"];
65
- }
66
- else if (choices.length > 1 && choices.includes("none")) {
67
- choices = choices.filter((choice) => choice !== "none");
57
+ if (seen.has("none") && seen.size > 1) {
58
+ throw new InitInstallerError("Manual commands only cannot be combined with automatic quota displays.");
68
59
  }
60
+ const choices = QUOTA_UI_CHOICE_ORDER.filter((choice) => seen.has(choice));
69
61
  const enableSidebarPanel = choices.includes("sidebar");
70
62
  const enableCompactStatus = choices.includes("compact_status");
71
63
  return {
72
64
  choices,
73
65
  enableToast: choices.includes("toast"),
74
- installTuiPlugin: enableSidebarPanel || enableCompactStatus,
66
+ installTuiPlugin: selections.interfaces !== "web",
75
67
  enableSidebarPanel,
76
68
  enableCompactStatus,
77
69
  };
@@ -84,7 +76,7 @@ function getUiLabel(choices) {
84
76
  return "Sidebar";
85
77
  if (choice === "compact_status")
86
78
  return "Compact status";
87
- return "No automatic UI surfaces";
79
+ return "Manual commands only";
88
80
  });
89
81
  return labels.join(" + ");
90
82
  }
@@ -94,13 +86,6 @@ function getProviderModeLabel(mode) {
94
86
  function getPercentDisplayModeLabel(mode) {
95
87
  return mode === "used" ? "Used" : "Remaining";
96
88
  }
97
- function getTuiCompactStatusLabel(mode) {
98
- if (mode === "home_bottom")
99
- return "Home bottom only";
100
- if (mode === "home_bottom_session_prompt")
101
- return "Home bottom + session prompt";
102
- return "Off";
103
- }
104
89
  function resolveRequestedProviders(selections) {
105
90
  if (selections.providerMode === "auto") {
106
91
  return "auto";
@@ -203,9 +188,11 @@ function setInstallerOwnedSetting(target, key, value, pathLabel, edit) {
203
188
  return;
204
189
  }
205
190
  if (!jsonEqual(target[key], value)) {
191
+ const previousValue = target[key];
206
192
  target[key] = value;
207
193
  edit.changed = true;
208
194
  edit.updatedKeys.push(pathLabel);
195
+ edit.valueChanges.push(`${pathLabel}: ${JSON.stringify(previousValue)} -> ${JSON.stringify(value)}`);
209
196
  }
210
197
  }
211
198
  function planTuiSidebarPanelConfig(params) {
@@ -304,11 +291,12 @@ async function readLegacyQuotaToastSeed(baseDir) {
304
291
  if (!target.existed) {
305
292
  return null;
306
293
  }
307
- const root = await readExistingConfig(target);
294
+ const root = await readExistingConfig({
295
+ path: target.sourcePath,
296
+ format: target.sourcePath.endsWith(".jsonc") ? "jsonc" : "json",
297
+ });
308
298
  const experimental = isPlainObject(root.experimental) ? root.experimental : null;
309
- const quotaToast = experimental && isPlainObject(experimental.quotaToast)
310
- ? experimental.quotaToast
311
- : null;
299
+ const quotaToast = experimental && isPlainObject(experimental.quotaToast) ? experimental.quotaToast : null;
312
300
  return quotaToast ? cloneJsonObject(quotaToast) : null;
313
301
  }
314
302
  function buildQuickSetupNotes(selections) {
@@ -367,7 +355,12 @@ function syncLegacyQuotaToast(params) {
367
355
  }
368
356
  }
369
357
  async function planOpencodeEdit(params) {
370
- const target = resolveEditableConfigPath({ dir: params.baseDir, kind: "opencode" });
358
+ const target = resolveEditableConfigPath({
359
+ dir: params.baseDir,
360
+ kind: "opencode",
361
+ preferredFormat: params.selections.configFormat ?? "jsonc",
362
+ convertJsonToJsonc: true,
363
+ });
371
364
  const edit = {
372
365
  kind: "opencode",
373
366
  path: target.path,
@@ -377,13 +370,19 @@ async function planOpencodeEdit(params) {
377
370
  addedPlugins: [],
378
371
  addedKeys: [],
379
372
  updatedKeys: [],
373
+ valueChanges: [],
380
374
  skippedValues: [],
381
- warnings: target.format === "jsonc"
382
- ? ["Existing JSONC comments/trailing commas will be stripped."]
383
- : [],
375
+ warnings: [],
384
376
  };
385
- const root = target.existed ? await readExistingConfig(target) : {};
386
- ensureSchema(root, OPENCODE_SCHEMA_URL, edit);
377
+ const root = target.existed
378
+ ? await readExistingConfig({
379
+ path: target.sourcePath,
380
+ format: target.sourcePath.endsWith(".jsonc") ? "jsonc" : "json",
381
+ })
382
+ : {};
383
+ if (!target.existed) {
384
+ ensureSchema(root, OPENCODE_SCHEMA_URL, edit);
385
+ }
387
386
  const plugin = ensureTopLevelPluginArray(root, edit);
388
387
  appendQuotaPluginIfMissing({
389
388
  container: plugin,
@@ -398,46 +397,109 @@ async function planOpencodeEdit(params) {
398
397
  edit,
399
398
  });
400
399
  }
401
- if (edit.changed) {
402
- edit.nextData = root;
403
- }
400
+ const documentEdit = await planConfigDocumentEdit({
401
+ target,
402
+ desiredData: root,
403
+ managedComments: [
404
+ {
405
+ path: ["plugin"],
406
+ text: TUI_COMMAND_DISPLAY_COMMENT,
407
+ },
408
+ {
409
+ path: ["plugin"],
410
+ text: "// OpenCode Quota: loads the server plugin for slash commands and quota checks.",
411
+ },
412
+ ],
413
+ });
414
+ edit.changed = documentEdit.changed;
415
+ edit.documentEdit = documentEdit;
404
416
  return edit;
405
417
  }
418
+ function resolveQuotaConfigTarget(baseDir, preferredFormat) {
419
+ const jsoncPath = getQuotaToastConfigPath(baseDir, "jsonc");
420
+ if (existsSync(jsoncPath)) {
421
+ return {
422
+ path: jsoncPath,
423
+ sourcePath: jsoncPath,
424
+ format: "jsonc",
425
+ existed: true,
426
+ };
427
+ }
428
+ const jsonPath = getQuotaToastConfigPath(baseDir, "json");
429
+ if (existsSync(jsonPath)) {
430
+ if (preferredFormat === "jsonc") {
431
+ return {
432
+ path: jsoncPath,
433
+ sourcePath: jsonPath,
434
+ removeSourcePath: jsonPath,
435
+ format: "jsonc",
436
+ existed: true,
437
+ };
438
+ }
439
+ return {
440
+ path: jsonPath,
441
+ sourcePath: jsonPath,
442
+ format: "json",
443
+ existed: true,
444
+ };
445
+ }
446
+ const path = getQuotaToastConfigPath(baseDir, preferredFormat);
447
+ return {
448
+ path,
449
+ sourcePath: path,
450
+ format: preferredFormat,
451
+ existed: false,
452
+ };
453
+ }
406
454
  async function planQuotaConfigEdit(params) {
407
- const path = getQuotaToastConfigPath(params.baseDir);
408
- const existed = existsSync(path);
455
+ const target = resolveQuotaConfigTarget(params.baseDir, params.selections.configFormat ?? "jsonc");
409
456
  const edit = {
410
457
  kind: "quota",
411
- path,
412
- existed,
413
- format: "json",
458
+ path: target.path,
459
+ existed: target.existed,
460
+ format: target.format,
414
461
  changed: false,
415
462
  addedPlugins: [],
416
463
  addedKeys: [],
417
464
  updatedKeys: [],
465
+ valueChanges: [],
418
466
  skippedValues: [],
419
467
  warnings: [],
420
468
  };
421
- const legacyQuotaToast = existed ? null : await readLegacyQuotaToastSeed(params.baseDir);
422
- const quotaToast = existed
423
- ? await readExistingConfig({ path, format: "json" })
469
+ if (target.path.endsWith(".jsonc") &&
470
+ existsSync(getQuotaToastConfigPath(params.baseDir, "json"))) {
471
+ edit.warnings.push("Both quota-toast.jsonc and quota-toast.json exist; using JSONC and preserving the JSON file.");
472
+ }
473
+ const legacyQuotaToast = target.existed ? null : await readLegacyQuotaToastSeed(params.baseDir);
474
+ const quotaToast = target.existed
475
+ ? await readExistingConfig({
476
+ path: target.sourcePath,
477
+ format: target.sourcePath.endsWith(".jsonc") ? "jsonc" : "json",
478
+ })
424
479
  : legacyQuotaToast
425
480
  ? cloneJsonObject(legacyQuotaToast)
426
481
  : {};
427
- if (!existed) {
428
- edit.changed = true;
482
+ if (!target.existed) {
429
483
  edit.addedKeys.push(legacyQuotaToast
430
- ? `${QUOTA_TOAST_CONFIG_RELATIVE_PATH} (seeded from experimental.quotaToast)`
431
- : QUOTA_TOAST_CONFIG_RELATIVE_PATH);
484
+ ? `${target.path.endsWith(".jsonc") ? QUOTA_TOAST_CONFIG_RELATIVE_PATHS[0] : QUOTA_TOAST_CONFIG_RELATIVE_PATH} (seeded from experimental.quotaToast)`
485
+ : target.path.endsWith(".jsonc")
486
+ ? QUOTA_TOAST_CONFIG_RELATIVE_PATHS[0]
487
+ : QUOTA_TOAST_CONFIG_RELATIVE_PATH);
432
488
  }
433
489
  setInstallerOwnedSetting(quotaToast, "enableToast", params.quotaUiIntent.enableToast, "quotaToast.enableToast", edit);
434
- addSettingIfMissing(quotaToast, "showSessionTokens", params.selections.showSessionTokens, "quotaToast.showSessionTokens", edit);
435
- addSettingIfMissing(quotaToast, "enabledProviders", resolveRequestedProviders(params.selections), "quotaToast.enabledProviders", edit);
436
- addSettingIfMissing(quotaToast, "formatStyle", pickFormatStyleToWrite({
437
- quotaToast,
438
- selectedFormatStyle: params.selections.formatStyle,
439
- }), "quotaToast.formatStyle", edit);
440
- addSettingIfMissing(quotaToast, "percentDisplayMode", params.selections.percentDisplayMode, "quotaToast.percentDisplayMode", edit);
490
+ if (params.selections.tuiCommandDisplay !== undefined) {
491
+ setInstallerOwnedSetting(quotaToast, "tuiCommandDisplay", params.selections.tuiCommandDisplay, "quotaToast.tuiCommandDisplay", edit);
492
+ }
493
+ setInstallerOwnedSetting(quotaToast, "showSessionTokens", params.selections.showSessionTokens, "quotaToast.showSessionTokens", edit);
494
+ const requestedProviders = resolveRequestedProviders(params.selections);
495
+ const enabledProviders = requestedProviders !== "auto" &&
496
+ Array.isArray(quotaToast.enabledProviders) &&
497
+ quotaToast.enabledProviders.includes(QUOTA_PROVIDERS_AGGREGATE_ID)
498
+ ? [...requestedProviders, QUOTA_PROVIDERS_AGGREGATE_ID]
499
+ : requestedProviders;
500
+ setInstallerOwnedSetting(quotaToast, "enabledProviders", enabledProviders, "quotaToast.enabledProviders", edit);
501
+ setInstallerOwnedSetting(quotaToast, "formatStyle", params.selections.formatStyle, "quotaToast.formatStyle", edit);
502
+ setInstallerOwnedSetting(quotaToast, "percentDisplayMode", params.selections.percentDisplayMode, "quotaToast.percentDisplayMode", edit);
441
503
  planTuiSidebarPanelConfig({
442
504
  quotaToast,
443
505
  quotaUiIntent: params.quotaUiIntent,
@@ -453,14 +515,86 @@ async function planQuotaConfigEdit(params) {
453
515
  selections: params.selections,
454
516
  edit,
455
517
  });
518
+ const documentEdit = await planConfigDocumentEdit({
519
+ target,
520
+ desiredData: quotaToast,
521
+ managedComments: [
522
+ {
523
+ path: ["enableToast"],
524
+ text: "// Automatic quota surfaces. Slash commands remain available when these are disabled.",
525
+ },
526
+ {
527
+ path: ["enabledProviders"],
528
+ text: '// Provider selection: "auto" detects providers; an array tracks only listed providers.',
529
+ },
530
+ {
531
+ path: ["formatStyle"],
532
+ text: "// Quota presentation and reset-period choices.",
533
+ },
534
+ {
535
+ path: ["showSessionTokens"],
536
+ text: "// Include or hide current-session input and output token totals.",
537
+ },
538
+ {
539
+ path: ["tuiSidebarPanel"],
540
+ text: "// TUI-only surfaces.",
541
+ },
542
+ {
543
+ path: ["maintainerAnnouncements"],
544
+ text: "// Optional bundled maintainer Home announcements.",
545
+ },
546
+ ],
547
+ });
548
+ edit.changed = documentEdit.changed;
549
+ edit.documentEdit = documentEdit;
456
550
  edit.plannedData = quotaToast;
457
- if (edit.changed) {
458
- edit.nextData = quotaToast;
459
- }
460
551
  return edit;
461
552
  }
553
+ function getCanonicalQuotaPackageSpecForRemoval(entry) {
554
+ const spec = typeof entry === "string"
555
+ ? entry
556
+ : Array.isArray(entry) && typeof entry[0] === "string"
557
+ ? entry[0]
558
+ : undefined;
559
+ if (spec === "@slkiser/opencode-quota")
560
+ return spec;
561
+ if (spec !== undefined &&
562
+ /^@slkiser\/opencode-quota@(?:v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?|[A-Za-z][0-9A-Za-z._-]*)$/.test(spec)) {
563
+ return spec;
564
+ }
565
+ return undefined;
566
+ }
567
+ function removeQuotaPluginsFromTui(root, edit) {
568
+ const containers = [
569
+ { value: root.plugin, pathLabel: "plugin" },
570
+ ];
571
+ if (isPlainObject(root.tui)) {
572
+ containers.push({ value: root.tui.plugin, pathLabel: "tui.plugin" });
573
+ }
574
+ for (const container of containers) {
575
+ if (!Array.isArray(container.value))
576
+ continue;
577
+ const retained = container.value.filter((entry) => {
578
+ const spec = getCanonicalQuotaPackageSpecForRemoval(entry);
579
+ if (spec !== undefined) {
580
+ edit.updatedKeys.push(`${container.pathLabel}: remove ${spec}`);
581
+ return false;
582
+ }
583
+ return true;
584
+ });
585
+ if (retained.length !== container.value.length) {
586
+ container.value.splice(0, container.value.length, ...retained);
587
+ edit.changed = true;
588
+ }
589
+ }
590
+ }
462
591
  async function planTuiEdit(params) {
463
- const target = resolveEditableConfigPath({ dir: params.baseDir, kind: "tui" });
592
+ const target = resolveEditableConfigPath({
593
+ dir: params.baseDir,
594
+ kind: "tui",
595
+ preferredFormat: params.selections.configFormat ?? "jsonc",
596
+ convertJsonToJsonc: params.selections.interfaces !== "web",
597
+ });
464
598
  const edit = {
465
599
  kind: "tui",
466
600
  path: target.path,
@@ -470,54 +604,95 @@ async function planTuiEdit(params) {
470
604
  addedPlugins: [],
471
605
  addedKeys: [],
472
606
  updatedKeys: [],
607
+ valueChanges: [],
473
608
  skippedValues: [],
474
- warnings: target.format === "jsonc"
475
- ? ["Existing JSONC comments/trailing commas will be stripped."]
476
- : [],
609
+ warnings: [],
477
610
  };
478
- const root = target.existed ? await readExistingConfig(target) : {};
479
- ensureSchema(root, TUI_SCHEMA_URL, edit);
480
- const existingPluginSpecs = extractPluginSpecsFromParsedConfig(root);
481
- if (existingPluginSpecs.some((spec) => isQuotaPluginSpec(spec, "tui"))) {
482
- edit.skippedValues.push(`tui config already includes ${QUOTA_PLUGIN_SPEC}`);
611
+ if (params.selections.interfaces === "web" && !target.existed) {
612
+ return edit;
613
+ }
614
+ const root = target.existed
615
+ ? await readExistingConfig({
616
+ path: target.sourcePath,
617
+ format: target.sourcePath.endsWith(".jsonc") ? "jsonc" : "json",
618
+ })
619
+ : {};
620
+ if (!target.existed) {
621
+ ensureSchema(root, TUI_SCHEMA_URL, edit);
483
622
  }
484
- else {
485
- const pluginTarget = ensureTuiPluginArray(root, edit);
486
- appendQuotaPluginIfMissing({
487
- container: pluginTarget.container,
488
- pathLabel: pluginTarget.pathLabel,
489
- kind: "tui",
490
- edit,
491
- });
623
+ if (params.selections.interfaces === "web") {
624
+ removeQuotaPluginsFromTui(root, edit);
492
625
  }
493
- if (edit.changed) {
494
- edit.nextData = root;
626
+ else {
627
+ const existingPluginSpecs = extractPluginSpecsFromParsedConfig(root);
628
+ if (existingPluginSpecs.some((spec) => isQuotaPluginSpec(spec, "tui"))) {
629
+ edit.skippedValues.push(`tui config already includes ${QUOTA_PLUGIN_SPEC}`);
630
+ }
631
+ else {
632
+ const pluginTarget = ensureTuiPluginArray(root, edit);
633
+ appendQuotaPluginIfMissing({
634
+ container: pluginTarget.container,
635
+ pathLabel: pluginTarget.pathLabel,
636
+ kind: "tui",
637
+ edit,
638
+ });
639
+ }
495
640
  }
641
+ const documentEdit = await planConfigDocumentEdit({
642
+ target,
643
+ desiredData: root,
644
+ managedComments: params.selections.interfaces === "web"
645
+ ? []
646
+ : [
647
+ {
648
+ path: ["plugin"],
649
+ text: TUI_COMMAND_DISPLAY_COMMENT,
650
+ },
651
+ {
652
+ path: ["plugin"],
653
+ text: "// OpenCode Quota: loads the TUI sidebar, compact status, and local commands.",
654
+ },
655
+ ],
656
+ });
657
+ edit.changed = documentEdit.changed;
658
+ edit.documentEdit = documentEdit;
496
659
  return edit;
497
660
  }
498
661
  function buildPlanSummary(plan) {
499
662
  const quotaUiIntent = normalizeQuotaUiIntent(plan.selections);
663
+ const opencodeFormat = plan.edits.find((edit) => edit.kind === "opencode")?.format ??
664
+ plan.selections.configFormat ??
665
+ "jsonc";
500
666
  const lines = [
667
+ `Interface: ${plan.selections.interfaces === "tui" ? "TUI" : plan.selections.interfaces === "web" ? "Web" : "Both"}`,
501
668
  `Scope: ${plan.selections.scope} (${plan.baseDir})`,
502
- `Quota UI: ${getUiLabel(quotaUiIntent.choices)}`,
503
- `Provider mode: ${getProviderModeLabel(plan.selections.providerMode)}`,
504
- `Quota reset periods: ${getQuotaFormatStyleLabel(plan.selections.formatStyle)}`,
505
- `Quota percentage meaning: ${getPercentDisplayModeLabel(plan.selections.percentDisplayMode)}`,
506
- `Session token details: ${plan.selections.showSessionTokens ? "Show" : "Hide"}`,
669
+ `Config format: ${opencodeFormat.toUpperCase()}`,
507
670
  ];
671
+ if (plan.selections.interfaces !== "web") {
672
+ lines.push(`TUI surfaces: ${getUiLabel(quotaUiIntent.choices)}`);
673
+ lines.push(`Command display: ${plan.selections.tuiCommandDisplay === "inline" ? "Inline with messages" : "Popup dialog"}`);
674
+ }
675
+ lines.push(`Provider mode: ${getProviderModeLabel(plan.selections.providerMode)}`, `Quota reset periods: ${getQuotaFormatStyleLabel(plan.selections.formatStyle)}`, `Quota percentage meaning: ${getPercentDisplayModeLabel(plan.selections.percentDisplayMode)}`, `Session input/output tokens: ${plan.selections.showSessionTokens ? "Show" : "Hide"}`);
508
676
  if (plan.selections.maintainerAnnouncements !== undefined) {
509
677
  lines.push(`Maintainer announcements: ${plan.selections.maintainerAnnouncements ? "Enabled" : "Disabled"}`);
510
678
  }
511
679
  if (quotaUiIntent.enableCompactStatus) {
512
- lines.push(`Compact status mode: ${getTuiCompactStatusLabel("home_bottom_session_prompt")}`);
680
+ lines.push("Compact status mode: Home bottom + session prompt");
513
681
  }
514
682
  const requestedProviders = resolveRequestedProviders(plan.selections);
515
683
  if (requestedProviders !== "auto") {
516
684
  lines.push(`Manual providers: ${requestedProviders.map((providerId) => getQuotaProviderDisplayLabel(providerId)).join(", ")}`);
517
685
  }
518
686
  for (const edit of plan.edits) {
519
- const mode = !edit.existed ? "create" : edit.changed ? "update" : "unchanged";
520
- lines.push(`${mode}: ${edit.path}`);
687
+ const convertedFrom = edit.documentEdit?.removeSourcePath;
688
+ const mode = convertedFrom
689
+ ? "convert"
690
+ : !edit.existed
691
+ ? "create"
692
+ : edit.changed
693
+ ? "update"
694
+ : "unchanged";
695
+ lines.push(convertedFrom ? `${mode}: ${convertedFrom} -> ${edit.path}` : `${mode}: ${edit.path}`);
521
696
  for (const plugin of edit.addedPlugins) {
522
697
  lines.push(` + plugin ${plugin}`);
523
698
  }
@@ -527,6 +702,9 @@ function buildPlanSummary(plan) {
527
702
  for (const key of edit.updatedKeys) {
528
703
  lines.push(` ~ ${key}`);
529
704
  }
705
+ for (const change of edit.valueChanges) {
706
+ lines.push(` ${change}`);
707
+ }
530
708
  for (const skipped of edit.skippedValues) {
531
709
  lines.push(` = ${skipped}`);
532
710
  }
@@ -568,10 +746,18 @@ export function resolveInitInstallerBaseDir(params) {
568
746
  return findGitWorktreeRoot(cwd) ?? cwd;
569
747
  }
570
748
  export async function planInitInstaller(params) {
571
- const quotaUiIntent = normalizeQuotaUiIntent(params.selections);
572
- const selections = {
749
+ const requestedSelections = {
573
750
  ...params.selections,
751
+ quotaUi: params.selections.interfaces === "web" ? ["none"] : params.selections.quotaUi,
752
+ };
753
+ const quotaUiIntent = normalizeQuotaUiIntent(requestedSelections);
754
+ const selections = {
755
+ ...requestedSelections,
756
+ configFormat: params.selections.configFormat ?? "jsonc",
574
757
  quotaUi: quotaUiIntent.choices,
758
+ tuiCommandDisplay: params.selections.interfaces === "web"
759
+ ? undefined
760
+ : (params.selections.tuiCommandDisplay ?? "inline"),
575
761
  maintainerAnnouncements: params.selections.maintainerAnnouncements,
576
762
  manualProviders: params.selections.providerMode === "manual"
577
763
  ? resolveRequestedProviders(params.selections)
@@ -592,8 +778,9 @@ export async function planInitInstaller(params) {
592
778
  }),
593
779
  quotaEdit,
594
780
  ];
595
- if (quotaUiIntent.installTuiPlugin) {
596
- edits.push(await planTuiEdit({ selections, baseDir }));
781
+ const tuiEdit = await planTuiEdit({ selections, baseDir });
782
+ if (selections.interfaces !== "web" || tuiEdit.existed) {
783
+ edits.push(tuiEdit);
597
784
  }
598
785
  const quickSetupNotes = buildQuickSetupNotes(selections);
599
786
  const warnings = edits.flatMap((edit) => edit.warnings);
@@ -611,13 +798,30 @@ export async function planInitInstaller(params) {
611
798
  export async function applyInitInstallerPlan(plan) {
612
799
  const writtenPaths = [];
613
800
  const unchangedPaths = [];
801
+ try {
802
+ await Promise.all(plan.edits.flatMap((edit) => edit.documentEdit ? [validateConfigDocumentEdit(edit.documentEdit)] : []));
803
+ }
804
+ catch (error) {
805
+ const path = error && typeof error === "object" && "path" in error
806
+ ? String(error.path ?? "")
807
+ : "";
808
+ throw new InitInstallerError(`Config changed since preview${path ? `: ${path}` : "."}`, {
809
+ path: path || undefined,
810
+ writtenPaths,
811
+ });
812
+ }
614
813
  for (const edit of plan.edits) {
615
- if (!edit.changed || !edit.nextData) {
814
+ if (!edit.changed || (!edit.nextData && !edit.documentEdit)) {
616
815
  unchangedPaths.push(edit.path);
617
816
  continue;
618
817
  }
619
818
  try {
620
- await writeJsonAtomic(edit.path, edit.nextData, { trailingNewline: true });
819
+ if (edit.documentEdit) {
820
+ await applyConfigDocumentEdit(edit.documentEdit);
821
+ }
822
+ else {
823
+ await writeJsonAtomic(edit.path, edit.nextData, { trailingNewline: true });
824
+ }
621
825
  writtenPaths.push(edit.path);
622
826
  }
623
827
  catch (error) {
@@ -632,40 +836,184 @@ export async function applyInitInstallerPlan(plan) {
632
836
  unchangedPaths,
633
837
  };
634
838
  }
635
- async function promptForSelections(prompts) {
839
+ async function readExistingInstallerAnswers(baseDir) {
840
+ const existingQuotaPath = QUOTA_TOAST_CONFIG_RELATIVE_PATHS.map((relativePath) => join(baseDir, relativePath)).find((path) => existsSync(path));
841
+ const existingHostPath = ["opencode.jsonc", "opencode.json"]
842
+ .map((name) => join(baseDir, name))
843
+ .find((path) => existsSync(path));
844
+ const answers = {
845
+ configFormat: existingQuotaPath?.endsWith(".jsonc") || existingHostPath?.endsWith(".jsonc")
846
+ ? "jsonc"
847
+ : existingQuotaPath || existingHostPath
848
+ ? "json"
849
+ : undefined,
850
+ };
851
+ if (!existingQuotaPath)
852
+ return answers;
853
+ const quotaToast = await readExistingConfig({
854
+ path: existingQuotaPath,
855
+ format: existingQuotaPath.endsWith(".jsonc") ? "jsonc" : "json",
856
+ });
857
+ const quotaUi = [];
858
+ if (isPlainObject(quotaToast.tuiSidebarPanel) && quotaToast.tuiSidebarPanel.enabled === true) {
859
+ quotaUi.push("sidebar");
860
+ }
861
+ if (quotaToast.enableToast === true)
862
+ quotaUi.push("toast");
863
+ if (isPlainObject(quotaToast.tuiCompactStatus) && quotaToast.tuiCompactStatus.enabled === true) {
864
+ quotaUi.push("compact_status");
865
+ }
866
+ answers.quotaUi = quotaUi.length > 0 ? quotaUi : ["none"];
867
+ if (quotaToast.enabledProviders === "auto") {
868
+ answers.providerMode = "auto";
869
+ answers.manualProviders = [];
870
+ }
871
+ else if (Array.isArray(quotaToast.enabledProviders)) {
872
+ answers.providerMode = "manual";
873
+ answers.manualProviders = quotaToast.enabledProviders.filter((value) => typeof value === "string" &&
874
+ QUOTA_PROVIDER_SHAPES.some((shape) => shape.id === normalizeQuotaProviderId(value)));
875
+ }
876
+ if (isQuotaFormatStyle(quotaToast.formatStyle)) {
877
+ answers.formatStyle = resolveQuotaFormatStyle(quotaToast.formatStyle);
878
+ }
879
+ if (quotaToast.percentDisplayMode === "remaining" || quotaToast.percentDisplayMode === "used") {
880
+ answers.percentDisplayMode = quotaToast.percentDisplayMode;
881
+ }
882
+ if (typeof quotaToast.showSessionTokens === "boolean") {
883
+ answers.showSessionTokens = quotaToast.showSessionTokens;
884
+ }
885
+ if (quotaToast.tuiCommandDisplay === "inline" || quotaToast.tuiCommandDisplay === "dialog") {
886
+ answers.tuiCommandDisplay = quotaToast.tuiCommandDisplay;
887
+ }
888
+ if (isPlainObject(quotaToast.maintainerAnnouncements) &&
889
+ typeof quotaToast.maintainerAnnouncements.enabled === "boolean") {
890
+ answers.maintainerAnnouncements = quotaToast.maintainerAnnouncements.enabled;
891
+ }
892
+ return answers;
893
+ }
894
+ async function promptForSelections(prompts, context) {
895
+ const interfaces = await prompts.select({
896
+ message: "Which OpenCode interfaces do you use?",
897
+ options: [
898
+ { label: "TUI", value: "tui", hint: "terminal interface" },
899
+ { label: "Web", value: "web", hint: "browser interface" },
900
+ {
901
+ label: "Both",
902
+ value: "both",
903
+ hint: "configure terminal and browser interfaces",
904
+ },
905
+ ],
906
+ });
907
+ if (prompts.isCancel(interfaces))
908
+ return null;
909
+ if (interfaces === "web") {
910
+ prompts.log.info("Web slash commands appear inline. TUI-only surfaces and popup dialogs are unavailable.");
911
+ }
636
912
  const scope = await prompts.select({
637
- message: "Install scope",
913
+ message: "Where should OpenCode Quota be configured?",
914
+ initialValue: "global",
638
915
  options: [
916
+ {
917
+ label: "Global OpenCode config (recommended)",
918
+ value: "global",
919
+ hint: "install for all projects using your global config",
920
+ },
639
921
  { label: "Project config", value: "project", hint: "install only for this repo/worktree" },
640
- { label: "Global OpenCode config", value: "global", hint: "install for all projects using your global config" },
641
922
  ],
642
923
  });
643
924
  if (prompts.isCancel(scope))
644
925
  return null;
645
- const quotaUi = await prompts.multiselect({
646
- message: "Quota UI",
647
- required: true,
926
+ const baseDir = resolveInitInstallerBaseDir({
927
+ scope: scope,
928
+ cwd: context.cwd,
929
+ env: context.env,
930
+ homeDir: context.homeDir,
931
+ });
932
+ const existing = await readExistingInstallerAnswers(baseDir);
933
+ const configFormat = await prompts.select({
934
+ message: "OpenCode config format",
935
+ initialValue: existing.configFormat ?? "jsonc",
648
936
  options: [
649
- { label: "Toast", value: "toast", hint: "popup quota summaries after idle/question/compact events" },
650
- { label: "Sidebar panel", value: "sidebar", hint: "full Quota panel in the OpenCode session sidebar" },
651
- { label: "Compact status line", value: "compact_status", hint: "short quota summary in the TUI status area" },
652
937
  {
653
- label: "No automatic UI surfaces",
654
- value: "none",
655
- hint: "no toast, sidebar, compact status, or TUI dialogs; server slash commands stay installed",
938
+ label: "JSONC (recommended)",
939
+ value: "jsonc",
940
+ hint: "keeps useful section comments and trailing commas",
656
941
  },
942
+ { label: "JSON", value: "json", hint: "strict JSON without comments" },
657
943
  ],
658
944
  });
659
- if (prompts.isCancel(quotaUi))
945
+ if (prompts.isCancel(configFormat))
660
946
  return null;
661
- if (!Array.isArray(quotaUi)) {
662
- throw new InitInstallerError("Quota UI requires selected options.");
947
+ let quotaUi = ["none"];
948
+ let tuiCommandDisplay;
949
+ let maintainerAnnouncements;
950
+ if (interfaces !== "web") {
951
+ while (true) {
952
+ quotaUi = await prompts.multiselect({
953
+ message: "Which automatic quota displays do you want?",
954
+ required: true,
955
+ initialValues: existing.quotaUi ?? ["sidebar"],
956
+ options: [
957
+ {
958
+ label: "Sidebar panel (TUI)",
959
+ value: "sidebar",
960
+ hint: "recommended; full Quota panel in the session sidebar",
961
+ },
962
+ { label: "Toast (TUI)", value: "toast", hint: "popup quota summaries" },
963
+ {
964
+ label: "Compact status line (TUI)",
965
+ value: "compact_status",
966
+ hint: "short quota summary below the message input",
967
+ },
968
+ {
969
+ label: "Manual commands only",
970
+ value: "none",
971
+ hint: "no automatic surfaces; slash commands remain available",
972
+ },
973
+ ],
974
+ });
975
+ if (prompts.isCancel(quotaUi))
976
+ return null;
977
+ if (!Array.isArray(quotaUi)) {
978
+ throw new InitInstallerError("Automatic quota surfaces require selected options.");
979
+ }
980
+ if (!(quotaUi.includes("none") && quotaUi.length > 1))
981
+ break;
982
+ prompts.log.error("Manual commands only cannot be combined with automatic surfaces.");
983
+ }
984
+ tuiCommandDisplay = await prompts.select({
985
+ message: "Where should slash commands (e.g. /quota) appear?",
986
+ initialValue: existing.tuiCommandDisplay ?? "inline",
987
+ options: [
988
+ {
989
+ label: "Inline with messages",
990
+ value: "inline",
991
+ hint: "persist output in the message transcript",
992
+ },
993
+ {
994
+ label: "Popup dialog",
995
+ value: "dialog",
996
+ hint: "show output in a temporary TUI popup",
997
+ },
998
+ ],
999
+ });
1000
+ if (prompts.isCancel(tuiCommandDisplay))
1001
+ return null;
663
1002
  }
664
1003
  const providerMode = await prompts.select({
665
- message: "Provider mode",
1004
+ message: "How should pre-configured providers be selected?",
1005
+ initialValue: existing.providerMode ?? "auto",
666
1006
  options: [
667
- { label: "Auto-detect providers", value: "auto", hint: "recommended; use providers found in your OpenCode/auth setup" },
668
- { label: "Choose providers manually", value: "manual", hint: "only track the providers you select" },
1007
+ {
1008
+ label: "Auto-detect providers",
1009
+ value: "auto",
1010
+ hint: "use providers found in your OpenCode configuration and authentication",
1011
+ },
1012
+ {
1013
+ label: "Choose providers manually",
1014
+ value: "manual",
1015
+ hint: "track only the pre-configured providers you select",
1016
+ },
669
1017
  ],
670
1018
  });
671
1019
  if (prompts.isCancel(providerMode))
@@ -673,8 +1021,9 @@ async function promptForSelections(prompts) {
673
1021
  let manualProviders = [];
674
1022
  if (providerMode === "manual") {
675
1023
  const selected = await prompts.multiselect({
676
- message: "Manual providers",
1024
+ message: "Which pre-configured providers should be tracked?",
677
1025
  required: true,
1026
+ initialValues: existing.manualProviders,
678
1027
  options: getInstallerProviderPromptOptions(),
679
1028
  });
680
1029
  if (prompts.isCancel(selected))
@@ -684,23 +1033,29 @@ async function promptForSelections(prompts) {
684
1033
  }
685
1034
  manualProviders = selected.filter((value) => typeof value === "string");
686
1035
  }
1036
+ prompts.log.info("Custom providers are configured after installation.");
1037
+ prompts.log.info("npx @slkiser/opencode-quota@latest provider add");
687
1038
  const formatStyle = await prompts.select({
688
1039
  message: "Quota reset periods",
1040
+ initialValue: existing.formatStyle ?? "allWindows",
689
1041
  options: [
690
1042
  {
691
- label: "All reset periods per provider (all windows; compare every tracked reset period)",
1043
+ label: "All reset periods",
692
1044
  value: "allWindows",
1045
+ hint: "show every available quota window",
693
1046
  },
694
1047
  {
695
- label: "One reset period per provider (single window; best for quick quota checks)",
1048
+ label: "One reset period (expiring soonest)",
696
1049
  value: "singleWindow",
1050
+ hint: "show only the next quota window to expire",
697
1051
  },
698
1052
  ],
699
1053
  });
700
1054
  if (prompts.isCancel(formatStyle))
701
1055
  return null;
702
1056
  const percentDisplayMode = await prompts.select({
703
- message: "Quota percentage meaning",
1057
+ message: "What should quota percentages show?",
1058
+ initialValue: existing.percentDisplayMode ?? "remaining",
704
1059
  options: [
705
1060
  { label: "Remaining quota", value: "remaining", hint: "show how much quota is left" },
706
1061
  { label: "Used quota", value: "used", hint: "show how much quota has been consumed" },
@@ -709,38 +1064,54 @@ async function promptForSelections(prompts) {
709
1064
  if (prompts.isCancel(percentDisplayMode))
710
1065
  return null;
711
1066
  const showSessionTokens = await prompts.select({
712
- message: "Session token details",
1067
+ message: "Session input/output tokens",
1068
+ initialValue: existing.showSessionTokens === true ? "yes" : "no",
713
1069
  options: [
714
- { label: "Hide session tokens", value: "no", hint: "keep quota output shorter" },
715
- { label: "Show session tokens", value: "yes", hint: "include current session input/output token counts when available" },
1070
+ { label: "Hide", value: "no", hint: "keep output shorter" },
1071
+ {
1072
+ label: "Show",
1073
+ value: "yes",
1074
+ hint: "include current-session input and output totals",
1075
+ },
716
1076
  ],
717
1077
  });
718
1078
  if (prompts.isCancel(showSessionTokens))
719
1079
  return null;
720
- const maintainerAnnouncements = await prompts.confirm({
721
- message: "Show bundled maintainer notices automatically when available?",
722
- initialValue: true,
723
- });
724
- if (prompts.isCancel(maintainerAnnouncements))
725
- return null;
1080
+ if (interfaces !== "web") {
1081
+ maintainerAnnouncements = await prompts.confirm({
1082
+ message: "Show maintainer announcements on the TUI Home screen when available?",
1083
+ initialValue: existing.maintainerAnnouncements ?? true,
1084
+ });
1085
+ if (prompts.isCancel(maintainerAnnouncements))
1086
+ return null;
1087
+ }
726
1088
  return {
1089
+ interfaces: interfaces,
727
1090
  scope: scope,
728
- quotaUi: quotaUi.filter((value) => typeof value === "string"),
1091
+ quotaUi: Array.isArray(quotaUi)
1092
+ ? quotaUi.filter((value) => typeof value === "string")
1093
+ : ["none"],
729
1094
  providerMode: providerMode,
730
1095
  manualProviders,
731
1096
  formatStyle: formatStyle,
732
1097
  percentDisplayMode: percentDisplayMode,
733
1098
  showSessionTokens: showSessionTokens === "yes",
734
- maintainerAnnouncements: maintainerAnnouncements !== false,
1099
+ tuiCommandDisplay: interfaces === "web" ? undefined : tuiCommandDisplay,
1100
+ maintainerAnnouncements: interfaces === "web" ? undefined : maintainerAnnouncements !== false,
1101
+ configFormat: configFormat,
735
1102
  };
736
1103
  }
737
1104
  export async function runInitInstaller(params) {
738
1105
  const prompts = params?.prompts ?? (await import("@clack/prompts"));
739
1106
  prompts.intro("Configure @slkiser/opencode-quota");
740
1107
  try {
741
- const selections = await promptForSelections(prompts);
1108
+ const selections = await promptForSelections(prompts, {
1109
+ cwd: params?.cwd,
1110
+ env: params?.env,
1111
+ homeDir: params?.homeDir,
1112
+ });
742
1113
  if (!selections) {
743
- prompts.outro("Cancelled");
1114
+ prompts.outro("OpenCode Quota setup cancelled — no files changed.");
744
1115
  return 0;
745
1116
  }
746
1117
  const plan = await planInitInstaller({
@@ -754,7 +1125,11 @@ export async function runInitInstaller(params) {
754
1125
  prompts.log.info(line);
755
1126
  }
756
1127
  if (!plan.edits.some((edit) => edit.changed)) {
757
- prompts.outro(`No changes needed — ${GITHUB_STAR_NOTE}`);
1128
+ prompts.outro(`OpenCode Quota is already configured and current. No files changed. ${GITHUB_STAR_NOTE}`);
1129
+ return 0;
1130
+ }
1131
+ if (params?.dryRun) {
1132
+ prompts.outro("OpenCode Quota setup preview complete — no files changed. Run npx @slkiser/opencode-quota@latest init to apply.");
758
1133
  return 0;
759
1134
  }
760
1135
  const confirmed = await prompts.confirm({
@@ -762,7 +1137,7 @@ export async function runInitInstaller(params) {
762
1137
  initialValue: true,
763
1138
  });
764
1139
  if (prompts.isCancel(confirmed) || !confirmed) {
765
- prompts.outro("Cancelled");
1140
+ prompts.outro("OpenCode Quota setup cancelled — no files changed.");
766
1141
  return 0;
767
1142
  }
768
1143
  const result = await applyInitInstallerPlan(plan);
@@ -778,20 +1153,33 @@ export async function runInitInstaller(params) {
778
1153
  prompts.log.info(`- ${note.label}: README.md#${note.anchor}`);
779
1154
  }
780
1155
  }
781
- prompts.outro(`Quota init complete — ${GITHUB_STAR_NOTE}`);
1156
+ const interfaceLabel = plan.selections.interfaces === "tui"
1157
+ ? "TUI"
1158
+ : plan.selections.interfaces === "web"
1159
+ ? "Web"
1160
+ : "TUI and Web";
1161
+ const configuredPaths = [...result.writtenPaths, ...result.unchangedPaths];
1162
+ prompts.outro([
1163
+ "OpenCode Quota setup complete.",
1164
+ `Interfaces: ${interfaceLabel}`,
1165
+ "Configured paths:",
1166
+ ...configuredPaths.map((path) => `- ${path}`),
1167
+ "Restart OpenCode and run /quota.",
1168
+ `If OpenCode Quota helps, please consider a star: ${GITHUB_REPO_URL}`,
1169
+ ].join("\n"));
782
1170
  return 0;
783
1171
  }
784
1172
  catch (error) {
785
- if (error instanceof InitInstallerError) {
786
- prompts.log.error(error.message);
787
- if (error.details?.writtenPaths?.length) {
788
- prompts.log.info(`Already written: ${error.details.writtenPaths.join(", ")}`);
789
- }
1173
+ const reason = error instanceof Error ? error.message : String(error);
1174
+ prompts.log.error(reason);
1175
+ const writtenPaths = error instanceof InitInstallerError ? (error.details?.writtenPaths ?? []) : [];
1176
+ if (writtenPaths.length > 0) {
1177
+ prompts.log.info(`Files changed before failure: ${writtenPaths.join(", ")}`);
1178
+ prompts.outro("OpenCode Quota setup failed after some files changed. Review the listed files, fix the reason above, then rerun init.");
790
1179
  }
791
1180
  else {
792
- prompts.log.error(error instanceof Error ? error.message : String(error));
1181
+ prompts.outro("OpenCode Quota setup failed before any files changed. Fix the reason above, then rerun init.");
793
1182
  }
794
- prompts.outro("Quota init failed");
795
1183
  return 1;
796
1184
  }
797
1185
  }