@wirecat/cli-core 0.421.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +29 -1
  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/commands-command.js +1 -1
  8. package/dist/cli/config-command.d.ts +0 -4
  9. package/dist/cli/config-command.js +36 -26
  10. package/dist/cli/config-issues.d.ts +52 -0
  11. package/dist/cli/config-issues.js +187 -0
  12. package/dist/cli/config-keys.js +4 -28
  13. package/dist/cli/embedding-choice.d.ts +1 -2
  14. package/dist/cli/embedding-choice.js +2 -4
  15. package/dist/cli/first-run.d.ts +0 -16
  16. package/dist/cli/first-run.js +1 -6
  17. package/dist/cli/index.d.ts +3 -1
  18. package/dist/cli/index.js +2 -1
  19. package/dist/cli/messenger/context.js +1 -1
  20. package/dist/cli/messenger/conversations-command.js +5 -9
  21. package/dist/cli/messenger/doctor-command.js +38 -4
  22. package/dist/cli/messenger/inbox.js +2 -6
  23. package/dist/cli/messenger/private-people-command.js +1 -2
  24. package/dist/cli/messenger/review.js +2 -5
  25. package/dist/cli/permission-keys.d.ts +9 -2
  26. package/dist/cli/permission-keys.js +21 -6
  27. package/dist/cli/preview.d.ts +3 -2
  28. package/dist/cli/preview.js +4 -3
  29. package/dist/cli/profile.d.ts +2 -0
  30. package/dist/cli/profile.js +10 -0
  31. package/dist/cli/program.d.ts +1 -1
  32. package/dist/cli/program.js +18 -10
  33. package/dist/cli/settings.d.ts +21 -117
  34. package/dist/cli/settings.js +107 -114
  35. package/dist/cli/shared-command-exports.d.ts +4 -232
  36. package/dist/mcp/surface.js +9 -3
  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/ports/app.d.ts +6 -0
  41. package/dist/search/account-list.js +8 -7
  42. package/dist/search/dictionary.d.ts +3 -6
  43. package/dist/search/dictionary.js +2 -1
  44. package/dist/search/languages.d.ts +28 -0
  45. package/dist/search/languages.js +7 -0
  46. package/dist/search/stem.d.ts +5 -4
  47. package/dist/search/stem.js +3 -2
  48. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,7 +5,35 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
- ## Unreleased
8
+ ## 0.423.0 — 25.10.2026
9
+
10
+ ### Added
11
+
12
+ - **`doctor --json` lists every problem in the config file as `config.issues`.** Each issue is `{ kind, file, profile?, key?, line?, column?, what, fix? }`, with `kind` one of `invalid_json`, `unknown_setting`, `unknown_permission`, `missing_setting` or `invalid_value`. `what` is one sentence that says what a value has to be and never quotes the saved value. `fix` is a `config unset` command that runs exactly as written, and is left out for invalid JSON (`line` and `column` say where parsing stopped). Doctor checks every section of the file (`defaults`, each profile, and the `personal` and `bot` sections) and every profile in it, and lists all the problems. A permission key that a nearer section hides is reported too. Doctor still exits 0, with no top-level `error`, when it finds problems; with no profile named it answers too, with `profile: null`. The shared schema marks no setting as required, so `missing_setting` appears only with an app's own schema.
13
+ - **`config show`, `config set` and `config unset` run on a file with problems.** `show` lists each problem with its fix. `set` is refused only when the change would add a problem or leave the setting it writes invalid. `unset` removes the setting it names even when no schema knows it. With no profile typed, `config unset <key>` removes a top-level key that has a problem (`tg config unset default_profile`). Every command other than `config` and `doctor` still refuses a file with problems. The refusal now names the first problem, how to remove it, and how many more `doctor` lists.
14
+
15
+ ### Changed — may break callers
16
+
17
+ - **`doctor`'s `config.error` is replaced by `config.issues`.** A caller that read the string reads the list. When a profile lock, a flag or an environment variable stops the settings, the message is in `settings.error`.
18
+ - **Old setting names and old option spellings are no longer recognised.** `default_profile` and `catch_up_marks_read` are unknown settings like any other: the file is refused with `<file>: setting default_profile is not a setting of <tool> — remove it with \`<tool> config unset default_profile\``, and nothing points to the profile word or to `chats mark-read`. `config set|unset` no longer list `default_profile`. A camelCase key gets no "rename it to" hint from `loadSnakeCaseConfig`. The dead code behind the old `--provider`, `--kind` and `--file` spellings and the old `--provider` of `conversations consents remove` is gone. `AnalysisOptions.provider` and `ModelOptions.provider` are removed; pass `analysisProvider` and `embeddingProvider`.
19
+ - **`analysis_provider`, `analysis_model` and `analysis_base_url` are no longer read**, nor their `<PREFIX>_ANALYSIS_*` environment variables. A file that still has one is refused like any unknown setting, and `doctor` reports it with its `config unset`. Use `models.analysis.provider`, `models.analysis.model` and `models.analysis.base_url` (`<PREFIX>_MODELS_ANALYSIS_*`); the old `analysis_provider: agent` is `models.analysis.provider: off`, or the provider left unset. `AISettings` loses `analysisProvider`, `analysisModel` and `analysisBaseUrl`, `AI_DEFAULTS` loses `analysisProvider`, `resolveModelSettings` drops its fourth argument, and `modelTarget` reads `models` only.
20
+ - **An unknown permission key in any section of the file is refused**, not only one in the sections a command reads. The refusal names the file and the exact `config unset` (with `--defaults`, `--personal` or `--bot` as the section needs). A `--permission` key that names no command is refused as a `validation_error`.
21
+ - **`changeSetting` works on the file as written.** An unset that empties a section also removes that section. `Settings` gains the optional `configIssues` and `savedPermissions` (an app that builds `Settings` itself can leave them out; its keys in force are then checked), and `ResolveOptions` gains `withIssues`. `preview` takes the resolved settings and the tool's command, not a permission map and an unset prefix. `refuseUnknownPermissionKeys` takes the settings and the tool's command.
22
+
23
+ ### Fixed
24
+
25
+ - **`--source obsidian:<id>` and `local:<id>` name one notes folder** (store version 32 gave folders these providers, and the list reader found no account under them). The id is the folder's id its hits show, its stored ulid or its name; `obsidian:` finds vaults only and `local:` plain folders only, so a folder of the other provider is "no obsidian account". The entry still lists as `notes:<id>`. `folder:<id>` stays refused in `--source`, as before.
26
+
27
+ ## 0.422.0 — 25.10.2026
28
+
29
+ ### Added
30
+
31
+ - **`AppIdentity.messenger`: set it to `false` in a CLI that is not a messenger** (a notes, files or calendar CLI from cli-template). The shared program, `commands`, `config` and MCP text then stop speaking of chats and messages: the profile example in the program description names the CLI's own first command, `commands info`'s example is `<cmd> commands info commands`, `--brief` and `--full` describe "found items", `--trace` and `--record` say "never content", and `config show` and the `config set`/`unset` list leave out `sender_colors`. Unset means a messenger: tg, max and wa print exactly what they did. A CLI that sets it refreshes its contract snapshots once.
32
+ - **The MCP `_tools_search`, `_read` and `_write` tools speak neutrally when the catalogue has no `messages` commands**: task words "list", "show", "change a setting", one of the CLI's own reading commands as the example, and "creates, edits, deletes" for writes. No setting is needed; a messenger's catalogue is unchanged.
33
+
34
+ ### Changed — may break callers
35
+
36
+ - **The languages search stems are one table, `LANGUAGES`, and each entry now names its Hunspell dictionary** (`dictionary: "dictionary-ru"`). `LANGUAGES` from `/cli` keeps its other fields. `LATIN_STEMMERS` is now `english, spanish, none`, in the order the error for an unknown stemmer already printed, so the `allowed` list of a `stemmer.invalid` error comes in that order too. A caller that compared that list in order sorts it first.
9
37
 
