@wirecat/cli-core 0.422.0 → 0.424.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/analysis/settings.d.ts +0 -6
  3. package/dist/analysis/settings.js +2 -5
  4. package/dist/cli/analysis-choice.d.ts +0 -1
  5. package/dist/cli/analysis-choice.js +9 -22
  6. package/dist/cli/bot/context.js +1 -1
  7. package/dist/cli/config-command.js +33 -25
  8. package/dist/cli/config-issues.d.ts +52 -0
  9. package/dist/cli/config-issues.js +187 -0
  10. package/dist/cli/config-keys.js +4 -28
  11. package/dist/cli/context.d.ts +1 -1
  12. package/dist/cli/context.js +3 -1
  13. package/dist/cli/embedding-choice.d.ts +1 -2
  14. package/dist/cli/embedding-choice.js +2 -4
  15. package/dist/cli/index.d.ts +2 -1
  16. package/dist/cli/index.js +1 -1
  17. package/dist/cli/messenger/context.js +1 -1
  18. package/dist/cli/messenger/conversations-command.js +5 -9
  19. package/dist/cli/messenger/doctor-command.js +38 -4
  20. package/dist/cli/messenger/inbox.js +2 -6
  21. package/dist/cli/messenger/private-people-command.js +1 -2
  22. package/dist/cli/messenger/review.js +2 -5
  23. package/dist/cli/permission-keys.d.ts +9 -2
  24. package/dist/cli/permission-keys.js +21 -6
  25. package/dist/cli/preview.d.ts +3 -2
  26. package/dist/cli/preview.js +4 -3
  27. package/dist/cli/program.d.ts +1 -1
  28. package/dist/cli/program.js +14 -9
  29. package/dist/cli/runs/search.d.ts +1 -1
  30. package/dist/cli/settings.d.ts +34 -117
  31. package/dist/cli/settings.js +130 -118
  32. package/dist/cli/shared-command-exports.d.ts +4 -232
  33. package/dist/entities/index.d.ts +3 -3
  34. package/dist/entities/recorded-run.d.ts +3 -3
  35. package/dist/entities/recorded-run.js +1 -1
  36. package/dist/mcp/tool.js +3 -1
  37. package/dist/meetings/mcp/index.d.ts +15 -15
  38. package/dist/models/settings.d.ts +1 -7
  39. package/dist/models/settings.js +1 -18
  40. package/dist/runs/recording.d.ts +2 -1
  41. package/dist/runs/recording.js +2 -2
  42. package/dist/runs/run.d.ts +3 -2
  43. package/dist/runs/run.js +1 -1
  44. package/dist/search/account-list.js +8 -7
  45. package/package.json +1 -1
