@wirecat/cli-core 0.529.0 → 0.530.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,6 +5,18 @@ 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
9
+
10
+ ## 0.530.0 — 01.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`configCommand(app, config, { ownerOnly })`**: settings and permission keys an agent with `mcp.authority` still
15
+ cannot change over MCP, beside `mcp.*` and `bot.api` — for a mail CLI, the server hosts its password goes to, and
16
+ the key that lets an agent mail other people. A permission key there covers the keys under it.
17
+ - **`safeName` from `./files`**: the file-name cleaner downloads use (no path, no leading dot, no control or direction
18
+ character; Windows' reserved names refused), for a CLI that saves a file someone else named.
19
+
8
20
  ## 0.529.0 — 01.11.2026
9
21
 
10
22
  ### Added
@@ -6,8 +6,18 @@ import { type Configuration } from "./settings.js";
6
6
  * The settings in force and where each came from, and changing them. Printed whole: no field the
7
7
  * configuration accepts can hold a secret — the schema has nowhere to put one.
8
8
  */
9
- export declare const configCommand: (app: AppIdentity, config: Configuration, { permissionKey }?: {
9
+ /**
10
+ * Settings and permission keys an agent with `mcp.authority` still may not change, beyond the ones that let it in:
11
+ * where the password goes (a server host), or a permission the app reads only from the owner's file.
12
+ */
13
+ export interface OwnerOnlySettings {
14
+ settings?: readonly string[];
15
+ /** A key here covers the keys under it too: `messages.send.others` covers `messages.send.others.x`. */
16
+ permissions?: readonly string[];
17
+ }
18
+ export declare const configCommand: (app: AppIdentity, config: Configuration, { permissionKey, ownerOnly, }?: {
10
19
  permissionKey?: PermissionKeySource | undefined;
20
+ ownerOnly?: OwnerOnlySettings;
11
21
  }) => Command;
12
22
  /**
13
23
  * A key no command or write is checked against would be saved and do nothing (BUG-137) — whether it
@@ -10,11 +10,7 @@ import { knownBeside, knownPermissionKeys, unknownPermissionIssues, } from "./pe
10
10
  import { fromFile, NONE_NAMED } from "./settings.js";
11
11
  import { nextStep } from "./steps.js";
12
12
  import { changeStoreSetting, isStoreSetting, offersStoreSetting, storeSettings, storeSettingsOf, } from "./store-settings.js";
13
- /**
14
- * The settings in force and where each came from, and changing them. Printed whole: no field the
15
- * configuration accepts can hold a secret — the schema has nowhere to put one.
16
- */
17
- export const configCommand = (app, config, { permissionKey = config.permissions } = {}) => {
13
+ export const configCommand = (app, config, { permissionKey = config.permissions, ownerOnly = {}, } = {}) => {
18
14
  const command = new Command("config").description("the settings in force, and where each one came from");
19
15
  // Only a messenger has bots, and with them the personal and bot sections of the file. Elsewhere the options stay
20
16
  // accepted, so a script that passes one still runs, but help, `commands --json` and MCP leave them out.
@@ -120,7 +116,7 @@ export const configCommand = (app, config, { permissionKey = config.permissions
120
116
  throw new CliError("permission_error", `this process is locked to profile ${settings.profile} (${lock}) — ${root ? setting : "--defaults"} changes other profiles`);
121
117
  }
122
118
  if (environmentOf(this).agent)
123
- refuseGuardedKey(setting, action === "set" ? String(given) : undefined);
119
+ refuseGuardedKey(setting, action === "set" ? String(given) : undefined, ownerOnly);
124
120
  if (action === "set")
125
121
  refuseUnknownKey(this, setting, String(given), permissionKey);
126
122
  const saved = config.changeSetting(settings.configPath, {
@@ -166,7 +162,11 @@ export const refuseUnknownKey = (command, setting, value, permissionKey) => {
166
162
  * profile, in the defaults, or by replacing `permissions` whole. Only the owner, in a terminal, turns it on or off
167
163
  * (NEED-1662, NEED-1679).
168
164
  */
169
- const refuseGuardedKey = (setting, value) => {
165
+ const refuseGuardedKey = (setting, value, ownerOnly) => {
166
+ if (ownerOnly.settings?.includes(setting))
167
+ throw new CliError("permission_error", `an agent cannot change ${setting} — the owner sets it in a terminal`, {
168
+ setting,
169
+ });
170
170
  const keys = setting.startsWith("permissions.")
171
171
  ? [setting.slice("permissions.".length)]
172
172
  : setting === "permissions"
@@ -174,11 +174,13 @@ const refuseGuardedKey = (setting, value) => {
174
174
  ? ["mcp"]
175
175
  : objectKeys(value)
176
176
  : [];
177
- const guarded = keys.some((key) => key === BOT_API_KEY || key.startsWith(`${BOT_API_KEY}.`))
177
+ const under = (key, guarded) => key === guarded || key.startsWith(`${guarded}.`);
178
+ const listed = keys.flatMap((key) => (ownerOnly.permissions ?? []).filter((guarded) => under(key, guarded)));
179
+ const guarded = keys.some((key) => under(key, BOT_API_KEY))
178
180
  ? BOT_API_KEY
179
- : keys.some((key) => key === "mcp" || key.startsWith("mcp."))
181
+ : keys.some((key) => under(key, "mcp"))
180
182
  ? AUTHORITY_KEY
181
- : undefined;
183
+ : listed[0];
182
184
  if (!guarded)
183
185
  return;
184
186
  throw new CliError("permission_error", `an agent cannot change permissions.${guarded} — the owner sets it in a terminal`, { permission: guarded });
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import type { Id } from "../../domain/models.js";
3
- export { downloadMessage, type Saved, safeName, save } from "../../services/file-download.js";
3
+ export { safeName } from "../../files/safe-name.js";
4
+ export { downloadMessage, type Saved, save } from "../../services/file-download.js";
4
5
  import { type Messenger } from "./context.js";
5
6
  /** `messages download`: every file of one message, or with `--all` of a whole chat, never over a file already there. */
6
7
  export declare const downloadSubcommand: (messages: Command, messenger: Messenger) => Command;
@@ -7,7 +7,8 @@ import { NOT_FILES } from "../../domain/attachments.js";
7
7
  import { FETCHING, keyOf } from "../../services/archive.js";
8
8
  import { batchProgress, mergeBatches } from "../../services/batch.js";
9
9
  import { saveFiles } from "../../services/file-download.js";
10
- export { downloadMessage, safeName, save } from "../../services/file-download.js";
10
+ export { safeName } from "../../files/safe-name.js";
11
+ export { downloadMessage, save } from "../../services/file-download.js";
11
12
  import { parseDuration } from "../../domain/duration.js";
12
13
  import { OFFLINE } from "../../services/deps.js";
13
14
  import { patiently } from "../../services/patience.js";
@@ -528,6 +528,7 @@ export declare const configCommand: (app: import("./entry.js").AppIdentity, conf
528
528
  permissions?: import("./permission-keys.js").CommandPermissions | undefined;
529
529
  }, args_2?: {
530
530
  permissionKey?: import("./permission-keys.js").PermissionKeySource | undefined;
531
+ ownerOnly?: import("./config-command.js").OwnerOnlySettings;
531
532
  } | undefined) => import("commander").Command;
532
533
  export declare const guideCommand: (app: Pick<import("./entry.js").AppIdentity, "command">, options: import("./guide.js").GuideOptions) => import("commander").Command;
533
534
  export declare const addMeetingBatchCommand: (program: import("commander").Command, deps: {
@@ -4,4 +4,5 @@ export { acquireLock, type FileLock } from "./lock.js";
4
4
  export type { NativeWriterLeaseLayout } from "./native-writer-lease.js";
5
5
  export { nativeWriterLeaseStore } from "./native-writer-lease.js";
6
6
  export { openOrdinaryFile, sameOrdinaryFile, syncDirectory } from "./ordinary-file.js";
7
+ export { safeName } from "./safe-name.js";
7
8
  export type { WriterLease, WriterLeaseAcquisition, WriterLeaseInfo, WriterLeaseOptions, WriterLeaseStore, } from "./writer-lease.js";
@@ -3,3 +3,4 @@ export { readBoundedUtf8File } from "./bounded-utf8.js";
3
3
  export { acquireLock } from "./lock.js";
4
4
  export { nativeWriterLeaseStore } from "./native-writer-lease.js";
5
5
  export { openOrdinaryFile, sameOrdinaryFile, syncDirectory } from "./ordinary-file.js";
6
+ export { safeName } from "./safe-name.js";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * A name another person chose cannot climb out of the folder, cannot be a dot file we would then
3
+ * hide, and carries no control or direction character to rewrite the terminal or disguise its extension.
4
+ */
5
+ export declare const safeName: (name: string | undefined, platform?: NodeJS.Platform) => string | undefined;
@@ -0,0 +1,18 @@
1
+ import { basename } from "node:path";
2
+ /**
3
+ * A name another person chose cannot climb out of the folder, cannot be a dot file we would then
4
+ * hide, and carries no control or direction character to rewrite the terminal or disguise its extension.
5
+ */
6
+ export const safeName = (name, platform = process.platform) => {
7
+ let plain = name === undefined
8
+ ? ""
9
+ : basename(name.replaceAll("\\", "/"))
10
+ .replace(/[\p{Cc}​-‏‪-‮⁦-⁩]/gu, "")
11
+ .replace(/^\.+/, "");
12
+ if (platform === "win32") {
13
+ plain = plain.replace(/[<>:"|?*]/g, "_").replace(/[. ]+$/, "");
14
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(plain))
15
+ return undefined;
16
+ }
17
+ return plain === "" ? undefined : plain;
18
+ };
@@ -1,5 +1,6 @@
1
1
  import type { RefreshedFile } from "../domain/attachments.js";
2
2
  import type { Id } from "../domain/models.js";
3
+ import { safeName } from "../files/safe-name.js";
3
4
  import type { RemoteFile } from "../ports/messenger.js";
4
5
  import { batchProgress } from "./batch.js";
5
6
  import type { MessagesService } from "./messages.js";
@@ -39,11 +40,7 @@ export declare const saveFiles: (messages: Pick<MessagesService, "keepDownloaded
39
40
  maxErrorPercent: number;
40
41
  };
41
42
  }>;
42
- /**
43
- * A name another person chose cannot climb out of the folder, cannot be a dot file we would then
44
- * hide, and carries no control or direction character to rewrite the terminal or disguise its extension.
45
- */
46
- export declare const safeName: (name: string | undefined, platform?: NodeJS.Platform) => string | undefined;
43
+ export { safeName };
47
44
  /**
48
45
  * The bytes go to a temporary name first and are then hard-linked to the real one, which fails if the
49
46
  * name is taken — so an existing file survives, and an interrupted download leaves no half file under
@@ -1,9 +1,10 @@
1
1
  import { createWriteStream, mkdirSync } from "node:fs";
2
2
  import { link, rm } from "node:fs/promises";
3
- import { basename, join, resolve } from "node:path";
3
+ import { join, resolve } from "node:path";
4
4
  import { Readable, Transform } from "node:stream";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { CliError } from "@wirecat/cli-base";
7
+ import { safeName } from "../files/safe-name.js";
7
8
  import { batchProgress } from "./batch.js";
8
9
  /** The files the messenger gave a new reference for while their bytes were read, to save without a path. */
9
10
  export const refreshedFiles = (files) => files.flatMap(({ kind, name, position, providerRef }) => providerRef === undefined || Object.keys(providerRef).length === 0
@@ -90,23 +91,7 @@ const EXTENSIONS = {
90
91
  };
91
92
  /** Telegram sends a photo with no name and no type; it is always a JPEG. */
92
93
  const BY_KIND = { photo: "jpg" };
93
- /**
94
- * A name another person chose cannot climb out of the folder, cannot be a dot file we would then
95
- * hide, and carries no control or direction character to rewrite the terminal or disguise its extension.
96
- */
97
- export const safeName = (name, platform = process.platform) => {
98
- let plain = name === undefined
99
- ? ""
100
- : basename(name.replaceAll("\\", "/"))
101
- .replace(/[\p{Cc}​-‏‪-‮⁦-⁩]/gu, "")
102
- .replace(/^\.+/, "");
103
- if (platform === "win32") {
104
- plain = plain.replace(/[<>:"|?*]/g, "_").replace(/[. ]+$/, "");
105
- if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(plain))
106
- return undefined;
107
- }
108
- return plain === "" ? undefined : plain;
109
- };
94
+ export { safeName };
110
95
  /**
111
96
  * The bytes go to a temporary name first and are then hard-linked to the real one, which fails if the
112
97
  * name is taken — so an existing file survives, and an interrupted download leaves no half file under
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.529.0",
3
+ "version": "0.530.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",