@wirecat/cli-core 0.420.0 → 0.422.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 (41) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/cli/bot/context.d.ts +3 -2
  3. package/dist/cli/bot/context.js +15 -0
  4. package/dist/cli/commands-command.js +1 -1
  5. package/dist/cli/config-command.d.ts +0 -4
  6. package/dist/cli/config-command.js +5 -4
  7. package/dist/cli/first-run.d.ts +0 -16
  8. package/dist/cli/first-run.js +1 -6
  9. package/dist/cli/index.d.ts +2 -2
  10. package/dist/cli/index.js +2 -2
  11. package/dist/cli/messenger/context.js +3 -6
  12. package/dist/cli/permission-keys.d.ts +2 -0
  13. package/dist/cli/permission-keys.js +10 -3
  14. package/dist/cli/preview.d.ts +2 -2
  15. package/dist/cli/preview.js +4 -3
  16. package/dist/cli/profile.d.ts +2 -0
  17. package/dist/cli/profile.js +10 -0
  18. package/dist/cli/program.js +14 -14
  19. package/dist/cli/settings.d.ts +0 -2
  20. package/dist/cli/settings.js +1 -5
  21. package/dist/mcp/surface.js +9 -7
  22. package/dist/mcp/tool.d.ts +1 -3
  23. package/dist/mcp/tool.js +1 -2
  24. package/dist/meetings/mcp/index.d.ts +15 -15
  25. package/dist/parity/manifest.js +0 -4
  26. package/dist/ports/app.d.ts +6 -0
  27. package/dist/search/dictionary.d.ts +3 -6
  28. package/dist/search/dictionary.js +2 -1
  29. package/dist/search/languages.d.ts +28 -0
  30. package/dist/search/languages.js +7 -0
  31. package/dist/search/stem.d.ts +5 -4
  32. package/dist/search/stem.js +3 -2
  33. package/dist/sends/index.d.ts +1 -1
  34. package/dist/sends/index.js +1 -1
  35. package/dist/sends/permissions.d.ts +0 -13
  36. package/dist/sends/permissions.js +15 -142
  37. package/package.json +1 -1
  38. package/dist/cli/moved.d.ts +0 -18
  39. package/dist/cli/moved.js +0 -72
  40. package/dist/sends/permission-aliases.d.ts +0 -5
  41. package/dist/sends/permission-aliases.js +0 -50
