@wirecat/cli-core 0.422.0 → 0.423.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { CliError, configFilePath, resolvePaths, saveConfigFile } from "@wirecat/cli-base";
3
3
  import * as v from "valibot";
4
4
  import { AI_ENTRIES, resolveAISettings } from "../analysis/settings.js";
@@ -7,7 +7,7 @@ import { envName, readOnlyLockOf } from "../ports/app.js";
7
7
  import { assertOverridesTighten, CEILING_KEY, LEVELS, layerPermissions, permissionOverrides, RESOURCES, } from "../sends/permissions.js";
8
8
  import { storePath } from "../store/path.js";
9
9
  import { sharedConfigPath } from "../store/shared-config.js";
10
- import { loadSnakeCaseConfig } from "./config-keys.js";
10
+ import { issueMessage, parseConfigText, publicIssue, savedPermissionKeys, schemaIssues, withoutIssues, } from "./config-issues.js";
11
11
  import { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
12
12
  import { profilesHere } from "./messenger/accounts.js";
13
13
  import { usableProfileName } from "./profile.js";
@@ -108,7 +108,6 @@ export const settingsFor = (app, extension = {}) => {
108
108
  profiles: v.optional(v.record(v.string(), strictOf(entries), "has to be an object of profiles, by name")),
109
109
  }, objectMessage);
