@wirecat/cli-core 0.543.0 → 0.544.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,30 @@ 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.544.0 — 02.11.2026
11
+
12
+ ### Added
13
+
14
+ - **The first run of a new version refreshes the agent instructions an older version installed**: each `SKILL.md`
15
+ copy whose `skill status` is `stale` (Claude Code's, `~/.agents/skills`, and Hermes's own copy) is rewritten once,
16
+ with one line on stderr — `Updated the tg instructions for Claude Code and other agents to 0.112.0`. A missing copy
17
+ stays missing, an `unversioned` one (no released `skill install` wrote it) and a symlink are left as they are, and a
18
+ failure is one note, never a failed command. The version tried is kept in `<state>/skill-refresh.json`. Off in a
19
+ moved run, under vitest, during `skill`, and with `<PREFIX>_NO_SKILL_REFRESH=1`; `--quiet` keeps the line out.
20
+
21
+ ### Changed — may break callers
22
+
23
+ - **An agent's own shell may tighten a permission but never loosen it.** `config set|unset permissions…` run where
24
+ `AI_AGENT`, `CLAUDECODE`, `OPENCLAW_SHELL=exec` or another agent marker is set is now treated like an MCP agent's
25
+ call: `mcp.authority` and `bot.api` stay owner-only, and a level may only go down (`readonly` → `deny`, never
26
+ `readonly` → `allow`). Unsetting a permission, replacing `permissions` whole, or `--defaults`/`--personal`/`--bot`
27
+ accept only `deny`. Before, Hermes or OpenClaw could undo the read-only the desktop app set, from any chat message
28
+ that talked it into running `config set`. The owner changes them in a terminal outside the agent; the desktop app
29
+ already runs the tools without these variables. Not a sandbox: an agent with a general shell can still edit the
30
+ file itself, so keep its own command approvals on.
31
+
8
32
  ## 0.543.0 — 02.11.2026
9
33
 
10
34
  ### Added
@@ -2,7 +2,8 @@ import { CliError, pathsAreOverridden } from "@wirecat/cli-base";
2
2
  import { annotate } from "@wirecat/cli-base/commands";
3
3
  import { Command, Option } from "commander";
4
4
  import { envName } from "../ports/app.js";
5
- import { AUTHORITY_KEY, BOT_API_KEY } from "../sends/permissions.js";
5
+ import { agentOf } from "../runs/run-context.js";
6
+ import { AUTHORITY_KEY, BOT_API_KEY, LEVELS, levelFor } from "../sends/permissions.js";
6
7
  import { baseContext, environmentOf } from "./context.js";
7
8
  import { grantsAuthority, LOCAL_READ, LOCAL_WRITE } from "./effects.js";
8
9
  import { itemOf, renderAnswer } from "./envelope.js";
