@bitkyc08/opencodex 2.77.0 → 2.78.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 (222) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-BtfG5id-.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
  5. package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  15. package/src/adapters/devin/trajectory.ts +49 -0
  16. package/src/adapters/devin.ts +13 -0
  17. package/src/adapters/ollama-native.ts +35 -19
  18. package/src/claude/intercept/client-hello.ts +103 -0
  19. package/src/claude/intercept/model-bindings.ts +3 -2
  20. package/src/claude/intercept/picker-listener.ts +279 -66
  21. package/src/cli/access-audio-input.ts +84 -0
  22. package/src/cli/access-audio-live.ts +99 -0
  23. package/src/cli/access-audio.ts +123 -0
  24. package/src/cli/access-data-client.ts +127 -0
  25. package/src/cli/access-data-plane.ts +104 -0
  26. package/src/cli/access-data-response.ts +76 -0
  27. package/src/cli/access.ts +65 -2
  28. package/src/cli/account-api.ts +30 -6
  29. package/src/cli/account-auth.ts +111 -42
  30. package/src/cli/account-key-quota.ts +118 -0
  31. package/src/cli/account-main.ts +11 -4
  32. package/src/cli/account-next-actions.ts +63 -0
  33. package/src/cli/account-policy-dto.ts +68 -0
  34. package/src/cli/account-policy.ts +167 -0
  35. package/src/cli/account-target.ts +8 -0
  36. package/src/cli/account.ts +34 -8
  37. package/src/cli/agent-runtime-settings.ts +130 -0
  38. package/src/cli/agent-settings.ts +101 -0
  39. package/src/cli/agent.ts +19 -2
  40. package/src/cli/alias.ts +7 -2
  41. package/src/cli/aside-profiles.ts +2 -2
  42. package/src/cli/capabilities-access-remote.ts +147 -0
  43. package/src/cli/capabilities-accounts.ts +195 -0
  44. package/src/cli/capabilities-agents-routing.ts +414 -0
  45. package/src/cli/capabilities-base.ts +783 -0
  46. package/src/cli/capabilities-command.ts +1 -0
  47. package/src/cli/capabilities-integrations.ts +264 -0
  48. package/src/cli/capabilities-lab.ts +214 -0
  49. package/src/cli/capabilities-observe-system.ts +662 -0
  50. package/src/cli/capabilities-provider-models.ts +302 -0
  51. package/src/cli/capabilities.ts +30 -1199
  52. package/src/cli/capability-types.ts +54 -0
  53. package/src/cli/catalog-command-result.ts +57 -0
  54. package/src/cli/chatgpt-command.ts +25 -2
  55. package/src/cli/claude-agent-startup-sync.ts +17 -0
  56. package/src/cli/claude-desktop-profile.ts +100 -0
  57. package/src/cli/claude-desktop.ts +39 -6
  58. package/src/cli/codex-shim-autorestore.ts +2 -1
  59. package/src/cli/codex-shim-readiness.ts +23 -3
  60. package/src/cli/combo-input.ts +88 -0
  61. package/src/cli/combo-result.ts +52 -0
  62. package/src/cli/combo-stats.ts +123 -0
  63. package/src/cli/combo.ts +37 -14
  64. package/src/cli/companion-timeline.ts +142 -0
  65. package/src/cli/companion-usage.ts +146 -0
  66. package/src/cli/companion.ts +12 -3
  67. package/src/cli/config-command.ts +24 -8
  68. package/src/cli/dispatch.ts +52 -79
  69. package/src/cli/doctor.ts +22 -4
  70. package/src/cli/help-catalog.ts +31 -14
  71. package/src/cli/help-recovery.ts +2 -2
  72. package/src/cli/help.ts +25 -11
  73. package/src/cli/index.ts +38 -27
  74. package/src/cli/injection-follow.ts +35 -0
  75. package/src/cli/inspect.ts +5 -0
  76. package/src/cli/integration-aside-sync.ts +69 -0
  77. package/src/cli/integration-cursor.ts +106 -0
  78. package/src/cli/integration-input.ts +60 -0
  79. package/src/cli/integration-journal.ts +49 -0
  80. package/src/cli/integration-plan-dto.ts +156 -0
  81. package/src/cli/integration-preview.ts +215 -0
  82. package/src/cli/integrations.ts +24 -18
  83. package/src/cli/json-input.ts +134 -0
  84. package/src/cli/link.ts +32 -4
  85. package/src/cli/local-sync-result.ts +41 -0
  86. package/src/cli/log-follow.ts +84 -0
  87. package/src/cli/log-view-filter.ts +146 -0
  88. package/src/cli/logout-command.ts +44 -0
  89. package/src/cli/model-picker-ordering.ts +137 -0
  90. package/src/cli/models-custom-input.ts +107 -0
  91. package/src/cli/models-custom-runtime.ts +112 -0
  92. package/src/cli/models-order.ts +194 -0
  93. package/src/cli/models-runtime-subcommands.ts +2 -0
  94. package/src/cli/models-runtime.ts +7 -0
  95. package/src/cli/models.ts +66 -81
  96. package/src/cli/observe-stream.ts +142 -0
  97. package/src/cli/observe.ts +75 -34
  98. package/src/cli/provider-batch.ts +77 -0
  99. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  100. package/src/cli/provider-result.ts +124 -0
  101. package/src/cli/provider-runtime.ts +15 -12
  102. package/src/cli/provider-settings.ts +143 -0
  103. package/src/cli/provider.ts +112 -34
  104. package/src/cli/registry.ts +62 -20
  105. package/src/cli/remote-workspace-hub.ts +83 -0
  106. package/src/cli/remote-workspace.ts +6 -1
  107. package/src/cli/restart-failure.ts +27 -0
  108. package/src/cli/root.ts +12 -0
  109. package/src/cli/route-policy-write.ts +137 -0
  110. package/src/cli/route-policy.ts +8 -0
  111. package/src/cli/runtime-api.ts +56 -79
  112. package/src/cli/secret-args.ts +149 -0
  113. package/src/cli/settings-result.ts +30 -0
  114. package/src/cli/storage.ts +77 -7
  115. package/src/cli/system-command.ts +60 -2
  116. package/src/cli/system-restart-client.ts +20 -3
  117. package/src/cli/system-settings-parity.ts +83 -0
  118. package/src/cli/update-restart-candidate.ts +21 -0
  119. package/src/cli/update-restart-child.ts +81 -0
  120. package/src/cli/update-restart-home.ts +38 -0
  121. package/src/cli/update-restart-transport.ts +193 -0
  122. package/src/cli/update-restart.ts +260 -0
  123. package/src/cli/usage-model-search.ts +58 -0
  124. package/src/cli/usage-report.ts +13 -5
  125. package/src/cli/v2-input.ts +66 -0
  126. package/src/cli/v2-local-output.ts +37 -0
  127. package/src/cli/v2-runtime.ts +90 -0
  128. package/src/cli/v2.ts +102 -144
  129. package/src/clients/config-export/dsh.ts +21 -0
  130. package/src/clients/config-export.ts +7 -1
  131. package/src/codex/catalog/build-entries.ts +1 -1
  132. package/src/codex/catalog/bundled.ts +9 -2
  133. package/src/codex/catalog/heal-observation.ts +66 -0
  134. package/src/codex/catalog/publication-observer.ts +19 -0
  135. package/src/codex/catalog/remote.ts +13 -15
  136. package/src/codex/catalog/restore.ts +19 -6
  137. package/src/codex/catalog/retained-sync.ts +58 -10
  138. package/src/codex/catalog/routed-removal.ts +116 -0
  139. package/src/codex/catalog/write-audit-contract.ts +21 -0
  140. package/src/codex/catalog/write-audit.ts +171 -0
  141. package/src/codex/catalog-self-heal.ts +284 -0
  142. package/src/codex/catalog-write-serialization.ts +71 -7
  143. package/src/codex/codex-home-owner.ts +124 -0
  144. package/src/codex/convergence.ts +55 -15
  145. package/src/codex/features.ts +8 -1
  146. package/src/codex/inject/restore.ts +46 -4
  147. package/src/codex/inject.ts +11 -1
  148. package/src/codex/internal/catalog-writer.ts +120 -13
  149. package/src/codex/journal.ts +49 -13
  150. package/src/codex/management-convergence.ts +3 -0
  151. package/src/codex/quota-types.ts +3 -0
  152. package/src/codex/quota.ts +13 -3
  153. package/src/codex/refresh.ts +20 -9
  154. package/src/codex/runtime.ts +7 -1
  155. package/src/codex/shim-diagnostics.ts +80 -0
  156. package/src/codex/shim-fingerprint.ts +7 -2
  157. package/src/codex/shim-migration.ts +153 -0
  158. package/src/codex/shim-overlay.ts +509 -0
  159. package/src/codex/shim-path-resolution.ts +14 -2
  160. package/src/codex/shim-probe.ts +4 -3
  161. package/src/codex/shim-state-file.ts +93 -7
  162. package/src/codex/shim.ts +15 -68
  163. package/src/codex/sync.ts +24 -4
  164. package/src/combos/failover.ts +19 -0
  165. package/src/combos/index.ts +1 -0
  166. package/src/combos/resolve.ts +12 -0
  167. package/src/config/atomic-write.ts +13 -8
  168. package/src/config/diagnostics.ts +19 -1
  169. package/src/config/load-degrade.ts +3 -0
  170. package/src/config/schema/chatgpt-desktop.ts +22 -0
  171. package/src/config/schema/config-schema.ts +32 -14
  172. package/src/config/schema/leaf-validators.ts +3 -2
  173. package/src/generated/compatibility-version.json +445 -129
  174. package/src/integrations/merge.ts +13 -5
  175. package/src/integrations/mutation-plan.ts +46 -3
  176. package/src/integrations/omp-yaml-source.ts +181 -1
  177. package/src/integrations/ownership-policy.ts +66 -0
  178. package/src/integrations/registry.ts +50 -1
  179. package/src/integrations/state.ts +46 -21
  180. package/src/integrations/target.ts +25 -2
  181. package/src/integrations/writer.ts +89 -31
  182. package/src/lib/package-tree-integrity.ts +42 -1
  183. package/src/lib/proxy-env.ts +3 -0
  184. package/src/lib/test-home-guard.ts +2 -1
  185. package/src/oauth/anthropic-account-refusal.ts +19 -8
  186. package/src/oauth/anthropic-routing.ts +17 -12
  187. package/src/oauth/anthropic.ts +27 -2
  188. package/src/oauth/index.ts +6 -8
  189. package/src/oauth/pool-settings-capability.ts +9 -2
  190. package/src/protocols/plan-snapshot.ts +1 -1
  191. package/src/protocols/settings.ts +28 -4
  192. package/src/server/caller-session-identity.ts +32 -0
  193. package/src/server/claude-messages.ts +11 -4
  194. package/src/server/index/serve-options.ts +12 -7
  195. package/src/server/index.ts +1 -1
  196. package/src/server/management/agent-settings-routes.ts +6 -5
  197. package/src/server/management/context.ts +8 -1
  198. package/src/server/management/cursor-integration-routes.ts +3 -3
  199. package/src/server/management/integration-routes.ts +5 -5
  200. package/src/server/management/model-routes.ts +3 -3
  201. package/src/server/management/native-integration-routes.ts +2 -2
  202. package/src/server/management/oauth-account-routes.ts +96 -30
  203. package/src/server/management/route-registry.ts +25 -24
  204. package/src/server/messages-native-eligibility.ts +3 -6
  205. package/src/server/messages-native-oauth.ts +82 -35
  206. package/src/server/messages-native.ts +78 -16
  207. package/src/server/responses/adapter-dispatch.ts +53 -28
  208. package/src/server/responses/core-auth.ts +10 -4
  209. package/src/server/responses/core-combo.ts +46 -6
  210. package/src/service/cli.ts +6 -1
  211. package/src/service/ownership-mutation-lease.d.mts +26 -0
  212. package/src/service/ownership-mutation-lease.mjs +76 -1
  213. package/src/types/config.ts +2 -0
  214. package/src/types/provider.ts +10 -10
  215. package/src/update/index.ts +2 -1
  216. package/src/update/update-failure-guidance.d.mts +6 -0
  217. package/src/update/update-failure-guidance.mjs +30 -5
  218. package/gui/dist/assets/App-COdEeqSj.css +0 -1
  219. package/gui/dist/assets/App-DEpCJEP_.js +0 -54
  220. package/gui/dist/assets/index-Cd7ittMv.css +0 -1
  221. package/gui/dist/assets/index-DwJKFJm5.js +0 -94
  222. package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
