toolcraft 0.0.25 → 0.0.27

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 (33) hide show
  1. package/dist/cli.js +11 -9
  2. package/dist/error-report.js +109 -36
  3. package/dist/redaction.d.ts +4 -0
  4. package/dist/redaction.js +70 -0
  5. package/node_modules/@poe-code/config-mutations/dist/execution/apply-mutation.js +33 -9
  6. package/node_modules/@poe-code/config-mutations/dist/formats/json.d.ts +2 -1
  7. package/node_modules/@poe-code/config-mutations/dist/formats/json.js +36 -9
  8. package/node_modules/@poe-code/config-mutations/dist/types.d.ts +2 -0
  9. package/node_modules/@poe-code/design-system/dist/components/browser.js +1 -1
  10. package/node_modules/@poe-code/design-system/dist/explorer/actions.js +1 -1
  11. package/node_modules/@poe-code/design-system/dist/explorer/keymap.js +11 -1
  12. package/node_modules/@poe-code/design-system/dist/explorer/reducer.js +64 -8
  13. package/node_modules/@poe-code/design-system/dist/explorer/render/detail.js +9 -11
  14. package/node_modules/@poe-code/design-system/dist/explorer/render/footer.js +18 -8
  15. package/node_modules/@poe-code/design-system/dist/explorer/render/header.js +11 -18
  16. package/node_modules/@poe-code/design-system/dist/explorer/render/index.js +2 -10
  17. package/node_modules/@poe-code/design-system/dist/explorer/render/list.js +32 -22
  18. package/node_modules/@poe-code/design-system/dist/explorer/render/modal.js +5 -9
  19. package/node_modules/@poe-code/design-system/dist/explorer/render/text.d.ts +12 -0
  20. package/node_modules/@poe-code/design-system/dist/explorer/render/text.js +81 -0
  21. package/node_modules/@poe-code/design-system/dist/explorer/state.d.ts +1 -0
  22. package/node_modules/@poe-code/design-system/dist/explorer/state.js +2 -0
  23. package/node_modules/@poe-code/design-system/dist/prompts/index.js +3 -3
  24. package/node_modules/@poe-code/process-runner/dist/docker/docker-execution-env.js +24 -3
  25. package/node_modules/@poe-code/process-runner/dist/docker/docker-runner.js +1 -0
  26. package/node_modules/@poe-code/task-list/dist/backends/gh-issues-sync.js +8 -0
  27. package/node_modules/auth-store/dist/keychain-store.js +20 -1
  28. package/node_modules/mcp-oauth/dist/client/auth-store-session-store.js +6 -3
  29. package/node_modules/tiny-mcp-client/dist/internal.d.ts +2 -0
  30. package/node_modules/tiny-mcp-client/dist/internal.js +30 -13
  31. package/node_modules/tiny-mcp-client/src/internal.ts +35 -16
  32. package/node_modules/tiny-mcp-client/src/transports.test.ts +68 -0
  33. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { invokeWithHumanInLoop } from "./human-in-loop/index.js";
9
9
  import { resolveMcpProxies } from "./mcp-proxy.js";
10
10
  import { getExpectedNumberDescription, isValidNumberSchemaValue } from "./number-schema.js";
11
11
  import { findEntrypointPackageMetadata } from "./package-metadata.js";
12
+ import { redactHttpBody, redactHttpHeaderValue } from "./redaction.js";
12
13
  import { renderResult } from "./renderer.js";
13
14
  import { renderSourceSnippet } from "./source-snippet.js";
14
15
  import { enableSourceMaps, formatDebugStack } from "./stack-trim.js";
@@ -2586,17 +2587,18 @@ function formatGraphQLErrorEnvelopeBody(body) {
2586
2587
  .join("\n\n");
2587
2588
  }