110
110
  const configEntries = {
111
- default_profile: v.optional(v.string(plain("has to be a profile name, in quotes"))),
112
111
  defaults: v.optional(strictOf(defaultsEntries)),
113
112
  profiles: v.optional(v.record(v.string(), strictOf(profileEntries), "has to be an object of profiles, by name"), {}),
114
113
  personal: v.optional(kindSection(profileEntries)),
@@ -117,22 +116,35 @@ export const settingsFor = (app, extension = {}) => {
117
116
  const schema = strictOf(configEntries);
118
117
  const defaultsOnlyKeys = Object.keys(defaultsOnly);
119
118
  const botOnlyKeys = Object.keys(BOT_ONLY_ENTRIES);
120
- const allSettings = [...Object.keys(profileEntries), ...botOnlyKeys, ...defaultsOnlyKeys, "default_profile"];
119
+ const allSettings = [...Object.keys(profileEntries), ...botOnlyKeys, ...defaultsOnlyKeys];
121
120
  const PROFILE = envName(app, "PROFILE");
122
121
  const PROFILE_LOCK = envName(app, "PROFILE_LOCK");
123
122
  const TIMEOUT = envName(app, "TIMEOUT");
124
123
  const REQUESTS_PER_MINUTE = envName(app, "REQUESTS_PER_MINUTE");
125
124
  const configPathFor = ({ env = process.env, configDir }) => configFilePath(configDir ?? resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).config);
126
- const readConfig = (path) => {
125
+ const readSchema = extension.schema ?? schema;
126
+ const context = (file) => ({ file, tool: app.command });
127
+ /** The file as written, `undefined` when there is none, or the issue that says it is not JSON. */
128
+ const readRaw = (path) => {
129
+ let text;
127
130
  try {
128
- return loadSnakeCaseConfig(path, extension.schema ?? schema, () => ({ profiles: {} }));
131
+ text = readFileSync(path, "utf8");
129
132
  }
130
- catch (error) {
131
- const message = error instanceof Error ? error.message : String(error);
132
- throw new CliError("configuration_error", message.includes("catch_up_marks_read")
133
- ? `${message}; remove catch_up_marks_read from the config file and use ${app.command} <profile> chats mark-read <chat>`
134
- : message);
133
+ catch {
134
+ return { raw: undefined };
135
135
  }
136
+ return parseConfigText(text, path);
137
+ };
138
+ /** The file with every problem found; what loads is the file without the settings they name. */
139
+ const loadConfig = (path) => {
140
+ const read = readRaw(path);
141
+ if ("issue" in read)
142
+ return { config: { profiles: {} }, issues: [read.issue], raw: undefined };
143
+ if (read.raw === undefined)
144
+ return { config: { profiles: {} }, issues: [], raw: undefined };
145
+ const issues = schemaIssues(read.raw, readSchema, context(path));
146
+ const parsed = v.safeParse(readSchema, withoutIssues(read.raw, issues));
147
+ return { config: (parsed.success ? parsed.output : { profiles: {} }), issues, raw: read.raw };
136
148
  };
137
149
  /**
138
150
  * A lock pins a process to one profile: the owner sets it where an agent runs, and a first word
@@ -157,10 +169,6 @@ export const settingsFor = (app, extension = {}) => {
157
169
  const unnamed = (config, env, needed) => {
158
170
  if (!needed)
159
171
  return { value: NO_PROFILE, from: NONE_NAMED };
160
- if (config.default_profile !== undefined) {
161
- throw new CliError("validation_error", `default_profile is no longer read — name the profile: \`${app.command} ${config.default_profile} …\`; ` +
162
- `\`${app.command} config unset default_profile\` removes it from the config file`, { profiles: profilesOf(config, env) });
163
- }
164
172
  const here = profilesOf(config, env);
165
173
  throw new CliError("validation_error", here.length === 0
166
174
  ? `name a profile: there is none on this computer yet — create one with \`${app.command} personal setup\` ` +
@@ -178,7 +186,10 @@ export const settingsFor = (app, extension = {}) => {
178
186
  catch (error) {
179
187
  throw new CliError("configuration_error", error instanceof Error ? error.message : String(error));
180
188
  }
181
- const config = readConfig(configPath);
189
+ const loaded = loadConfig(configPath);
190
+ if (loaded.issues.length > 0 && !options.withIssues)
191
+ throw new CliError("configuration_error", issueMessage(loaded.issues.map(publicIssue)));
192
+ const { config } = loaded;
182
193
  const lock = given(env[PROFILE_LOCK]);
183
194
  const named = first([
184
195
  ["first word", flags.profile],
@@ -202,9 +213,6 @@ export const settingsFor = (app, extension = {}) => {
202
213
  [extension.sourcePaths ? "config file: defaults" : "config defaults", shared],
203
214
  ];
204
215
  const fromLayers = (key, fallback, only = layers) => first(only.map(([from, scope]) => [from, scope?.[key]]), fallback);
205
- if (fromLayers("catch_up_marks_read", undefined).value !== undefined) {
206
- throw new CliError("configuration_error", `remove catch_up_marks_read from the config file and use ${app.command} <profile> chats mark-read <chat>`);
207
- }
208
216
  const kindLayers = layers.filter(([from]) => from.startsWith(`config file: ${kind}.`));
209
217
  const limit = flags.limit === undefined ? fromLayers("limit", DEFAULT_LIMIT) : { value: flags.limit, from: "flag" };
210
218
  const record = flags.record === undefined ? fromLayers("record", false) : { value: flags.record, from: "flag" };
@@ -303,6 +311,8 @@ export const settingsFor = (app, extension = {}) => {
303
311
  },
304
312
  configured,
305
313
  shared,
314
+ configIssues: loaded.issues.map(publicIssue),
315
+ savedPermissions: savedPermissionKeys(config),
306
316
  };
307
317
  // The file was checked by the schema; a flag was not, and `--limit abc` is `NaN` by the time it
308
318
  // gets here, which slices an array to nothing without complaining.
@@ -319,16 +329,52 @@ export const settingsFor = (app, extension = {}) => {
319
329
  const extra = extension.resolve?.({ flags, settings, layers, fromLayers });
320
330
  return { ...settings, ...extra?.values, sources: { ...settings.sources, ...extra?.sources } };
321
331
  };
322
- const configuredProfiles = (options = {}) => namedProfiles(readConfig(configPathFor(options)));
323
- /** `config set` and `config unset`: checked by the same schema the reader uses, so it never writes a file the reader refuses. */
324
- const changeSetting = (path, { profile, setting, value, kind, }) => {
332
+ const configuredProfiles = (options = {}) => {
333
+ const { config, issues } = loadConfig(configPathFor(options));
334
+ if (issues.length > 0 && !options.withIssues)
335
+ throw new CliError("configuration_error", issueMessage(issues.map(publicIssue)));
336
+ return namedProfiles(config);
337
+ };
338
+ /**
339
+ * `config set` and `config unset`, on the file as written, so they also run on a file with problems. A set is
340
+ * refused only for a problem it would add; an unset removes the setting whether or not the schema knows it.
341
+ * `root` reaches a path outside any profile or `defaults`, and only one that has a problem.
342
+ */
343
+ const changeSetting = (path, { profile, setting, value, kind, root = false, }) => {
325
344
  const [name = setting, ...rest] = setting.split(".");
326
345
  const level = name === "permissions" && rest.length > 0 ? rest.join(".") : undefined;
327
346
  const modelField = /^models\.([a-z][a-z0-9-]*)\.(provider|model|base_url)$/.exec(setting);
328
- if (!allSettings.includes(setting) && level === undefined && !modelField) {
329
- const offered = value === undefined ? allSettings : allSettings.filter((one) => one !== "default_profile");
330
- throw new CliError("validation_error", `no setting called "${setting}" — one of: ${offered.join(", ")}`);
347
+ const known = allSettings.includes(setting) || level !== undefined || modelField !== null;
348
+ const read = readRaw(path);
349
+ if ("issue" in read)
350
+ throw new CliError("configuration_error", issueMessage([publicIssue(read.issue)]));
351
+ const raw = read.raw === undefined ? {} : read.raw;
352
+ if (!isScope(raw))
353
+ throw new CliError("configuration_error", `${path}: the file has to be an object, in braces`);
354
+ const scopePath = root
355
+ ? []
356
+ : [...(kind === undefined ? [] : [kind]), ...(profile === undefined ? ["defaults"] : ["profiles", profile])];
357
+ const keyPath = root
358
+ ? setting.split(".")
359
+ : level !== undefined
360
+ ? ["permissions", level]
361
+ : modelField
362
+ ? ["models", modelField[1], modelField[2]]
363
+ : [setting];
364
+ const at = [...scopePath, ...keyPath];
365
+ const before = schemaIssues(raw, readSchema, context(path));
366
+ if (value === undefined) {
367
+ if (root && !before.some((issue) => issue.at.join(".") === at.join(".")))
368
+ throw new CliError("validation_error", `${setting} has no problem to remove — name the profile, or add --defaults, to unset a setting`);
369
+ if (!known && valueAt(raw, at) === undefined)
370
+ throw new CliError("validation_error", `no setting called "${setting}" — one of: ${allSettings.join(", ")}`);
371
+ const changed = structuredClone(raw);
372
+ removeAt(changed, at);
373
+ saveConfigFile(path, changed);
374
+ return null;
331
375
  }
376
+ if (!known)
377
+ throw new CliError("validation_error", `no setting called "${setting}" — one of: ${allSettings.join(", ")}`);
332
378
  if (profile !== undefined && defaultsOnlyKeys.includes(setting)) {
333
379
  throw new CliError("validation_error", `${setting} is one setting for the whole program, not per profile — add --defaults`);
334
380
  }
@@ -338,98 +384,45 @@ export const settingsFor = (app, extension = {}) => {
338
384
  if (kind !== "bot" && botOnlyKeys.includes(setting)) {
339
385
  throw new CliError("validation_error", `${setting} is a bot's setting — add --bot`);
340
386
  }
341
- const config = readConfig(path);
342
- if (setting === "default_profile") {
343
- if (kind !== undefined) {
344
- throw new CliError("validation_error", "default_profile is one for the whole file — drop --personal and --bot");
345
- }
346
- if (value !== undefined) {
347
- throw new CliError("validation_error", `default_profile is gone: a command names its profile every time — \`${app.command} <profile> …\` or ${PROFILE}`);
348
- }
349
- const { default_profile: _, ...rest } = config;
350
- saveConfigFile(path, rest);
351
- return null;
352
- }
353
- const section = kind === undefined ? undefined : (config[kind] ?? {});
354
- const current = section === undefined
355
- ? profile === undefined
356
- ? config.defaults
357
- : config.profiles[profile]
358
- : profile === undefined
359
- ? section.defaults
360
- : section.profiles?.[profile];
361
- const scope = { ...current };
362
- if (modelField) {
363
- const purpose = modelField[1];
364
- const field = modelField[2];
365
- const models = { ...scope.models };
366
- const target = { ...models[purpose] };
367
- if (value === undefined)
368
- delete target[field];
369
- else
370
- target[field] = parseValue(value);
371
- if (Object.keys(target).length > 0)
372
- models[purpose] = target;
373
- else
374
- delete models[purpose];
375
- if (Object.keys(models).length > 0)
376
- scope.models = models;
377
- else
378
- delete scope.models;
379
- }
380
- else if (level !== undefined) {
381
- const levels = { ...scope.permissions };
382
- if (value === undefined)
383
- delete levels[level];
384
- else
385
- levels[level] = value.trim();
386
- if (Object.keys(levels).length > 0)
387
- scope.permissions = levels;
388
- else
389
- delete scope.permissions;
390
- }
391
- else if (value === undefined)
392
- delete scope[setting];
393
- else
394
- scope[setting] = parseValue(value);
395
- const empty = Object.keys(scope).length === 0;
396
- let changed;
397
- if (kind === undefined || section === undefined) {
398
- changed =
399
- profile === undefined
400
- ? { ...config, defaults: scope }
401
- : { ...config, profiles: { ...config.profiles, [profile]: scope } };
402
- if (profile === undefined && empty)
403
- delete changed.defaults;
404
- if (profile !== undefined && empty)
405
- delete changed.profiles[profile];
406
- }
407
- else {
408
- const next = profile === undefined
409
- ? { ...section, defaults: scope }
410
- : { ...section, profiles: { ...section.profiles, [profile]: scope } };
411
- if (profile === undefined && empty)
412
- delete next.defaults;
413
- if (profile !== undefined && empty && next.profiles)
414
- delete next.profiles[profile];
415
- if (next.profiles && Object.keys(next.profiles).length === 0)
416
- delete next.profiles;
417
- changed = { ...config, [kind]: next };
418
- if (Object.keys(next).length === 0)
419
- delete changed[kind];
420
- }
421
- const checked = v.safeParse(extension.schema ?? schema, changed);
422
- if (!checked.success) {
423
- throw new CliError("validation_error", `${setting} cannot be "${value}": ${checked.issues[0]?.message ?? "invalid"}`);
424
- }
425
- saveConfigFile(path, checked.output);
426
- if (modelField)
427
- return (scope.models?.[modelField[1]]?.[modelField[2]] ??
428
- null);
429
- return level === undefined ? (scope[setting] ?? null) : (scope.permissions?.[level] ?? null);
387
+ const changed = structuredClone(raw);
388
+ setAt(changed, at, level === undefined ? parseValue(value) : value.trim());
389
+ const had = new Set(before.map((issue) => issue.at.join(".")));
390
+ const added = schemaIssues(changed, readSchema, context(path)).find((issue) => !had.has(issue.at.join(".")) || issue.at.join(".") === at.join("."));
391
+ if (added)
392
+ throw new CliError("validation_error", `${setting} cannot be "${value}": ${added.what}`);
393
+ const checked = v.safeParse(readSchema, changed);
394
+ saveConfigFile(path, checked.success ? checked.output : changed);
395
+ return valueAt(checked.success ? checked.output : changed, at) ?? null;
430
396
  };
431
397
  return { resolveSettings, configuredProfiles, changeSetting, allSettings, defaultsOnlyKeys, schema };
432
398
  };
399
+ const isScope = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
400
+ const valueAt = (node, path) => path.reduce((at, key) => (isScope(at) ? at[key] : undefined), node);
401
+ const setAt = (node, path, value) => {
402
+ let at = node;
403
+ for (const key of path.slice(0, -1)) {
404
+ if (!isScope(at[key]))
405
+ at[key] = {};
406
+ at = at[key];
407
+ }
408
+ at[path.at(-1)] = value;
409
+ };
410
+ /** Removes the value and every section it leaves empty, so an unset leaves no `{}` behind. */
411
+ const removeAt = (node, path) => {
412
+ const [key, ...rest] = path;
413
+ if (key === undefined)
414
+ return;
415
+ if (rest.length === 0) {
416
+ delete node[key];
417
+ return;
418
+ }
419
+ const child = node[key];
420
+ if (!isScope(child))
421
+ return;
422
+ removeAt(child, rest);
423
+ if (Object.keys(child).length === 0)
424
+ delete node[key];
425
+ };
433
426
  /** Every profile the file names, in any section. */
434
427
  const namedProfiles = (config) => [
435
428
  ...new Set([