@@ -115,8 +116,13 @@ export const configCommand = (app, config, { permissionKey = config.permissions,
115
116
  // The defaults are every other profile's settings too, and the top of the file is everyone's.
116
117
  throw new CliError("permission_error", `this process is locked to profile ${settings.profile} (${lock}) — ${root ? setting : "--defaults"} changes other profiles`);
117
118
  }
118
- if (environmentOf(this).agent)
119
+ // An agent's own shell counts too: Hermes and OpenClaw run the tool there for anyone their chat accepts (SEC-116).
120
+ if (environmentOf(this).agent || agentOf(env)) {
119
121
  refuseGuardedKey(setting, action === "set" ? String(given) : undefined, ownerOnly);
122
+ refuseLoosening(settings.permissions, setting, action === "set" ? String(given) : undefined, {
123
+ wide: Boolean(everyone || root || personal || bot),
124
+ });
125
+ }
120
126
  if (action === "set")
121
127
  refuseUnknownKey(this, setting, String(given), permissionKey);
122
128
  const saved = config.changeSetting(settings.configPath, {
@@ -185,6 +191,30 @@ const refuseGuardedKey = (setting, value, ownerOnly) => {
185
191
  return;
186
192
  throw new CliError("permission_error", `an agent cannot change permissions.${guarded} — the owner sets it in a terminal`, { permission: guarded });
187
193
  };
194
+ /**
195
+ * An agent may tighten a permission, never loosen one: a message in a chat the agent reads must not be able to talk it
196
+ * into lifting the owner's `readonly`. Removing a key, replacing `permissions` whole, or changing what several profiles
197
+ * get can loosen in ways this profile's levels cannot show, so there an agent may only set `deny`.
198
+ */
199
+ const refuseLoosening = (levels, setting, value, { wide }) => {
200
+ if (setting !== "permissions" && !setting.startsWith("permissions."))
201
+ return;
202
+ const key = setting.slice("permissions.".length);
203
+ const refuse = (why) => {
204
+ throw new CliError("permission_error", `an agent cannot loosen permissions: ${why} — the owner does it in a terminal`, {
205
+ permission: key || "permissions",
206
+ });
207
+ };
208
+ if (value === "deny" && setting !== "permissions")
209
+ return;
210
+ if (value === undefined)
211
+ refuse(`unsetting ${setting} can lift a limit`);
212
+ if (setting === "permissions" || wide)
213
+ refuse(`only deny may be set for ${setting} with --defaults, --personal, --bot or the whole object`);
214
+ const now = levelFor(levels, key).level;
215
+ if (LEVELS.indexOf(value) > LEVELS.indexOf(now))
216
+ refuse(`${setting} is ${now}, ${value} is looser`);
217
+ };
188
218
  /** The keys of a `permissions` object given whole; anything else is left to the schema to refuse. */
189
219
  const objectKeys = (value) => {
190
220
  try {
@@ -31,6 +31,8 @@ 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 { skillSourceOf } from "./skill-command.js";
35
+ import { refreshSkills, skipsSkillRefresh } from "./skill-refresh.js";
34
36
  import { commandAt, failureSteps, lineOf, withLines } from "./steps.js";
35
37
  // An MCP server builds the tree on every tool call; the contracts of one definition's tree do not change between builds.
36
38
  const contractsChecked = new WeakSet();
@@ -470,6 +472,9 @@ const runOnce = async (argv, definition, options) => {
470
472
  try {
471
473
  await control.race(() => program.parseAsync(rest, { from: "user" }));
472
474
  const code = exit.code ?? (process.exitCode === undefined ? 0 : Number(process.exitCode));
475
+ const refreshed = code === 0 ? refreshFor(definition, program, rest, options) : undefined;
476
+ if (refreshed && !rest.includes("--quiet"))
477
+ streams.diagnostic(refreshed);
473
478
  const hint = code === 0 && rest[0] !== "commands" && rest[0] !== "help" && !rest.includes("--quiet")
474
479
  ? hintFor(definition, rest, options)
475
480
  : undefined;
@@ -555,6 +560,23 @@ const failingNoticeFor = (app, env, program) => {
555
560
  return undefined;
556
561
  }
557
562
  };
563
+ const NO_REFRESH_COMMANDS = ["skill", "complete", "commands", "help"];
564
+ /** Not while `skill` runs: right after an update, `skill status` must report the copies as they are. */
565
+ const refreshFor = ({ app }, program, argv, options) => {
566
+ const env = options.env ?? process.env;
567
+ try {
568
+ if (NO_REFRESH_COMMANDS.includes(argv[0] ?? "") || skipsSkillRefresh(app, env))
569
+ return undefined;
570
+ const skill = skillSourceOf(program, app.appName);
571
+ if (!skill)
572
+ return undefined;
573
+ const state = resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state;
574
+ return refreshSkills({ app, skill, env, markerPath: join(state, "skill-refresh.json") });
575
+ }
576
+ catch {
577
+ return undefined;
578
+ }
579
+ };
558
580
  /** The update notice's state file, so both daily lines share one file per CLI. */
559
581
  const hintFor = ({ app, configuration }, argv, options) => {
560
582
  const env = options.env ?? process.env;
@@ -1,5 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import type { AppIdentity } from "../ports/app.js";
3
+ /** The SKILL.md a program's `skill` command installs, so a run can refresh old copies of it. */
4
+ export declare const skillSourceOf: (program: Command, appName: string) => URL | undefined;
3
5
  /**
4
6
  * `skill show` and `skill install`. `skill` is the CLI's own SKILL.md, shipped beside `dist/` in the
5
7
  * package and in a checkout alike, so the skill printed is always this version's.
@@ -3,6 +3,14 @@ import { skillCommand as sharedSkillCommand } from "@wirecat/cli-base/skill";
3
3
  import { environmentOf, outputFor } from "./context.js";
4
4
  import { grantsAuthority } from "./effects.js";
5
5
  import { printItem } from "./envelope.js";
6
+ const skillSources = new WeakMap();
7
+ // zm and memo mount their own `skill` command in place of this one (`customizeSharedCommand`), from the same file.
8
+ const skillSourcesByApp = new Map();
9
+ /** The SKILL.md a program's `skill` command installs, so a run can refresh old copies of it. */
10
+ export const skillSourceOf = (program, appName) => {
11
+ const command = program.commands.find((sub) => sub.name() === "skill");
12
+ return command ? (skillSources.get(command) ?? skillSourcesByApp.get(appName)) : undefined;
13
+ };
6
14
  /**
7
15
  * `skill show` and `skill install`. `skill` is the CLI's own SKILL.md, shipped beside `dist/` in the
8
16
  * package and in a checkout alike, so the skill printed is always this version's.
@@ -15,6 +23,8 @@ export const skillCommand = (app, skill) => {
15
23
  const renderer = { ...output.renderer, result: (value) => printItem(output, value) };
16
24
  return { ...output, renderer, ...(env ? { env } : {}) };
17
25
  }, app.messenger === false ? {} : SHARED_SKILLS);
26
+ skillSources.set(command, skill);
27
+ skillSourcesByApp.set(app.appName, skill);
18
28
  // An installed skill is instructions every later agent follows.
19
29
  const install = command.commands.find((sub) => sub.name() === "install");
20
30
  if (install) {
@@ -0,0 +1,18 @@
1
+ import { type SkillApp } from "@wirecat/cli-base/skill";
2
+ import { type AppIdentity } from "../ports/app.js";
3
+ type Env = Readonly<Record<string, string | undefined>>;
4
+ /** Not where the owner's own agents look: a test, a moved HOME or state folder, or the owner said no. */
5
+ export declare const skipsSkillRefresh: (app: AppIdentity, env: NodeJS.ProcessEnv) => boolean;
6
+ export interface SkillRefreshRequest {
7
+ app: SkillApp;
8
+ skill: URL;
9
+ env: Env;
10
+ /** `<state>/skill-refresh.json`: the version this was last tried for, so each version tries once. */
11
+ markerPath: string;
12
+ }
13
+ /**
14
+ * On the first run of a new version, rewrites the installed SKILL.md copies that an older version wrote, and answers
15
+ * the line for stderr — or `undefined` when there was nothing to do. A failure is a one-line note, never an error.
16
+ */
17
+ export declare const refreshSkills: ({ app, skill, env, markerPath }: SkillRefreshRequest) => string | undefined;
18
+ export {};
@@ -0,0 +1,82 @@
1
+ import { lstatSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { isMovedRun, loadConfigFile, saveConfigFile } from "@wirecat/cli-base";
3
+ import { hermesCopyPath, installSkill, skillStatus, skillVersion, withVersion, } from "@wirecat/cli-base/skill";
4
+ import { isNewer } from "@wirecat/cli-base/update";
5
+ import * as v from "valibot";
6
+ import { envName } from "../ports/app.js";
7
+ const marker = v.object({ version: v.optional(v.string()) });
8
+ const AGENT_NAMES = {
9
+ claude: "Claude Code",
10
+ agents: "other agents",
11
+ hermes: "Hermes",
12
+ };
13
+ /** Not where the owner's own agents look: a test, a moved HOME or state folder, or the owner said no. */
14
+ export const skipsSkillRefresh = (app, env) => env.VITEST !== undefined ||
15
+ Boolean(env[envName(app, "NO_SKILL_REFRESH")]) ||
16
+ isMovedRun({ appName: app.appName, prefix: app.envPrefix, env });
17
+ // A symlink points into a checkout or a dotfiles repo; writing through it changes a file the tool never wrote.
18
+ const isPlainFile = (path) => {
19
+ try {
20
+ return lstatSync(path).isFile();
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ };
26
+ /**
27
+ * Only `stale` copies: one with a version stamp that `skill install` wrote for an older release. `unversioned` means
28
+ * no released `skill install` wrote it (each stamps `metadata.version`), so it is somebody's own file and stays.
29
+ */
30
+ const staleTargets = (app, env) => skillStatus(app, env)
31
+ .filter(({ state, path }) => state === "stale" && isPlainFile(path))
32
+ .map(({ target }) => target);
33
+ /** Hermes's own copy, read from disk: asking Hermes would start a program and could take seconds. */
34
+ const refreshHermesCopy = (app, skill, env) => {
35
+ const path = hermesCopyPath(app.appName, env);
36
+ if (!isPlainFile(path))
37
+ return false;
38
+ const installed = skillVersion(readFileSync(path, "utf8"));
39
+ if (installed === undefined || !isNewer(app.version, installed))
40
+ return false;
41
+ writeFileSync(path, withVersion(readFileSync(skill, "utf8"), app.version));
42
+ return true;
43
+ };
44
+ /**
45
+ * On the first run of a new version, rewrites the installed SKILL.md copies that an older version wrote, and answers
46
+ * the line for stderr — or `undefined` when there was nothing to do. A failure is a one-line note, never an error.
47
+ */
48
+ export const refreshSkills = ({ app, skill, env, markerPath }) => {
49
+ try {
50
+ const seen = loadConfigFile(markerPath, marker, () => ({})).version;
51
+ if (seen === app.version)
52
+ return undefined;
53
+ }
54
+ catch {
55
+ // An unreadable marker counts as a new version; the write below replaces it.
56
+ }
57
+ const save = () => {
58
+ try {
59
+ saveConfigFile(markerPath, { version: app.version });
60
+ }
61
+ catch {
62
+ // Unsaved, the next run tries again; that costs two file reads.
63
+ }
64
+ };
65
+ try {
66
+ const targets = staleTargets(app, env);
67
+ if (targets.length > 0)
68
+ installSkill(app, skill, { targets, env });
69
+ const hermes = refreshHermesCopy(app, skill, env);
70
+ save();
71
+ const names = [...targets, ...(hermes ? ["hermes"] : [])].map((target) => AGENT_NAMES[target]);
72
+ if (names.length === 0)
73
+ return undefined;
74
+ return `Updated the ${app.command} instructions for ${listed(names)} to ${app.version}`;
75
+ }
76
+ catch (error) {
77
+ save();
78
+ const reason = error instanceof Error ? error.message.split("\n")[0] : String(error);
79
+ return `Could not update the ${app.command} instructions for agents (${reason}); \`${app.command} skill install\` does it`;
80
+ }
81
+ };
82
+ const listed = (names) => names.length < 2 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.543.0",
3
+ "version": "0.544.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",