2588
2589
  function formatHttpErrorBody(body) {
2589
- if (typeof body === "string") {
2590
- return body;
2590
+ const redactedBody = redactHttpBody(body);
2591
+ if (typeof redactedBody === "string") {
2592
+ return redactedBody;
2591
2593
  }
2592
- if (isProblemDetailsLike(body)) {
2593
- return formatProblemDetailsBody(body);
2594
+ if (isProblemDetailsLike(redactedBody)) {
2595
+ return formatProblemDetailsBody(redactedBody);
2594
2596
  }
2595
- if (isGraphQLErrorEnvelopeLike(body)) {
2596
- return formatGraphQLErrorEnvelopeBody(body);
2597
+ if (isGraphQLErrorEnvelopeLike(redactedBody)) {
2598
+ return formatGraphQLErrorEnvelopeBody(redactedBody);
2597
2599
  }
2598
- const serialized = JSON.stringify(body, null, 2);
2599
- return serialized === undefined ? String(body) : serialized;
2600
+ const serialized = JSON.stringify(redactedBody, null, 2);
2601
+ return serialized === undefined ? String(redactedBody) : serialized;
2600
2602
  }
2601
2603
  function indentHttpErrorBlock(value) {
2602
2604
  return value
@@ -2605,7 +2607,7 @@ function indentHttpErrorBlock(value) {
2605
2607
  .join("\n");
2606
2608
  }
2607
2609
  function formatHttpHeaderValue(name, value) {
2608
- return name.toLowerCase() === "authorization" ? "Bearer ****" : value;
2610
+ return redactHttpHeaderValue(name, value);
2609
2611
  }
2610
2612
  function formatHttpErrorHeaders(headers) {
2611
2613
  return Object.entries(headers).map(([name, value]) => ` ${name}: ${formatHttpHeaderValue(name, value)}`);
@@ -6,9 +6,9 @@ import { CommanderError } from "commander";
6
6
  import { ApprovalDeclinedError } from "./human-in-loop/types.js";
7
7
  import { findProjectRoot } from "./mcp-proxy.js";
8
8
  import { findPackageMetadata } from "./package-metadata.js";
9
+ import { isSensitiveName, redactHttpBody, redactHttpHeaderValue } from "./redaction.js";
9
10
  import { UserError } from "./user-error.js";
10
11
  const ERROR_REPORTS_ENV = "TOOLCRAFT_ERROR_REPORTS";
11
- const DEFAULT_SENSITIVE_NAMES = ["password", "token", "apikey", "secret"];
12
12
  function isPlainObject(value) {
13
13
  return typeof value === "object" && value !== null && !Array.isArray(value);
14
14
  }
@@ -116,9 +116,24 @@ function redactValue(value) {
116
116
  }
117
117
  return `<set, ${value.length} chars>`;
118
118
  }
119
- function isSensitiveName(name) {
120
- const normalized = name.toLowerCase();
121
- return DEFAULT_SENSITIVE_NAMES.some((candidate) => normalized.includes(candidate));
119
+ function collectStringLeaves(value, output) {
120
+ if (typeof value === "string") {
121
+ if (value.length > 0) {
122
+ output.add(value);
123
+ }
124
+ return;
125
+ }
126
+ if (Array.isArray(value)) {
127
+ for (const entry of value) {
128
+ collectStringLeaves(entry, output);
129
+ }
130
+ return;
131
+ }
132
+ if (isPlainObject(value)) {
133
+ for (const entry of Object.values(value)) {
134
+ collectStringLeaves(entry, output);
135
+ }
136
+ }
122
137
  }
123
138
  function schemaSecretValue(schema) {
124
139
  const unwrapped = unwrapOptional(schema);
@@ -159,6 +174,52 @@ function redactParams(params, command) {
159
174
  }
160
175
  return redactParamsValue(params, command.params, "");
161
176
  }
177
+ function collectSensitiveParamValues(value, schema, name, output) {
178
+ if (shouldRedactParam(name, schema)) {
179
+ collectStringLeaves(value, output);
180
+ return;
181
+ }
182
+ const unwrapped = unwrapOptional(schema);
183
+ if (unwrapped.kind === "object" && isPlainObject(value)) {
184
+ for (const [key, childValue] of Object.entries(value)) {
185
+ const childSchema = unwrapped.shape[key];
186
+ if (childSchema !== undefined) {
187
+ collectSensitiveParamValues(childValue, childSchema, key, output);
188
+ }
189
+ }
190
+ return;
191
+ }
192
+ if (unwrapped.kind === "array" && Array.isArray(value)) {
193
+ for (const entry of value) {
194
+ collectSensitiveParamValues(entry, unwrapped.item, name, output);
195
+ }
196
+ }
197
+ }
198
+ function createReportStringRedactor(context, env) {
199
+ const values = new Set();
200
+ for (const value of Object.values(context.secrets ?? {})) {
201
+ if (value !== undefined && value.length > 0) {
202
+ values.add(value);
203
+ }
204
+ }
205
+ for (const [name, secret] of Object.entries(context.command?.secrets ?? {})) {
206
+ const value = context.secrets?.[name] ?? env[secret.env];
207
+ if (value !== undefined && value.length > 0) {
208
+ values.add(value);
209
+ }
210
+ }
211
+ if (context.command !== undefined) {
212
+ collectSensitiveParamValues(context.params, context.command.params, "", values);
213
+ }
214
+ const orderedValues = [...values].sort((left, right) => right.length - left.length);
215
+ return (value) => {
216
+ let redacted = value;
217
+ for (const secretValue of orderedValues) {
218
+ redacted = redacted.split(secretValue).join("<redacted>");
219
+ }
220
+ return redacted;
221
+ };
222
+ }
162
223
  function commandSecretEnvNames(secrets) {
163
224
  if (secrets === undefined) {
164
225
  return [];
@@ -207,26 +268,36 @@ function redactArgv(argv, options) {
207
268
  function stableJson(value) {
208
269
  return JSON.stringify(value, null, 2) ?? "undefined";
209
270
  }
210
- function redactStructuredErrorField(name, value) {
211
- if (typeof value === "string" && name.toLowerCase() === "authorization") {
212
- return "Bearer ****";
271
+ function redactStructuredErrorField(name, value, redactString) {
272
+ if (typeof value === "string") {
273
+ const redactedHeaderValue = redactHttpHeaderValue(name, value);
274
+ if (redactedHeaderValue !== value) {
275
+ return redactedHeaderValue;
276
+ }
277
+ if (isSensitiveName(name)) {
278
+ return "<redacted>";
279
+ }
280
+ return redactString(value);
213
281
  }
214
282
  if (Array.isArray(value)) {
215
- return value.map((entry) => redactStructuredErrorField(name, entry));
283
+ return value.map((entry) => redactStructuredErrorField(name, entry, redactString));
216
284
  }
217
285
  if (isPlainObject(value)) {
218
- return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redactStructuredErrorField(key, entry)]));
286
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
287
+ key,
288
+ redactStructuredErrorField(key, entry, redactString)
289
+ ]));
219
290
  }
220
291
  return value;
221
292
  }
222
- function ownStructuredFields(error) {
293
+ function ownStructuredFields(error, redactString) {
223
294
  const fields = {};
224
295
  for (const key of Object.keys(error)) {
225
296
  if (key === "name" || key === "message" || key === "stack" || key === "cause") {
226
297
  continue;
227
298
  }
228
299
  Object.defineProperty(fields, key, {
229
- value: redactStructuredErrorField(key, error[key]),
300
+ value: redactStructuredErrorField(key, error[key], redactString),
230
301
  enumerable: true,
231
302
  configurable: true,
232
303
  writable: true
@@ -234,46 +305,47 @@ function ownStructuredFields(error) {
234
305
  }
235
306
  return fields;
236
307
  }
237
- function formatStackChain(error) {
308
+ function formatStackChain(error, redactString) {
238
309
  const lines = [];
239
310
  let current = error;
240
311
  let index = 0;
241
312
  while (current !== undefined) {
242
313
  if (current instanceof Error) {
243
- lines.push(index === 0
244
- ? (current.stack ?? String(current))
245
- : `Caused by: ${current.stack ?? String(current)}`);
314
+ const stack = current.stack ?? String(current);
315
+ lines.push(redactString(index === 0 ? stack : `Caused by: ${stack}`));
246
316
  current = current.cause;
247
317
  }
248
318
  else {
249
- lines.push(index === 0 ? String(current) : `Caused by: ${String(current)}`);
319
+ const message = String(current);
320
+ lines.push(redactString(index === 0 ? message : `Caused by: ${message}`));
250
321
  current = undefined;
251
322
  }
252
323
  index += 1;
253
324
  }
254
325
  return lines.join("\n");
255
326
  }
256
- function formatHeaderValue(name, value) {
257
- return name.toLowerCase() === "authorization" ? "Bearer ****" : value;
327
+ function formatHeaderValue(name, value, redactString) {
328
+ return redactString(redactHttpHeaderValue(name, value));
258
329
  }
259
- function formatHeaders(headers) {
330
+ function formatHeaders(headers, redactString) {
260
331
  return Object.entries(headers)
261
- .map(([name, value]) => `${name}: ${formatHeaderValue(name, value)}`)
332
+ .map(([name, value]) => `${name}: ${formatHeaderValue(name, value, redactString)}`)
262
333
  .join("\n");
263
334
  }
264
- function formatBody(body) {
265
- if (typeof body === "string") {
266
- return body;
335
+ function formatBody(body, redactString) {
336
+ const redactedBody = redactHttpBody(body);
337
+ if (typeof redactedBody === "string") {
338
+ return redactString(redactedBody);
267
339
  }
268
- return stableJson(body);
340
+ return redactString(stableJson(redactedBody));
269
341
  }
270
- function formatHttpTranscript(error) {
342
+ function formatHttpTranscript(error, redactString) {
271
343
  const requestLines = [
272
344
  `${error.request.method} ${error.request.url}`,
273
- formatHeaders(error.request.headers)
345
+ formatHeaders(error.request.headers, redactString)
274
346
  ].filter((line) => line.length > 0);
275
347
  if (error.request.body !== undefined) {
276
- requestLines.push("", formatBody(error.request.body));
348
+ requestLines.push("", formatBody(error.request.body, redactString));
277
349
  }
278
350
  return [
279
351
  "Request:",
@@ -281,9 +353,9 @@ function formatHttpTranscript(error) {
281
353
  "",
282
354
  "Response:",
283
355
  `${error.response.status} ${error.response.statusText}`,
284
- formatHeaders(error.response.headers),
356
+ formatHeaders(error.response.headers, redactString),
285
357
  "",
286
- formatBody(error.response.body)
358
+ formatBody(error.response.body, redactString)
287
359
  ].join("\n");
288
360
  }
289
361
  function resolveToolcraftVersion(version) {
@@ -294,9 +366,10 @@ function resolveToolcraftVersion(version) {
294
366
  function buildReport(context) {
295
367
  const env = context.env ?? process.env;
296
368
  const error = context.error;
369
+ const redactString = createReportStringRedactor(context, env);
297
370
  const errorName = error instanceof Error ? error.name : typeof error;
298
- const errorMessage = error instanceof Error ? error.message : String(error);
299
- const structuredFields = error instanceof Error ? ownStructuredFields(error) : {};
371
+ const errorMessage = redactString(error instanceof Error ? error.message : String(error));
372
+ const structuredFields = error instanceof Error ? ownStructuredFields(error, redactString) : {};
300
373
  const secretLines = Object.entries(context.command?.secrets ?? {}).map(([name, secret]) => {
301
374
  const value = context.secrets?.[name] ?? env[secret.env];
302
375
  return `${secret.env}=${redactValue(value)}`;
@@ -310,7 +383,7 @@ function buildReport(context) {
310
383
  `platform: ${process.platform} ${process.arch}`,
311
384
  "",
312
385
  "Argv",
313
- stableJson(redactArgv(context.argv, { command: context.command, secrets: context.secrets })),
386
+ redactString(stableJson(redactArgv(context.argv, { command: context.command, secrets: context.secrets }))),
314
387
  "",
315
388
  "Resolved Secrets",
316
389
  ...(secretLines.length === 0 ? ["<none>"] : secretLines),
@@ -321,19 +394,19 @@ function buildReport(context) {
321
394
  : context.commandPath,
322
395
  "",
323
396
  "Parsed Params",
324
- stableJson(redactParams(context.params, context.command)),
397
+ redactString(stableJson(redactParams(context.params, context.command))),
325
398
  "",
326
399
  "Error",
327
400
  `name: ${errorName}`,
328
401
  `message: ${errorMessage}`,
329
402
  "structured fields:",
330
- stableJson(structuredFields),
403
+ redactString(stableJson(structuredFields)),
331
404
  "",
332
405
  "Stack",
333
- formatStackChain(error)
406
+ formatStackChain(error, redactString)
334
407
  ];
335
408
  if (hasHttpContext(error)) {
336
- lines.push("", "HTTP Transcript", formatHttpTranscript(error));
409
+ lines.push("", "HTTP Transcript", formatHttpTranscript(error, redactString));
337
410
  }
338
411
  return `${lines.join("\n")}\n`;
339
412
  }
@@ -0,0 +1,4 @@
1
+ export declare function isSensitiveName(name: string): boolean;
2
+ export declare function redactSecretLikeFields(value: unknown, name?: string): unknown;
3
+ export declare function redactHttpBody(body: unknown): unknown;
4
+ export declare function redactHttpHeaderValue(name: string, value: string): string;
@@ -0,0 +1,70 @@
1
+ const REDACTED_VALUE = "<redacted>";
2
+ const SENSITIVE_NAME_PARTS = ["password", "token", "apikey", "secret"];
3
+ const AUTHORIZATION_HEADER_NAMES = new Set(["authorization", "proxyauthorization"]);
4
+ const SECRET_HEADER_NAMES = new Set(["cookie", "setcookie"]);
5
+ function isPlainObject(value) {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+ function normalizeName(name) {
9
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "");
10
+ }
11
+ export function isSensitiveName(name) {
12
+ const normalized = normalizeName(name);
13
+ return SENSITIVE_NAME_PARTS.some((candidate) => normalized.includes(candidate));
14
+ }
15
+ function redactSecretLikeFieldsValue(value, name, seen) {
16
+ if (name.length > 0 && isSensitiveName(name)) {
17
+ return REDACTED_VALUE;
18
+ }
19
+ if (Array.isArray(value)) {
20
+ if (seen.has(value)) {
21
+ return "[Circular]";
22
+ }
23
+ seen.add(value);
24
+ return value.map((entry) => redactSecretLikeFieldsValue(entry, name, seen));
25
+ }
26
+ if (isPlainObject(value)) {
27
+ if (seen.has(value)) {
28
+ return "[Circular]";
29
+ }
30
+ seen.add(value);
31
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
32
+ key,
33
+ redactSecretLikeFieldsValue(entry, key, seen)
34
+ ]));
35
+ }
36
+ return value;
37
+ }
38
+ export function redactSecretLikeFields(value, name = "") {
39
+ return redactSecretLikeFieldsValue(value, name, new WeakSet());
40
+ }
41
+ function parseJsonObjectOrArray(value) {
42
+ const trimmed = value.trim();
43
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
44
+ return undefined;
45
+ }
46
+ try {
47
+ const parsed = JSON.parse(trimmed);
48
+ return isPlainObject(parsed) || Array.isArray(parsed) ? parsed : undefined;
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ export function redactHttpBody(body) {
55
+ if (typeof body === "string") {
56
+ const parsed = parseJsonObjectOrArray(body);
57
+ return parsed === undefined ? body : redactSecretLikeFields(parsed);
58
+ }
59
+ return redactSecretLikeFields(body);
60
+ }
61
+ export function redactHttpHeaderValue(name, value) {
62
+ const normalized = normalizeName(name);
63
+ if (AUTHORIZATION_HEADER_NAMES.has(normalized)) {
64
+ return "Bearer ****";
65
+ }
66
+ if (SECRET_HEADER_NAMES.has(normalized) || isSensitiveName(name)) {
67
+ return REDACTED_VALUE;
68
+ }
69
+ return value;
70
+ }
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { renderTemplate } from "@poe-code/design-system";
3
3
  import { getConfigFormat, detectFormat } from "../formats/index.js";
4
+ import { cloneConfigObject, setConfigEntry } from "../formats/object.js";
4
5
  import { resolvePath } from "./path-utils.js";
5
6
  import { isNotFound, readFileIfExists, pathExists, createTimestamp } from "../fs-utils.js";
6
7
  // ============================================================================
@@ -120,7 +121,7 @@ function pruneKeysByPrefix(table, prefix) {
120
121
  const result = {};
121
122
  for (const [key, value] of Object.entries(table)) {
122
123
  if (!key.startsWith(prefix)) {
123
- result[key] = value;
124
+ setConfigEntry(result, key, value);
124
125
  }
125
126
  }
126
127
  return result;
@@ -129,25 +130,44 @@ function isConfigObject(value) {
129
130
  return typeof value === "object" && value !== null && !Array.isArray(value);
130
131
  }
131
132
  function mergeWithPruneByPrefix(base, patch, pruneByPrefix) {
132
- const result = { ...base };
133
+ const result = cloneConfigObject(base);
133
134
  const prefixMap = pruneByPrefix ?? {};
134
135
  for (const [key, value] of Object.entries(patch)) {
136
+ if (value === undefined) {
137
+ continue;
138
+ }
135
139
  const current = result[key];
136
140
  const prefix = prefixMap[key];
137
141
  if (isConfigObject(current) && isConfigObject(value)) {
138
142
  if (prefix) {
139
143
  const pruned = pruneKeysByPrefix(current, prefix);
140
- result[key] = { ...pruned, ...value };
144
+ setConfigEntry(result, key, mergePrunedConfigObject(pruned, value));
141
145
  }
142
146
  else {
143
- result[key] = mergeWithPruneByPrefix(current, value, prefixMap);
147
+ setConfigEntry(result, key, mergeWithPruneByPrefix(current, value, prefixMap));
144
148
  }
145
149
  continue;
146
150
  }
147
- result[key] = value;
151
+ setConfigEntry(result, key, value);
148
152
  }
149
153
  return result;
150
154
  }
155
+ function mergePrunedConfigObject(base, patch) {
156
+ const result = cloneConfigObject(base);
157
+ for (const [key, value] of Object.entries(patch)) {
158
+ if (value === undefined) {
159
+ continue;
160
+ }
161
+ setConfigEntry(result, key, value);
162
+ }
163
+ return result;
164
+ }
165
+ function serializeConfigUpdate(format, rawContent, current, next) {
166
+ if (rawContent !== null && format.serializeUpdate) {
167
+ return format.serializeUpdate(rawContent, current, next);
168
+ }
169
+ return format.serialize(next);
170
+ }
151
171
  // ============================================================================
152
172
  // Apply Mutation
153
173
  // ============================================================================
@@ -480,6 +500,7 @@ async function applyConfigMerge(mutation, context, options) {
480
500
  }
481
501
  const format = getConfigFormat(formatName);
482
502
  const rawContent = await readFileIfExists(context.fs, targetPath);
503
+ let preserveContent = rawContent;
483
504
  let current;
484
505
  try {
485
506
  current = rawContent === null ? {} : format.parse(rawContent);
@@ -490,9 +511,10 @@ async function applyConfigMerge(mutation, context, options) {
490
511
  await backupInvalidDocument(context, targetPath, rawContent);
491
512
  }
492
513
  current = {};
514
+ preserveContent = null;
493
515
  }
494
516
  const value = resolveValue(mutation.value, options);
495
- // Use mergeWithPruneByPrefix for TOML files with pruneByPrefix option
517
+ // Keep prefix pruning on the same proto-safe object-write path as normal merges.
496
518
  let merged;
497
519
  if (mutation.pruneByPrefix) {
498
520
  merged = mergeWithPruneByPrefix(current, value, mutation.pruneByPrefix);
@@ -500,7 +522,7 @@ async function applyConfigMerge(mutation, context, options) {
500
522
  else {
501
523
  merged = format.merge(current, value);
502
524
  }
503
- const serialized = format.serialize(merged);
525
+ const serialized = serializeConfigUpdate(format, preserveContent, current, merged);
504
526
  const changed = serialized !== rawContent;
505
527
  if (changed && !context.dryRun) {
506
528
  await writeAtomically(context, targetPath, serialized);
@@ -570,7 +592,7 @@ async function applyConfigPrune(mutation, context, options) {
570
592
  details
571
593
  };
572
594
  }
573
- const serialized = format.serialize(result);
595
+ const serialized = serializeConfigUpdate(format, rawContent, current, result);
574
596
  if (!context.dryRun) {
575
597
  await writeAtomically(context, targetPath, serialized);
576
598
  }
@@ -593,6 +615,7 @@ async function applyConfigTransform(mutation, context, options) {
593
615
  }
594
616
  const format = getConfigFormat(formatName);
595
617
  const rawContent = await readFileIfExists(context.fs, targetPath);
618
+ let preserveContent = rawContent;
596
619
  let current;
597
620
  try {
598
621
  current = rawContent === null ? {} : format.parse(rawContent);
@@ -602,6 +625,7 @@ async function applyConfigTransform(mutation, context, options) {
602
625
  await backupInvalidDocument(context, targetPath, rawContent);
603
626
  }
604
627
  current = {};
628
+ preserveContent = null;
605
629
  }
606
630
  const { content: transformed, changed } = mutation.transform(current, options);
607
631
  if (!changed) {
@@ -626,7 +650,7 @@ async function applyConfigTransform(mutation, context, options) {
626
650
  details
627
651
  };
628
652
  }
629
- const serialized = format.serialize(transformed);
653
+ const serialized = serializeConfigUpdate(format, preserveContent, current, transformed);
630
654
  if (!context.dryRun) {
631
655
  await writeAtomically(context, targetPath, serialized);
632
656
  }
@@ -27,5 +27,6 @@ declare function mergePreservingComments(content: string, patch: ConfigObject):
27
27
  * @returns The modified JSON content with comments preserved
28
28
  */
29
29
  declare function removeAtPath(content: string, path: (string | number)[]): string;
30
- export { detectIndent, modifyAtPath, mergePreservingComments, removeAtPath };
30
+ declare function serializeUpdate(content: string, current: ConfigObject, next: ConfigObject): string;
31
+ export { detectIndent, modifyAtPath, mergePreservingComments, removeAtPath, serializeUpdate };
31
32
  export declare const jsonFormat: ConfigFormat;
@@ -48,6 +48,9 @@ function merge(base, patch) {
48
48
  }
49
49
  return result;
50
50
  }
51
+ function configValuesEqual(left, right) {
52
+ return JSON.stringify(left) === JSON.stringify(right);
53
+ }
51
54
  function prune(obj, shape) {
52
55
  let changed = false;
53
56
  const result = cloneConfigObject(obj);
@@ -116,14 +119,8 @@ function modifyAtPath(content, path, value) {
116
119
  * @returns The modified JSON content with comments preserved
117
120
  */
118
121
  function mergePreservingComments(content, patch) {
119
- let result = content || "{}";
120
- for (const [key, value] of Object.entries(patch)) {
121
- if (value === undefined) {
122
- continue;
123
- }
124
- result = modifyAtPath(result, [key], value);
125
- }
126
- return result;
122
+ const current = parse(content);
123
+ return serializeUpdate(content || "{}", current, merge(current, patch));
127
124
  }
128
125
  /**
129
126
  * Remove a key from JSON content while preserving comments and formatting.
@@ -135,10 +132,40 @@ function mergePreservingComments(content, patch) {
135
132
  function removeAtPath(content, path) {
136
133
  return modifyAtPath(content, path, undefined);
137
134
  }
138
- export { detectIndent, modifyAtPath, mergePreservingComments, removeAtPath };
135
+ function serializeUpdate(content, current, next) {
136
+ let result = content || "{}";
137
+ result = applyObjectUpdate(result, [], current, next);
138
+ if (!result.endsWith("\n")) {
139
+ result += "\n";
140
+ }
141
+ return result;
142
+ }
143
+ function applyObjectUpdate(content, path, current, next) {
144
+ let result = content;
145
+ for (const key of Object.keys(current)) {
146
+ if (!hasConfigEntry(next, key)) {
147
+ result = removeAtPath(result, [...path, key]);
148
+ }
149
+ }
150
+ for (const [key, nextValue] of Object.entries(next)) {
151
+ const nextPath = [...path, key];
152
+ const hasCurrent = hasConfigEntry(current, key);
153
+ const currentValue = hasCurrent ? current[key] : undefined;
154
+ if (hasCurrent && isConfigObject(currentValue) && isConfigObject(nextValue)) {
155
+ result = applyObjectUpdate(result, nextPath, currentValue, nextValue);
156
+ continue;
157
+ }
158
+ if (!hasCurrent || !configValuesEqual(currentValue, nextValue)) {
159
+ result = modifyAtPath(result, nextPath, nextValue);
160
+ }
161
+ }
162
+ return result;
163
+ }
164
+ export { detectIndent, modifyAtPath, mergePreservingComments, removeAtPath, serializeUpdate };
139
165
  export const jsonFormat = {
140
166
  parse,
141
167
  serialize,
168
+ serializeUpdate,
142
169
  merge,
143
170
  prune
144
171
  };
@@ -35,6 +35,8 @@ export interface ConfigFormat {
35
35
  parse(content: string): ConfigObject;
36
36
  /** Serialize object to string (with consistent formatting) */
37
37
  serialize(obj: ConfigObject): string;
38
+ /** Serialize an update against original content while preserving comments when possible */
39
+ serializeUpdate?(content: string, current: ConfigObject, next: ConfigObject): string;
38
40
  /** Deep merge patch into base, returning new object */
39
41
  merge(base: ConfigObject, patch: ConfigObject): ConfigObject;
40
42
  /** Remove keys matching shape from object, returning new object */
@@ -10,7 +10,7 @@ function browserCommand(url, platform) {
10
10
  return { command: "open", args: [url] };
11
11
  }
12
12
  if (platform === "win32") {
13
- return { command: "cmd", args: ["/c", "start", "", url] };
13
+ return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
14
14
  }
15
15
  return { command: "xdg-open", args: [url] };
16
16
  }
@@ -32,7 +32,7 @@ function currentDetailItem(state) {
32
32
  return state.detail.items?.[state.detail.cursor];
33
33
  }
34
34
  function selectedRows(state, fallback) {
35
- if (state.selected.size === 0) {
35
+ if (!state.multiSelect || state.selected.size === 0) {
36
36
  return fallback.id === "" ? [] : [fallback];
37
37
  }
38
38
  return state.rows.filter((row) => state.selected.has(row.id));
@@ -45,14 +45,24 @@ const baseBuiltinCommands = [
45
45
  "extendSelectionDown"
46
46
  ];
47
47
  const reorderCommands = ["reorderUp", "reorderDown"];
48
+ const selectionCommands = new Set([
49
+ "toggleSelect",
50
+ "selectAll",
51
+ "clearSelection",
52
+ "extendSelectionUp",
53
+ "extendSelectionDown"
54
+ ]);
48
55
  const reservedActionIds = new Set(["quit"]);
49
56
  export function resolveBindings(config, defaults = {}) {
50
- const commands = config.reorder === undefined
57
+ const baseCommands = config.reorder === undefined
51
58
  ? baseBuiltinCommands
52
59
  : [
53
60
  ...baseBuiltinCommands.filter((command) => command !== "extendSelectionUp" && command !== "extendSelectionDown"),
54
61
  ...reorderCommands
55
62
  ];
63
+ const commands = config.multiSelect === false
64
+ ? baseCommands.filter((command) => !selectionCommands.has(command))
65
+ : baseCommands;
56
66
  const commandBindings = new Map();
57
67
  const flatBindings = new Map();
58
68
  const targetKeys = new Map();