@kontextmind/kxm 0.7.177 → 0.7.178

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.
@@ -23948,6 +23948,7 @@ function loadKxmConfig(repoRoot = process.cwd(), options = {}) {
23948
23948
  const userConfigFile = join(userDir, "config.yaml");
23949
23949
  const repoDir = repoConfigDirectory(repoRoot);
23950
23950
  const repoConfigFile = join(repoDir, "config.yaml");
23951
+ const includeProject = options.layers !== "user";
23951
23952
  let userRaw = {};
23952
23953
  let userLoadedPath;
23953
23954
  if (existsSync(userConfigFile)) {
@@ -23961,7 +23962,7 @@ function loadKxmConfig(repoRoot = process.cwd(), options = {}) {
23961
23962
  }
23962
23963
  let repoRaw = {};
23963
23964
  let repoLoadedPath;
23964
- if (existsSync(repoConfigFile)) {
23965
+ if (includeProject && existsSync(repoConfigFile)) {
23965
23966
  try {
23966
23967
  const text = readFileSync(repoConfigFile, "utf8");
23967
23968
  repoRaw = (0, import_yaml.parse)(text) ?? {};
@@ -25461,7 +25462,7 @@ function sessionTokenFixHint(policy) {
25461
25462
  }
25462
25463
 
25463
25464
  // plugins/kxm/src/mcp-server.ts
25464
- var VERSION = "0.7.177";
25465
+ var VERSION = "0.7.178";
25465
25466
  var CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
25466
25467
  var inbox = /* @__PURE__ */ new Map();
25467
25468
  var notifiedInbox = /* @__PURE__ */ new Set();
@@ -25121,6 +25121,7 @@ function loadKxmConfig(repoRoot = process.cwd(), options = {}) {
25121
25121
  const userConfigFile = join11(userDir, "config.yaml");
25122
25122
  const repoDir = repoConfigDirectory(repoRoot);
25123
25123
  const repoConfigFile = join11(repoDir, "config.yaml");
25124
+ const includeProject = options.layers !== "user";
25124
25125
  let userRaw = {};
25125
25126
  let userLoadedPath;
25126
25127
  if (existsSync10(userConfigFile)) {
@@ -25134,7 +25135,7 @@ function loadKxmConfig(repoRoot = process.cwd(), options = {}) {
25134
25135
  }
25135
25136
  let repoRaw = {};
25136
25137
  let repoLoadedPath;
25137
- if (existsSync10(repoConfigFile)) {
25138
+ if (includeProject && existsSync10(repoConfigFile)) {
25138
25139
  try {
25139
25140
  const text = readFileSync8(repoConfigFile, "utf8");
25140
25141
  repoRaw = (0, import_yaml5.parse)(text) ?? {};
@@ -25865,6 +25865,7 @@ function loadKxmConfig(repoRoot = process.cwd(), options = {}) {
25865
25865
  const userConfigFile = join11(userDir, "config.yaml");
25866
25866
  const repoDir = repoConfigDirectory(repoRoot);
25867
25867
  const repoConfigFile = join11(repoDir, "config.yaml");
25868
+ const includeProject = options.layers !== "user";
25868
25869
  let userRaw = {};
25869
25870
  let userLoadedPath;
25870
25871
  if (existsSync10(userConfigFile)) {
@@ -25878,7 +25879,7 @@ function loadKxmConfig(repoRoot = process.cwd(), options = {}) {
25878
25879
  }
25879
25880
  let repoRaw = {};
25880
25881
  let repoLoadedPath;
25881
- if (existsSync10(repoConfigFile)) {
25882
+ if (includeProject && existsSync10(repoConfigFile)) {
25882
25883
  try {
25883
25884
  const text = readFileSync8(repoConfigFile, "utf8");
25884
25885
  repoRaw = (0, import_yaml5.parse)(text) ?? {};
@@ -23447,6 +23447,7 @@ function loadKxmConfig(repoRoot = process.cwd(), options = {}) {
23447
23447
  const userConfigFile = join7(userDir, "config.yaml");
23448
23448
  const repoDir = repoConfigDirectory(repoRoot);
23449
23449
  const repoConfigFile = join7(repoDir, "config.yaml");
23450
+ const includeProject = options.layers !== "user";
23450
23451
  let userRaw = {};
23451
23452
  let userLoadedPath;
23452
23453
  if (existsSync7(userConfigFile)) {
@@ -23460,7 +23461,7 @@ function loadKxmConfig(repoRoot = process.cwd(), options = {}) {
23460
23461
  }
23461
23462
  let repoRaw = {};
23462
23463
  let repoLoadedPath;
23463
- if (existsSync7(repoConfigFile)) {
23464
+ if (includeProject && existsSync7(repoConfigFile)) {
23464
23465
  try {
23465
23466
  const text2 = readFileSync5(repoConfigFile, "utf8");
23466
23467
  repoRaw = (0, import_yaml6.parse)(text2) ?? {};
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.177",
3
+ "version": "0.7.178",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -53,7 +53,7 @@ const SUBCOMMANDS: Record<string, string[]> = {
53
53
  memory: ["brief", "note", "sync"],
54
54
  routing: ["report", "benchmark"],
55
55
  hub: ["view", "start", "stop", "bind", "unbind"],
56
- config: ["get", "set", "list"],
56
+ config: ["get", "set", "edit", "list"],
57
57
  goal: ["create", "list", "get"],
58
58
  task: ["create", "list", "get", "run", "sync"],
59
59
  studio: ["layout", "serve"],
@@ -179,7 +179,7 @@ _kxm() {
179
179
  _arguments '1:shell:(bash zsh fish)'
180
180
  ;;
181
181
  config)
182
- _arguments '1:action:(get set list)'
182
+ _arguments '1:action:(get set edit list)'
183
183
  ;;
184
184
  runs)
185
185
  _arguments '1:subcommand:(status cancel list)'
@@ -224,7 +224,7 @@ complete -c kxm -n "__fish_seen_subcommand_from completion" -a "bash zsh fish"
224
224
  complete -c kxm -n "__fish_seen_subcommand_from dash" -l screen -a "agents tasks workflows plans inbox procs spend"
225
225
 
226
226
  # Config subcommand
227
- complete -c kxm -n "__fish_seen_subcommand_from config" -a "get set list"
227
+ complete -c kxm -n "__fish_seen_subcommand_from config" -a "get set edit list"
228
228
 
229
229
  # Runs subcommand
230
230
  complete -c kxm -n "__fish_seen_subcommand_from runs" -a "status cancel list"
@@ -48,7 +48,9 @@ import {
48
48
  setKxmConfigValue,
49
49
  getKxmConfigValue,
50
50
  formatKxmConfig,
51
+ userConfigDirForEnv,
51
52
  } from "../config.ts";
53
+ import { runKxmConfigEditor } from "../config-editor.ts";
52
54
  import { generateShellCompletion, type SupportedShell } from "../autocomplete.ts";
53
55
  import {
54
56
  completionRcTarget,
@@ -736,6 +738,41 @@ export async function cmdConfigSet(
736
738
  }
737
739
  }
738
740
 
741
+ export async function cmdConfigEdit(
742
+ runtime: Runtime,
743
+ options: { scope?: string },
744
+ isTty = Boolean(process.stdin.isTTY && process.stdout.isTTY),
745
+ ): Promise<number> {
746
+ const scope = options.scope;
747
+ if (scope !== "project" && scope !== "user") {
748
+ const detail = "config edit requires --scope project or --scope user";
749
+ print(runtime.io, runtime.json, { ok: false, command: "config edit", error: "config_edit_unsupported", detail }, detail);
750
+ return 2;
751
+ }
752
+ if (runtime.json) {
753
+ const detail = "config edit does not print machine-readable output";
754
+ print(runtime.io, runtime.json, { ok: false, command: "config edit", error: "config_edit_unsupported", detail }, detail);
755
+ return 2;
756
+ }
757
+ try {
758
+ const userConfigDir = userConfigDirForEnv(runtime.env);
759
+ return await runKxmConfigEditor({
760
+ target: {
761
+ cwd: runtime.cwd,
762
+ scope,
763
+ ...(userConfigDir === undefined ? {} : { userConfigDir }),
764
+ },
765
+ stdout: (text) => runtime.io.stdout(text),
766
+ isTty,
767
+ dryRun: runtime.dryRun,
768
+ });
769
+ } catch (error) {
770
+ const message = error instanceof Error ? error.message : String(error);
771
+ runtime.io.stderr(`config edit failed: ${message}\n`);
772
+ return 1;
773
+ }
774
+ }
775
+
739
776
  export async function cmdConfigList(runtime: Runtime): Promise<number> {
740
777
  try {
741
778
  const config = loadKxmConfig(runtime.cwd);
@@ -165,6 +165,7 @@ import {
165
165
  cmdImprove,
166
166
  cmdConfigGet,
167
167
  cmdConfigSet,
168
+ cmdConfigEdit,
168
169
  cmdConfigList,
169
170
  cmdCompletion,
170
171
  cmdCompletionInstall,
@@ -228,7 +229,7 @@ const DRY_RUN_COMMANDS: ReadonlySet<string> = new Set([
228
229
  "routing report", "routing benchmark",
229
230
  "ssh info", "ssh run", "ssh file", "ssh close",
230
231
  "hub view", "hub start", "hub stop", "hub bind", "hub unbind",
231
- "config get", "config set", "config list",
232
+ "config get", "config set", "config edit", "config list",
232
233
  "goal create", "goal list",
233
234
  "task create", "task list", "task get", "task run", "task sync",
234
235
  "studio layout", "studio serve",
@@ -1430,6 +1431,11 @@ function createProgram(ctx: CliContext, result: { code: number }, argv: readonly
1430
1431
  .action(async function configSetAction(this: Command, key: string, value: string, options: { scope: string }) {
1431
1432
  result.code = await cmdConfigSet(runtimeFrom(ctx, this), key, value, options);
1432
1433
  });
1434
+ addGlobalOptions(configCmd.command("edit").description("Edit supported non-secret settings in the terminal panel"))
1435
+ .option("--scope <scope>", "Write scope: project or user")
1436
+ .action(async function configEditAction(this: Command, options: { scope?: string }) {
1437
+ result.code = await cmdConfigEdit(runtimeFrom(ctx, this), options);
1438
+ });
1433
1439
  addGlobalOptions(configCmd.command("list", { isDefault: true }).description("List resolved configuration values"))
1434
1440
  .action(async function configListAction(this: Command) {
1435
1441
  result.code = await cmdConfigList(runtimeFrom(ctx, this));
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Owner for the `kxm.config.v1` terminal editor.
3
+ *
4
+ * The panel publishes this contribution and routes every write back here.
5
+ * `listSections` re-reads the merged files on each publish. `set` and `clear`
6
+ * validate the field and the value, then call the config owner. A refusal
7
+ * throws before either owner call, so the file bytes stay as they were.
8
+ */
9
+
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { parse } from "yaml";
12
+ import {
13
+ KXM_TUI_CLEAR_ACTION,
14
+ KXM_TUI_SET_ACTION,
15
+ createKxmTuiRegistry,
16
+ createKxmTuiPiPanel,
17
+ createPlainKxmTuiTheme,
18
+ runKxmTuiPanel,
19
+ type KxmTuiContribution,
20
+ type KxmTuiField,
21
+ type KxmTuiSectionView,
22
+ } from "@kontextmind/tui";
23
+ import {
24
+ deleteKxmConfigValue,
25
+ getKxmConfigValue,
26
+ kxmConfigFileForScope,
27
+ loadKxmConfig,
28
+ setKxmConfigValue,
29
+ userConfigDirForEnv,
30
+ type KxmResolvedConfig,
31
+ } from "./config.ts";
32
+
33
+ export const KXM_CONFIG_EDITOR_SOURCE = "kxm/config";
34
+
35
+ export type KxmConfigEditorScope = "project" | "user";
36
+
37
+ const INTEGER_TEXT = /^(0|[1-9][0-9]*)$/;
38
+ const DECIMAL_TEXT = /^(0|[1-9][0-9]*)(\.[0-9]{1,6})?$/;
39
+ /** Workflow and harness ids already in use, including `@`, with no whitespace. */
40
+ const TEXT_VALUE = /^[A-Za-z0-9@][A-Za-z0-9@._:/-]{0,127}$/;
41
+
42
+ interface EnumSpec {
43
+ readonly kind: "enum";
44
+ readonly choices: readonly string[];
45
+ }
46
+
47
+ interface NumberSpec {
48
+ readonly kind: "number";
49
+ readonly integer: boolean;
50
+ readonly min: number;
51
+ readonly max: number;
52
+ }
53
+
54
+ interface TextSpec {
55
+ readonly kind: "text";
56
+ }
57
+
58
+ type ValueSpec = EnumSpec | NumberSpec | TextSpec;
59
+
60
+ interface ConfigEditorFieldSpec {
61
+ readonly id: string;
62
+ readonly label: string;
63
+ readonly keyPath: string;
64
+ readonly spec: ValueSpec;
65
+ readonly note?: string;
66
+ }
67
+
68
+ interface ConfigEditorSectionSpec {
69
+ readonly id: string;
70
+ readonly title: string;
71
+ readonly order: number;
72
+ readonly fields: readonly ConfigEditorFieldSpec[];
73
+ }
74
+
75
+ const SCREEN_CHOICES = ["agents", "tasks", "workflows", "plans", "inbox", "procs", "spend"] as const;
76
+
77
+ const SECTIONS: readonly ConfigEditorSectionSpec[] = [
78
+ {
79
+ id: "user",
80
+ title: "User",
81
+ order: 10,
82
+ fields: [
83
+ { id: "user-theme", label: "Theme", keyPath: "user.theme", spec: { kind: "enum", choices: ["dark", "light", "minimal"] } },
84
+ { id: "user-token-budget", label: "Token budget", keyPath: "user.tokenBudget", spec: { kind: "number", integer: true, min: 1, max: 2_000_000 } },
85
+ ],
86
+ },
87
+ {
88
+ id: "defaults",
89
+ title: "Defaults",
90
+ order: 20,
91
+ fields: [
92
+ { id: "defaults-workflow", label: "Workflow", keyPath: "defaults.workflow", spec: { kind: "text" } },
93
+ { id: "defaults-harness", label: "Harness", keyPath: "defaults.harness", spec: { kind: "text" } },
94
+ ],
95
+ },
96
+ {
97
+ id: "dash",
98
+ title: "Dashboard",
99
+ order: 30,
100
+ fields: [
101
+ { id: "dash-screen", label: "Default screen", keyPath: "dash.defaultScreen", spec: { kind: "enum", choices: SCREEN_CHOICES } },
102
+ { id: "dash-refresh", label: "Refresh interval", keyPath: "dash.refreshIntervalMs", spec: { kind: "number", integer: true, min: 250, max: 3_600_000 } },
103
+ { id: "dash-auto-open", label: "Auto-open", keyPath: "dash.autoOpen", spec: { kind: "enum", choices: ["true", "false"] } },
104
+ ],
105
+ },
106
+ {
107
+ id: "sync",
108
+ title: "Sync",
109
+ order: 40,
110
+ fields: [
111
+ { id: "sync-tracker", label: "Default tracker", keyPath: "sync.defaultTracker", spec: { kind: "enum", choices: ["github", "jira", "none"] } },
112
+ ],
113
+ },
114
+ {
115
+ id: "hub",
116
+ title: "Hub",
117
+ order: 50,
118
+ fields: [
119
+ {
120
+ id: "hub-auto-start",
121
+ label: "Auto-start",
122
+ keyPath: "hub.autoStart",
123
+ spec: { kind: "enum", choices: ["background", "off"] },
124
+ note: "Hub URLs and key references stay out of this panel.",
125
+ },
126
+ ],
127
+ },
128
+ {
129
+ id: "improvement",
130
+ title: "Improvement",
131
+ order: 60,
132
+ fields: [
133
+ { id: "improvement-policy", label: "Promotion policy", keyPath: "improvement.promotionPolicy", spec: { kind: "enum", choices: ["manual_pr", "critic_quorum", "auto_threshold"] } },
134
+ { id: "improvement-half-life", label: "Telemetry half-life", keyPath: "improvement.telemetryHalfLifeDays", spec: { kind: "number", integer: false, min: 0.000001, max: 3650 } },
135
+ { id: "improvement-min-runs", label: "Minimum runs", keyPath: "improvement.autoThreshold.minRuns", spec: { kind: "number", integer: true, min: 1, max: 1_000_000 } },
136
+ { id: "improvement-min-pass-rate", label: "Minimum pass rate", keyPath: "improvement.autoThreshold.minPassRate", spec: { kind: "number", integer: false, min: 0, max: 1 } },
137
+ { id: "improvement-min-cost-savings", label: "Minimum cost savings", keyPath: "improvement.autoThreshold.minCostSavings", spec: { kind: "number", integer: false, min: 0, max: 1_000_000 } },
138
+ ],
139
+ },
140
+ {
141
+ id: "routing",
142
+ title: "Routing",
143
+ order: 70,
144
+ fields: [
145
+ {
146
+ id: "routing-shadow",
147
+ label: "Shadow execution",
148
+ keyPath: "routing.shadowExecution.enabled",
149
+ spec: { kind: "enum", choices: ["true", "false"] },
150
+ note: "Strategy, fallback, and context limits stay in role and route files.",
151
+ },
152
+ { id: "routing-sample-rate", label: "Sample rate", keyPath: "routing.shadowExecution.sampleRate", spec: { kind: "number", integer: false, min: 0, max: 1 } },
153
+ { id: "routing-breaker-mode", label: "Breaker mode", keyPath: "routing.circuitBreaker.mode", spec: { kind: "enum", choices: ["soft_demotion", "quarantine"] } },
154
+ { id: "routing-failure-threshold", label: "Failure threshold", keyPath: "routing.circuitBreaker.failureThreshold", spec: { kind: "number", integer: true, min: 1, max: 10_000 } },
155
+ { id: "routing-window-seconds", label: "Window seconds", keyPath: "routing.circuitBreaker.windowSeconds", spec: { kind: "number", integer: true, min: 1, max: 31_536_000 } },
156
+ { id: "routing-cooldown-seconds", label: "Cooldown seconds", keyPath: "routing.circuitBreaker.cooldownSeconds", spec: { kind: "number", integer: true, min: 0, max: 31_536_000 } },
157
+ { id: "routing-penalty", label: "Penalty multiplier", keyPath: "routing.circuitBreaker.penaltyMultiplier", spec: { kind: "number", integer: false, min: 0.000001, max: 1000 } },
158
+ ],
159
+ },
160
+ {
161
+ id: "telemetry",
162
+ title: "Telemetry",
163
+ order: 80,
164
+ fields: [
165
+ { id: "telemetry-federated", label: "Federated", keyPath: "telemetry.federated", spec: { kind: "enum", choices: ["true", "false"] } },
166
+ { id: "telemetry-anonymize", label: "Anonymize", keyPath: "telemetry.anonymize", spec: { kind: "enum", choices: ["true", "false"] } },
167
+ ],
168
+ },
169
+ ];
170
+
171
+
172
+ export class KxmConfigEditorError extends Error {
173
+ readonly code = "config_edit_refused";
174
+
175
+ constructor(message: string) {
176
+ super(message);
177
+ this.name = "KxmConfigEditorError";
178
+ }
179
+ }
180
+
181
+ export interface KxmConfigEditorTarget {
182
+ readonly cwd: string;
183
+ readonly scope: KxmConfigEditorScope;
184
+ readonly userConfigDir?: string | undefined;
185
+ }
186
+
187
+ export function kxmConfigEditorKeyPaths(): readonly string[] {
188
+ return SECTIONS.flatMap((section) => section.fields.map((field) => field.keyPath));
189
+ }
190
+
191
+ export function createKxmConfigEditorContribution(target: KxmConfigEditorTarget): KxmTuiContribution {
192
+ return {
193
+ source: KXM_CONFIG_EDITOR_SOURCE,
194
+ republishAfterWrite: true,
195
+ listSections: () => listConfigEditorSections(target),
196
+ handlers: {
197
+ [KXM_TUI_SET_ACTION]: (input) => {
198
+ writeConfigEditorValue(target, input.sectionId, input.fieldId, input.value);
199
+ },
200
+ [KXM_TUI_CLEAR_ACTION]: (input) => {
201
+ clearConfigEditorValue(target, input.sectionId, input.fieldId);
202
+ },
203
+ },
204
+ describeError: (error) => (error instanceof Error && error.message ? error.message : "config edit failed"),
205
+ };
206
+ }
207
+
208
+ function scopeFile(target: KxmConfigEditorTarget): string {
209
+ return kxmConfigFileForScope(target.cwd, target.scope, target.userConfigDir);
210
+ }
211
+
212
+ function scopePhrase(scope: KxmConfigEditorScope): string {
213
+ return scope === "project" ? "the project file .kxm/config.yaml" : "the user file config.yaml";
214
+ }
215
+
216
+ function listConfigEditorSections(target: KxmConfigEditorTarget): KxmTuiSectionView[] {
217
+ const config = loadKxmConfig(target.cwd, {
218
+ ...(target.userConfigDir === undefined ? {} : { userConfigDir: target.userConfigDir }),
219
+ layers: target.scope === "user" ? "user" : "all",
220
+ });
221
+ const owned = readScopeDocument(scopeFile(target));
222
+ const where = scopePhrase(target.scope);
223
+ return SECTIONS.map((section) => {
224
+ const fields = section.fields.map((field) => publishField(field, config, owned, where));
225
+ const diagnosis = fields.find((field) => field.statusText)?.statusText;
226
+ return {
227
+ source: KXM_CONFIG_EDITOR_SOURCE,
228
+ id: section.id,
229
+ title: section.title,
230
+ order: section.order,
231
+ detail: `kxm.config.v1 · ${target.scope} scope. Re-read from disk after each write.`,
232
+ ...(diagnosis === undefined ? {} : { notice: diagnosis, noticeLevel: "error" as const }),
233
+ fields,
234
+ };
235
+ });
236
+ }
237
+
238
+ function publishField(
239
+ field: ConfigEditorFieldSpec,
240
+ config: KxmResolvedConfig,
241
+ owned: Record<string, unknown> | undefined,
242
+ where: string,
243
+ ): KxmTuiField {
244
+ const effective = getKxmConfigValue(config, field.keyPath);
245
+ const stored = ownValue(owned, field.keyPath);
246
+ const shown = displayScalar(effective);
247
+ const inherited = !stored.present;
248
+ const diagnosis = storedDiagnosis(field, stored, effective);
249
+ const detail = [
250
+ `Writes ${where}.`,
251
+ inherited ? "Inherited." : "Set in this scope.",
252
+ "Clear removes only this override.",
253
+ field.note ?? "",
254
+ diagnosis ?? "",
255
+ ].filter((part) => part.length > 0).join(" ");
256
+ const choices = field.spec.kind === "enum"
257
+ ? field.spec.choices.map((choice) => ({ id: choice, label: choice }))
258
+ : undefined;
259
+ return {
260
+ id: field.id,
261
+ label: field.label,
262
+ kind: field.spec.kind === "enum" ? "enum" : "text",
263
+ keyPath: field.keyPath,
264
+ detail,
265
+ status: "ready",
266
+ ...(shown === undefined ? {} : { value: shown }),
267
+ ...(choices === undefined ? {} : { choices }),
268
+ ...(diagnosis === undefined ? {} : { statusText: diagnosis }),
269
+ };
270
+ }
271
+
272
+ function displayScalar(value: unknown): string | undefined {
273
+ if (typeof value === "string" && value.length > 0) return value;
274
+ if (typeof value === "boolean") return value ? "true" : "false";
275
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
276
+ return undefined;
277
+ }
278
+
279
+ function isBooleanEnum(spec: ValueSpec): boolean {
280
+ return spec.kind === "enum"
281
+ && spec.choices.length === 2
282
+ && spec.choices.every((choice) => choice === "true" || choice === "false");
283
+ }
284
+
285
+ /** A display string such as `"true"` is not a boolean. The stored YAML type has to match the descriptor. */
286
+ function storedTypeMatches(field: ConfigEditorFieldSpec, value: unknown): boolean {
287
+ if (field.spec.kind === "number") {
288
+ return typeof value === "number"
289
+ && Number.isFinite(value)
290
+ && (!field.spec.integer || Number.isInteger(value));
291
+ }
292
+ if (isBooleanEnum(field.spec)) return typeof value === "boolean";
293
+ return typeof value === "string";
294
+ }
295
+
296
+ function storedDiagnosis(
297
+ field: ConfigEditorFieldSpec,
298
+ stored: { present: boolean; value: unknown },
299
+ effective: unknown,
300
+ ): string | undefined {
301
+ if (!stored.present) return undefined;
302
+ if (!storedTypeMatches(field, stored.value)) return "stored type does not match this field";
303
+ if (field.spec.kind === "enum" && !isBooleanEnum(field.spec) && !field.spec.choices.includes(stored.value as string)) {
304
+ return "stored value is not a supported scalar";
305
+ }
306
+ if (field.spec.kind === "number") {
307
+ const value = stored.value as number;
308
+ if (value < field.spec.min || value > field.spec.max) return "stored value is not a supported scalar";
309
+ }
310
+ if (stored.value !== effective) return "stored value is not a supported scalar";
311
+ return undefined;
312
+ }
313
+
314
+ function writeConfigEditorValue(target: KxmConfigEditorTarget, sectionId: string, fieldId: string, raw: string | undefined): void {
315
+ const field = requireField(sectionId, fieldId);
316
+ const value = parseEditorValue(field, raw);
317
+ setKxmConfigValue(target.cwd, field.keyPath, value, {
318
+ scope: target.scope,
319
+ ...(target.userConfigDir === undefined ? {} : { userConfigDir: target.userConfigDir }),
320
+ });
321
+ }
322
+
323
+ function clearConfigEditorValue(target: KxmConfigEditorTarget, sectionId: string, fieldId: string): void {
324
+ const field = requireField(sectionId, fieldId);
325
+ deleteKxmConfigValue(target.cwd, field.keyPath, {
326
+ scope: target.scope,
327
+ ...(target.userConfigDir === undefined ? {} : { userConfigDir: target.userConfigDir }),
328
+ });
329
+ }
330
+
331
+ function requireField(sectionId: string, fieldId: string): ConfigEditorFieldSpec {
332
+ const section = SECTIONS.find((item) => item.id === sectionId);
333
+ const field = section?.fields.find((item) => item.id === fieldId);
334
+ if (!field) throw new KxmConfigEditorError("unknown configuration field");
335
+ return field;
336
+ }
337
+
338
+ function parseEditorValue(field: ConfigEditorFieldSpec, raw: string | undefined): string | number | boolean {
339
+ if (raw === undefined) throw new KxmConfigEditorError("a value is required");
340
+ if (raw.length === 0 || raw.length > 128 || /[\u0000-\u001f\u007f]/u.test(raw)) {
341
+ throw new KxmConfigEditorError("value must be a single bounded line");
342
+ }
343
+ if (field.spec.kind === "enum") {
344
+ if (!field.spec.choices.includes(raw)) throw new KxmConfigEditorError("value is not a supported choice");
345
+ if (isBooleanEnum(field.spec)) return raw === "true";
346
+ return raw;
347
+ }
348
+ if (field.spec.kind === "text") {
349
+ if (!TEXT_VALUE.test(raw)) throw new KxmConfigEditorError("value must be a single bounded line");
350
+ return raw;
351
+ }
352
+ if (!(field.spec.integer ? INTEGER_TEXT : DECIMAL_TEXT).test(raw)) {
353
+ throw new KxmConfigEditorError("value must be a finite number in range");
354
+ }
355
+ const parsed = Number(raw);
356
+ if (!Number.isFinite(parsed) || (field.spec.integer && !Number.isInteger(parsed)) || parsed < field.spec.min || parsed > field.spec.max) {
357
+ throw new KxmConfigEditorError("value must be a finite number in range");
358
+ }
359
+ return parsed;
360
+ }
361
+
362
+ function readScopeDocument(file: string): Record<string, unknown> | undefined {
363
+ if (!existsSync(file)) return undefined;
364
+ const parsed = parse(readFileSync(file, "utf8")) as unknown;
365
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
366
+ throw new KxmConfigEditorError(`config file ${file} must be a mapping`);
367
+ }
368
+ return parsed as Record<string, unknown>;
369
+ }
370
+
371
+ function ownValue(root: Record<string, unknown> | undefined, keyPath: string): { present: boolean; value: unknown } {
372
+ if (!root) return { present: false, value: undefined };
373
+ let cursor: unknown = root;
374
+ for (const part of keyPath.split(".")) {
375
+ if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) return { present: false, value: undefined };
376
+ const record = cursor as Record<string, unknown>;
377
+ if (!Object.prototype.hasOwnProperty.call(record, part)) return { present: false, value: undefined };
378
+ cursor = record[part];
379
+ }
380
+ return { present: true, value: cursor };
381
+ }
382
+
383
+ const EDITOR_HELP = [
384
+ "↑↓ move · enter edits · x clears this scope's override · q quits",
385
+ "Writes are checked, saved, then re-read. A refused edit leaves the file unchanged.",
386
+ ] as const;
387
+
388
+ const FRAME_NOW_MS = Date.UTC(2026, 9, 11);
389
+
390
+ export async function runKxmConfigEditor(input: {
391
+ readonly target: KxmConfigEditorTarget;
392
+ readonly stdout: (text: string) => void;
393
+ readonly isTty: boolean;
394
+ readonly dryRun: boolean;
395
+ }): Promise<number> {
396
+ const registry = createKxmTuiRegistry();
397
+ try {
398
+ registry.register(createKxmConfigEditorContribution(input.target));
399
+ registry.getSections();
400
+ const failed = registry.getIssues()[0];
401
+ if (failed) throw new KxmConfigEditorError(failed.message);
402
+ const interactive = input.isTty && !input.dryRun;
403
+ return await runKxmTuiPanel({
404
+ registry,
405
+ title: "KXM configuration",
406
+ breadcrumb: `kxm.config.v1 · ${input.target.scope} scope`,
407
+ helpLines: EDITOR_HELP,
408
+ stdout: input.stdout,
409
+ isTty: interactive,
410
+ ...(interactive ? {} : { theme: createPlainKxmTuiTheme() }),
411
+ now: () => FRAME_NOW_MS,
412
+ });
413
+ } finally {
414
+ registry.dispose();
415
+ }
416
+ }
417
+
418
+ interface ConfigPanelUi {
419
+ notify?: ((message: string, type?: "error" | "info" | "warning") => void) | undefined;
420
+ custom?: ((
421
+ factory: (
422
+ tui: { requestRender(): void },
423
+ theme: { fg(token: string, text: string): string; bg(token: string, text: string): string },
424
+ keybindings: unknown,
425
+ done: (result: undefined) => void,
426
+ ) => { render(width: number): string[]; handleInput(data: string): void; invalidate(): void },
427
+ options?: { overlay?: boolean },
428
+ ) => Promise<unknown>) | undefined;
429
+ }
430
+
431
+ /** Mount the project-scope editor on Pi's generic custom panel. Only a TUI session may open it. */
432
+ export async function openKxmProjectConfigPanel(
433
+ ui: { notify?: (message: string, type?: "error" | "info" | "warning") => void; custom?: unknown },
434
+ cwd: string,
435
+ env: NodeJS.ProcessEnv = process.env,
436
+ mode?: string,
437
+ ): Promise<void> {
438
+ if (mode !== "tui") {
439
+ ui.notify?.(
440
+ "kxm: /kxm config opens only in the Pi TUI. Nothing was written. Run kxm config edit --scope project",
441
+ "warning",
442
+ );
443
+ return;
444
+ }
445
+ if (typeof ui.custom !== "function") {
446
+ ui.notify?.(
447
+ "kxm: /kxm config needs Pi's custom panel. Open the repository in the Pi TUI, or run kxm config edit --scope project",
448
+ "warning",
449
+ );
450
+ return;
451
+ }
452
+ const custom = ui.custom as NonNullable<ConfigPanelUi["custom"]>;
453
+ const registry = createKxmTuiRegistry();
454
+ const userConfigDir = userConfigDirForEnv(env);
455
+ try {
456
+ registry.register(createKxmConfigEditorContribution({
457
+ cwd,
458
+ scope: "project",
459
+ ...(userConfigDir === undefined ? {} : { userConfigDir }),
460
+ }));
461
+ await custom(
462
+ (tui, theme, _keybindings, done) => createKxmTuiPiPanel({
463
+ registry,
464
+ tui,
465
+ theme,
466
+ title: "KXM configuration",
467
+ breadcrumb: "kxm.config.v1 · project scope",
468
+ helpLines: EDITOR_HELP,
469
+ done: () => {
470
+ registry.dispose();
471
+ done(undefined);
472
+ },
473
+ }),
474
+ { overlay: true },
475
+ );
476
+ } finally {
477
+ registry.dispose();
478
+ }
479
+ }