From 80bc35617e9abd0b5005f929c0a001e1f661a65e Mon Sep 17 00:00:00 2001 From: leemour Date: Thu, 1 Oct 2026 22:57:58 +0200 Subject: [PATCH] feat(commands): annotate a write as local; the page gives it its own line (0.15.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One marker meant two things: `mutates` was set both on commands that change the messenger and on ones that change only local files (config set, group rules, recipient lists, the bot token). The commands page then said "Changes something in Telegram" under `config set`. `local: true` beside `mutates: true` says the write stays on this machine. `mutates` keeps meaning "a write", so `commands --json` still shows `writes: yes`. The page shows `text.mutatesLocal` for such a command, or no line when the caller gives none — never the messenger label. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 11 +++++++++++ package.json | 2 +- src/commands/commands.test.ts | 31 +++++++++++++++++++++++++++++++ src/commands/index.ts | 5 ++++- src/commands/page.ts | 5 ++++- 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fe7dcb..7d5b471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ Versions before 0.8.0 are in the [git tags](https://github.com/leemour/cli-core/ Every entry says what changed as a caller sees it, why, and what to watch for — the rules are [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog). +## 0.15.0 — 01.10.2026 + +### Added + +- **`annotate(command, { mutates: true, local: true })`** marks a write that changes only this + machine — a file, the keyring — and never the service. `CommandInfo.local` carries it, and + `mutates` stays true, so a caller that tells reads from writes still sees a write. +- **`commandsPage` text takes `mutatesLocal`**, the line shown under such a command instead of + `mutates`. Without it a local write gets no line. Before this, `config set` was labelled as + changing Telegram or MAX. + ## 0.14.0 — 01.10.2026 ### Added diff --git a/package.json b/package.json index 43ffe8f..1ede394 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@leemour/cli-core", - "version": "0.14.0", + "version": "0.15.0", "description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks", "license": "MIT", "author": "Viacheslav Ptsarev", diff --git a/src/commands/commands.test.ts b/src/commands/commands.test.ts index 24ad724..d4e0106 100644 --- a/src/commands/commands.test.ts +++ b/src/commands/commands.test.ts @@ -132,6 +132,18 @@ describe("hidden commands", () => { }) }) +const COMMON_TEXT = { + banner: "", + title: "Commands", + intro: "", + globalHeading: "Global options", + globalIntro: "", + mutates: "", + exitHeading: "Exit codes", + exitIntro: "", + outro: "", +} + describe("commandsPage", () => { const page = () => commandsPage({ @@ -166,6 +178,25 @@ describe("commandsPage", () => { expect(text.endsWith("| `1` | anything else |\n")).toBe(true) }) + it("labels a write that changes only this machine with its own line, or none", () => { + const root = new Command("tool") + annotate(root.command("send").description("send"), { mutates: true }) + annotate(root.command("save").description("save"), { mutates: true, local: true }) + const text = (mutatesLocal?: string) => + commandsPage({ + cli: "tool", + commands: describeProgram(root), + options: [], + labels: COMMANDS_PAGE_LABELS.en, + text: { ...COMMON_TEXT, mutates: "**Changes the service.**", ...(mutatesLocal ? { mutatesLocal } : {}) }, + }) + + expect(text("**Changes this machine only.**")).toContain("## `tool send`\n\nsend\n\n**Changes the service.**") + expect(text("**Changes this machine only.**")).toContain("## `tool save`\n\nsave\n\n**Changes this machine only.**") + expect(text()).toContain("## `tool save`\n\nsave\n\n```sh") + expect(describeProgram(root)[1]).toMatchObject({ mutates: true, local: true }) + }) + it("keeps a pipe or a tilde from breaking its row, and lists the allowed values", () => { expect(page()).toContain("| `--sort ` | order. One of: `new`, `old`. |") const piped = commandsPage({ diff --git a/src/commands/index.ts b/src/commands/index.ts index 463fb29..ee99c73 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -18,8 +18,10 @@ export interface CommandMeta { origin?: Origin /** The catalog operation a generated command was made from. */ operationId?: string - /** True when running the command changes something outside this machine. */ + /** True when running the command changes something: a write, wherever it lands. */ mutates?: boolean + /** With `mutates`: what it changes is only on this machine — a file, the keyring — never the service. */ + local?: boolean state?: CommandState examples?: readonly string[] } @@ -62,6 +64,7 @@ export interface CommandInfo { origin: Origin operationId?: string mutates?: boolean + local?: boolean state?: CommandState examples?: readonly string[] arguments: readonly ArgumentInfo[] diff --git a/src/commands/page.ts b/src/commands/page.ts index d7b61e9..ec32203 100644 --- a/src/commands/page.ts +++ b/src/commands/page.ts @@ -61,6 +61,8 @@ export type CommandsPageText = { globalIntro: string /** Shown under a command that changes something outside this machine. */ mutates: string + /** Shown instead under a command marked `local`, which changes only this machine; without it, nothing is. */ + mutatesLocal?: string exitHeading: string exitIntro: string /** Markdown after the exit codes; may be empty. */ @@ -111,7 +113,8 @@ export const commandsPage = ({ cli, commands, options, labels, text }: CommandsP const body = (command: CommandInfo): string[] => { const parts = [cell(command.description), ""] - if (command.mutates) parts.push(text.mutates, "") + const label = command.mutates ? (command.local ? text.mutatesLocal : text.mutates) : undefined + if (label) parts.push(label, "") parts.push("```sh", command.usage, "```") if (command.arguments.length > 0) parts.push("", `| ${labels.argument} | | ${labels.isWhat} |`, "|---|---|---|", argumentRows(command.arguments))