10
38
  ## 0.421.0 — 25.10.2026
11
39
 
@@ -5,9 +5,6 @@ declare const AI_FIELDS: {
5
5
  embeddingModel: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.TrimAction, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, 200, undefined>]>, undefined>;
6
6
  embeddingBaseUrl: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.CheckAction<string, "use an HTTP/S endpoint without credentials, query or fragment">]>, undefined>;
7
7
  embeddingDims: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 65536, undefined>]>, undefined>;
8
- analysisProvider: v.OptionalSchema<v.PicklistSchema<["agent", "openai", "anthropic"], undefined>, undefined>;
9
- analysisModel: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.TrimAction, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, 200, undefined>]>, undefined>;
10
- analysisBaseUrl: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.CheckAction<string, "use an HTTP/S endpoint without credentials, query or fragment">]>, undefined>;
11
8
  models: v.OptionalSchema<v.RecordSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, undefined>]>, v.StrictObjectSchema<{
12
9
  readonly provider: v.OptionalSchema<v.PicklistSchema<["off", "openai", "anthropic"], undefined>, undefined>;
13
10
  readonly model: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.TrimAction, v.MinLengthAction<string, 1, undefined>, v.MaxLengthAction<string, 200, undefined>]>, undefined>;
@@ -20,9 +17,6 @@ export declare const AI_ENTRIES: {
20
17
  embedding_model: (typeof AI_FIELDS)["embeddingModel"];
21
18
  embedding_base_url: (typeof AI_FIELDS)["embeddingBaseUrl"];
22
19
  embedding_dims: (typeof AI_FIELDS)["embeddingDims"];
23
- analysis_provider: (typeof AI_FIELDS)["analysisProvider"];
24
- analysis_model: (typeof AI_FIELDS)["analysisModel"];
25
- analysis_base_url: (typeof AI_FIELDS)["analysisBaseUrl"];
26
20
  models: (typeof AI_FIELDS)["models"];
27
21
  };
28
22
  export type AISettings = v.InferOutput<v.ObjectSchema<typeof AI_FIELDS, undefined>>;
@@ -10,15 +10,12 @@ const AI_FIELDS = {
10
10
  embeddingModel: v.optional(model),
11
11
  embeddingBaseUrl: v.optional(url),
12
12
  embeddingDims: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(65536))),
13
- analysisProvider: v.optional(v.picklist(["agent", "openai", "anthropic"])),
14
- analysisModel: v.optional(model),
15
- analysisBaseUrl: v.optional(url),
16
13
  models: v.optional(modelSettingsShape),
17
14
  };
18
15
  const fileKey = (key) => key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
19
16
  /** The AI settings as the config file names them, in snake_case; `AISettings` is what the code reads. */
20
17
  export const AI_ENTRIES = Object.fromEntries(Object.entries(AI_FIELDS).map(([key, schema]) => [fileKey(key), schema]));
21
- export const AI_DEFAULTS = { embeddingProvider: "local", analysisProvider: "agent" };
18
+ export const AI_DEFAULTS = { embeddingProvider: "local" };
22
19
  export const AI_SETTING_KEYS = Object.keys(AI_FIELDS);