package/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.422.0 — 25.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`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.
15
+ - **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.
16
+
17
+ ### Changed — may break callers
18
+
19
+ - **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.
20
+
21
+ ## 0.421.0 — 25.10.2026
22
+
23
+ ### Changed — may break callers
24
+
25
+ - **Old command names and old permission keys are no longer recognised.** A retired command path (`contacts profile`, `bot store fetch`, `search mail` and the rest) gets the ordinary "unknown command" answer, on the command line and over MCP, with no pointer to a replacement. A saved permission key is read only as written: nothing maps an old key to a new one, and `chats.mute` no longer covers `chats unmute`. Removed with it: `movedCommandError` from `/cli`; `PERMISSIONS`, `Permission`, `permissionFor` and `assertStatsPermissionsCurrent` from `/sends`; the `permission` field of an MCP tool definition; and the `skipMovedPermissions` option of `resolveSettings`. A CLI that imports one of them drops the import; nothing replaces them.
26
+ - **Bot commands and `--dry-run` refuse a saved permission key that no command checks**, as messenger commands already did. They stop with a `configuration_error` that names the key and the `config unset` that removes it: "permissions.bot.store.fetch names no command — remove it with \`tg sales config unset --bot permissions.bot.store.fetch\`". `config show`, `set` and `unset` still run, so the file can be repaired. Before, a bot command did not look at the keys at all, and an old key was quietly renamed or ignored.
27
+ - **`messages sync` and `bot messages sync` are checked as `messages.sync` and `bot.messages.sync`**, their own paths. A level on `messages` or `bot.messages` still applies to them; `commands --json` now reports the longer key.
28
+
29
+ ### Fixed
30
+
31
+ - **A deny saved on the sync command itself now stops it.** `permissions.bot.messages.sync: deny` was ignored, because `bot messages sync` was checked under `bot.messages`; `permissions.messages.sync: deny` could not be saved at all and was refused as naming no command. Both are now honoured on the command line, in MCP discovery and by the MCP server. Bot commands also refuse a denied read (`bot.messages: deny` stops `bot messages list` and `bot messages sync`) before they connect, as messenger commands already did.
32
+
10
33
  ## 0.420.0 — 25.10.2026
11
34
 
12
35
  ### Changed — may break callers
@@ -1,7 +1,8 @@
1
1
  import type { Command } from "commander";
2
2
  import { SendJournal } from "../../sends/journal.js";
3
- import type { Level, PermissionKey } from "../../sends/permissions.js";
3
+ import { type Level, type PermissionKey } from "../../sends/permissions.js";
4
4
  import { RecipientList } from "../../sends/recipients.js";
5
+ import type { Settings } from "../settings.js";
5
6
  import type { BotAdapter, BotChatRef, BotConnectOptions, BotMessenger } from "./port.js";
6
7
  import { ChatRegistry } from "./registry.js";
7
8
  import { BotTokenStore } from "./token.js";
@@ -9,7 +10,7 @@ import { BotTokenStore } from "./token.js";
9
10
  export declare const botWords: (bot: BotMessenger, profile: string) => string;
10
11
  /** What a bot command needs, resolved once: the bot's settings, its token, its lists. */
11
12
  export declare const botContext: (command: Command, bot: BotMessenger) => {
12
- settings: import("../settings.js").Settings;
13
+ settings: Settings;
13
14
  renderer: import("@wirecat/cli-base").Renderer;
14
15
  format: import("@wirecat/cli-base").RenderFormat;
15
16
  color: boolean;
@@ -2,16 +2,30 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { pickChat } from "../../resolve.js";
3
3
  import { sendGuard } from "../../sends/guard.js";
4
4
  import { SendJournal } from "../../sends/journal.js";
5
+ import { levelFor, readKeysForCommand } from "../../sends/permissions.js";
5
6
  import { RecipientList } from "../../sends/recipients.js";
6
7
  import { readSecret } from "../../terminal/prompt.js";
8
+ import { commandPathOf } from "../command-contract.js";
7
9
  import { baseContext, secretInputOf } from "../context.js";
8
10
  import { terminalAsker } from "../messenger/ask.js";
11
+ import { refuseUnknownPermissionKeys } from "../permission-keys.js";
9
12
  import { asFirstWord } from "../profile.js";
10
13
  import { botCopy } from "./copy.js";
11
14
  import { botFiles, ChatRegistry } from "./registry.js";
12
15
  import { BotTokenStore } from "./token.js";
13
16
  /** How the owner types this bot — `max sales bot` — so a refusal names the command that fixes it. */
14
17
  export const botWords = (bot, profile) => `${bot.app.command} ${asFirstWord(profile)}bot`;
18
+ /** What a messenger command refuses before it connects, for a bot: an unknown key, then a denied read. */
19
+ const refuseBotPermissions = (command, bot, settings) => {
20
+ refuseUnknownPermissionKeys(command, settings.permissions, `${bot.app.command} ${asFirstWord(settings.profile)}config unset --bot`);
21
+ for (const key of readKeysForCommand(commandPathOf(command))) {
22
+ const { level, key: named } = levelFor(settings.permissions, key);
23
+ if (level !== "deny")
24
+ continue;
25
+ throw new CliError("permission_error", `bot profile ${settings.profile} denies ${key} (permissions.${named} is deny, from the ` +
26
+ `${settings.permissionSources[named ?? ""] ?? "default"})`, { permission: key });
27
+ }
28
+ };
15
29
  /** What a bot command needs, resolved once: the bot's settings, its token, its lists. */
16
30
  export const botContext = (command, bot) => {
17
31
  const base = baseContext(command, (flags, options) => bot.resolveSettings(flags, { ...options, kind: "bot" }));
@@ -21,6 +35,7 @@ export const botContext = (command, bot) => {
21
35
  const tokens = bot.tokenStore?.(command, profile) ??
22
36
  new BotTokenStore({ app: bot.app, profile, env, ...(keyring ? { keyring } : {}) });
23
37
  const words = botWords(bot, profile);
38
+ refuseBotPermissions(command, bot, settings);
24
39
  const registry = bot.registry?.(command, profile) ?? new ChatRegistry(bot.app, profile, env);
25
40
  const recipients = () => new RecipientList(files.recipients, words);
26
41
  const journal = () => new SendJournal(files.journal, { service: bot.app.command, version: bot.app.version });
@@ -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;
@@ -15,6 +15,8 @@ import { changeStoreSetting, isStoreSetting, STORE_SETTINGS, storeSettings } fro
15
15
  * The settings in force and where each came from, and changing them. Printed whole: no field the
16
16
  * configuration accepts can hold a secret — the schema has nowhere to put one.
17
17
  */
18
+ /** `sender_colors` colours who wrote a message; a CLI without messages has nothing to colour. */
19
+ const shownSettings = (app, config) => app.messenger === false ? config.allSettings.filter((setting) => setting !== "sender_colors") : config.allSettings;
18
20
  export const configCommand = (app, config, { permissionKey } = {}) => {
19
21
  const command = new Command("config").description("the settings in force, and where each one came from");
20
22
  annotate(command.command("show"), { effects: LOCAL_READ, examples: [`${app.command} personal config show`] })
@@ -32,7 +34,7 @@ export const configCommand = (app, config, { permissionKey } = {}) => {
32
34
  config_file: settings.configPath,
33
35
  config_found: settings.configFound,
34
36
  paths_overridden: overridden,
35
- settings: [...config.allSettings, "command_timeout_ms"]
37
+ settings: [...shownSettings(app, config), "command_timeout_ms"]
36
38
  .filter((setting) => bot || setting !== "read_other_bots")
37
39
  .map((setting) => sourced(settings, setting, config.schema)),
38
40
  store_settings: await storeSettings(env),
@@ -49,13 +51,13 @@ export const configCommand = (app, config, { permissionKey } = {}) => {
49
51
  }))
50
52
  .argument("<setting>",
51
53
  // 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(", ")}`)
54
+ `one of: ${[...shownSettings(app, config).filter((one) => action === "unset" || one !== "default_profile"), ...STORE_SETTINGS, "account_groups.<name>"].join(", ")}`)
53
55
  .option("--defaults", "change what every profile gets, rather than this profile")
54
56
  .option("--personal", "only for personal accounts — the personal section of the file")
