@wirecat/cli-core 0.467.0 → 0.469.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.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,44 @@ 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.469.0 — 27.10.2026
9
+
10
+ ### Changed — may break callers
11
+
12
+ - **cli-core needs `@wirecat/cli-base` 0.65.** The peer range moves from `>=0.64.0 <0.65.0` to `>=0.65.0 <0.66.0`. A
13
+ CLI that pins cli-base moves its pin with this release. cli-base 0.65 puts `serve` and `revoke` in the command
14
+ vocabulary, so `cli-dev lint` fails on a baseline entry that names them: remove `mcp serve`, `bot mcp serve`,
15
+ `mcp revoke` and `replies consents revoke` from a CLI's `lint/baseline/unknown-verb.json` (cli-core's own are gone).
16
+ Its `cli-dev audit` compares gcal only on the commands it already shares, and its handbook changes the rules on
17
+ deadlines, MCP profiles and what counts as a write; nothing cli-core calls changed.
18
+
19
+ ## 0.468.0 — 27.10.2026
20
+
21
+ ### Changed — may break callers
22
+
23
+ - **An MCP server offers a command that only fetches into the store through its read tool.** A command that reads
24
+ the provider and only keeps what it read in the owner's store (`effects: { remote: "read", local: "write" }`,
25
+ `FETCH`) — `contacts sync`, `metadata sync`, `chats members fetch`, a calendar's `sync` and `calendars list` — is
26
+ now `writes: false` in the catalogue and runs with `<cli>_read`; `<cli>_write` refuses it and names `_read` (owner
27
+ ruling, 2026-10-27; WC-CMD-17). These stay writes: a local edit (`changesStore`, `tags rebuild`), an authority
28
+ change (`grantsAuthority`, `recipients add`), a call to another service (`reachesOtherService`), a command that
29
+ saves files (named `download`, or with a folder to write into: `messages download`, `models download`,
30
+ `attachments extract --output-dir`), and anything that changes the provider. Permissions do not change: a
31
+ `readonly` level still refuses a fetch. New `fetchesOnly(command)` and `fetchesOnlyAt(root)`; `toolsFromCommands`
32
+ takes `fetchOnly`, and without it every write stays one (a CLI with its own server passes
33
+ `fetchOnly: fetchesOnlyAt(program)`). A CLI's `contracts/mcp-catalogue.json` changes where it has such commands.
34
+ - **A CLI that is not a messenger (`AppIdentity.messenger: false`) no longer offers messenger settings.** `config
35
+ set|unset` lists and takes neither `sends_per_hour`, `sender_colors`, `transcribe_with`, `search_catch_up` and
36
+ `read_other_bots` (`settingsFor(...).allSettings` leaves them out; a file that has one is still read) nor the store
37
+ settings of messages — `phone_countries`, `task_*` and `account_groups.<name>` — which it would have changed for
38
+ tg and MAX. `search_stemmers.*` stays. `config show` leaves the same profile settings out.
39
+ - **A model that is not downloaded names its download as the error's `next` step, only in a CLI that has the
40
+ command.** The `model.not-downloaded` error of a text model said `` `<cli> models download <id>` `` in its message,
41
+ also in a CLI without `models` (gcal). Its message is now `<id> is not downloaded on this machine`; the step
42
+ (`models.download`, or `models.download-image` for a picture model) comes as `error.next` with its `line`, the
43
+ profile in it, and only where the program has the command. Any error's `next` is now dropped when it names a
44
+ command the program lacks.
45
+ - **`skill show` offers `follow-ups` and `link-conversations` only in a messenger CLI.** Both work on chats.
9
46
 
10
47
  ## 0.467.0 — 27.10.2026
11
48
 
@@ -3,7 +3,7 @@ import { CliError } from "@wirecat/cli-base";
3
3
  import { annotate, describeProgram } from "@wirecat/cli-base/commands";
4
4
  import { Command } from "commander";
