akm-cli 0.3.1 → 0.4.1

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/dist/common.js CHANGED
@@ -8,6 +8,11 @@ export const IS_WINDOWS = process.platform === "win32";
8
8
  export function isHttpUrl(value) {
9
9
  return !!value && /^https?:\/\//.test(value);
10
10
  }
11
+ export function filterNonEmptyStrings(value) {
12
+ if (!Array.isArray(value))
13
+ return undefined;
14
+ return value.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
15
+ }
11
16
  // ── Validators ──────────────────────────────────────────────────────────────
12
17
  export function isAssetType(type) {
13
18
  return Object.hasOwn(TYPE_DIRS, type);
@@ -26,6 +26,16 @@ export function parseConfigValue(key, value) {
26
26
  return { output: { format: parseOutputFormat(value) } };
27
27
  case "output.detail":
28
28
  return { output: { detail: parseOutputDetail(value) } };
29
+ case "security.installAudit.enabled":
30
+ return { security: { installAudit: { enabled: parseBooleanValue(value, key) } } };
31
+ case "security.installAudit.blockOnCritical":
32
+ return { security: { installAudit: { blockOnCritical: parseBooleanValue(value, key) } } };
33
+ case "security.installAudit.blockUnlistedRegistries":
34
+ return { security: { installAudit: { blockUnlistedRegistries: parseBooleanValue(value, key) } } };
35
+ case "security.installAudit.registryAllowlist":
36
+ return { security: { installAudit: { registryAllowlist: parseStringArrayValue(value, key) } } };
37
+ case "security.installAudit.registryWhitelist":
38
+ return { security: { installAudit: { registryAllowlist: parseStringArrayValue(value, key) } } };
29
39
  default:
30
40
  throw new UsageError(`Unknown config key: ${key}`);
31
41
  }
@@ -48,6 +58,18 @@ export function getConfigValue(config, key) {
48
58
  return config.output?.format ?? null;
49
59
  case "output.detail":
50
60
  return config.output?.detail ?? null;
61
+ case "security":
62
+ return config.security ?? null;
63
+ case "security.installAudit.enabled":
64
+ return config.security?.installAudit?.enabled ?? null;
65
+ case "security.installAudit.blockOnCritical":
66
+ return config.security?.installAudit?.blockOnCritical ?? null;
67
+ case "security.installAudit.blockUnlistedRegistries":
68
+ return config.security?.installAudit?.blockUnlistedRegistries ?? null;
69
+ case "security.installAudit.registryAllowlist":
70
+ return getInstallAuditAllowlist(config);
71
+ case "security.installAudit.registryWhitelist":
72
+ return getInstallAuditAllowlist(config);
51
73
  default:
52
74
  throw new UsageError(`Unknown config key: ${key}`);
53
75
  }
@@ -62,6 +84,11 @@ export function setConfigValue(config, key, rawValue) {
62
84
  case "stashes":
63
85
  case "output.format":
64
86
  case "output.detail":
87
+ case "security.installAudit.enabled":
88
+ case "security.installAudit.blockOnCritical":
89
+ case "security.installAudit.blockUnlistedRegistries":
90
+ case "security.installAudit.registryAllowlist":
91
+ case "security.installAudit.registryWhitelist":
65
92
  return mergeConfigValue(config, parseConfigValue(key, rawValue));
66
93
  default:
67
94
  throw new UsageError(`Unknown config key: ${key}`);
@@ -83,6 +110,28 @@ export function unsetConfigValue(config, key) {
83
110
  return { ...config, output: mergeOutputConfig(config.output, { format: undefined }) };
84
111
  case "output.detail":
85
112
  return { ...config, output: mergeOutputConfig(config.output, { detail: undefined }) };
113
+ case "security":
114
+ return { ...config, security: undefined };
115
+ case "security.installAudit.enabled":
116
+ return { ...config, security: mergeSecurityConfig(config.security, { installAudit: { enabled: undefined } }) };
117
+ case "security.installAudit.blockOnCritical":
118
+ return {
119
+ ...config,
120
+ security: mergeSecurityConfig(config.security, { installAudit: { blockOnCritical: undefined } }),
121
+ };
122
+ case "security.installAudit.blockUnlistedRegistries":
123
+ return {
124
+ ...config,
125
+ security: mergeSecurityConfig(config.security, { installAudit: { blockUnlistedRegistries: undefined } }),
126
+ };
127
+ case "security.installAudit.registryAllowlist":
128
+ case "security.installAudit.registryWhitelist":
129
+ return {
130
+ ...config,
131
+ security: mergeSecurityConfig(config.security, {
132
+ installAudit: { registryAllowlist: undefined, registryWhitelist: undefined },
133
+ }),
134
+ };
86
135
  default:
87
136
  throw new UsageError(`Unknown or unsupported unset key: ${key}`);
88
137
  }
@@ -100,6 +149,8 @@ export function listConfig(config) {
100
149
  result.embedding = config.embedding;
101
150
  if (config.llm)
102
151
  result.llm = config.llm;
152
+ if (config.security)
153
+ result.security = config.security;
103
154
  return result;
104
155
  }
105
156
  function mergeConfigValue(config, partial) {
@@ -107,6 +158,7 @@ function mergeConfigValue(config, partial) {
107
158
  ...config,
108
159
  ...partial,
109
160
  output: mergeOutputConfig(config.output, partial.output),
161
+ security: mergeSecurityConfig(config.security, partial.security),
110
162
  };
111
163
  }
112
164
  function mergeOutputConfig(base, override) {
@@ -116,6 +168,18 @@ function mergeOutputConfig(base, override) {
116
168
  };
117
169
  return merged.format || merged.detail ? merged : undefined;
118
170
  }
171
+ function mergeSecurityConfig(base, override) {
172
+ const mergedInstallAudit = mergeInstallAuditConfig(base?.installAudit, override?.installAudit);
173
+ return mergedInstallAudit ? { installAudit: mergedInstallAudit } : undefined;
174
+ }
175
+ function mergeInstallAuditConfig(base, override) {
176
+ const merged = {
177
+ ...(base ?? {}),
178
+ ...(override ?? {}),
179
+ };
180
+ const hasValue = Object.values(merged).some((value) => value !== undefined);
181
+ return hasValue ? merged : undefined;
182
+ }
119
183
  function parseOutputFormat(value) {
120
184
  if (value === "json" || value === "yaml" || value === "text")
121
185
  return value;
@@ -126,6 +190,29 @@ function parseOutputDetail(value) {
126
190
  return value;
127
191
  throw new UsageError(`Invalid value for output.detail: expected one of brief|normal|full`);
128
192
  }
193
+ function parseBooleanValue(value, key) {
194
+ if (value === "true")
195
+ return true;
196
+ if (value === "false")
197
+ return false;
198
+ throw new UsageError(`Invalid value for ${key}: expected true or false`);
199
+ }
200
+ function parseStringArrayValue(value, key) {
201
+ let parsed;
202
+ try {
203
+ parsed = JSON.parse(value);
204
+ }
205
+ catch {
206
+ throw new UsageError(`Invalid value for ${key}: expected a JSON array of strings`);
207
+ }
208
+ if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) {
209
+ throw new UsageError(`Invalid value for ${key}: expected a JSON array of strings`);
210
+ }
211
+ return parsed;
212
+ }
213
+ function getInstallAuditAllowlist(config) {
214
+ return config.security?.installAudit?.registryAllowlist ?? config.security?.installAudit?.registryWhitelist ?? null;
215
+ }
129
216
  function parseRegistriesValue(value) {
130
217
  if (value === "null" || value === "")
131
218
  return undefined;
package/dist/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { filterNonEmptyStrings } from "./common";
3
4
  import { getConfigDir as _getConfigDir, getConfigPath as _getConfigPath } from "./paths";
4
5
  // ── Defaults ────────────────────────────────────────────────────────────────
5
6
  export const DEFAULT_CONFIG = {
@@ -21,42 +22,47 @@ export function getConfigPath() {
21
22
  return _getConfigPath();
22
23
  }
23
24
  // ── Load / Save / Update ────────────────────────────────────────────────────
25
+ const PROJECT_CONFIG_RELATIVE_PATH = path.join(".akm", "config.json");
24
26
  let cachedConfig;
27
+ let cachedUserConfig;
25
28
  export function resetConfigCache() {
26
29
  cachedConfig = undefined;
30
+ cachedUserConfig = undefined;
27
31
  }
28
- export function loadConfig() {
32
+ export function loadUserConfig() {
29
33
  const configPath = getConfigPath();
30
34
  let stat;
31
35
  try {
32
36
  stat = fs.statSync(configPath);
33
- if (cachedConfig && cachedConfig.path === configPath && cachedConfig.mtime === stat.mtimeMs) {
34
- return cachedConfig.config;
37
+ if (cachedUserConfig && cachedUserConfig.path === configPath && cachedUserConfig.mtime === stat.mtimeMs) {
38
+ return cachedUserConfig.config;
35
39
  }
36
40
  }
37
41
  catch {
38
- // File doesn't exist — return defaults below
39
- cachedConfig = undefined;
40
- return { ...DEFAULT_CONFIG };
41
- }
42
- const raw = readConfigObject(configPath);
43
- const expanded = raw ? expandEnvVars(raw) : undefined;
44
- const config = expanded ? pickKnownKeys(expanded) : { ...DEFAULT_CONFIG };
45
- // Legacy: inject API keys from well-known env vars when not set via ${} substitution
46
- if (config.embedding && !config.embedding.apiKey) {
47
- const envKey = process.env.AKM_EMBED_API_KEY?.trim();
48
- if (envKey)
49
- config.embedding.apiKey = envKey;
42
+ cachedUserConfig = undefined;
43
+ return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
50
44
  }
51
- if (config.llm && !config.llm.apiKey) {
52
- const envKey = process.env.AKM_LLM_API_KEY?.trim();
53
- if (envKey)
54
- config.llm.apiKey = envKey;
45
+ const config = mergeLoadedConfig(DEFAULT_CONFIG, readNormalizedConfig(configPath));
46
+ const finalConfig = applyRuntimeEnvApiKeys(config);
47
+ cachedUserConfig = { config: finalConfig, path: configPath, mtime: stat.mtimeMs };
48
+ return finalConfig;
49
+ }
50
+ export function loadConfig() {
51
+ const configPaths = getEffectiveConfigPaths();
52
+ const signature = getConfigSignature(configPaths);
53
+ if (cachedConfig && cachedConfig.signature === signature) {
54
+ return cachedConfig.config;
55
+ }
56
+ let config = loadUserConfig();
57
+ const userConfigPath = getConfigPath();
58
+ for (const configPath of configPaths) {
59
+ if (configPath === userConfigPath)
60
+ continue;
61
+ config = mergeLoadedConfig(config, readNormalizedConfig(configPath));
55
62
  }
56
- // Cache the parsed config with its path and mtime for subsequent calls.
57
- // Reuse the stat already obtained above (avoids a second syscall + TOCTOU gap).
58
- cachedConfig = { config, path: configPath, mtime: stat.mtimeMs };
59
- return config;
63
+ const finalConfig = applyRuntimeEnvApiKeys(config);
64
+ cachedConfig = { config: finalConfig, signature };
65
+ return finalConfig;
60
66
  }
61
67
  export function saveConfig(config) {
62
68
  cachedConfig = undefined;
@@ -98,7 +104,7 @@ function sanitizeConfigForWrite(config) {
98
104
  return sanitized;
99
105
  }
100
106
  export function updateConfig(partial) {
101
- const current = loadConfig();
107
+ const current = loadUserConfig();
102
108
  // Shallow-merge for top-level scalar fields; deep-merge known object-type config keys.
103
109
  const merged = { ...current, ...partial };
104
110
  // Deep-merge output — partial update should not wipe sibling keys
@@ -113,12 +119,22 @@ export function updateConfig(partial) {
113
119
  if (current.llm && partial.llm && partial.llm !== current.llm) {
114
120
  merged.llm = { ...current.llm, ...partial.llm };
115
121
  }
122
+ if (current.security && partial.security && partial.security !== current.security) {
123
+ merged.security = mergeSecurityConfig(current.security, partial.security);
124
+ }
116
125
  saveConfig(merged);
117
126
  return merged;
118
127
  }
119
128
  // ── Helpers ─────────────────────────────────────────────────────────────────
129
+ /**
130
+ * Normalize a raw config object into a sparse config layer containing only
131
+ * recognized keys that were valid in the source object. This function does not
132
+ * merge with DEFAULT_CONFIG; callers are responsible for layering defaults and
133
+ * combining multiple config sources so project config files only override what
134
+ * they set.
135
+ */
120
136
  function pickKnownKeys(raw) {
121
- const config = { ...DEFAULT_CONFIG };
137
+ const config = {};
122
138
  if (typeof raw.stashDir === "string" && raw.stashDir.trim()) {
123
139
  config.stashDir = raw.stashDir.trim();
124
140
  }
@@ -159,14 +175,25 @@ function pickKnownKeys(raw) {
159
175
  const registries = parseRegistriesConfig(raw.registries);
160
176
  if (registries)
161
177
  config.registries = registries;
178
+ if (typeof raw.disableGlobalStashes === "boolean") {
179
+ config.disableGlobalStashes = raw.disableGlobalStashes;
180
+ }
162
181
  const stashes = parseStashesConfig(raw.stashes);
163
182
  if (stashes)
164
183
  config.stashes = stashes;
184
+ const security = parseSecurityConfig(raw.security);
185
+ if (security)
186
+ config.security = security;
165
187
  const output = parseOutputConfig(raw.output);
166
188
  if (output)
167
189
  config.output = output;
168
190
  return config;
169
191
  }
192
+ function readNormalizedConfig(configPath) {
193
+ const raw = readConfigObject(configPath);
194
+ const expanded = raw ? expandEnvVars(raw) : undefined;
195
+ return expanded ? pickKnownKeys(expanded) : undefined;
196
+ }
170
197
  function parseOutputConfig(value) {
171
198
  if (typeof value !== "object" || value === null || Array.isArray(value))
172
199
  return undefined;
@@ -440,6 +467,32 @@ function parseStashesConfig(value) {
440
467
  .filter((entry) => entry !== undefined);
441
468
  return entries;
442
469
  }
470
+ function parseSecurityConfig(value) {
471
+ if (typeof value !== "object" || value === null || Array.isArray(value))
472
+ return undefined;
473
+ const obj = value;
474
+ const installAudit = parseInstallAuditConfig(obj.installAudit);
475
+ if (!installAudit)
476
+ return undefined;
477
+ return { installAudit };
478
+ }
479
+ function parseInstallAuditConfig(value) {
480
+ if (typeof value !== "object" || value === null || Array.isArray(value))
481
+ return undefined;
482
+ const obj = value;
483
+ const config = {};
484
+ if (typeof obj.enabled === "boolean")
485
+ config.enabled = obj.enabled;
486
+ if (typeof obj.blockOnCritical === "boolean")
487
+ config.blockOnCritical = obj.blockOnCritical;
488
+ if (typeof obj.blockUnlistedRegistries === "boolean")
489
+ config.blockUnlistedRegistries = obj.blockUnlistedRegistries;
490
+ const rawAllowlist = filterNonEmptyStrings(obj.registryAllowlist) ?? filterNonEmptyStrings(obj.registryWhitelist);
491
+ if (rawAllowlist) {
492
+ config.registryAllowlist = rawAllowlist;
493
+ }
494
+ return Object.keys(config).length > 0 ? config : undefined;
495
+ }
443
496
  function parseStashConfigEntry(value) {
444
497
  if (typeof value !== "object" || value === null || Array.isArray(value))
445
498
  return undefined;
@@ -485,3 +538,122 @@ function parseRegistryConfigEntry(value) {
485
538
  }
486
539
  return entry;
487
540
  }
541
+ function mergeSecurityConfig(base, override) {
542
+ if (!base && !override)
543
+ return undefined;
544
+ const installAudit = mergeInstallAuditConfig(base?.installAudit, override?.installAudit);
545
+ return installAudit ? { installAudit } : undefined;
546
+ }
547
+ function mergeInstallAuditConfig(base, override) {
548
+ if (!base && !override)
549
+ return undefined;
550
+ const merged = {
551
+ ...(base ?? {}),
552
+ ...(override ?? {}),
553
+ };
554
+ return Object.values(merged).some((value) => value !== undefined) ? merged : undefined;
555
+ }
556
+ /**
557
+ * Merge a normalized config layer into an accumulated config.
558
+ *
559
+ * Scalar fields follow normal override semantics. Known nested objects are
560
+ * deep-merged so project config files can override individual fields without
561
+ * clobbering sibling settings. `stashes` are additive by default, but a later
562
+ * layer can set `disableGlobalStashes: true` to drop inherited stashes first.
563
+ */
564
+ function mergeLoadedConfig(base, override) {
565
+ if (!override)
566
+ return { ...base };
567
+ const merged = {
568
+ ...base,
569
+ ...override,
570
+ };
571
+ if (base.output && override.output) {
572
+ merged.output = { ...base.output, ...override.output };
573
+ }
574
+ if (base.embedding && override.embedding) {
575
+ merged.embedding = { ...base.embedding, ...override.embedding };
576
+ }
577
+ if (base.llm && override.llm) {
578
+ merged.llm = { ...base.llm, ...override.llm };
579
+ }
580
+ if (base.security && override.security) {
581
+ merged.security = mergeSecurityConfig(base.security, override.security);
582
+ }
583
+ if (override.disableGlobalStashes) {
584
+ merged.stashes = [...(override.stashes ?? [])];
585
+ }
586
+ else if (override.stashes) {
587
+ merged.stashes = [...(base.stashes ?? []), ...override.stashes];
588
+ }
589
+ return merged;
590
+ }
591
+ function applyRuntimeEnvApiKeys(config) {
592
+ const next = { ...config };
593
+ if (next.embedding && !next.embedding.apiKey) {
594
+ const envKey = process.env.AKM_EMBED_API_KEY?.trim();
595
+ if (envKey)
596
+ next.embedding = { ...next.embedding, apiKey: envKey };
597
+ }
598
+ if (next.llm && !next.llm.apiKey) {
599
+ const envKey = process.env.AKM_LLM_API_KEY?.trim();
600
+ if (envKey)
601
+ next.llm = { ...next.llm, apiKey: envKey };
602
+ }
603
+ return next;
604
+ }
605
+ /**
606
+ * Return config file paths in merge order: user config first, then project
607
+ * config files from the outermost parent directory down to the current working
608
+ * directory. Later entries have higher precedence when merged.
609
+ */
610
+ function getEffectiveConfigPaths() {
611
+ const configPath = getConfigPath();
612
+ const paths = [];
613
+ if (isFile(configPath)) {
614
+ paths.push(configPath);
615
+ }
616
+ return [...paths, ...discoverProjectConfigPaths(process.cwd())];
617
+ }
618
+ /**
619
+ * Walk from `startDir` up to the filesystem root and collect `.akm/config.json`
620
+ * files. Paths are returned from outermost parent to innermost directory so
621
+ * nearer project directories override broader project settings.
622
+ */
623
+ function discoverProjectConfigPaths(startDir) {
624
+ const paths = [];
625
+ let currentDir = path.resolve(startDir);
626
+ while (true) {
627
+ const configPath = path.join(currentDir, PROJECT_CONFIG_RELATIVE_PATH);
628
+ if (isFile(configPath)) {
629
+ paths.unshift(configPath);
630
+ }
631
+ const parentDir = path.dirname(currentDir);
632
+ if (parentDir === currentDir) {
633
+ break;
634
+ }
635
+ currentDir = parentDir;
636
+ }
637
+ return paths;
638
+ }
639
+ function getConfigSignature(configPaths) {
640
+ if (configPaths.length === 0)
641
+ return "defaults";
642
+ return configPaths.map((configPath) => `${configPath}:${getFileSignatureToken(configPath)}`).join("|");
643
+ }
644
+ function isFile(filePath) {
645
+ try {
646
+ return fs.statSync(filePath).isFile();
647
+ }
648
+ catch {
649
+ return false;
650
+ }
651
+ }
652
+ function getFileSignatureToken(filePath) {
653
+ try {
654
+ return String(fs.statSync(filePath).mtimeMs);
655
+ }
656
+ catch {
657
+ return "missing";
658
+ }
659
+ }
package/dist/embedder.js CHANGED
@@ -102,6 +102,28 @@ function l2Normalize(vec) {
102
102
  return vec.map((v) => v / norm);
103
103
  }
104
104
  // ── OpenAI-compatible remote embedder ───────────────────────────────────────
105
+ function normalizeEmbeddingEndpoint(endpoint) {
106
+ let parsed;
107
+ try {
108
+ parsed = new URL(endpoint);
109
+ }
110
+ catch {
111
+ return endpoint;
112
+ }
113
+ const normalizedPath = parsed.pathname.replace(/\/+$/, "");
114
+ if (normalizedPath.endsWith("/embeddings")) {
115
+ return parsed.toString();
116
+ }
117
+ parsed.pathname = normalizedPath ? `${normalizedPath}/embeddings` : "/embeddings";
118
+ return parsed.toString();
119
+ }
120
+ function embeddingEndpointPathHint(endpoint) {
121
+ const normalizedEndpoint = normalizeEmbeddingEndpoint(endpoint);
122
+ if (normalizedEndpoint !== endpoint) {
123
+ return ` Check that your endpoint includes the full embeddings path (for example "${normalizedEndpoint}", not just "${endpoint}").`;
124
+ }
125
+ return "";
126
+ }
105
127
  async function embedRemote(text, config) {
106
128
  const headers = { "Content-Type": "application/json" };
107
129
  if (config.apiKey) {
@@ -114,7 +136,7 @@ async function embedRemote(text, config) {
114
136
  if (config.dimension) {
115
137
  body.dimensions = config.dimension;
116
138
  }
117
- const response = await fetchWithTimeout(config.endpoint, {
139
+ const response = await fetchWithTimeout(normalizeEmbeddingEndpoint(config.endpoint), {
118
140
  method: "POST",
119
141
  headers,
120
142
  body: JSON.stringify(body),
@@ -125,7 +147,7 @@ async function embedRemote(text, config) {
125
147
  }
126
148
  const json = (await response.json());
127
149
  if (!json.data?.[0]?.embedding) {
128
- throw new Error("Unexpected embedding response format: missing data[0].embedding");
150
+ throw new Error(`Unexpected embedding response format: missing data[0].embedding.${embeddingEndpointPathHint(config.endpoint)}`);
129
151
  }
130
152
  return l2Normalize(json.data[0].embedding);
131
153
  }
@@ -228,7 +250,7 @@ async function embedRemoteBatch(texts, config) {
228
250
  if (config.dimension) {
229
251
  body.dimensions = config.dimension;
230
252
  }
231
- const response = await fetchWithTimeout(config.endpoint, {
253
+ const response = await fetchWithTimeout(normalizeEmbeddingEndpoint(config.endpoint), {
232
254
  method: "POST",
233
255
  headers,
234
256
  body: JSON.stringify(body),
@@ -239,7 +261,7 @@ async function embedRemoteBatch(texts, config) {
239
261
  }
240
262
  const json = (await response.json());
241
263
  if (!json.data || json.data.length !== batch.length) {
242
- throw new Error(`Unexpected embedding batch response: expected ${batch.length} embeddings, got ${json.data?.length ?? 0}`);
264
+ throw new Error(`Unexpected embedding batch response: expected ${batch.length} embeddings, got ${json.data?.length ?? 0}.${embeddingEndpointPathHint(config.endpoint)}`);
243
265
  }
244
266
  // Sort by index to guarantee correct order (OpenAI API doesn't guarantee order)
245
267
  const sorted = [...json.data].sort((a, b) => a.index - b.index);
package/dist/init.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { TYPE_DIRS } from "./asset-spec";
10
- import { getConfigPath, loadConfig, saveConfig } from "./config";
10
+ import { getConfigPath, loadUserConfig, saveConfig } from "./config";
11
11
  import { getBinDir, getDefaultStashDir } from "./paths";
12
12
  import { ensureRg } from "./ripgrep-install";
13
13
  export async function akmInit(options) {
@@ -25,7 +25,7 @@ export async function akmInit(options) {
25
25
  }
26
26
  // Persist stashDir in config.json
27
27
  const configPath = getConfigPath();
28
- const existing = loadConfig();
28
+ const existing = loadUserConfig();
29
29
  if (!existing.stashDir || existing.stashDir !== stashDir) {
30
30
  saveConfig({ ...existing, stashDir });
31
31
  }