23
20
  export const resolveAISettings = (prefix, layers, env) => {
24
21
  const resolved = Object.entries(AI_FIELDS)
@@ -41,7 +38,7 @@ export const resolveAISettings = (prefix, layers, env) => {
41
38
  });
42
39
  const values = Object.fromEntries(resolved.map(({ key, value }) => [key, value]));
43
40
  const sources = Object.fromEntries(resolved.map(({ key, from }) => [key, from]));
44
- const models = resolveModelSettings(prefix, layers, env, { values, sources });
41
+ const models = resolveModelSettings(prefix, layers, env);
45
42
  return {
46
43
  values: { ...values, models: models.models },
47
44
  sources: {
@@ -4,7 +4,6 @@ import type { AppIdentity } from "../ports/app.js";
4
4
  import type { Settings } from "./settings.js";
5
5
  export interface AnalysisOptions {
6
6
  analysisProvider?: string;
7
- provider?: string;
8
7
  model?: string;
9
8
  baseUrl?: string;
10
9
  }
@@ -2,33 +2,20 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { endpoint } from "../analysis/settings.js";
3
3
  import { embeddingKeys, endpointKeyName } from "../embeddings/keys.js";
4
4
  export const analysisChoice = (options, app, settings, env, keyring) => {
5
- if (options.analysisProvider !== undefined && options.provider !== undefined)
6
- throw new CliError("validation_error", "give --analysis-provider or its legacy --provider spelling, not both");
7
- options = { ...options, provider: options.analysisProvider ?? options.provider };
8
5
  const configured = { ...settings.models?.default, ...settings.models?.analysis };
9
- settings = {
10
- ...settings,
11
- analysisProvider: configured.provider === "off" ? "agent" : (configured.provider ?? settings.analysisProvider),
12
- analysisModel: configured.model ?? settings.analysisModel,
13
- analysisBaseUrl: configured.base_url ?? settings.analysisBaseUrl,
14
- };
15
- const override = (options.provider !== undefined && options.provider !== (settings.analysisProvider ?? "agent")) ||
16
- (options.baseUrl !== undefined &&
17
- options.baseUrl.replace(/\/+$/, "") !== settings.analysisBaseUrl?.replace(/\/+$/, ""));
18
- const provider = options.provider ??
19
- (settings.analysisProvider && settings.analysisProvider !== "agent"
20
- ? settings.analysisProvider
21
- : options.baseUrl
22
- ? "openai"
23
- : "agent");
6
+ // `off` and no provider both leave the linking to the agent.
7
+ const saved = configured.provider === undefined || configured.provider === "off" ? "agent" : configured.provider;
8
+ const override = (options.analysisProvider !== undefined && options.analysisProvider !== saved) ||
9
+ (options.baseUrl !== undefined && options.baseUrl.replace(/\/+$/, "") !== configured.base_url?.replace(/\/+$/, ""));
10
+ const provider = options.analysisProvider ?? (saved !== "agent" ? saved : options.baseUrl ? "openai" : "agent");
24
11
  if (provider === "agent")
25
- throw new CliError("validation_error", "analysis_provider is agent; use your linking skill, or configure openai/anthropic to use --analyze");
12
+ throw new CliError("validation_error", "models.analysis.provider is off or not set, so the agent links the chat: use your linking skill, or set it to openai or anthropic to use --analyze");
26
13
  if (provider !== "openai" && provider !== "anthropic")
27
14
  throw new CliError("validation_error", "analysis provider must be agent, openai or anthropic");
28
- const model = options.model ?? (override ? undefined : settings.analysisModel);
15
+ const model = options.model ?? (override ? undefined : configured.model);
29
16
  if (!model?.trim())
30
- throw new CliError("validation_error", "set analysis_model or give --model before --analyze");
31
- const customUrl = options.baseUrl ?? (override ? undefined : settings.analysisBaseUrl);
17
+ throw new CliError("validation_error", "set models.analysis.model or give --model before --analyze");
18
+ const customUrl = options.baseUrl ?? (override ? undefined : configured.base_url);
32
19
  const baseUrl = customUrl ?? (provider === "anthropic" ? "https://api.anthropic.com" : "https://api.openai.com/v1");
33
20
  if (!endpoint(baseUrl))
34
21
  throw new CliError("validation_error", "use an HTTP/S analysis endpoint without credentials, query or fragment");
@@ -17,7 +17,7 @@ import { BotTokenStore } from "./token.js";
17
17
  export const botWords = (bot, profile) => `${bot.app.command} ${asFirstWord(profile)}bot`;
18
18
  /** What a messenger command refuses before it connects, for a bot: an unknown key, then a denied read. */
19
19
  const refuseBotPermissions = (command, bot, settings) => {
20
- refuseUnknownPermissionKeys(command, settings.permissions, `${bot.app.command} ${asFirstWord(settings.profile)}config unset --bot`);
20
+ refuseUnknownPermissionKeys(command, settings, bot.app.command);
21
21
  for (const key of readKeysForCommand(commandPathOf(command))) {
22
22
  const { level, key: named } = levelFor(settings.permissions, key);
23
23
  if (level !== "deny")
@@ -59,7 +59,7 @@ export const commandsCommand = (app) => {
59
59
  });
60
60
  command.addCommand(annotate(new Command("info"), {
61
61
  effects: NO_EFFECTS,
62
- examples: [`${app.command} commands info messages list`],
62
+ examples: [`${app.command} commands info ${app.messenger === false ? "commands" : "messages list"}`],
63
63
  })
64
64
  .description(DISCOVERY_DESCRIPTIONS.info)
65
65
  .argument("[path...]", "one command path; without one, export schemas, error reasons and explicit overrides")
@@ -2,10 +2,6 @@ import { Command } from "commander";
2
2
  import { type AppIdentity } from "../ports/app.js";
3
3
  import { type PermissionKeyOf } from "./permission-keys.js";
4
4
  import { type Configuration } from "./settings.js";
5
- /**
6
- * The settings in force and where each came from, and changing them. Printed whole: no field the
7
- * configuration accepts can hold a secret — the schema has nowhere to put one.
8
- */
9
5
  export declare const configCommand: (app: AppIdentity, config: Configuration, { permissionKey }?: {
10
6
  permissionKey?: PermissionKeyOf;
11
7
  }) => Command;
@@ -3,11 +3,10 @@ import { annotate } from "@wirecat/cli-base/commands";
3
3
  import { Command } from "commander";
4
4
  import { envName } from "../ports/app.js";
5
5
  import { AUTHORITY_KEY, BOT_API_KEY } from "../sends/permissions.js";
6
- import { loadSnakeCaseConfig } from "./config-keys.js";
7
6
  import { baseContext, environmentOf } from "./context.js";
8
7
  import { grantsAuthority, LOCAL_READ, LOCAL_WRITE } from "./effects.js";
9
8
  import { itemOf, renderAnswer } from "./envelope.js";
10
- import { knownBeside, knownPermissionKeys } from "./permission-keys.js";
9
+ import { knownBeside, knownPermissionKeys, unknownPermissionIssues } from "./permission-keys.js";
11
10
  import { fromFile, NONE_NAMED } from "./settings.js";
12
11
  import { nextStep } from "./steps.js";
13
12
  import { changeStoreSetting, isStoreSetting, STORE_SETTINGS, storeSettings } from "./store-settings.js";
@@ -15,6 +14,8 @@ import { changeStoreSetting, isStoreSetting, STORE_SETTINGS, storeSettings } fro
15
14
  * The settings in force and where each came from, and changing them. Printed whole: no field the
16
15
  * configuration accepts can hold a secret — the schema has nowhere to put one.
17
16
  */
17
+ /** `sender_colors` colours who wrote a message; a CLI without messages has nothing to colour. */
18
+ const shownSettings = (app, config) => app.messenger === false ? config.allSettings.filter((setting) => setting !== "sender_colors") : config.allSettings;
18
19
  export const configCommand = (app, config, { permissionKey } = {}) => {
19
20
  const command = new Command("config").description("the settings in force, and where each one came from");
20
21
  annotate(command.command("show"), { effects: LOCAL_READ, examples: [`${app.command} personal config show`] })
@@ -22,7 +23,12 @@ export const configCommand = (app, config, { permissionKey } = {}) => {
22
23
  .option("--bot", "the settings a bot command on this profile gets, rather than the personal account's")
23
24
  .action(async function () {
24
25
  const bot = this.opts().bot === true;
25
- const context = baseContext(this, (flags, options) => config.resolveSettings(flags, { ...options, needsProfile: false, ...(bot ? { kind: "bot" } : {}) }));
26
+ const context = baseContext(this, (flags, options) => config.resolveSettings(flags, {
27
+ ...options,
28
+ needsProfile: false,
29
+ withIssues: true,
30
+ ...(bot ? { kind: "bot" } : {}),
31
+ }));
26
32
  const { settings, renderer, env } = context;
27
33
  const overridden = pathsAreOverridden({ appName: app.appName, prefix: app.envPrefix, env });
28
34
  renderAnswer(this, context, itemOf({
@@ -32,11 +38,16 @@ export const configCommand = (app, config, { permissionKey } = {}) => {
32
38
  config_file: settings.configPath,
33
39
  config_found: settings.configFound,
34
40
  paths_overridden: overridden,
35
- settings: [...config.allSettings, "command_timeout_ms"]
41
+ settings: [...shownSettings(app, config), "command_timeout_ms"]
36
42
  .filter((setting) => bot || setting !== "read_other_bots")
37
- .map((setting) => sourced(settings, setting, config.schema)),
43
+ .map((setting) => sourced(settings, setting)),
38
44
  store_settings: await storeSettings(env),
39
45
  }));
46
+ for (const issue of [
47
+ ...(settings.configIssues ?? []),
48
+ ...unknownPermissionIssues(this, settings, app.command, permissionKey),
49
+ ])
50
+ renderer.warn(`${issue.what}, so it is left out${issue.fix ? ` — \`${issue.fix}\` removes it` : ""}`);
40
51
  if (overridden) {
41
52
  renderer.note(`${envName(app, "CONFIG_DIR")}, ${envName(app, "STATE_DIR")} or ${envName(app, "CACHE_DIR")} is set — ` +
42
53
  "a profile's keyring entry is not the usual one, so a session made without them reads as none");
@@ -47,9 +58,7 @@ export const configCommand = (app, config, { permissionKey } = {}) => {
47
58
  effects: LOCAL_WRITE,
48
59
  examples: [`${app.command} personal config ${action} limit${action === "set" ? " 20" : ""}`],
49
60
  }))
50
- .argument("<setting>",
51
- // default_profile is only removed now: `config unset` still takes it, `config set` refuses it.
52
- `one of: ${[...config.allSettings.filter((one) => action === "unset" || one !== "default_profile"), ...STORE_SETTINGS, "account_groups.<name>"].join(", ")}`)
61
+ .argument("<setting>", `one of: ${[...shownSettings(app, config), ...STORE_SETTINGS, "account_groups.<name>"].join(", ")}`)
53
62
  .option("--defaults", "change what every profile gets, rather than this profile")
54
63
  .option("--personal", "only for personal accounts — the personal section of the file")
55
64
  .option("--bot", "only for bots — the bot section of the file");
@@ -61,10 +70,7 @@ export const configCommand = (app, config, { permissionKey } = {}) => {
61
70
  sub.description("remove a setting from the configuration file");
62
71
  sub.action(async function (setting, given) {
63
72
  const { defaults: everyone, personal, bot, } = this.opts();
64
- const context = baseContext(this, (flags, options) => config.resolveSettings(flags, {
65
- ...options,
66
- needsProfile: !everyone && setting !== "default_profile" && !isStoreSetting(setting),
67
- }));
73
+ const context = baseContext(this, (flags, options) => config.resolveSettings(flags, { ...options, needsProfile: false, withIssues: true }));
68
74
  const { settings, renderer, env } = context;
69
75
  if (isStoreSetting(setting)) {
70
76
  if (everyone || personal || bot)
@@ -85,29 +91,38 @@ export const configCommand = (app, config, { permissionKey } = {}) => {
85
91
  return;
86
92
  }
87
93
  const defaults = everyone === true;
88
- if (setting === "default_profile" && (defaults || personal || bot))
89
- throw new CliError("validation_error", "default_profile is one for the whole file — drop --defaults, --personal and --bot");
94
+ // The unset `doctor` gives for a problem outside any profile: one with no profile typed reaches it, whatever
95
+ // profile the environment names.
96
+ const root = action === "unset" &&
97
+ !defaults &&
98
+ !personal &&
99
+ !bot &&
100
+ settings.sources.profile !== "first word" &&
101
+ (settings.configIssues ?? []).some((issue) => issue.fix === `${app.command} config unset ${setting}`);
102
+ if (!root && !defaults && settings.sources.profile === NONE_NAMED)
103
+ config.resolveSettings(this.optsWithGlobals(), { env, withIssues: true });
90
104
  if (personal && bot)
91
105
  throw new CliError("validation_error", "--personal and --bot name different sections; use one");
92
106
  const kind = bot ? "bot" : personal ? "personal" : undefined;
93
107
  const lock = envName(app, "PROFILE_LOCK");
94
- if ((defaults || setting === "default_profile") && env[lock]) {
95
- // The defaults are every other profile's settings too, and default_profile picks one of them.
96
- throw new CliError("permission_error", `this process is locked to profile ${settings.profile} (${lock}) — ${setting === "default_profile" ? setting : "--defaults"} changes other profiles`);
108
+ if ((defaults || root) && env[lock]) {
109
+ // The defaults are every other profile's settings too, and the top of the file is everyone's.
110
+ throw new CliError("permission_error", `this process is locked to profile ${settings.profile} (${lock}) — ${root ? setting : "--defaults"} changes other profiles`);
97
111
  }
98
112
  if (environmentOf(this).agent)
99
113
  refuseGuardedKey(setting, action === "set" ? String(given) : undefined);
100
114
  if (action === "set")
101
115
  refuseUnknownKey(this, setting, String(given), permissionKey);
102
116
  const saved = config.changeSetting(settings.configPath, {
103
- profile: defaults || setting === "default_profile" ? undefined : settings.profile,
117
+ profile: defaults || root ? undefined : settings.profile,
104
118
  setting,
105
119
  value: action === "set" ? String(given) : undefined,
106
120
  kind,
121
+ root,
107
122
  });
108
123
  renderAnswer(this, context, itemOf({
109
124
  config_file: settings.configPath,
110
- scope: setting === "default_profile" ? "default_profile" : scopeOf(kind, defaults ? undefined : settings.profile),
125
+ scope: root ? "file" : scopeOf(kind, defaults ? undefined : settings.profile),
111
126
  setting,
112
127
  value: saved,
113
128
  }));
@@ -170,11 +185,7 @@ const objectKeys = (value) => {
170
185
  };
171
186
  const scopeOf = (kind, profile) => [kind, profile === undefined ? "defaults" : `profiles.${profile}`].filter(Boolean).join(".");
172
187
  /** A shared setting from what was resolved; a messenger's own from the file, where it lives. */
173
- const sourced = (settings, setting, schema) => {
174
- if (setting === "default_profile") {
175
- const named = loadSnakeCaseConfig(settings.configPath, schema, () => ({ profiles: {} })).default_profile;
176
- return { setting, value: named ?? null, from: named === undefined ? "default" : "config file" };
177
- }
188
+ const sourced = (settings, setting) => {
178
189
  if (setting === "models") {
179
190
  return {
180
191
  setting,
@@ -192,8 +203,7 @@ const sourced = (settings, setting, schema) => {
192
203
  const field = setting.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase());
193
204
  if (field in settings.sources) {
194
205
  const value = settings[field];
195
- // No list is every action, and `null` would read as none.
196
- return { setting, value: setting === "allow" ? (value ?? "all") : (value ?? null), from: settings.sources[field] };
206
+ return { setting, value: value ?? null, from: settings.sources[field] };
197
207
  }
198
208
  const { value, from } = fromFile(settings, setting, UNLISTED_DEFAULTS[setting] ?? null);
199
209
  return { setting, value, from };
@@ -0,0 +1,52 @@
1
+ import * as v from "valibot";
2
+ export type ConfigIssueKind = "invalid_json" | "unknown_setting" | "unknown_permission" | "missing_setting" | "invalid_value";
3
+ /** One problem in a config file, as `doctor` reports it. `what` never quotes a value: a secret may be pasted anywhere. */
4
+ export interface ConfigIssue {
5
+ kind: ConfigIssueKind;
6
+ file: string;
7
+ profile?: string;
8
+ key?: string;
9
+ line?: number;
10
+ column?: number;
11
+ what: string;
12
+ fix?: string;
13
+ }
14
+ /** An issue with the path in the file that `config unset` removes, which the loader drops too. */
15
+ export interface LocatedIssue extends ConfigIssue {
16
+ at: string[];
17
+ }
18
+ /** An issue at `at`, cut to the setting `config unset` removes, with that command as its fix. */
19
+ export declare const locate: (kind: ConfigIssueKind, at: readonly string[], context: {
20
+ file: string;
21
+ tool: string;
22
+ }, what: (key: string) => string) => LocatedIssue;
23
+ /** The file's JSON, or the one issue that says where it stopped parsing. */
24
+ export declare const parseConfigText: (text: string, file: string) => {
25
+ raw: unknown;
26
+ } | {
27
+ issue: LocatedIssue;
28
+ };
29
+ /** Every problem the schema finds, never only the first, each cut to the setting that holds it. */
30
+ export declare const schemaIssues: (raw: unknown, schema: v.GenericSchema, context: {
31
+ file: string;
32
+ tool: string;
33
+ }) => LocatedIssue[];
34
+ /** Every saved permission key, in every section and profile of the file — not only the ones that win. */
35
+ export declare const savedPermissionKeys: (raw: unknown) => {
36
+ key: string;
37
+ at: string[];
38
+ }[];
39
+ /** A saved key no command checks would do nothing (BUG-137): one issue per key, wherever it is saved. */
40
+ export declare const permissionIssues: (saved: readonly {
41
+ key: string;
42
+ at: readonly string[];
43
+ }[], known: ReadonlySet<string>, context: {
44
+ file: string;
45
+ tool: string;
46
+ }) => LocatedIssue[];
47
+ /** The file without the settings the issues name: what still loads, and what `config unset` would leave. */
48
+ export declare const withoutIssues: (raw: unknown, issues: readonly LocatedIssue[]) => unknown;
49
+ /** The public shape: where in the file is the loader's own business. */
50
+ export declare const publicIssue: ({ at: _, ...issue }: LocatedIssue) => ConfigIssue;
51
+ /** One line for a command that refuses to run on the file: the first problem, how to fix it, and how many more. */
52
+ export declare const issueMessage: (issues: readonly ConfigIssue[]) => string;
@@ -0,0 +1,187 @@
1
+ import * as v from "valibot";
2
+ const isNode = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3
+ const SECTIONS = ["personal", "bot"];
4
+ const SHELL_SAFE = /^[A-Za-z0-9_.:@-]*$/;
5
+ /** Where a path of the file sits: the profile and the flags `config unset` needs, and the setting under it. */
6
+ const scopeOf = (at) => {
7
+ const [first, second, third] = at;
8
+ if (first === "profiles" && second !== undefined && at.length > 2)
9
+ return { profile: second, flags: "", rest: at.slice(2) };
10
+ if (first === "defaults" && at.length > 1)
11
+ return { flags: " --defaults", rest: at.slice(1) };
12
+ if (SECTIONS.includes(first)) {
13
+ if (second === "profiles" && third !== undefined && at.length > 3)
14
+ return { profile: third, flags: ` --${first}`, rest: at.slice(3) };
15
+ if (second === "defaults" && at.length > 2)
16
+ return { flags: ` --defaults --${first}`, rest: at.slice(2) };
17
+ }
18
+ return { flags: "", rest: [...at] };
19
+ };
20
+ /** The part of a scope's path `config unset` takes as one setting: a permission key, a model field, or the setting. */
21
+ const settingPath = (rest) => {
22
+ if (rest[0] === "permissions" && rest.length > 1)
23
+ return rest.slice(0, 2);
24
+ if (rest[0] === "models" && rest.length > 1)
25
+ return rest.slice(0, 3);
26
+ return rest.slice(0, 1);
27
+ };
28
+ const settingOf = (path) => path[0] === "permissions" && path.length > 1 ? `permissions.${path[1]}` : path.join(".");
29
+ /** An issue at `at`, cut to the setting `config unset` removes, with that command as its fix. */
30
+ export const locate = (kind, at, context, what) => {
31
+ const { profile, flags, rest } = scopeOf(at);
32
+ const inScope = rest.length < at.length;
33
+ // Outside a section the path itself is the key: `profiles.work` with the wrong type is removed alone.
34
+ const setting = inScope ? settingPath(rest) : [...rest];
35
+ const key = inScope ? settingOf(setting) : setting.join(".");
36
+ const prefix = at.slice(0, at.length - rest.length);
37
+ return {
38
+ kind,
39
+ file: context.file,
40
+ ...(profile === undefined ? {} : { profile }),
41
+ key,
42
+ what: what(key),
43
+ // A fix runs exactly as written; a name a shell would split or expand gets none.
44
+ ...([key, profile ?? ""].every((word) => SHELL_SAFE.test(word))
45
+ ? { fix: `${context.tool}${profile === undefined ? "" : ` ${profile}`} config unset${flags} ${key}` }
46
+ : {}),
47
+ at: [...prefix, ...setting],
48
+ };
49
+ };
50
+ /** Node prints `(line 3 column 5)`, older runtimes only `position 41`, and Bun neither. */
51
+ const placeOf = (message, text) => {
52
+ const said = /line (\d+) column (\d+)/.exec(message);
53
+ if (said)
54
+ return { line: Number(said[1]), column: Number(said[2]) };
55
+ const position = /position (\d+)/.exec(message);
56
+ if (!position)
57
+ return {};
58
+ const before = text.slice(0, Number(position[1])).split("\n");
59
+ return { line: before.length, column: (before.at(-1)?.length ?? 0) + 1 };
60
+ };
61
+ /** The file's JSON, or the one issue that says where it stopped parsing. */
62
+ export const parseConfigText = (text, file) => {
63
+ try {
64
+ return { raw: JSON.parse(text) };
65
+ }
66
+ catch (error) {
67
+ const place = placeOf(error instanceof Error ? error.message : "", text);
68
+ const where = place.line === undefined ? "" : ` at line ${place.line}, column ${place.column}`;
69
+ return { issue: { kind: "invalid_json", file, ...place, what: `the file is not valid JSON${where}`, at: [] } };
70
+ }
71
+ };
72
+ const keyOfPath = (issue) => (issue.path ?? []).map((item) => String(item.key));
73
+ /** A key the schema does not know: valibot says it expected nothing there. */
74
+ const isUnknownKey = (issue) => issue.expected === "never";
75
+ /** A `permissions` key refused by its pattern, rather than its level. */
76
+ const isPermissionKey = (issue, at) => {
77
+ const last = issue.path?.at(-1);
78
+ return at.at(-2) === "permissions" && last !== undefined && "origin" in last && last.origin === "key";
79
+ };
80
+ const isMissing = (issue) => {
81
+ const last = issue.path?.at(-1);
82
+ return last !== undefined && "origin" in last && last.origin === "key" && issue.input === undefined;
83
+ };
84
+ /**
85
+ * What a value has to be, never what it was: the schema's own "has to be …" without its ", not <value>", else
86
+ * the type or choices valibot expected.
87
+ */
88
+ const ruleOf = (issue) => {
89
+ const own = /^(has to be [\s\S]*?)(, not [\s\S]*)?$/.exec(issue.message);
90
+ if (own?.[1])
91
+ return own[1];
92
+ if (issue.type === "picklist")
93
+ return `has to be one of ${issue.expected}`;
94
+ // valibot's own messages start "Invalid" and quote the value; a schema's own words do not.
95
+ if (!issue.message.startsWith("Invalid "))
96
+ return `is refused: ${issue.message}`;
97
+ if (issue.kind === "schema")
98
+ return `has to be of type ${issue.expected}`;
99
+ return issue.expected === null ? "has a value it does not take" : `has to be ${issue.expected}`;
100
+ };
101
+ /** One pass of the schema: valibot names only the first unknown key of each object. */
102
+ const onePass = (raw, schema, context) => {
103
+ const result = v.safeParse(schema, raw);
104
+ if (result.success)
105
+ return [];
106
+ return result.issues.map((issue) => {
107
+ const at = keyOfPath(issue);
108
+ const located = isPermissionKey(issue, at)
109
+ ? locate("unknown_permission", at, context, (key) => `${key} names no command`)
110
+ : isUnknownKey(issue)
111
+ ? locate("unknown_setting", at, context, (key) => `setting ${key} is not a setting of ${context.tool}`)
112
+ : isMissing(issue)
113
+ ? locate("missing_setting", at, context, (key) => `setting ${key} is missing`)
114
+ : locate("invalid_value", at, context, (key) => at.length === 0 ? "the file has to be an object, in braces" : `setting ${key} ${ruleOf(issue)}`);
115
+ if (located.kind === "missing_setting" || at.length === 0)
116
+ delete located.fix;
117
+ return located;
118
+ });
119
+ };
120
+ /** Every problem the schema finds, never only the first, each cut to the setting that holds it. */
121
+ export const schemaIssues = (raw, schema, context) => {
122
+ const found = new Map();
123
+ for (;;) {
124
+ const fresh = onePass(withoutIssues(raw, [...found.values()]), schema, context).filter((issue) => !found.has(issue.at.join("\u0000")));
125
+ if (fresh.length === 0)
126
+ return [...found.values()];
127
+ for (const issue of fresh)
128
+ found.set(issue.at.join("\u0000"), issue);
129
+ // A missing setting cannot be removed, so another pass would find it again.
130
+ if (fresh.every((issue) => issue.kind === "missing_setting" || issue.at.length === 0))
131
+ return [...found.values()];
132
+ }
133
+ };
134
+ /** Every section of the file that may hold `permissions`, with its path. */
135
+ const scopes = (raw) => {
136
+ if (!isNode(raw))
137
+ return [];
138
+ const out = [];
139
+ const add = (path, node) => {
140
+ if (isNode(node))
141
+ out.push([path, node]);
142
+ };
143
+ const addSection = (path, section) => {
144
+ if (!isNode(section))
145
+ return;
146
+ add([...path, "defaults"], section.defaults);
147
+ if (isNode(section.profiles))
148
+ for (const [name, scope] of Object.entries(section.profiles))
149
+ add([...path, "profiles", name], scope);
150
+ };
151
+ addSection([], raw);
152
+ for (const name of SECTIONS)
153
+ addSection([name], raw[name]);
154
+ return out;
155
+ };
156
+ /** Every saved permission key, in every section and profile of the file — not only the ones that win. */
157
+ export const savedPermissionKeys = (raw) => scopes(raw).flatMap(([path, scope]) => isNode(scope.permissions)
158
+ ? Object.keys(scope.permissions).map((key) => ({ key, at: [...path, "permissions", key] }))
159
+ : []);
160
+ /** A saved key no command checks would do nothing (BUG-137): one issue per key, wherever it is saved. */
161
+ export const permissionIssues = (saved, known, context) => saved
162
+ .filter(({ key }) => !known.has(key))
163
+ .map(({ at }) => locate("unknown_permission", at, context, (key) => `${key} names no command`));
164
+ /** The file without the settings the issues name: what still loads, and what `config unset` would leave. */
165
+ export const withoutIssues = (raw, issues) => {
166
+ if (issues.some((issue) => issue.at.length === 0))
167
+ return {};
168
+ const copy = structuredClone(raw);
169
+ for (const { at } of issues) {
170
+ const parent = at.slice(0, -1).reduce((node, key) => (isNode(node) ? node[key] : undefined), copy);
171
+ if (isNode(parent))
172
+ delete parent[at.at(-1)];
173
+ }
174
+ return copy;
175
+ };
176
+ /** The public shape: where in the file is the loader's own business. */
177
+ export const publicIssue = ({ at: _, ...issue }) => issue;
178
+ /** One line for a command that refuses to run on the file: the first problem, how to fix it, and how many more. */
179
+ export const issueMessage = (issues) => {
180
+ const [first] = issues;
181
+ if (!first)
182
+ return "";
183
+ const more = issues.length - 1;
184
+ return (`${first.file}: ${first.what}` +
185
+ (first.fix ? ` — remove it with \`${first.fix}\`` : "") +
186
+ (more === 0 ? "" : ` (and ${more} more problem${more === 1 ? "" : "s"} — \`doctor\` lists them all)`));
187
+ };
@@ -1,20 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { CliError } from "@wirecat/cli-base";
3
3
  import * as v from "valibot";
4
- const isNode = (value) => typeof value === "object" && value !== null;
5
- const unwrap = (schema) => {
6
- let current = schema;
7
- while (isNode(current) && "wrapped" in current)
8
- current = current.wrapped;
9
- return isNode(current) ? current : undefined;
10
- };
11
- const child = (schema, key) => {
12
- const node = unwrap(schema);
13
- if (isNode(node?.entries))
14
- return node.entries[key];
15
- return node?.type === "record" ? node.value : undefined;
16
- };
17
- const snakeCase = (key) => key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
18
4
  const reasonOf = (issue) => {
19
5
  if (issue.expected === "never")
20
6
  return "is an unknown setting";
@@ -25,25 +11,15 @@ const reasonOf = (issue) => {
25
11
  return "has an invalid value";
26
12
  return "has the wrong type";
27
13
  };
28
- const renameHint = (issue, schema) => {
29
- if (issue.expected !== "never" || !issue.path)
30
- return "";
31
- const keys = issue.path.map((item) => String(item.key));
32
- const key = keys.pop() ?? "";
33
- const known = unwrap(keys.reduce(child, schema))?.entries;
34
- const snake = snakeCase(key);
35
- return snake !== key && isNode(known) && snake in known ? `; rename it to ${snake}` : "";
36
- };
37
14
  /**
38
15
  * The first problem only, by key and a fixed reason: valibot's own messages quote the value they got, and a
39
16
  * config value can be a secret pasted into the wrong place.
40
17
  */
41
- const problemOf = (issues, schema) => {
42
- // A file from an older version is missing every new key; the rename is what tells the reader how to fix it.
43
- const issue = issues.find((candidate) => renameHint(candidate, schema)) ?? issues[0];
18
+ const problemOf = (issues) => {
19
+ const [issue] = issues;
44
20
  const more = issues.length - 1;
45
21
  const count = more === 0 ? "" : ` (and ${more} more problem${more === 1 ? "" : "s"})`;
46
- return `${v.getDotPath(issue) ?? "the whole file"} ${reasonOf(issue)}${renameHint(issue, schema)}${count}`;
22
+ return `${v.getDotPath(issue) ?? "the whole file"} ${reasonOf(issue)}${count}`;
47
23
  };
48
24
  /** Reads and checks a config file; a missing one is the fallback, a broken one an error naming its first problem. */
49
25
  export const loadSnakeCaseConfig = (path, schema, fallback) => {
@@ -63,6 +39,6 @@ export const loadSnakeCaseConfig = (path, schema, fallback) => {
63
39
  }
64
40
  const result = v.safeParse(schema, parsed);
65
41
  if (!result.success)
66
- throw new CliError("configuration_error", `${path}: ${problemOf(result.issues, schema)}`);
42
+ throw new CliError("configuration_error", `${path}: ${problemOf(result.issues)}`);
67
43
  return result.output;
68
44
  };
@@ -5,13 +5,12 @@ import type { ModelChoice } from "../services/embeddings.js";
5
5
  export interface ModelOptions {
6
6
  embeddingProvider?: string;
7
7
  model?: string;
8
- provider?: string;
9
8
  baseUrl?: string;
10
9
  dims?: number;
11
10
  concurrency?: number;
12
11
  }
13
12
  /** A local model id, or a remote model with its key from `models keys set`. */
14
- export declare const embeddingChoice: ({ model, provider, embeddingProvider, baseUrl, dims, concurrency }: ModelOptions, app: AppIdentity, settings: AISettings, env: NodeJS.ProcessEnv, { needKey, keyring }?: {
13
+ export declare const embeddingChoice: ({ model, embeddingProvider, baseUrl, dims, concurrency }: ModelOptions, app: AppIdentity, settings: AISettings, env: NodeJS.ProcessEnv, { needKey, keyring }?: {
15
14
  needKey?: boolean;
16
15
  keyring?: KeyringStore | undefined;
17
16
  }) => ModelChoice;