5
5
  import { environmentOf } from "../context.js";
6
- import { changesAuthorityAt, LOCAL_READ, REMOTE_WRITE } from "../effects.js";
6
+ import { changesAuthorityAt, fetchesOnlyAt, LOCAL_READ, REMOTE_WRITE } from "../effects.js";
7
7
  import { itemOf, renderAnswer } from "../envelope.js";
8
8
  import { serverEntry } from "../messenger/mcp-command.js";
9
9
  import { nextStep, rootOf } from "../steps.js";
@@ -37,6 +37,7 @@ export const botMcpCommand = (bot) => {
37
37
  bot,
38
38
  program: { run, commands: describeProgram(rootOf(this)) },
39
39
  authority: changesAuthorityAt(rootOf(this)),
40
+ fetchOnly: fetchesOnlyAt(rootOf(this)),
40
41
  settings: context.settings,
41
42
  levels: () => bot.resolveSettings(this.optsWithGlobals(), { env: context.env, kind: "bot" }).permissions,
42
43
  env: context.env,
@@ -2,6 +2,10 @@ import { Command } from "commander";
2
2
  import { type AppIdentity } from "../ports/app.js";
3
3
  import { type PermissionKeySource } 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
+ */
5
9
  export declare const configCommand: (app: AppIdentity, config: Configuration, { permissionKey }?: {
6
10
  permissionKey?: PermissionKeySource | undefined;
7
11
  }) => Command;
@@ -9,13 +9,11 @@ import { itemOf, renderAnswer } from "./envelope.js";
9
9
  import { knownBeside, knownPermissionKeys, unknownPermissionIssues, } from "./permission-keys.js";
10
10
  import { fromFile, NONE_NAMED } from "./settings.js";
11
11
  import { nextStep } from "./steps.js";
12
- import { changeStoreSetting, isStoreSetting, STORE_SETTINGS, storeSettings } from "./store-settings.js";
12
+ import { changeStoreSetting, isStoreSetting, offersStoreSetting, storeSettings, storeSettingsOf, } from "./store-settings.js";
13
13
  /**
14
14
  * The settings in force and where each came from, and changing them. Printed whole: no field the
15
15
  * configuration accepts can hold a secret — the schema has nowhere to put one.
16
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;
19
17
  export const configCommand = (app, config, { permissionKey = config.permissions } = {}) => {
20
18
  const command = new Command("config").description("the settings in force, and where each one came from");
21
19
  annotate(command.command("show"), { effects: LOCAL_READ, examples: [`${app.command} personal config show`] })
@@ -38,7 +36,7 @@ export const configCommand = (app, config, { permissionKey = config.permissions
38
36
  config_file: settings.configPath,
39
37
  config_found: settings.configFound,
40
38
  paths_overridden: overridden,
41
- settings: [...shownSettings(app, config), "command_timeout_ms"]
39
+ settings: [...config.allSettings, "command_timeout_ms"]
42
40
  .filter((setting) => bot || setting !== "read_other_bots")
43
41
  .map((setting) => sourced(settings, setting)),
44
42
  store_settings: await storeSettings(env),
@@ -58,7 +56,7 @@ export const configCommand = (app, config, { permissionKey = config.permissions
58
56
  effects: LOCAL_WRITE,
59
57
  examples: [`${app.command} personal config ${action} limit${action === "set" ? " 20" : ""}`],
60
58
  }))
61
- .argument("<setting>", `one of: ${[...shownSettings(app, config), ...STORE_SETTINGS, "account_groups.<name>"].join(", ")}`)
59
+ .argument("<setting>", `one of: ${[...config.allSettings, ...storeSettingsOf(app)].join(", ")}`)
62
60
  .option("--defaults", "change what every profile gets, rather than this profile")
63
61
  .option("--personal", "only for personal accounts — the personal section of the file")
64
62
  .option("--bot", "only for bots — the bot section of the file");
@@ -72,6 +70,8 @@ export const configCommand = (app, config, { permissionKey = config.permissions
72
70
  const { defaults: everyone, personal, bot, } = this.opts();
73
71
  const context = baseContext(this, (flags, options) => config.resolveSettings(flags, { ...options, needsProfile: false, withIssues: true }));
74
72
  const { settings, renderer, env } = context;
73
+ if (isStoreSetting(setting) && !offersStoreSetting(app, setting))
74
+ throw new CliError("validation_error", `no setting called "${setting}" here — it belongs to the messenger CLIs; one of: ${[...config.allSettings, ...storeSettingsOf(app)].join(", ")}`);
75
75
  if (isStoreSetting(setting)) {
76
76
  if (everyone || personal || bot)
77
77
  throw new CliError("validation_error", `${setting} is store-wide — --defaults, --personal and --bot do not apply`);
@@ -41,6 +41,15 @@ export declare const isLogin: (command: Command) => boolean;
41
41
  export declare const destroysNothing: <T extends Command>(command: T) => T;
42
42
  /** A local edit remains an edit even when it first fetches metadata from the messenger. */
43
43
  export declare const changesStore: <T extends Command>(command: T) => T;
44
+ /**
45
+ * Reads the provider and only keeps what it read in the owner's store — a sync, a list that refreshes it. An MCP server
46
+ * offers it through the read tool (owner ruling, 2026-10-27; WC-CMD-17): it changes nothing at the provider or for
47
+ * other people. A local edit (`changesStore`), an authority change, a call to another service (`reachesOtherService`,
48
+ * a model download) and a command that saves files (a download, a folder to write into) stay writes.
49
+ */
50
+ export declare const fetchesOnly: (command: Command) => boolean;
51
+ /** `fetchesOnly` by command path under `root`, as an MCP catalogue asks it; an unknown path is not one. */
52
+ export declare const fetchesOnlyAt: (root: Command) => (path: readonly string[]) => boolean;
44
53
  /**
45
54
  * Whether `<PREFIX>_READ_ONLY` refuses the command as a change to the store. Every write that stays on this machine
46
55
  * is refused unless `destroysNothing` marks it, so a new one fails closed (WC-PERM-12). A fetch (`remote: "read"`)
@@ -66,6 +66,33 @@ export const changesStore = (command) => {
66
66
  STORE_WRITES.add(command);
67
67
  return command;
68
68
  };
69
+ /** A download or a folder to write into: files outside the store, which the owner places, not an agent. */
70
+ const savesFiles = (command) => command.name() === "download" ||
71
+ [...command.options.map((option) => option.attributeName()), ...command.registeredArguments.map((one) => one.name())]
72
+ .map((name) => name.toLowerCase())
73
+ .some((name) => name === "dir" || name.endsWith("dir") || name === "output");
74
+ /**
75
+ * Reads the provider and only keeps what it read in the owner's store — a sync, a list that refreshes it. An MCP server
76
+ * offers it through the read tool (owner ruling, 2026-10-27; WC-CMD-17): it changes nothing at the provider or for
77
+ * other people. A local edit (`changesStore`), an authority change, a call to another service (`reachesOtherService`,
78
+ * a model download) and a command that saves files (a download, a folder to write into) stay writes.
79
+ */
80
+ export const fetchesOnly = (command) => {
81
+ const effects = metaOf(command).effects;
82
+ return (effects?.remote === "read" &&
83
+ effects.local === "write" &&
84
+ !STORE_WRITES.has(command) &&
85
+ !changesAuthority(command) &&
86
+ !OTHER_SERVICE.has(command) &&
87
+ !savesFiles(command));
88
+ };
89
+ /** `fetchesOnly` by command path under `root`, as an MCP catalogue asks it; an unknown path is not one. */
90
+ export const fetchesOnlyAt = (root) => (path) => {
91
+ let at = root;
92
+ for (const word of path)
93
+ at = at?.commands.find((one) => one.name() === word);
94
+ return at !== undefined && fetchesOnly(at);
95
+ };
69
96
  /**
70
97
  * Whether `<PREFIX>_READ_ONLY` refuses the command as a change to the store. Every write that stays on this machine
71
98
  * is refused unless `destroysNothing` marks it, so a new one fails closed (WC-PERM-12). A fetch (`remote: "read"`)
@@ -63,7 +63,7 @@ export { sharedCommandTemplate } from "./contract-templates.js";
63
63
  export { type Closeable, withDeadline } from "./deadline.js";
64
64
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
65
65
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
66
- export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, refuseUnderReadOnly, storeChangeRefusal, } from "./effects.js";
66
+ export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, refuseUnderReadOnly, storeChangeRefusal, } from "./effects.js";
67
67
  export { type CliEntry, type EntryContext, type EntryIo, startCli } from "./entry.js";
68
68
  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";
69
69
  export { withAbort } from "./execution.js";
package/dist/cli/index.js CHANGED
@@ -56,7 +56,7 @@ export { sharedCommandTemplate } from "./contract-templates.js";
56
56
  export { withDeadline } from "./deadline.js";
57
57
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
58
58
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
59
- export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, refuseUnderReadOnly, storeChangeRefusal, } from "./effects.js";
59
+ export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, fetchesOnly, fetchesOnlyAt, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, previewFlag, REMOTE_READ, REMOTE_WRITE, reachesOtherService, refuseUnderReadOnly, storeChangeRefusal, } from "./effects.js";
60
60
  export { startCli } from "./entry.js";
61
61
  export { answerSchema, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
62
62
  export { withAbort } from "./execution.js";
@@ -31,7 +31,7 @@ import { commandWords, isProfileName, liftProfile, sampleCommand, takesProfile,
31
31
  import { absolutePaths, relayTarget } from "./relay-route.js";
32
32
  import { NoProfileHere, namedProfile, settingsFor, } from "./settings.js";
33
33
  import { assertSharedContracts } from "./shared-contracts.js";
34
- import { commandAt, lineOf, withLines } from "./steps.js";
34
+ import { commandAt, failureSteps, lineOf, withLines } from "./steps.js";
35
35
  /**
36
36
  * The command tree, built fresh on each call. Commander is global state by default —
37
37
  * `exitOverride` and the write hooks turn it into a value a test can drive.
@@ -659,11 +659,14 @@ const failed = ({ app }, program, rest, error, settled, debug) => {
659
659
  ? new CliError(told.code, told.message, { ...told.details, settlement_failed: true })
660
660
  : told;
661
661
  const effects = judgedEffects(path ? metaOf(commandAt(program, path) ?? program).effects : undefined);
662
- const object = errorObjectOf(failure, {
662
+ const built = errorObjectOf(failure, {
663
663
  ...failureContext(app.errors),
664
664
  targets: judgedTargets(program),
665
665
  ...(effects ? { effects } : {}),
666
666
  });
667
+ const { next: _named, ...said } = built.error;
668
+ const steps = failureSteps(program, built.error);
669
+ const object = { ...built, error: { ...said, ...(steps.length > 0 ? { next: steps } : {}) } };
667
670
  showInputFields(object, (path && commandAt(program, path)) || program);
668
671
  const command = path || app.command;
669
672
  return {
@@ -11,6 +11,11 @@ export declare const count: v.SchemaWithPipe<readonly [v.NumberSchema<(issue: v.
11
11
  export declare const flag: v.BooleanSchema<(issue: v.BaseIssue<unknown>) => string>;
12
12
  /** `record_input`: true, false, or "failed" — the words typed kept only in a run that failed. */
13
13
  export declare const recordInputSetting: v.UnionSchema<[v.BooleanSchema<undefined>, v.LiteralSchema<"failed", undefined>], (issue: v.BaseIssue<unknown>) => string>;
14
+ /**
15
+ * Settings about messages, senders and bots. A CLI that is not a messenger reads none of them, so it neither lists nor
16
+ * takes them; a file that still has one is read as before.
17
+ */
18
+ export declare const MESSENGER_ONLY_SETTINGS: readonly string[];
14
19
  /**
15
20
  * **What one messenger adds to the file**, merged into the one strict schema — a field an adapter
16
21
  * reads but the schema did not know would be refused as unknown. Each entry must be optional.
@@ -82,6 +82,17 @@ const SHARED_PROFILE_ENTRIES = {
82
82
  const BOT_ONLY_ENTRIES = {
83
83
  read_other_bots: v.optional(v.union([flag, v.array(v.string(), plain("has to be a list of profile names"))], plain("has to be true, false or a list of profile names"))),
84
84
  };
85
+ /**
86
+ * Settings about messages, senders and bots. A CLI that is not a messenger reads none of them, so it neither lists nor
87
+ * takes them; a file that still has one is read as before.
88
+ */
89
+ export const MESSENGER_ONLY_SETTINGS = [
90
+ "sender_colors",
91
+ "sends_per_hour",
92
+ "transcribe_with",
93
+ "search_catch_up",
94
+ "read_other_bots",
95
+ ];
85
96
  /** One program, one version: whether to look for a newer one is not a per-profile matter. */
86
97
  const SHARED_DEFAULTS_ONLY = {
87
98
  update_check: v.optional(flag),
@@ -151,7 +162,7 @@ export const settingsFor = (app, extension = {}) => {
151
162
  const schema = strictOf(configEntries);
152
163
  const defaultsOnlyKeys = Object.keys(defaultsOnly);
153
164
  const botOnlyKeys = Object.keys(BOT_ONLY_ENTRIES);
154
- const allSettings = [...Object.keys(profileEntries), ...botOnlyKeys, ...defaultsOnlyKeys];
165
+ const allSettings = [...Object.keys(profileEntries), ...botOnlyKeys, ...defaultsOnlyKeys].filter((setting) => app.messenger !== false || !MESSENGER_ONLY_SETTINGS.includes(setting));
155
166
  const PROFILE = envName(app, "PROFILE");
156
167
  const PROFILE_LOCK = envName(app, "PROFILE_LOCK");
157
168
  const TIMEOUT = envName(app, "TIMEOUT");
@@ -14,7 +14,7 @@ export const skillCommand = (app, skill) => {
14
14
  // cli-base prints each answer bare; the envelope is cli-core's.
15
15
  const renderer = { ...output.renderer, result: (value) => printItem(output, value) };
16
16
  return { ...output, renderer, ...(env ? { env } : {}) };
17
- }, SHARED_SKILLS);
17
+ }, app.messenger === false ? {} : SHARED_SKILLS);
18
18
  // An installed skill is instructions every later agent follows.
