Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
31 changes: 31 additions & 0 deletions src/commands/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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>` | order. One of: `new`, `old`. |")
const piped = commandsPage({
Expand Down
5 changes: 4 additions & 1 deletion src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
Expand Down Expand Up @@ -62,6 +64,7 @@ export interface CommandInfo {
origin: Origin
operationId?: string
mutates?: boolean
local?: boolean
state?: CommandState
examples?: readonly string[]
arguments: readonly ArgumentInfo[]
Expand Down
5 changes: 4 additions & 1 deletion src/commands/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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))
Expand Down
Loading