@@ -0,0 +1,54 @@
1
+ /** A management route a capability drives. Path text only; never a handler reference. */
2
+ export interface CapabilityRoute {
3
+ readonly method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
4
+ readonly path: string;
5
+ }
6
+
7
+ export interface CapabilityFlag {
8
+ readonly name: string;
9
+ readonly value?: "string" | "number" | "boolean";
10
+ readonly required?: boolean;
11
+ readonly summary: string;
12
+ }
13
+
14
+ /**
15
+ * How a capability emits JSON.
16
+ *
17
+ * - `payload`: the API payload, largely unwrapped.
18
+ * - `envelope`: a CLI-shaped object with its own schema.
19
+ * - `none`: no `--json` mode.
20
+ */
21
+ export type CapabilityJsonMode = "payload" | "envelope" | "none";
22
+
23
+ export interface Capability {
24
+ /** Command path, e.g. `["account", "pause"]`. */
25
+ readonly command: readonly string[];
26
+ readonly summary: string;
27
+ readonly routes: readonly CapabilityRoute[];
28
+ readonly flags: readonly CapabilityFlag[];
29
+ readonly mutates: boolean;
30
+ readonly json: CapabilityJsonMode;
31
+ /** Complete CLI usage, including operands, when verified for this leaf. */
32
+ readonly usage?: string;
33
+ readonly details?: readonly string[];
34
+ /**
35
+ * Extra banner rows this capability owns, for surfaces the banner shows separately
36
+ * from the bare command (`ocx restore back`, `ocx doctor --reclaim-response-temps`).
37
+ * Without this the banner cannot equal the capability set: it legitimately carries more
38
+ * rows than there are commands.
39
+ */
40
+ readonly bannerLines?: readonly string[];
41
+ }
42
+
43
+ /**
44
+ * Surfaces resolved in the CLI head, before dispatch.
45
+ *
46
+ * They belong in `ocx capabilities` output and in the banner, but not in `CLI_COMMANDS`:
47
+ * `--version`, `-v`, and `version` are answered at `root.ts` and exit, so none of them is
48
+ * a runner key to parity-check against.
49
+ */
50
+ export interface HeadCapability {
51
+ readonly invocations: readonly string[];
52
+ readonly summary: string;
53
+ readonly bannerLine: string;
54
+ }
@@ -0,0 +1,57 @@
1
+ import { normalizeCatalogDisposition } from "../codex/catalog-refresh-status";
2
+ import { CliUsageError, RuntimeApiError, PROXY_NOT_RUNNING_MESSAGE, printData, terminalSafeText } from "./runtime-api";
3
+
4
+ function own(value: unknown, key: string): unknown {
5
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
6
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
7
+ return descriptor && "value" in descriptor ? descriptor.value : undefined;
8
+ }
9
+
10
+ /** A small error/exit boundary; routes and domain DTO validation belong to callers. */
11
+ export async function runCatalogAction(
12
+ action: () => Promise<number | void>, knownErrors: Readonly<Record<string, string>> = {},
13
+ ): Promise<number> {
14
+ try {
15
+ return (await action()) ?? 0;
16
+ } catch (error) {
17
+ if (error instanceof CliUsageError) {
18
+ // New domain parsers use static messages instead of raw JSON/validator exceptions.
19
+ console.error(`Error: ${terminalSafeText(error.message)}`);
20
+ if (error.usage) for (const line of error.usage.split("\n")) console.error(terminalSafeText(line));
21
+ return 2;
22
+ }
23
+ if (error instanceof RuntimeApiError) {
24
+ if (error.code) {
25
+ console.error(`Error: ${error.code === "proxy_not_running" ? PROXY_NOT_RUNNING_MESSAGE
26
+ : "This listener has no management API. Run management commands on the connected Hub. No request was sent."}`);
27
+ return 1;
28
+ }
29
+ const code = own(error.body, "code") ?? own(own(error.body, "error"), "code");
30
+ const known = typeof code === "string" && Object.hasOwn(knownErrors, code) ? knownErrors[code] : undefined;
31
+ const message = error.status === 404 ? "The requested record or operation was not found on the selected target."
32
+ : error.status === 409 ? "The selected target refused a conflicting change. Read back before trying again."
33
+ : error.status === 503 ? "Management API is unavailable. Use the intended running proxy; connected clients must make management changes on their Hub. A write outcome may be unknown."
34
+ : "The management request failed. Inspect the selected target before retrying; the write outcome may be unknown.";
35
+ console.error(`Error: ${terminalSafeText(known ?? message)}`);
36
+ return error.status === 404 ? 4 : error.status === 409 ? 5 : 1;
37
+ }
38
+ console.error("Error: Management operation did not return a usable outcome. Inspect the selected target before retrying; no rollback is implied.");
39
+ return 1;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Caller supplies a validated, rebuilt public DTO, not an arbitrary server body.
45
+ * This helper normalizes only the shared catalog disposition and preserves its exit.
46
+ */
47
+ export function printCatalogResult(
48
+ data: Record<string, unknown>, refresh: unknown, wantsJson: boolean, lines: readonly string[],
49
+ ): number {
50
+ const catalogRefresh = normalizeCatalogDisposition(refresh);
51
+ if (catalogRefresh === null) throw new Error("Invalid catalog outcome");
52
+ const converged = catalogRefresh.status === "committed" && !catalogRefresh.degraded;
53
+ const outcome = converged ? "Catalog committed."
54
+ : "Change saved, but catalog convergence is incomplete. Read back before retrying.";
55
+ printData({ ...data, catalogRefresh }, wantsJson, [...lines, outcome]);
56
+ return converged ? 0 : 1;
57
+ }
@@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process";
2
2
  import { existsSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { loadConfig } from "../config";
5
+ import { readConfigFileSnapshot } from "../config/diagnostics";
6
+ import { chatgptDesktopConfigIssue } from "../config/schema/chatgpt-desktop";
5
7
  import {
6
8
  chatgptShimLauncherPath,
7
9
  resolveChatgptCodexBinary,
@@ -21,6 +23,20 @@ function run(command: string, args: string[]) {
21
23
  return { ok: result.status === 0, output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() };
22
24
  }
23
25
 
26
+ /**
27
+ * Why the config file's `chatgptDesktop` block reads as absent, or null when it is valid, absent
28
+ * or the file cannot be parsed (the config loader reports that case itself).
29
+ */
30
+ function chatgptDesktopIssueInFile(): string | null {
31
+ const { raw } = readConfigFileSnapshot();
32
+ if (raw === undefined) return null;
33
+ try {
34
+ return chatgptDesktopConfigIssue(JSON.parse(raw.replace(/^\uFEFF/, "")));
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
24
40
  /**
25
41
  * The installed app, found and confirmed by bundle identifier (com.openai.codex) the same way the
26
42
  * desktop restart adapter does. "ChatGPT" is a display name another app can share, so quitting,
@@ -90,7 +106,10 @@ export async function handleChatgptCommand(args: string[], platform: NodeJS.Plat
90
106
  const install = discoverApp();
91
107
  if (sub === "status") {
92
108
  const app = install ? appState(install, launcher) : { running: false, shim: false };
93
- console.log(`app-server shim (experimental): ${config.chatgptDesktop?.appServerShim === true ? "on" : "off"}
109
+ const configIssue = config.chatgptDesktop?.appServerShim === true ? null : chatgptDesktopIssueInFile();
110
+ const flag = config.chatgptDesktop?.appServerShim === true ? "on"
111
+ : configIssue ? `off (config.json ${configIssue}; the whole chatgptDesktop block is ignored)` : "off";
112
+ console.log(`app-server shim (experimental): ${flag}
94
113
  launcher: ${existsSync(launcher) ? "present" : "absent"}
95
114
  app: ${install ? (app.running ? "running" : "not running") : "not installed"}
96
115
  CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`);
@@ -104,7 +123,11 @@ CODEX_CLI_PATH launcher: ${app.shim ? "yes" : "no"}`);
104
123
  let binary: string | undefined;
105
124
  if (sub === "launch") {
106
125
  if (config.chatgptDesktop?.appServerShim !== true) {
107
- console.error('Experimental shim disabled; set chatgptDesktop.appServerShim: true before launching.');
126
+ // Read the file only to explain a refusal; restore and a valid launch never touch it.
127
+ const configIssue = chatgptDesktopIssueInFile();
128
+ console.error(configIssue
129
+ ? `Experimental shim disabled: config.json ${configIssue}. The whole chatgptDesktop block is ignored until that is fixed.`
130
+ : "Experimental shim disabled; set chatgptDesktop.appServerShim: true before launching.");
108
131
  return 1;
109
132
  }
110
133
  binary = resolveChatgptCodexBinary(install.root) ?? undefined;
@@ -64,6 +64,23 @@ export async function reconcileClientStartupBeforeReady<T>(
64
64
  return result;
65
65
  }
66
66
 
67
+ /** Establish catalog observation before any unrelated startup reconciliation awaits. */
68
+ export async function syncCodexBeforeCatalogObservation<T>(
69
+ gate: ReadinessGate,
70
+ syncCodex: (gate: ReadinessGate) => Promise<T>,
71
+ startObservation: () => void,
72
+ ): Promise<T> {
73
+ let ready = false;
74
+ const forwardingGate: ReadinessGate = {
75
+ getStatus: () => gate.getStatus(),
76
+ markReady: () => { ready = true; gate.markReady(); },
77
+ markFailed: () => { ready = false; gate.markFailed(); },
78
+ };
79
+ const result = await syncCodex(forwardingGate);
80
+ if (ready) startObservation();
81
+ return result;
82
+ }
83
+
67
84
  /**
68
85
  * Reconcile the generated Claude Code roster after the proxy listener is live.
69
86
  *
@@ -0,0 +1,100 @@
1
+ /** Runtime-owned Desktop profile editing. Saving never applies a native configuration. */
2
+ import { DESKTOP_FAMILIES, parseDesktopProfile, type DesktopProfile } from "../claude/desktop-profile";
3
+ import { runCatalogAction } from "./catalog-command-result";
4
+ import { readJsonInput, serializeManagementJson } from "./json-input";
5
+ import { CliUsageError, printData, runtimeRequest, takeFlag, type RuntimeApiDeps } from "./runtime-api";
6
+
7
+ export interface DesktopProfileCliDeps extends RuntimeApiDeps {
8
+ runtimeRequestImpl?: typeof runtimeRequest;
9
+ }
10
+
11
+ const USAGE = "Usage: ocx claude desktop profile show [--json]\n ocx claude desktop profile import FILE|- [--json]";
12
+
13
+ function record(value: unknown): Record<string, unknown> {
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Desktop state");
15
+ return value as Record<string, unknown>;
16
+ }
17
+
18
+ function text(value: unknown): string {
19
+ if (typeof value !== "string" || !value.length) throw new Error("Invalid Desktop text");
20
+ return value;
21
+ }
22
+
23
+ function boolean(value: unknown): boolean {
24
+ if (typeof value !== "boolean") throw new Error("Invalid Desktop flag");
25
+ return value;
26
+ }
27
+
28
+ function modelIdentity(row: Record<string, unknown>) {
29
+ const route = text(row.route);
30
+ if (!route.includes("/")) throw new Error("Invalid Desktop route");
31
+ if (row.contextWindow !== undefined && (typeof row.contextWindow !== "number"
32
+ || !Number.isSafeInteger(row.contextWindow) || row.contextWindow <= 0)) throw new Error("Invalid Desktop context window");
33
+ return { route, label: text(row.label), ...(row.contextWindow === undefined ? {} : { contextWindow: row.contextWindow as number }) };
34
+ }
35
+
36
+ function desktopState(raw: unknown, saved: boolean) {
37
+ const value = record(raw);
38
+ if (saved && value.ok !== true) throw new Error("Desktop save was not confirmed");
39
+ const profile = parseDesktopProfile(value.profile);
40
+ if (!Array.isArray(value.models) || !Array.isArray(value.rendered)
41
+ || !Number.isSafeInteger(value.port) || (value.port as number) < 1 || (value.port as number) > 65535) {
42
+ throw new Error("Invalid Desktop state");
43
+ }
44
+ const seen = new Set<string>();
45
+ const models = value.models.map(rawModel => {
46
+ const row = record(rawModel), identity = modelIdentity(row), assignment = record(row.assignment);
47
+ const expected = profile.assignments[identity.route];
48
+ if (!expected || seen.has(identity.route) || assignment.family !== expected.family || assignment.alias !== expected.alias) {
49
+ throw new Error("Invalid Desktop assignment");
50
+ }
51
+ seen.add(identity.route);
52
+ return { ...identity, available: boolean(row.available), effortSupported: boolean(row.effortSupported),
53
+ supports1m: boolean(row.supports1m), assignment: { family: expected.family, alias: expected.alias } };
54
+ });
55
+ if (seen.size !== Object.keys(profile.assignments).length) throw new Error("Incomplete Desktop models");
56
+ const renderedSeen = new Set<string>();
57
+ const rendered = value.rendered.map(rawModel => {
58
+ const row = record(rawModel), identity = modelIdentity(row);
59
+ if (!DESKTOP_FAMILIES.includes(row.family as typeof DESKTOP_FAMILIES[number])
60
+ || profile.assignments[identity.route]?.family !== row.family || renderedSeen.has(identity.route)
61
+ || !models.some(model => model.route === identity.route && model.available)) throw new Error("Invalid rendered Desktop model");
62
+ renderedSeen.add(identity.route);
63
+ return { ...identity, name: text(row.name), family: row.family as typeof DESKTOP_FAMILIES[number],
64
+ isFamilyDefault: boolean(row.isFamilyDefault), supports1m: boolean(row.supports1m) };
65
+ });
66
+ if (renderedSeen.size !== models.filter(model => model.available).length) throw new Error("Incomplete rendered Desktop models");
67
+ return { ...(saved ? { ok: true as const } : {}), profile, models, rendered, port: value.port as number };
68
+ }
69
+
70
+ /** Arguments start immediately after `claude desktop profile`. */
71
+ export async function handleClaudeDesktopProfileCommand(argv: string[], deps: DesktopProfileCliDeps = {}): Promise<number> {
72
+ return runCatalogAction(async () => {
73
+ const args = [...argv], wantsJson = takeFlag(args, "--json");
74
+ const action = args.shift();
75
+ if ((action !== "show" && action !== "import") || (action === "show" && args.length !== 0)
76
+ || (action === "import" && (args.length !== 1 || !args[0]?.trim() || (args[0]!.startsWith("-") && args[0] !== "-")))) {
77
+ throw new CliUsageError("Desktop profile accepts show or import with an explicit file or '-'. Saving and applying are separate actions.", USAGE);
78
+ }
79
+ let body: string | undefined;
80
+ if (action === "import") {
81
+ const input = await readJsonInput(args[0]!, deps);
82
+ let profile: DesktopProfile;
83
+ try { profile = parseDesktopProfile(input); }
84
+ catch { throw new CliUsageError("Desktop profile input is invalid. Supply a version-1 profile with valid assignments and family defaults."); }
85
+ body = serializeManagementJson({ profile });
86
+ }
87
+ const raw = await (deps.runtimeRequestImpl ?? runtimeRequest)("/api/claude-desktop", {
88
+ method: action === "show" ? "GET" : "PUT", redirect: "error",
89
+ ...(body === undefined ? {} : { headers: { "content-type": "application/json" }, body }),
90
+ }, deps);
91
+ const state = desktopState(raw, action === "import");
92
+ printData(state, wantsJson, [
93
+ action === "import" ? "Desktop profile saved on the selected proxy. It has not been applied by this command." : "Desktop profile on the selected proxy.",
94
+ `Models: ${state.models.length}; available: ${state.models.filter(model => model.available).length}; port: ${state.port}`,
95
+ ...DESKTOP_FAMILIES.map(family => `${family}: ${state.profile.defaults[family] ?? "none"}`),
96
+ ...(action === "import" ? ["Next: review with ocx claude desktop profile show; apply separately with ocx claude desktop apply."] : []),
97
+ ]);
98
+ return 0;
99
+ });
100
+ }
@@ -321,6 +321,27 @@ export function defaultDesktopApplyMode(
321
321
  return connection.kind === "connected" ? "gateway" : "first-party";
322
322
  }
323
323
 
324
+ const DESKTOP_APPLY_FLAGS = ["--first-party", "--gateway", "--static", "--hybrid", "--discovery-only"] as const;
325
+
326
+ /**
327
+ * A rejected argument is never echoed: an option-shaped token can carry a value (`-tVALUE`,
328
+ * `--opt=VALUE`, a dash-prefixed operand) and any token can carry terminal control characters, so
329
+ * the error counts them and lists the options that do exist instead.
330
+ */
331
+ function unknownApplyArgsError(count: number): string {
332
+ return `알 수 없는 인자 ${count}개 (값은 표시하지 않습니다). 사용 가능한 옵션: ${DESKTOP_APPLY_FLAGS.join(" ")}`;
333
+ }
334
+
335
+ /** A rejected route is not echoed: whatever sits in that position may be a misplaced credential. */
336
+ const UNAVAILABLE_ROUTE = "현재 사용할 수 없는 모델입니다. 사용 가능한 모델은 ocx claude desktop show로 확인하세요.";
337
+
338
+ /** Profile files are user-chosen paths; a failure reports the operation and error code only. */
339
+ function profileFileError(operation: "export" | "import", error: unknown): Error {
340
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
341
+ const reason = typeof code === "string" && /^E[A-Z]+$/.test(code) ? code : error instanceof SyntaxError ? "invalid JSON" : "failed";
342
+ return new Error(`Claude Desktop 프로필 ${operation === "export" ? "내보내기" : "가져오기"} 실패: ${reason}`);
343
+ }
344
+
324
345
  export function parseDesktopApplyArgs(
325
346
  flags: string[],
326
347
  config: Pick<OcxConfig, "claudeCode" | "port" | "runtimeRole">,
@@ -330,8 +351,8 @@ export function parseDesktopApplyArgs(
330
351
  const wantsFirstParty = flags.includes("--first-party");
331
352
  const wantsGateway = flags.includes("--gateway") || shapeFlags.length > 0;
332
353
  if (wantsFirstParty && wantsGateway) return { error: "--first-party cannot be combined with --gateway or gateway shape flags." };
333
- const unknown = flags.filter(arg => !["--first-party", "--gateway", "--static", "--hybrid", "--discovery-only"].includes(arg));
334
- if (unknown.length > 0) return { error: `알 수 없는 인자: ${unknown.join(" ")}` };
354
+ const unknown = flags.filter(arg => !(DESKTOP_APPLY_FLAGS as readonly string[]).includes(arg));
355
+ if (unknown.length > 0) return { error: unknownApplyArgsError(unknown.length) };
335
356
  const kind: ClaudeDesktopMode = wantsFirstParty
336
357
  ? "first-party"
337
358
  : wantsGateway ? "gateway" : defaultDesktopApplyMode(config, readClientConnectionState(), observed ?? observeClaudeDesktopMode(config));
@@ -679,6 +700,12 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
679
700
  printDesktopHelp();
680
701
  return 0;
681
702
  }
703
+ if (command === "profile") {
704
+ const { handleClaudeDesktopProfileCommand } = await import("./claude-desktop-profile");
705
+ return handleClaudeDesktopProfileCommand(argv.slice(1), {
706
+ runtimeRequestImpl: deps.runtimeRequestImpl, findLiveProxy: deps.findLiveProxyImpl,
707
+ });
708
+ }
682
709
 
683
710
  // Mode flags remain apply aliases and are parsed before subcommands.
684
711
  const applyFlags = argv.filter(arg => APPLY_FLAGS.includes(arg));
@@ -806,7 +833,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
806
833
  if (command === "move") {
807
834
  const [, route, familyRaw, ...flags] = argv;
808
835
  if (!route || !isFamily(familyRaw) || flags.some(flag => flag !== "--default")) throw new CliUsageError("Usage: ocx claude desktop move <route> <family> [--default]");
809
- if (!state.models.some(model => model.route === route && model.available)) throw new Error(`현재 사용할 수 없는 모델입니다: ${route}`);
836
+ if (!state.models.some(model => model.route === route && model.available)) throw new Error(UNAVAILABLE_ROUTE);
810
837
  const profile = moveDesktopRoute(state.profile, route, familyRaw, flags.includes("--default"));
811
838
  saveLocalDesktopProfile(profile, config.claudeCode?.desktopProfile, connection, deps);
812
839
  console.log(`${route} 모델을 ${familyRaw} 그룹으로 옮겼습니다.`);
@@ -816,7 +843,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
816
843
  const [, familyRaw, routeRaw] = argv;
817
844
  if (!isFamily(familyRaw) || !routeRaw || argv.length !== 3) throw new CliUsageError("Usage: ocx claude desktop default <family> <route|none>");
818
845
  const route = routeRaw === "none" ? null : routeRaw;
819
- if (route && !state.models.some(model => model.route === route && model.available)) throw new Error(`현재 사용할 수 없는 모델입니다: ${route}`);
846
+ if (route && !state.models.some(model => model.route === route && model.available)) throw new Error(UNAVAILABLE_ROUTE);
820
847
  const profile = setDesktopFamilyDefault(state.profile, familyRaw, route);
821
848
  saveLocalDesktopProfile(profile, config.claudeCode?.desktopProfile, connection, deps);
822
849
  console.log(`${familyRaw} 기본 모델을 ${route ?? "없음"}으로 지정했습니다.`);
@@ -827,14 +854,20 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
827
854
  if (!target || argv.length !== 2) throw new CliUsageError("Usage: ocx claude desktop export <path|->");
828
855
  const json = JSON.stringify(state.profile, null, 2) + "\n";
829
856
  if (target === "-") process.stdout.write(json);
830
- else writeFileSync(resolve(target), json, { encoding: "utf8", mode: 0o600 });
857
+ else {
858
+ try { writeFileSync(resolve(target), json, { encoding: "utf8", mode: 0o600 }); }
859
+ catch (error) { throw profileFileError("export", error); }
860
+ }
831
861
  return 0;
832
862
  }
833
863
  if (command === "import") {
834
864
  const source = argv[1];
835
865
  const flags = argv.slice(2);
836
866
  if (!source || flags.some(flag => flag !== "--apply")) throw new CliUsageError("Usage: ocx claude desktop import <path> [--apply]");
837
- const profile = parseDesktopProfile(JSON.parse(readFileSync(resolve(source), "utf8")));
867
+ let raw: unknown;
868
+ try { raw = JSON.parse(readFileSync(resolve(source), "utf8")); }
869
+ catch (error) { throw profileFileError("import", error); }
870
+ const profile = parseDesktopProfile(raw);
838
871
  const reconciled = (await buildClaudeDesktopState(config, profile)).profile;
839
872
  if (flags.includes("--apply")) assertNoClientDisconnectPending();
840
873
  if (flags.includes("--apply") && readClientConnectionState().kind !== "disconnected") {
@@ -17,6 +17,7 @@ const DEFAULT_DEPS: CodexShimAutoRestoreCliDeps = {
17
17
 
18
18
  export function skipsCodexShimAutoRestore(command: string | undefined, args: string[]): boolean {
19
19
  if (command === "uninstall" || command === "remove") return true;
20
+ if (command === "status" || command === "doctor") return true;
20
21
  // `lab` is read-only inspection; it must not trigger shim side effects.
21
22
  if (command === "lab") return true;
22
23
  // `resolve` is read-only inspection for embedding shells: a lookup made to populate
@@ -29,7 +30,7 @@ export function skipsCodexShimAutoRestore(command: string | undefined, args: str
29
30
  // or future actions. A later `apply` implementation must own its preflight.
30
31
  if (command === "system" && args[1] === "codex-cli-update") return true;
31
32
  if (command === "__update-badge") return true;
32
- return command === "codex-shim" && ["install", "uninstall", "remove"].includes(args[1] ?? "");
33
+ return command === "codex-shim" && ["install", "status", "uninstall", "remove"].includes(args[1] ?? "");
33
34
  }
34
35
 
35
36
  export function maybeAutoRestoreCodexShim(
@@ -4,6 +4,7 @@ import {
4
4
  type CodexRoutingKind,
5
5
  } from "../codex/inject";
6
6
  import { diagnoseCodexShim, type CodexShimDiagnostic } from "../codex/shim";
7
+ import { overlayActivationHint } from "../codex/shim-overlay";
7
8
  import { findFirstCodexOnPath, type CodexPathCandidate } from "../codex/shim-path-resolution";
8
9
  import { loadConfig, resolveEnvValue } from "../config";
9
10
 
@@ -32,7 +33,7 @@ export interface CodexConnectShimReadiness {
32
33
  }
33
34
 
34
35
  export interface CodexConnectShimInspectionDeps {
35
- diagnose?: () => Pick<CodexShimDiagnostic, "installed" | "healthy" | "summary">;
36
+ diagnose?: () => Pick<CodexShimDiagnostic, "installed" | "healthy" | "summary" | "runnable" | "active">;
36
37
  findOnPath?: () => CodexPathCandidate | null;
37
38
  }
38
39
 
@@ -42,10 +43,29 @@ const CODEX_TOKEN_NOTE = "The connected Codex config uses OPENCODEX_API_AUTH_TOK
42
43
  const CODEX_TOKEN_ACTION = `${CODEX_TOKEN_NOTE} Run 'ocx codex-shim install' to repair it.`;
43
44
 
44
45
  export function codexConnectShimReadiness(inputs: {
45
- diagnosis: Pick<CodexShimDiagnostic, "installed" | "healthy" | "summary">;
46
+ diagnosis: Pick<CodexShimDiagnostic, "installed" | "healthy" | "summary" | "runnable" | "active">;
46
47
  commandPath: string | null;
47
48
  commandIsShim?: boolean;
48
49
  }): CodexConnectShimReadiness {
50
+ if (inputs.diagnosis.runnable && inputs.diagnosis.active !== undefined) {
51
+ if (inputs.diagnosis.active === null) {
52
+ return {
53
+ status: "unverified",
54
+ message: `PATH activation could not be verified. Shim state: ${inputs.diagnosis.summary}. `
55
+ + `Check PATH and retry 'ocx connect'. ${CODEX_TOKEN_NOTE}`,
56
+ };
57
+ }
58
+ if (!inputs.diagnosis.active) {
59
+ return {
60
+ status: "missing",
61
+ message: `installed but not active; ${inputs.commandPath
62
+ ? `PATH resolves 'codex' to ${inputs.commandPath}`
63
+ : "no 'codex' executable was found on PATH"}. `
64
+ + `Shim state: ${inputs.diagnosis.summary}. ${overlayActivationHint()} ${CODEX_TOKEN_NOTE}`,
65
+ };
66
+ }
67
+ return { status: "ready", message: "installed and healthy" };
68
+ }
49
69
  if (inputs.diagnosis.installed && !inputs.diagnosis.healthy) {
50
70
  return {
51
71
  status: "unhealthy",
@@ -87,7 +107,7 @@ export function codexConnectShimReadiness(inputs: {
87
107
  export function inspectCodexShimForConnect(
88
108
  deps: CodexConnectShimInspectionDeps = {},
89
109
  ): CodexConnectShimReadiness {
90
- let diagnosis: Pick<CodexShimDiagnostic, "installed" | "healthy" | "summary">;
110
+ let diagnosis: Pick<CodexShimDiagnostic, "installed" | "healthy" | "summary" | "runnable" | "active">;
91
111
  try {
92
112
  diagnosis = (deps.diagnose ?? diagnoseCodexShim)();
93
113
  } catch {
@@ -0,0 +1,88 @@
1
+ import { JEV_MAX_CANDIDATE_FIELD_CHARS } from "../combos/types";
2
+ import { isCodexReasoningEffort } from "../reasoning-effort";
3
+ import { readJsonInput } from "./json-input";
4
+ import { CliUsageError, takeOptionWithSyntax, type RuntimeApiDeps } from "./runtime-api";
5
+
6
+ type ComboInput = {
7
+ targets?: Array<Record<string, unknown>>;
8
+ imageInput?: "auto" | "disabled";
9
+ reasoningEffortMode?: "strict" | "adaptive";
10
+ nativeAlias?: boolean;
11
+ };
12
+ const TARGET_KEYS = new Set(["provider", "model", "weight", "reasoningEfforts", "modelProfile", "lastResort"]);
13
+
14
+ export function parseComboTargets(value: unknown): Array<Record<string, unknown>> {
15
+ if (!Array.isArray(value) || value.length === 0) throw new CliUsageError("Targets input must be a nonempty array");
16
+ return value.map(target => {
17
+ if (target === null || typeof target !== "object" || Array.isArray(target)
18
+ || Object.keys(target).some(key => !TARGET_KEYS.has(key))) {
19
+ throw new CliUsageError("Targets input contains an invalid object or unsupported field");
20
+ }
21
+ for (const key of ["provider", "model"]) {
22
+ if (typeof target[key] !== "string" || !target[key].trim() || /[\u0000-\u001f\u007f]/u.test(target[key])) {
23
+ throw new CliUsageError("Each target requires a nonblank provider and raw model ID without control characters");
24
+ }
25
+ }
26
+ if (Object.hasOwn(target, "weight") && (typeof target.weight !== "number"
27
+ || !Number.isInteger(target.weight) || target.weight < 1 || target.weight > 10_000)) {
28
+ throw new CliUsageError("Target weight must be an integer from 1 to 10000");
29
+ }
30
+ if (Object.hasOwn(target, "reasoningEfforts") && (!Array.isArray(target.reasoningEfforts)
31
+ || target.reasoningEfforts.length === 0
32
+ || target.reasoningEfforts.some((effort: unknown) => typeof effort !== "string" || !isCodexReasoningEffort(effort))
33
+ || new Set(target.reasoningEfforts).size !== target.reasoningEfforts.length)) {
34
+ throw new CliUsageError("Target reasoningEfforts must contain unique low, medium, high, xhigh, max or ultra values");
35
+ }
36
+ if (Object.hasOwn(target, "modelProfile") && (typeof target.modelProfile !== "string"
37
+ || !target.modelProfile.trim() || target.modelProfile.length > JEV_MAX_CANDIDATE_FIELD_CHARS
38
+ || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(target.modelProfile))) {
39
+ throw new CliUsageError("Target modelProfile must be nonblank, at most 512 characters, with no controls except tab or newline");
40
+ }
41
+ if (Object.hasOwn(target, "lastResort") && typeof target.lastResort !== "boolean") {
42
+ throw new CliUsageError("Target lastResort must be a boolean");
43
+ }
44
+ // Preserve raw slashes, target order, explicit false and omitted metadata.
45
+ return { ...target };
46
+ });
47
+ }
48
+
49
+ function takeNativeAlias(args: string[]): boolean | undefined {
50
+ const flag = "--native-alias";
51
+ const positions = args.flatMap((value, index) => value === flag || value.startsWith(`${flag}=`) ? [index] : []);
52
+ if (positions.length === 0) return undefined;
53
+ if (positions.length !== 1) throw new CliUsageError("--native-alias was given more than once");
54
+ const index = positions[0]!;
55
+ const token = args[index]!;
56
+ const inline = token !== flag;
57
+ const next = inline ? token.slice(flag.length + 1) : args[index + 1];
58
+ if (!inline && (next === undefined || next.startsWith("--"))) {
59
+ args.splice(index, 1);
60
+ return true;
61
+ }
62
+ if (next !== "on" && next !== "off") throw new CliUsageError("--native-alias accepts on, off, or the legacy bare flag");
63
+ args.splice(index, inline ? 1 : 2);
64
+ return next === "on";
65
+ }
66
+
67
+ /** Consume only extension options, before the parent set flow resolves a target or reads HTTP. */
68
+ export async function prepareComboInput(args: string[], deps: RuntimeApiDeps = {}): Promise<ComboInput> {
69
+ const file = takeOptionWithSyntax(args, "--targets-file")?.value;
70
+ const image = takeOptionWithSyntax(args, "--image-input")?.value;
71
+ const reasoning = takeOptionWithSyntax(args, "--reasoning-effort-mode")?.value;
72
+ const nativeAlias = takeNativeAlias(args);
73
+ if (file !== undefined && args.some(arg => arg === "--targets" || arg.startsWith("--targets="))) {
74
+ throw new CliUsageError("--targets-file cannot be combined with --targets");
75
+ }
76
+ if (image !== undefined && image !== "auto" && image !== "disabled") {
77
+ throw new CliUsageError("--image-input must be auto or disabled");
78
+ }
79
+ if (reasoning !== undefined && reasoning !== "strict" && reasoning !== "adaptive") {
80
+ throw new CliUsageError("--reasoning-effort-mode must be strict or adaptive");
81
+ }
82
+ return {
83
+ ...(file === undefined ? {} : { targets: parseComboTargets(await readJsonInput(file, deps)) }),
84
+ ...(image === undefined ? {} : { imageInput: image }),
85
+ ...(reasoning === undefined ? {} : { reasoningEffortMode: reasoning }),
86
+ ...(nativeAlias === undefined ? {} : { nativeAlias }),
87
+ };
88
+ }
@@ -0,0 +1,52 @@
1
+ import { parseComboTargets } from "./combo-input";
2
+ import { printCatalogResult } from "./catalog-command-result";
3
+
4
+ const ENUMS: Readonly<Record<string, readonly (string | null)[]>> = {
5
+ strategy: ["failover", "round-robin", "random", "least-used", "reset-window", "jev"],
6
+ defaultEffort: [null, "low", "medium", "high", "xhigh", "max", "ultra"],
7
+ defaultEffortMode: ["fallback", "force"], reasoningEffortMode: ["strict", "adaptive"],
8
+ imageInput: ["auto", "disabled"], cooldownWaitPolicy: ["before-last-resort"],
9
+ };
10
+ const STRINGS = new Set(["alias", "displayName", "decisionProvider", "decisionModel"]);
11
+ const RANGES: Readonly<Record<string, readonly [number, number]>> = {
12
+ stickyLimit: [1, 100], cooldownMs: [1, 600_000], waitForCooldownMs: [0, 600_000],
13
+ decisionTimeoutMs: [1_000, 120_000],
14
+ };
15
+ function record(value: unknown): value is Record<string, unknown> {
16
+ return value !== null && typeof value === "object" && !Array.isArray(value)
17
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
18
+ }
19
+ function invalid(): never { throw new Error("Invalid combo receipt"); }
20
+
21
+ /** Shape/field ownership only; target graph/alias semantics remain on the server. */
22
+ function publicCombo(value: unknown): Record<string, unknown> {
23
+ if (!record(value)) invalid();
24
+ let targets: Array<Record<string, unknown>>;
25
+ try { targets = parseComboTargets(value.targets); } catch { return invalid(); }
26
+ const result: Record<string, unknown> = { targets };
27
+ for (const [key, child] of Object.entries(value)) {
28
+ if (key === "targets") continue;
29
+ if (Object.hasOwn(ENUMS, key)) {
30
+ if (!ENUMS[key]!.includes(child as string | null)) invalid();
31
+ } else if (STRINGS.has(key)) {
32
+ if (typeof child !== "string" || child.length > 4096) invalid();
33
+ } else if (Object.hasOwn(RANGES, key)) {
34
+ const [min, max] = RANGES[key]!;
35
+ if (typeof child !== "number" || !Number.isSafeInteger(child) || child < min || child > max) invalid();
36
+ } else if (key === "nativeAlias") {
37
+ if (typeof child !== "boolean") invalid();
38
+ } else invalid();
39
+ result[key] = child;
40
+ }
41
+ return result;
42
+ }
43
+
44
+ export function printComboWriteResult(value: unknown, id: string, wantsJson: boolean): number {
45
+ if (!record(value) || value.success !== true || value.id !== id || typeof value.model !== "string"
46
+ || Object.keys(value).some(key => !["success", "id", "model", "combo", "catalogRefresh"].includes(key))) invalid();
47
+ const combo = publicCombo(value.combo);
48
+ const model = typeof combo.alias === "string" && combo.alias.trim() ? combo.alias.trim() : `combo/${id}`;
49
+ if (value.model !== model) invalid();
50
+ return printCatalogResult({ success: true, id, model: value.model, combo }, value.catalogRefresh,
51
+ wantsJson, [`Saved combo ${id}.`]);
52
+ }