19
19
  const install = command.commands.find((sub) => sub.name() === "install");
20
20
  if (install) {
@@ -23,10 +23,18 @@ export const skillCommand = (app, skill) => {
23
23
  }
24
24
  const show = command.commands.find((sub) => sub.name() === "show");
25
25
  if (show)
26
- annotate(show, { examples: [`${app.command} skill show`, `${app.command} skill show follow-ups`] });
26
+ annotate(show, {
27
+ examples: [
28
+ `${app.command} skill show`,
29
+ ...(app.messenger === false ? [] : [`${app.command} skill show follow-ups`]),
30
+ ],
31
+ });
27
32
  return command;
28
33
  };
29
- /** Skills this package ships for every CLI on it, printed by `skill show <name>`: `skills/` beside `dist/` and `src/`. */
34
+ /**
35
+ * Skills this package ships for the messenger CLIs, printed by `skill show <name>`: `skills/` beside `dist/` and `src/`.
36
+ * Both work on chats, so a CLI without them offers neither.
37
+ */
30
38
  const SHARED_SKILLS = {
31
39
  "follow-ups": new URL("../../skills/follow-ups/SKILL.md", import.meta.url),
32
40
  "link-conversations": new URL("../../skills/link-conversations/SKILL.md", import.meta.url),
@@ -35,6 +35,15 @@ type Targets = NonNullable<StepInput["targets"]>;
35
35
  * remotely.
36
36
  */
37
37
  export declare const runnable: (root: Command, steps: readonly NextStep[] | undefined) => NextStep[];
38
+ /**
39
+ * The step a failure names, where its reason has one and this CLI has the command: a model that is not downloaded
40
+ * names its download only in a CLI with `models`. A step the error already carries is kept, also only if runnable.
41
+ */
42
+ export declare const failureSteps: (root: Command, error: {
43
+ reason?: string;
44
+ details?: Record<string, unknown>;
45
+ next?: readonly NextStep[];
46
+ }) => NextStep[];
38
47
  /** Each command step judged by its command's effects: safe to run unasked only when it writes nothing remotely. */
39
48
  export declare const withSafety: (steps: readonly NextStep[], targets: Targets) => NextStep[];
40
49
  /** Each command step with `line`, the whole command to type — only the CLI knows the profile, so MCP gets none. */
package/dist/cli/steps.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { STEPS as BASE_STEPS, defineSteps, MAX_NEXT_STEPS, stepOf, writes, } from "@wirecat/cli-base/standards";
2
+ import { IMAGE_MODELS } from "../images/picture/models.js";
2
3
  import { typedCommand } from "../runs/scope.js";
3
4
  import { judgedTargets } from "./effects.js";
4
5
  /**
@@ -245,6 +246,21 @@ export const commandAt = (root, path) => {
245
246
  export const runnable = (root, steps) => withSafety((steps ?? [])
246
247
  .filter((step) => step.kind === "user_action" || commandAt(root, step.command) !== undefined)
247
248
  .slice(0, MAX_NEXT_STEPS), judgedTargets(root));
249
+ /**
250
+ * The step a failure names, where its reason has one and this CLI has the command: a model that is not downloaded
251
+ * names its download only in a CLI with `models`. A step the error already carries is kept, also only if runnable.
252
+ */
253
+ export const failureSteps = (root, error) => {
254
+ const model = error.details?.model;
255
+ const own = error.reason === "model.not-downloaded" && typeof model === "string"
256
+ ? [
257
+ nextStep(IMAGE_MODELS.some((one) => one.id === model) ? "models.download-image" : "models.download", {
258
+ args: { model },
259
+ }),
260
+ ]
261
+ : [];
262
+ return runnable(root, [...(error.next ?? []), ...own]);
263
+ };
248
264
  /** Each command step judged by its command's effects: safe to run unasked only when it writes nothing remotely. */
249
265
  export const withSafety = (steps, targets) => steps.map((step) => {
250
266
  if (step.kind === "user_action")
@@ -1,6 +1,12 @@
1
1
  import type { AppIdentity } from "../ports/app.js";
2
2
  /** Settings of the store file, not of a profile: the store is one file for every profile, tg and MAX. */
3
3
  export declare const STORE_SETTINGS: string[];
4
+ /**
5
+ * The store settings a CLI offers. Phone countries, task rules and account groups belong to messages; a CLI that is
6
+ * not a messenger would change them for tg and MAX without reading them itself.
7
+ */
8
+ export declare const storeSettingsOf: (app: Pick<AppIdentity, "messenger">) => string[];
9
+ export declare const offersStoreSetting: (app: Pick<AppIdentity, "messenger">, setting: string) => boolean;
4
10
  export declare const isStoreSetting: (setting: string) => boolean;
5
11
  export declare const changeStoreSetting: (app: AppIdentity, env: NodeJS.ProcessEnv, setting: string, value: string | undefined) => Promise<{
6
12
  result: {
@@ -29,6 +29,12 @@ export const STORE_SETTINGS = [
29
29
  FIRST_PASS,
30
30
  ];
31
31
  const GROUP_PREFIX = `${ACCOUNT_GROUPS_SETTING}.`;
32
+ /**
33
+ * The store settings a CLI offers. Phone countries, task rules and account groups belong to messages; a CLI that is
34
+ * not a messenger would change them for tg and MAX without reading them itself.
35
+ */
36
+ export const storeSettingsOf = (app) => app.messenger === false ? Object.keys(SCRIPTS) : [...STORE_SETTINGS, `${GROUP_PREFIX}<name>`];
37
+ export const offersStoreSetting = (app, setting) => app.messenger !== false || setting in SCRIPTS;
32
38
  export const isStoreSetting = (setting) => setting in SCRIPTS ||
33
39
  setting === PHONE_COUNTRIES ||
34
40
  setting === TASK_CATEGORIES ||
@@ -70,6 +70,8 @@ export interface CatalogueOptions {
70
70
  permissions?: CommandPermissions | undefined;
71
71
  /** Whether a command changes what a later run may do (`grantsAuthority`): execution requires `mcp.authority`. */
72
72
  authority?: (path: readonly string[]) => boolean;
73
+ /** Whether a command only fetches into this machine (`fetchesOnlyAt`): offered as a read. Unset, every write stays one. */
74
+ fetchOnly?: (path: readonly string[]) => boolean;
73
75
  /** Arguments left out of every tool, by name. */
74
76
  omit?: readonly string[];
75
77
  /** Offer `offline` on commands that read the messenger: answer from the copy on this machine. */
package/dist/mcp/agent.js CHANGED
@@ -154,7 +154,7 @@ export const toolsFromCommands = (commands, options = {}) => {
154
154
  path: info.path,
155
155
  title: info.summary ?? info.description,
156
156
  description: info.description,
157
- writes: writes(info.effects),
157
+ writes: writes(info.effects) && options.fetchOnly?.(info.path) !== true,
158
158
  input: schemaOf(fieldsOf(info, options)),
159
159
  ...(info.output ? { output: info.output } : {}),
160
160
  }));
@@ -16,6 +16,8 @@ export interface BotServerOptions {
16
16
  keyOf?: (path: readonly string[]) => PermissionKey | null | undefined;
17
17
  /** Whether a command changes what a later run may do; such a command requires `mcp.authority` to execute. */
18
18
  authority?: (path: readonly string[]) => boolean;
19
+ /** Whether a command only fetches into this machine (`fetchesOnlyAt`): offered through the read tool. */
20
+ fetchOnly?: (path: readonly string[]) => boolean;
19
21
  env: NodeJS.ProcessEnv;
20
22
  skill?: URL;
21
23
  }
@@ -33,6 +33,7 @@ export const createBotServer = (options) => {
33
33
  readOnly: readOnlyLockOf(bot.app, env) !== undefined,
34
34
  ...(options.keyOf ? { keyOf: options.keyOf } : {}),
35
35
  authority,
36
+ ...(options.fetchOnly ? { fetchOnly: options.fetchOnly } : {}),
36
37
  offline: true,
37
38
  // Another bot's copy is this bot's to read only where `read_other_bots` says so.
38
39
  ...(settings.readOtherBots ? {} : { omit: ["all_bots", "bots"] }),
@@ -8,7 +8,7 @@ import * as v from "valibot";
8
8
  import { findCommand } from "../cli/command-contract.js";
9
9
  import { environmentOf } from "../cli/context.js";
10
10
  import { refuseWriteBeforeConnecting } from "../cli/early-refusal.js";
11
- import { changesAuthorityAt, judgedTargets } from "../cli/effects.js";
11
+ import { changesAuthorityAt, fetchesOnlyAt, judgedTargets } from "../cli/effects.js";
12
12
  import { itemOf } from "../cli/envelope.js";
13
13
  import { DEFAULT_OUTPUT_BYTES } from "../cli/execution.js";
14
14
  import { recalledAccount } from "../cli/messenger/accounts.js";
@@ -127,6 +127,7 @@ export const createServer = (command, context, messenger, sessionOptions) => {
127
127
  readOnly: readOnlyLockOf(app, context.env) !== undefined,
128
128
  keyOf,
129
129
  authority,
130
+ fetchOnly: fetchesOnlyAt(root),
130
131
  offline: true,
131
132
  stdin,
132
133
  }).map((one) => "sync_first" in one.input.entries && !syncAllowed(one.path)
@@ -19,10 +19,8 @@ const PER_SESSION = 8;
19
19
  const PER_REQUEST = 256;
20
20
  /** e5's tokenizer reads ~3.7 characters a token in Russian; 3 keeps the bound above the real count. */
21
21
  const CHARS_PER_TOKEN_AT_LEAST = 3;
22
- const notDownloaded = (deps, id) => new CliError("not_found", `${id} is not downloaded — \`${typedCommand(deps.messenger.app.command, `models download ${id}`)}\``, {
23
- reason: "model.not-downloaded",
24
- model: id,
25
- });
22
+ /** The download is the error's `next` step, named only by a CLI that has `models` (`failureSteps`). */
23
+ const notDownloaded = (id) => new CliError("not_found", `${id} is not downloaded on this machine`, { reason: "model.not-downloaded", model: id });
26
24
  export const notBuiltError = (deps, chatId) => new CliError("not_found", `chat ${chatId} has no conversations yet — \`${typedCommand(deps.messenger.app.command, `conversations links rebuild --chat ${chatId}`)}\``);
27
25
  export const embeddingTarget = (deps, choice = DEFAULT_TEXT_MODEL) => {
28
26
  if (typeof choice !== "string") {
@@ -50,7 +48,7 @@ export const embeddingTarget = (deps, choice = DEFAULT_TEXT_MODEL) => {
50
48
  perBatch: PER_SESSION,
51
49
  open: async ({ threads, apart = false, } = {}) => {
52
50
  if (!isTextModelInstalled(model, directory))
53
- throw notDownloaded(deps, model.id);
51
+ throw notDownloaded(model.id);
54
52
  if (apart) {
55
53
  const { openProcess } = await import("../embeddings/process.js");
56
54
  return openProcess(model, directory, { threads: threads ?? defaultThreads() });
@@ -264,7 +262,7 @@ export const embeddingsMaintenance = (deps) => {
264
262
  const account = await deps.account();
265
263
  const target = embeddingTarget(deps, choice);
266
264
  if (embed && !build && !target.installed)
267
- throw notDownloaded(deps, target.id);
265
+ throw notDownloaded(target.id);
268
266
  const chatId = chat === undefined ? undefined : await storedChatId(deps.messenger, chat, store, account, { includeLeft: true });
269
267
  const scan = async () => ({
270
268
  chats: (await readinessOf(deps, chatId, target.key)).sort(byOldestBuild),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.467.0",
3
+ "version": "0.469.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
@@ -233,7 +233,7 @@
233
233
  "@types/node": "^22.20.5",
234
234
  "@types/pngjs": "6.0.5",
235
235
  "@vitest/coverage-v8": "^5.0.5",
236
- "@wirecat/cli-base": "0.64.0",
236
+ "@wirecat/cli-base": "0.65.0",
237
237
  "@wirecat/cli-core-legacy-tags": "npm:@wirecat/cli-core@0.414.0",
238
238
  "commander": "15.0.0",
239
239
  "cspell": "10.4.0",
@@ -253,7 +253,7 @@
253
253
  },
254
254
  "peerDependencies": {
255
255
  "@napi-rs/canvas": ">=0.1.69 <2",
256
- "@wirecat/cli-base": ">=0.64.0 <0.65.0",
256
+ "@wirecat/cli-base": ">=0.65.0 <0.66.0",
257
257
  "commander": ">=15"
258
258
  },
259
259
  "bin": {