@@ -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";
@@ -81,6 +81,26 @@ const SHARED_DEFAULTS_ONLY = {
81
81
  export const NONE_NAMED = "none named";
82
82
  /** A name for a command that speaks for no account; never chosen for one that does. */
83
83
  const NO_PROFILE = "default";
84
+ /** The profile a run is for: none when nothing named one, even though `Settings.profile` holds a placeholder. */
85
+ export const namedProfile = (settings) => settings.sources?.profile === NONE_NAMED ? undefined : settings.profile;
86
+ /**
87
+ * No profile named and none on this computer. The settings cannot see the program, so the program swaps the
88
+ * `setup` this suggests for `withoutSetup` when it has no such command.
89
+ */
90
+ export class NoProfileHere extends CliError {
91
+ app;
92
+ constructor(app) {
93
+ super("validation_error", `name a profile: there is none on this computer yet — create one with \`${app.command} personal setup\` ` +
94
+ "(any name in place of personal)", { profiles: [] });
95
+ this.app = app;
96
+ }
97
+ /** `words`: the command that was typed, without its arguments, so the suggestion runs as written. */
98
+ withoutSetup(words) {
99
+ const command = words ? `${this.app.command} personal ${words}` : `${this.app.command} personal …`;
100
+ return new CliError("validation_error", `name a profile: there is none on this computer yet — any name works, such as \`${command}\` ` +
101
+ `(or set ${envName(this.app, "PROFILE")})`, { profiles: [] });
102
+ }
103
+ }
84
104
  /** The first candidate that is set, and which layer it came from. */
85
105
  export const first = (candidates, fallback) => {
86
106
  for (const [from, value] of candidates)
@@ -108,7 +128,6 @@ export const settingsFor = (app, extension = {}) => {
108
128
  profiles: v.optional(v.record(v.string(), strictOf(entries), "has to be an object of profiles, by name")),
109
129
  }, objectMessage);
110
130
  const configEntries = {
111
- default_profile: v.optional(v.string(plain("has to be a profile name, in quotes"))),
112
131
  defaults: v.optional(strictOf(defaultsEntries)),
113
132
  profiles: v.optional(v.record(v.string(), strictOf(profileEntries), "has to be an object of profiles, by name"), {}),
114
133
  personal: v.optional(kindSection(profileEntries)),
@@ -117,22 +136,35 @@ export const settingsFor = (app, extension = {}) => {
117
136
  const schema = strictOf(configEntries);
118
137
  const defaultsOnlyKeys = Object.keys(defaultsOnly);
119
138
  const botOnlyKeys = Object.keys(BOT_ONLY_ENTRIES);
120
- const allSettings = [...Object.keys(profileEntries), ...botOnlyKeys, ...defaultsOnlyKeys, "default_profile"];
139
+ const allSettings = [...Object.keys(profileEntries), ...botOnlyKeys, ...defaultsOnlyKeys];
121
140
  const PROFILE = envName(app, "PROFILE");
122
141
  const PROFILE_LOCK = envName(app, "PROFILE_LOCK");
123
142
  const TIMEOUT = envName(app, "TIMEOUT");
124
143
  const REQUESTS_PER_MINUTE = envName(app, "REQUESTS_PER_MINUTE");
125
144
  const configPathFor = ({ env = process.env, configDir }) => configFilePath(configDir ?? resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).config);
126
- const readConfig = (path) => {
145
+ const readSchema = extension.schema ?? schema;
146
+ const context = (file) => ({ file, tool: app.command });
147
+ /** The file as written, `undefined` when there is none, or the issue that says it is not JSON. */
148
+ const readRaw = (path) => {
149
+ let text;
127
150
  try {
128
- return loadSnakeCaseConfig(path, extension.schema ?? schema, () => ({ profiles: {} }));
151
+ text = readFileSync(path, "utf8");
129
152
  }
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);
153
+ catch {
154
+ return { raw: undefined };
135
155
  }
156
+ return parseConfigText(text, path);
157
+ };
158
+ /** The file with every problem found; what loads is the file without the settings they name. */
159
+ const loadConfig = (path) => {
160
+ const read = readRaw(path);
161
+ if ("issue" in read)
162
+ return { config: { profiles: {} }, issues: [read.issue], raw: undefined };
163
+ if (read.raw === undefined)
164
+ return { config: { profiles: {} }, issues: [], raw: undefined };
165
+ const issues = schemaIssues(read.raw, readSchema, context(path));
166
+ const parsed = v.safeParse(readSchema, withoutIssues(read.raw, issues));
167
+ return { config: (parsed.success ? parsed.output : { profiles: {} }), issues, raw: read.raw };
136
168
  };
137
169
  /**
138
170
  * A lock pins a process to one profile: the owner sets it where an agent runs, and a first word
@@ -157,15 +189,10 @@ export const settingsFor = (app, extension = {}) => {
157
189
  const unnamed = (config, env, needed) => {
158
190
  if (!needed)
159
191
  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
192
  const here = profilesOf(config, env);
165
- throw new CliError("validation_error", here.length === 0
166
- ? `name a profile: there is none on this computer yet — create one with \`${app.command} personal setup\` ` +
167
- "(any name in place of personal)"
168
- : `name a profile: \`${app.command} <profile> …\` — profiles here: ${here.join(", ")} (or set ${PROFILE})`, { profiles: here });
193
+ if (here.length === 0)
194
+ throw new NoProfileHere(app);
195
+ throw new CliError("validation_error", `name a profile: \`${app.command} <profile> …\` — profiles here: ${here.join(", ")} (or set ${PROFILE})`, { profiles: here });
169
196
  };
170
197
  const profilesOf = (config, env) => profilesHere(app, [...namedProfiles(config), ...(extension.profiles?.(env) ?? [])], env);
171
198
  const resolveSettings = (flags = {}, options = {}) => {
@@ -178,7 +205,10 @@ export const settingsFor = (app, extension = {}) => {
178
205
  catch (error) {
179
206
  throw new CliError("configuration_error", error instanceof Error ? error.message : String(error));
180
207
  }
181
- const config = readConfig(configPath);
208
+ const loaded = loadConfig(configPath);
209
+ if (loaded.issues.length > 0 && !options.withIssues)
210
+ throw new CliError("configuration_error", issueMessage(loaded.issues.map(publicIssue)));
211
+ const { config } = loaded;
182
212
  const lock = given(env[PROFILE_LOCK]);
183
213
  const named = first([
184
214
  ["first word", flags.profile],
@@ -202,9 +232,6 @@ export const settingsFor = (app, extension = {}) => {
202
232
  [extension.sourcePaths ? "config file: defaults" : "config defaults", shared],
203
233
  ];
204
234
  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
235
  const kindLayers = layers.filter(([from]) => from.startsWith(`config file: ${kind}.`));
209
236
  const limit = flags.limit === undefined ? fromLayers("limit", DEFAULT_LIMIT) : { value: flags.limit, from: "flag" };
210
237
  const record = flags.record === undefined ? fromLayers("record", false) : { value: flags.record, from: "flag" };
@@ -303,6 +330,8 @@ export const settingsFor = (app, extension = {}) => {
303
330
  },
304
331
  configured,
305
332
  shared,
333
+ configIssues: loaded.issues.map(publicIssue),
334
+ savedPermissions: savedPermissionKeys(config),
306
335
  };
307
336
  // The file was checked by the schema; a flag was not, and `--limit abc` is `NaN` by the time it
308
337
  // gets here, which slices an array to nothing without complaining.
@@ -319,16 +348,52 @@ export const settingsFor = (app, extension = {}) => {
319
348
  const extra = extension.resolve?.({ flags, settings, layers, fromLayers });
320
349
  return { ...settings, ...extra?.values, sources: { ...settings.sources, ...extra?.sources } };
321
350
  };
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, }) => {
351
+ const configuredProfiles = (options = {}) => {
352
+ const { config, issues } = loadConfig(configPathFor(options));
353
+ if (issues.length > 0 && !options.withIssues)
354
+ throw new CliError("configuration_error", issueMessage(issues.map(publicIssue)));
355
+ return namedProfiles(config);
356
+ };
357
+ /**
358
+ * `config set` and `config unset`, on the file as written, so they also run on a file with problems. A set is
359
+ * refused only for a problem it would add; an unset removes the setting whether or not the schema knows it.
360
+ * `root` reaches a path outside any profile or `defaults`, and only one that has a problem.
361
+ */
362
+ const changeSetting = (path, { profile, setting, value, kind, root = false, }) => {
325
363
  const [name = setting, ...rest] = setting.split(".");
326
364
  const level = name === "permissions" && rest.length > 0 ? rest.join(".") : undefined;
327
365
  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(", ")}`);
366
+ const known = allSettings.includes(setting) || level !== undefined || modelField !== null;
367
+ const read = readRaw(path);
368
+ if ("issue" in read)
369
+ throw new CliError("configuration_error", issueMessage([publicIssue(read.issue)]));
370
+ const raw = read.raw === undefined ? {} : read.raw;
371
+ if (!isScope(raw))
372
+ throw new CliError("configuration_error", `${path}: the file has to be an object, in braces`);
373
+ const scopePath = root
374
+ ? []
375
+ : [...(kind === undefined ? [] : [kind]), ...(profile === undefined ? ["defaults"] : ["profiles", profile])];
376
+ const keyPath = root
377
+ ? setting.split(".")
378
+ : level !== undefined
379
+ ? ["permissions", level]
380
+ : modelField
381
+ ? ["models", modelField[1], modelField[2]]
382
+ : [setting];
383
+ const at = [...scopePath, ...keyPath];
384
+ const before = schemaIssues(raw, readSchema, context(path));
385
+ if (value === undefined) {
386
+ if (root && !before.some((issue) => issue.at.join(".") === at.join(".")))
387
+ throw new CliError("validation_error", `${setting} has no problem to remove — name the profile, or add --defaults, to unset a setting`);
388
+ if (!known && valueAt(raw, at) === undefined)
389
+ throw new CliError("validation_error", `no setting called "${setting}" — one of: ${allSettings.join(", ")}`);
390
+ const changed = structuredClone(raw);
391
+ removeAt(changed, at);
392
+ saveConfigFile(path, changed);
393
+ return null;
331
394
  }
395
+ if (!known)
396
+ throw new CliError("validation_error", `no setting called "${setting}" — one of: ${allSettings.join(", ")}`);
332
397
  if (profile !== undefined && defaultsOnlyKeys.includes(setting)) {
333
398
  throw new CliError("validation_error", `${setting} is one setting for the whole program, not per profile — add --defaults`);
334
399
  }
@@ -338,98 +403,45 @@ export const settingsFor = (app, extension = {}) => {
338
403
  if (kind !== "bot" && botOnlyKeys.includes(setting)) {
339
404
  throw new CliError("validation_error", `${setting} is a bot's setting — add --bot`);
340
405
  }
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);
406
+ const changed = structuredClone(raw);
407
+ setAt(changed, at, level === undefined ? parseValue(value) : value.trim());
408
+ const had = new Set(before.map((issue) => issue.at.join(".")));
409
+ const added = schemaIssues(changed, readSchema, context(path)).find((issue) => !had.has(issue.at.join(".")) || issue.at.join(".") === at.join("."));
410
+ if (added)
411
+ throw new CliError("validation_error", `${setting} cannot be "${value}": ${added.what}`);
412
+ const checked = v.safeParse(readSchema, changed);
413
+ saveConfigFile(path, checked.success ? checked.output : changed);
414
+ return valueAt(checked.success ? checked.output : changed, at) ?? null;
430
415
  };
431
416
  return { resolveSettings, configuredProfiles, changeSetting, allSettings, defaultsOnlyKeys, schema };
432
417
  };
418
+ const isScope = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
419
+ const valueAt = (node, path) => path.reduce((at, key) => (isScope(at) ? at[key] : undefined), node);
420
+ const setAt = (node, path, value) => {
421
+ let at = node;
422
+ for (const key of path.slice(0, -1)) {
423
+ if (!isScope(at[key]))
424
+ at[key] = {};
425
+ at = at[key];
426
+ }
427
+ at[path.at(-1)] = value;
428
+ };
429
+ /** Removes the value and every section it leaves empty, so an unset leaves no `{}` behind. */
430
+ const removeAt = (node, path) => {
431
+ const [key, ...rest] = path;
432
+ if (key === undefined)
433
+ return;
434
+ if (rest.length === 0) {
435
+ delete node[key];
436
+ return;
437
+ }
438
+ const child = node[key];
439
+ if (!isScope(child))
440
+ return;
441
+ removeAt(child, rest);
442
+ if (Object.keys(child).length === 0)
443
+ delete node[key];
444
+ };
433
445
  /** Every profile the file names, in any section. */
434
446
  const namedProfiles = (config) => [
435
447
  ...new Set([