@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +10 -0
- package/docs/architecture/index.md +2 -1
- package/docs/architecture/inventory.md +8 -6
- package/docs/contributing/development.md +4 -2
- package/docs/contributing/test-matrix.md +1 -0
- package/docs/contributing/tui-components.md +22 -4
- package/docs/reference/cli-reference.md +29 -1
- package/docs/reference/config-reference.md +17 -3
- package/package.json +2 -2
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/claude-hook.js +4 -3
- package/plugins/kxm/dist/cli.js +1771 -46
- package/plugins/kxm/dist/extension.js +11238 -1470
- package/plugins/kxm/dist/mcp-server.js +3 -2
- package/plugins/kxm/dist/runtime-supervisor.js +2 -1
- package/plugins/kxm/dist/runtime.js +2 -1
- package/plugins/kxm/dist/server.js +2 -1
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/autocomplete.ts +3 -3
- package/plugins/kxm/src/cli/system.ts +37 -0
- package/plugins/kxm/src/cli.ts +7 -1
- package/plugins/kxm/src/config-editor.ts +479 -0
- package/plugins/kxm/src/config.ts +3 -2
- package/plugins/kxm/src/extension.ts +9 -2
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/session-work.ts +2 -1
|
@@ -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.
|
|
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) ?? {};
|
package/plugins/kxm/package.json
CHANGED
|
@@ -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);
|
package/plugins/kxm/src/cli.ts
CHANGED
|
@@ -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
|
+
}
|