55
57
  .option("--bot", "only for bots — the bot section of the file");
56
58
  if (action === "set")
57
59
  sub
58
- .argument("<value>", "a number, true or false, or for allow a list like send,reaction; for a group tg:work,max:Alice,email:me@x")
60
+ .argument("<value>", "a number, true or false; for a group tg:work,max:Alice,email:me@x")
59
61
  .description("save a setting to the configuration file");
60
62
  else
61
63
  sub.description("remove a setting from the configuration file");
@@ -64,7 +66,6 @@ export const configCommand = (app, config, { permissionKey } = {}) => {
64
66
  const context = baseContext(this, (flags, options) => config.resolveSettings(flags, {
65
67
  ...options,
66
68
  needsProfile: !everyone && setting !== "default_profile" && !isStoreSetting(setting),
67
- skipMovedPermissions: true,
68
69
  }));
69
70
  const { settings, renderer, env } = context;
70
71
  if (isStoreSetting(setting)) {
@@ -1,22 +1,6 @@
1
1
  import { type Installer } from "@wirecat/cli-base/update";
2
2
  import { type Question } from "./setup-answers.js";
3
3
  /** The languages cli-core stems and has a dictionary for; each is one stemmer of its alphabet. */
4
- export declare const LANGUAGES: readonly [{
5
- readonly name: "russian";
6
- readonly code: "ru";
7
- readonly label: "Russian";
8
- readonly script: "cyrillic";
9
- }, {
10
- readonly name: "english";
11
- readonly code: "en";
12
- readonly label: "English";
13
- readonly script: "latin";
14
- }, {
15
- readonly name: "spanish";
16
- readonly code: "es";
17
- readonly label: "Spanish";
18
- readonly script: "latin";
19
- }];
20
4
  export interface FirstRunIo {
21
5
  ask: (prompt: string) => Promise<string>;
22
6
  say: (text: string) => void;
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
2
2
  import { accessSync, constants, realpathSync } from "node:fs";
3
3
  import { delimiter, join } from "node:path";
4
4
  import { installerOf, updateCommand } from "@wirecat/cli-base/update";
5
+ import { LANGUAGES } from "../search/languages.js";
5
6
  import { DEFAULT_STEMMERS } from "../search/stem.js";
6
7
  import { openCache } from "../store/open.js";
7
8
  import { storePath } from "../store/path.js";
@@ -11,12 +12,6 @@ import { readSecret } from "../terminal/prompt.js";
11
12
  import { WIRECAT_TOOLS } from "./help/first-screen.js";
12
13
  import { buildStems } from "./messenger/store-maintenance-command.js";
13
14
  import { QUESTIONS, readSetupAnswers, unanswered, writeSetupAnswers, } from "./setup-answers.js";
14
- /** The languages cli-core stems and has a dictionary for; each is one stemmer of its alphabet. */
15
- export const LANGUAGES = [
16
- { name: "russian", code: "ru", label: "Russian", script: "cyrillic" },
17
- { name: "english", code: "en", label: "English", script: "latin" },
18
- { name: "spanish", code: "es", label: "Spanish", script: "latin" },
19
- ];
20
15
  /** Commands that run without first-run questions: they serve agents, help, or open the store themselves. */
21
16
  const SKIPPED = new Set(["help", "guide", "completion", "mcp", "serve", "server", "update", "store"]);
22
17
  /**
@@ -26,6 +26,7 @@ export { CORE_LOG_EVENTS } from "../runs/log-events.js";
26
26
  export { crashOf, type Recording, type RecordingOptions, recorded, startRecording, wasSettled, } from "../runs/recording.js";
27
27
  export { findRun, KEEP_RUNS_FOR_DAYS, listRuns, pruneRuns, type RunMetadata, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
28
28
  export { currentRun, inRun, type RunScope, useAsProcessRun } from "../runs/scope.js";
29
+ export { LANGUAGES } from "../search/languages.js";
29
30
  export { guardedApp, guardedPress, guardedStart, type Pressed } from "../sends/buttons.js";
30
31
  export { guardedClose, guardedCreatePoll, guardedVote } from "../sends/polls.js";
31
32
  export type { InboxReader } from "../services/inbox.js";
@@ -62,7 +63,7 @@ export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNoth
62
63
  export { type CliEntry, type EntryContext, type EntryIo, startCli } from "./entry.js";
63
64
  export { type AnswerOutput, answerSchema, type Body, type ItemBody, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, type ListBody, type ListInput, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
64
65
  export { withAbort } from "./execution.js";
65
- export { askSetupQuestions, asksAtStart, type FirstRun, type FirstRunIo, LANGUAGES, setupAtStart, systemLanguage, } from "./first-run.js";
66
+ export { askSetupQuestions, asksAtStart, type FirstRun, type FirstRunIo, setupAtStart, systemLanguage, } from "./first-run.js";
66
67
  export { checkGuide, checkStep, type GuideOptions, type GuideReference, type GuideStep, loadScenarios, MEMO_SCENARIOS, type Scenario, shellProblems, } from "./guide.js";
67
68
  export { type CommandHelp, commandHelp, commandHelpMarkdown, commandHelpText, exampleReport, type NextChoice, nextUsually, } from "./help/command-help.js";
68
69
  export { commandsText } from "./help/commands-text.js";
@@ -96,7 +97,6 @@ export type { Running, ServerOptions, ServerProcess, ServerSystem } from "./mess
96
97
  export { storeSummary } from "./messenger/store-maintenance-command.js";
97
98
  export { type Saving, stored } from "./messenger/stored.js";
98
99
  export { MODEL_CATALOGUE_QUERY, type ModelCatalogueOptions } from "./model-catalogue-command.js";
99
- export { movedCommandError } from "./moved.js";
100
100
  export { window, withPaging } from "./paging.js";
101
101
  export { knownBeside, knownPermissionKeys, type PermissionKeyOf, unknownPermissionKeys } from "./permission-keys.js";
102
102
  export { asFirstWord, commandWords, liftProfile, refuseCommandName, rootOf, usableProfileName } from "./profile.js";
package/dist/cli/index.js CHANGED
@@ -22,6 +22,7 @@ export { CORE_LOG_EVENTS } from "../runs/log-events.js";
22
22
  export { crashOf, recorded, startRecording, wasSettled, } from "../runs/recording.js";
23
23
  export { findRun, KEEP_RUNS_FOR_DAYS, listRuns, pruneRuns, readEvents, runsDirFor, runtime, startRun, } from "../runs/run.js";
24
24
  export { currentRun, inRun, useAsProcessRun } from "../runs/scope.js";
25
+ export { LANGUAGES } from "../search/languages.js";
25
26
  export { guardedApp, guardedPress, guardedStart } from "../sends/buttons.js";
26
27
  export { guardedClose, guardedCreatePoll, guardedVote } from "../sends/polls.js";
27
28
  export { INBOX_CHATS, newIn, REVIEW_CHATS, reviewIn, unreadIn } from "../services/inbox.js";
@@ -55,7 +56,7 @@ export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNoth
55
56
  export { startCli } from "./entry.js";
56
57
  export { answerSchema, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
57
58
  export { withAbort } from "./execution.js";
58
- export { askSetupQuestions, asksAtStart, LANGUAGES, setupAtStart, systemLanguage, } from "./first-run.js";
59
+ export { askSetupQuestions, asksAtStart, setupAtStart, systemLanguage, } from "./first-run.js";
59
60
  export { checkGuide, checkStep, loadScenarios, MEMO_SCENARIOS, shellProblems, } from "./guide.js";
60
61
  export { commandHelp, commandHelpMarkdown, commandHelpText, exampleReport, nextUsually, } from "./help/command-help.js";
61
62
  export { commandsText } from "./help/commands-text.js";
@@ -87,7 +88,6 @@ export { STEMS_EVERY_MS, STEMS_SLICE_MS, stemFills } from "./messenger/serve-ste
87
88
  export { storeSummary } from "./messenger/store-maintenance-command.js";
88
89
  export { stored } from "./messenger/stored.js";
89
90
  export { MODEL_CATALOGUE_QUERY } from "./model-catalogue-command.js";
90
- export { movedCommandError } from "./moved.js";
91
91
  export { window, withPaging } from "./paging.js";
92
92
  export { knownBeside, knownPermissionKeys, unknownPermissionKeys } from "./permission-keys.js";
93
93
  export { asFirstWord, commandWords, liftProfile, refuseCommandName, rootOf, usableProfileName } from "./profile.js";
@@ -3,12 +3,12 @@ import { servingProfiles } from "../../background/lock.js";
3
3
  import { FloodMemory, floodPathFor } from "../../sends/flood.js";
4
4
  import { ceilingRefusal, guardFor } from "../../sends/guard.js";
5
5
  import { DEFAULT_PACE, Pacer, pacePathFor } from "../../sends/pace.js";
6
- import { assertStatsPermissionsCurrent, CEILING_KEY, levelFor, readKeysForCommand, } from "../../sends/permissions.js";
6
+ import { CEILING_KEY, levelFor, readKeysForCommand } from "../../sends/permissions.js";
7
7
  import { RecipientList, recipientsPathFor } from "../../sends/recipients.js";
8
8
  import { OFFLINE, servicesFor } from "../../services/index.js";
9
9
  import { openStore } from "../../store/store.js";
10
10
  import { baseContext, environmentOf } from "../context.js";
11
- import { knownPermissionKeys, unknownPermissionKeys } from "../permission-keys.js";
11
+ import { refuseUnknownPermissionKeys } from "../permission-keys.js";
12
12
  import { asFirstWord } from "../profile.js";
13
13
  import { rootOf } from "../steps.js";
14
14
  import { recalledAccount, rememberAccount } from "./accounts.js";
@@ -325,7 +325,6 @@ const pathOf = (command) => {
325
325
  };
326
326
  /** `deny` stops a read too, before anything connects: what the profile may not see is never fetched. */
327
327
  const refuseDenied = (command, settings) => {
328
- assertStatsPermissionsCurrent(pathOf(command), settings.permissions);
329
328
  for (const key of readKeysForCommand(pathOf(command))) {
330
329
  const { level, key: named } = levelFor(settings.permissions, key);
331
330
  if (level !== "deny")
@@ -356,9 +355,7 @@ export const messengerContext = (command, messenger, resolving = {}) => {
356
355
  const { profile } = base.settings;
357
356
  refuseDenied(command, base.settings);
358
357
  refuseLacking(messenger, pathOf(command));
359
- for (const key of unknownPermissionKeys(base.settings.permissions, knownPermissionKeys(command, messenger.permissionKey))) {
360
- throw new CliError("configuration_error", `permissions.${key} names no command — remove it with \`${app.command} ${asFirstWord(profile)}config unset permissions.${key}\``, { permission: key });
361
- }
358
+ refuseUnknownPermissionKeys(command, base.settings.permissions, `${app.command} ${asFirstWord(profile)}config unset`, messenger.permissionKey);
362
359
  // Read when warning, not now: serve swaps the renderer's methods for log records after this.
363
360
  const warn = (text, warning) => base.renderer.warn(text, warning);
364
361
  const accountFor = () => {
@@ -9,3 +9,5 @@ export declare const knownPermissionKeys: (command: Command, keyOf?: PermissionK
9
9
  /** The known keys one level under the unknown key's parent, to name in the refusal. */
10
10
  export declare const knownBeside: (key: PermissionKey, known: ReadonlySet<PermissionKey>) => PermissionKey[];
11
11
  export declare const unknownPermissionKeys: (levels: Readonly<Record<PermissionKey, unknown>>, known: ReadonlySet<PermissionKey>) => PermissionKey[];
12
+ /** A saved key no command checks would do nothing, so the command refuses to run; `unset` names the fix. */
13
+ export declare const refuseUnknownPermissionKeys: (command: Command, levels: Readonly<Record<PermissionKey, unknown>>, unset: string, keyOf?: PermissionKeyOf) => void;
@@ -1,4 +1,4 @@
1
- import { canonicalPermissionKey } from "../sends/permission-aliases.js";
1
+ import { CliError } from "@wirecat/cli-base";
2
2
  import { CEILING_KEY, keyForCommand, WRITE_KEYS } from "../sends/permissions.js";
3
3
  const pathOf = (command) => {
4
4
  const words = [];
@@ -20,12 +20,12 @@ const everyCommand = (command) => [command, ...command.commands.flatMap(everyCom
20
20
  export const knownPermissionKeys = (command, keyOf = (one) => keyForCommand(pathOf(one)), extra = []) => {
21
21
  const known = new Set();
22
22
  const add = (key) => {
23
- key = canonicalPermissionKey(key);
24
23
  for (let parts = key.split("."); parts.length > 0; parts = parts.slice(0, -1))
25
24
  known.add(parts.join("."));
26
25
  };
27
26
  for (const one of everyCommand(rootOf(command))) {
28
- const key = keyOf(one);
27
+ const own = keyOf(one);
28
+ const key = own === undefined ? keyForCommand(pathOf(one)) : own;
29
29
  if (key)
30
30
  add(key);
31
31
  }
@@ -42,3 +42,10 @@ export const knownBeside = (key, known) => {
42
42
  .sort();
43
43
  };
44
44
  export const unknownPermissionKeys = (levels, known) => Object.keys(levels).filter((key) => key !== CEILING_KEY && !known.has(key));
45
+ /** A saved key no command checks would do nothing, so the command refuses to run; `unset` names the fix. */
46
+ export const refuseUnknownPermissionKeys = (command, levels, unset, keyOf) => {
47
+ const [key] = unknownPermissionKeys(levels, knownPermissionKeys(command, keyOf));
48
+ if (key === undefined)
49
+ return;
50
+ throw new CliError("configuration_error", `permissions.${key} names no command — remove it with \`${unset} permissions.${key}\``, { permission: key });
51
+ };
@@ -1,8 +1,8 @@
1
1
  import type { Command } from "commander";
2
- import { type Level } from "../sends/permissions.js";
2
+ import { type Level, type PermissionKey } from "../sends/permissions.js";
3
3
  export declare class PreviewComplete extends Error {
4
4
  }
5
- export declare const preview: (command: Command, permissions: Readonly<Record<string, Level>>) => {
5
+ export declare const preview: (command: Command, permissions: Readonly<Record<string, Level>>, unset: string, keyOf?: (path: readonly string[]) => PermissionKey | null | undefined) => {
6
6
  preview: boolean;
7
7
  path: string[];
8
8
  permission: {
@@ -1,15 +1,16 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { metaOf } from "@wirecat/cli-base/commands";
3
3
  import { writes as writesAnything } from "@wirecat/cli-base/standards";
4
- import { assertStatsPermissionsCurrent, keyForCommand, levelFor, readKeysForCommand, WRITE_KEYS, } from "../sends/permissions.js";
4
+ import { keyForCommand, levelFor, readKeysForCommand, WRITE_KEYS, } from "../sends/permissions.js";
5
5
  import { commandPathOf } from "./command-contract.js";
6
+ import { refuseUnknownPermissionKeys } from "./permission-keys.js";
6
7
  export class PreviewComplete extends Error {
7
8
  }
8
- export const preview = (command, permissions) => {
9
+ export const preview = (command, permissions, unset, keyOf) => {
9
10
  const path = commandPathOf(command);
11
+ refuseUnknownPermissionKeys(command, permissions, unset, keyOf && ((one) => keyOf(commandPathOf(one))));
10
12
  const key = keyForCommand(path);
11
13
  const level = key ? levelFor(permissions, key).level : "allow";
12
- assertStatsPermissionsCurrent(path, permissions);
13
14
  for (const source of readKeysForCommand(path)) {
14
15
  if (levelFor(permissions, source).level === "deny")
15
16
  throw new CliError("permission_error", `this profile does not permit ${source}`, { permission: source });
@@ -28,3 +28,5 @@ export declare const valuedOptions: (program: Command) => Set<string>;
28
28
  export declare const refuseCommandName: (profile: string, words: Set<string>, command: string) => void;
29
29
  /** The root of the command tree, from anywhere inside it. */
30
30
  export declare const rootOf: (command: Command) => Command;
31
+ /** A command this CLI really has, for an example: the first that takes a profile, down to its first leaf. */
32
+ export declare const sampleCommand: (commands: readonly Command[]) => string | undefined;
@@ -59,3 +59,13 @@ export const rootOf = (command) => {
59
59
  root = root.parent;
60
60
  return root;
61
61
  };
62
+ /** A command this CLI really has, for an example: the first that takes a profile, down to its first leaf. */
63
+ export const sampleCommand = (commands) => {
64
+ const words = [];
65
+ let command = commands.find((one) => !PROFILE_FREE.has(one.name()));
66
+ while (command) {
67
+ words.push(command.name());
68
+ command = command.commands.find((one) => one.name() !== "help");
69
+ }
70
+ return words.length ? words.join(" ") : undefined;
71
+ };
@@ -25,9 +25,8 @@ import { installHelp } from "./help/first-screen.js";
25
25
  import { showInputFields } from "./input-fields.js";
26
26
  import { MAX_BUFFERED_INPUT, provideInputPolicy } from "./input-policy.js";
27
27
  import { checkJson, liftJson } from "./json-fields.js";
28
- import { movedCommandError } from "./moved.js";
29
28
  import { PreviewComplete, preview } from "./preview.js";
30
- import { commandWords, liftProfile, PROFILE_FREE, valuedOptions } from "./profile.js";
29
+ import { asFirstWord, commandWords, liftProfile, PROFILE_FREE, sampleCommand, valuedOptions } from "./profile.js";
31
30
  import { absolutePaths, relayTarget } from "./relay-route.js";
32
31
  import { settingsFor } from "./settings.js";
33
32
  import { assertSharedContracts } from "./shared-contracts.js";
@@ -38,11 +37,15 @@ import { commandAt, lineOf, withLines } from "./steps.js";
38
37
  */
39
38
  export const createProgram = ({ app, commands, configure, firstScreen }, { out, err } = {}) => {
40
39
  const program = new Command();
40
+ const mounted = commands();
41
+ const messenger = app.messenger !== false;
42
+ const found = messenger ? "found messages" : "found items";
43
+ const content = messenger ? "message content" : "content";
41
44
  program
42
45
  .name(app.command)
43
46
  .usage("[profile] [options] <command>")
44
47
  .description(`${app.description}\n\n` +
45
- `The first word is the profile whenever it is not a command — \`${app.command} personal chats list\`.\n` +
48
+ `The first word is the profile whenever it is not a command — \`${app.command} personal ${messenger ? "chats list" : (sampleCommand(mounted) ?? "<command>")}\`.\n` +
46
49
  `\`${app.envPrefix}_PROFILE\` says the same thing for a whole shell session.\n` +
47
50
  "No profile is assumed: without one, a command that uses an account stops and lists the profiles here.")
48
51
  .version(app.version, "-V, --version")
@@ -50,21 +53,21 @@ export const createProgram = ({ app, commands, configure, firstScreen }, { out,
50
53
  .option("--json", "machine-readable output: one JSON value on stdout; --json=id,text keeps only those fields")
51
54
  .option("--jsonl", "machine-readable output: one JSON object per line, for streaming and jq")
52
55
  .option("--quiet", "diagnostics off; a failure is still said")
53
- .option("--trace", "the connection's own log lines on stderr — never message content")
56
+ .option("--trace", `the connection's own log lines on stderr — never ${content}`)
54
57
  .option("--debug", "on a failure, its whole record — cause and stack — in place of the short line")
55
58
  .option("--timeout <duration>", "give up on the whole command after this — 30s, 2m, 500ms")
56
59
  .option("--offline", "answer from what was recorded and never connect; fails if nothing was")
57
60
  .option("--no-input", "never prompt or open interactive login; piped input remains available")
58
61
  .option("--max-input-bytes <bytes>", "maximum buffered input bytes (default: 16777216)")
59
62
  .option("--max-output-bytes <bytes>", "maximum machine output bytes (default: 4194304; 0 disables)")
60
- .option("--brief", "found messages as reference, title, time, sender and excerpt only")
61
- .option("--full", "found messages with their whole text and every provider field")
63
+ .option("--brief", `${found} as reference, title, time, ${messenger ? "sender" : "author"} and excerpt only`)
64
+ .option("--full", `${found} with their whole text and every provider field`)
62
65
  .option("--dry-run", "preview parsed arguments and permissions before running the action")
63
66
  .option("--yes", "go ahead without the question an ask level puts before a write")
64
- .option("--record", "keep this run — ids and timings, never message content")
67
+ .option("--record", `keep this run — ids and timings, never ${content}`)
65
68
  .option("--no-record", "do not keep it, whatever the configuration says")
66
69
  .showHelpAfterError();
67
- for (const command of commands())
70
+ for (const command of mounted)
68
71
  program.addCommand(command);
69
72
  configure?.(program);
70
73
  assertSharedContracts(program);
@@ -377,7 +380,9 @@ const runOnce = async (argv, definition, options) => {
377
380
  needsProfile: keyForCommand(path) !== null,
378
381
  });
379
382
  const permissions = "permissions" in resolved ? resolved.permissions : {};
380
- renderAnswer(action, outputFor(action), itemOf(snakeKeys(preview(action, permissions))));
383
+ const unset = `${command} ${asFirstWord(resolved.profile)}config unset${path[0] === "bot" ? " --bot" : ""}`;
384
+ const previewed = preview(action, permissions, unset, definition.configuration?.permissionKey);
385
+ renderAnswer(action, outputFor(action), itemOf(snakeKeys(previewed)));
381
386
  throw new PreviewComplete();
382
387
  }
383
388
  // Not inside the scheduled run itself: its own failure is already on its way to last.json.
@@ -421,11 +426,6 @@ const runOnce = async (argv, definition, options) => {
421
426
  const settled = await settleFailure(failure, { definition, program, rest, profile, options });
422
427
  return report(streams, reporting, failed(definition, program, rest, failure, settled, debug));
423
428
  }
424
- const failure = movedCommandError(program, rest);
425
- if (failure !== undefined) {
426
- const settled = await settleFailure(failure, { definition, program, rest, profile, options });
427
- return report(streams, reporting, failed(definition, program, rest, failure, settled, debug));
428
- }
429
429
  try {
430
430
  await control.race(() => program.parseAsync(rest, { from: "user" }));
431
431
  const code = exit.code ?? (process.exitCode === undefined ? 0 : Number(process.exitCode));
@@ -140,8 +140,6 @@ export interface ResolveOptions {
140
140
  needsProfile?: boolean;
141
141
  /** Read the config file without writing it: no default file. For a dry run. */
142
142
  readOnly?: boolean;
143
- /** Skip moved permission keys instead of refusing them, so `config set` and `unset` can fix one. */
144
- skipMovedPermissions?: boolean;
145
143
  }
146
144
  /** Where `profile` came from when none was named and the command needs none. */
147
145
  export declare const NONE_NAMED = "none named";
@@ -4,7 +4,6 @@ import * as v from "valibot";
4
4
  import { AI_ENTRIES, resolveAISettings } from "../analysis/settings.js";
5
5
  import { parseDuration } from "../domain/duration.js";
6
6
  import { envName, readOnlyLockOf } from "../ports/app.js";
7
- import { canonicalPermissionKey } from "../sends/permission-aliases.js";
8
7
  import { assertOverridesTighten, CEILING_KEY, LEVELS, layerPermissions, permissionOverrides, RESOURCES, } from "../sends/permissions.js";
9
8
  import { storePath } from "../store/path.js";
10
9
  import { sharedConfigPath } from "../store/shared-config.js";
@@ -78,7 +77,6 @@ const SHARED_DEFAULTS_ONLY = {
78
77
  update_check: v.optional(flag),
79
78
  skill_hint: v.optional(flag),
80
79
  };
81
- const withoutMoved = (levels) => levels && Object.fromEntries(Object.entries(levels).filter(([key]) => canonicalPermissionKey(key) === key));
82
80
  /** Where `profile` came from when none was named and the command needs none. */
83
81
  export const NONE_NAMED = "none named";
84
82
  /** A name for a command that speaks for no account; never chosen for one that does. */
@@ -232,9 +230,7 @@ export const settingsFor = (app, extension = {}) => {
232
230
  : { value: false, from: "default" };
233
231
  const fileLayers = layers.map(([from, scope]) => [
234
232
  from,
235
- options.skipMovedPermissions
236
- ? withoutMoved(scope?.permissions)
237
- : scope?.permissions,
233
+ scope?.permissions,
238
234
  ]);
239
235
  const overrides = permissionOverrides(flags.permission);
240
236
  assertOverridesTighten(overrides, layerPermissions(fileLayers).levels);
@@ -2,7 +2,6 @@ import { toJsonSchema, toStandardJsonSchema } from "@valibot/to-json-schema";
2
2
  import { CliError } from "@wirecat/cli-base";
3
3
  import * as v from "valibot";
4
4
  import { answerSchema, listOf } from "../cli/envelope.js";
5
- import { movedPath, movedPointer } from "../cli/moved.js";
6
5
  import { ENTITIES } from "../entities/index.js";
7
6
  import { agentArguments } from "./text.js";
8
7
  import { answered, commandOf, entryRunner, failed, inputOf, outputSchemaOf, READ, syncAllowedFor, UNTRUSTED, WRITE, } from "./tool.js";
@@ -63,10 +62,16 @@ export const registerCommands = (server, prefix, commands, errors,
63
62
  /** Throws the refusal of a command left out because the lock or a level stops it, so the agent learns why. */
64
63
  withheld) => {
65
64
  const byNormal = new Map(Object.keys(commands).map((command) => [normal(command), command]));
65
+ // No AppIdentity reaches here; only a messenger's catalogue has `messages` commands.
66
+ const messenger = Object.keys(commands).some((command) => command.split(" ")[0] === "messages");
67
+ const tasks = messenger ? '"unread", "send message", "chat members"' : '"list", "show", "change a setting"';
68
+ const sample = messenger
69
+ ? "messages list"
70
+ : (Object.entries(commands).find(([, one]) => !one.writes)?.[0] ?? Object.keys(commands)[0] ?? "");
66
71
  server.registerTool(`${prefix}_tools_search`, {
67
72
  outputSchema: toStandardJsonSchema(v.looseObject({})),
68
73
  title: "Find a command",
69
- description: `Find the command for a task — "unread", "send message", "chat members" — before calling ` +
74
+ description: `Find the command for a task — ${tasks} — before calling ` +
70
75
  `${prefix}_read or ${prefix}_write with it. Each match gives its command, whether it writes, its arguments and the shape of its answer. ` +
71
76
  "With no words, lists every command and title, without arguments.",
72
77
  inputSchema: toStandardJsonSchema(v.strictObject({ query: v.optional(v.pipe(v.string(), v.description("words that describe the task"))) })),
@@ -103,9 +108,6 @@ withheld) => {
103
108
  const one = command === undefined ? undefined : commands[command];
104
109
  if (command === undefined || !one) {
105
110
  withheld?.(normal(asked));
106
- const moved = movedPath(normal(asked).split(" "), (path) => byNormal.has(path));
107
- if (moved !== undefined)
108
- throw new CliError("validation_error", `no command "${asked}" here — ${movedPointer(moved)}`);
109
111
  const near = nearest(commands, asked);
110
112
  throw new CliError("validation_error", `no command "${asked}" here${near.length ? ` — did you mean ${near.map((name) => `"${name}"`).join(", ")}?` : ""}; ${prefix}_tools_search finds one`);
111
113
  }
@@ -126,7 +128,7 @@ withheld) => {
126
128
  }
127
129
  };
128
130
  const call = v.strictObject({
129
- command: v.pipe(v.string(), v.minLength(1), v.description(`a command ${prefix}_tools_search returned: "messages list"`)),
131
+ command: v.pipe(v.string(), v.minLength(1), v.description(`a command ${prefix}_tools_search returned: "${sample}"`)),
130
132
  arguments: v.optional(v.pipe(v.record(v.string(), v.unknown()), v.description("that command's arguments"))),
131
133
  });
132
134
  server.registerTool(`${prefix}_read`, {
@@ -140,7 +142,7 @@ withheld) => {
140
142
  server.registerTool(`${prefix}_write`, {
141
143
  outputSchema: toStandardJsonSchema(ANSWER),
142
144
  title: "Run a writing command",
143
- description: `Run one command that changes something — sends, edits, deletes, joins — with the arguments ${prefix}_tools_search showed. ${UNTRUSTED}`,
145
+ description: `Run one command that changes something — ${messenger ? "sends, edits, deletes, joins" : "creates, edits, deletes"} — with the arguments ${prefix}_tools_search showed. ${UNTRUSTED}`,
144
146
  inputSchema: toStandardJsonSchema(call),
145
147
  annotations: WRITE,
146
148
  }, runner(true));
@@ -10,7 +10,7 @@ import { type EntityName } from "../entities/index.js";
10
10
  import { type AppErrors } from "../errors/reasons.js";
11
11
  import type { MessengerAdapter } from "../ports/messenger.js";
12
12
  import type { SendGuard } from "../sends/guard.js";
13
- import { type Permission, type PermissionKey } from "../sends/permissions.js";
13
+ import { type PermissionKey } from "../sends/permissions.js";
14
14
  import { type Services } from "../services/index.js";
15
15
  import type { AccountKey, MessageStore } from "../store/store.js";
16
16
  import type { MessengerSession } from "./session.js";
@@ -50,8 +50,6 @@ interface Tool<S extends Input> {
50
50
  /** What it touches, where its hints cannot say: a read of the messenger kept in the store is not a write there. */
51
51
  effects?: Effects;
52
52
  _meta?: Record<string, unknown>;
53
- /** A write, and what the profile's `allow` must name for it to be offered. */
54
- permission?: Permission;
55
53
  /** Writes what the owner keeps, as its command (`isStoreChange`): `<PREFIX>_READ_ONLY` refuses it. */
56
54
  changesStore?: true;
57
55
  /** The CLI command it answers as, where its name does not spell it: `chats send-as`. Its level is read from it. */
package/dist/mcp/tool.js CHANGED
@@ -12,7 +12,7 @@ import { failureContext } from "../errors/reasons.js";
12
12
  import { partialOutcome, startRecording } from "../runs/recording.js";
13
13
  import { newRunId } from "../runs/run.js";
14
14
  import { inRun } from "../runs/scope.js";
15
- import { assertStatsPermissionsCurrent, keyForCommand, levelFor, readKeysForCommand, } from "../sends/permissions.js";
15
+ import { keyForCommand, levelFor, readKeysForCommand } from "../sends/permissions.js";
16
16
  import { onlineDeps, storeModeDeps } from "../services/deps.js";
17
17
  import { servicesFor } from "../services/index.js";
18
18
  import { agentJson } from "./text.js";
@@ -178,7 +178,6 @@ const callRunner = ({ messenger, session, withStore, withServices, defaults, aro
178
178
  definition.check?.(args);
179
179
  if (definition.changesStore)
180
180
  refuseStoreChange(messenger.app, defaults.env);
181
- assertStatsPermissionsCurrent(path, defaults.settings.permissions ?? {});
182
181
  refuseLacking(messenger, path);
183
182
  for (const permission of readKeysForCommand(path)) {
184
183
  if (levelFor(defaults.settings.permissions ?? {}, permission).level === "deny")