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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
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).

## Unreleased

### Added

- **A command says how long it runs**: `CommandMeta.lifetime` (`persistent` until stopped, `interactive` until a person
answers; unset is bounded by the default timeout) and `cooperative` (stops at its own pace when aborted from outside),
set with `annotate` and printed in `commands --json`. A runner reads them with `metaOf` instead of matching command
names.

## 0.37.0 — 16.10.2026

### Added
Expand Down
5 changes: 5 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ describeProgram(program) // [{ path: ["send"], usage, origin, effects, options:
(`error.retry.safe`), and a next step that runs it is never `safe_to_auto_execute`. `stepTargets(program)` gives
`stepOf` and `errorObjectOf` each command's effects and the names it takes, so a step that would not bind is dropped.

`lifetime` says how long a command runs: `persistent` until it is stopped (a server, a watch), `interactive` until a
person answers (a login); unset, the runner bounds it with its default timeout. `cooperative` marks a long command that
stops at its own pace when the run is aborted from outside, keeping what it already did. A runner reads both with
`metaOf(command)` rather than matching command names.

Each option says whether it `takes_value` and, separately, whether it is `mandatory` — not
Commander's `required`, which means "takes a value when given" — plus its choices, default,
environment variable, the options it `conflicts` with and the values it `implies`, keyed by long flag.
Expand Down
16 changes: 16 additions & 0 deletions src/commands/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,22 @@ describe("commands document", () => {
expect(parseCommandsDocument(JSON.parse(JSON.stringify(document)))).toEqual({ document })
})

it("says how long a command runs, and reads it back", () => {
const root = new Command("tool")
annotate(root.command("watch").description("until stopped"), { lifetime: "persistent" })
annotate(root.command("login").description("asks a person"), { lifetime: "interactive" })
annotate(root.command("sync").description("a long fetch"), { cooperative: true })
root.command("show").description("bounded")
const document = commandsDocument(root, about)
const byName = (name: string) => document.commands.find((command) => command.name === name)

expect(byName("watch")?.lifetime).toBe("persistent")
expect(byName("login")?.lifetime).toBe("interactive")
expect(byName("sync")?.cooperative).toBe(true)
expect(byName("show")).not.toHaveProperty("lifetime")
expect(parseCommandsDocument(JSON.parse(JSON.stringify(document)))).toEqual({ document })
})

it("carries a list's output entity and its query fields, without the fields' own checks", () => {
const root = new Command("tool")
annotate(root.command("tasks").command("list").argument("[query...]", "field:value terms"), {
Expand Down
2 changes: 2 additions & 0 deletions src/commands/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const commandInfoSchema: v.GenericSchema<CommandInfo> = v.object({
effects: v.optional(effectsSchema),
state: v.optional(v.picklist(["beta", "deprecated"])),
partial: v.optional(v.boolean()),
lifetime: v.optional(v.picklist(["persistent", "interactive"])),
cooperative: v.optional(v.boolean()),
examples: v.optional(v.array(v.string())),
output: v.optional(v.object({ entity: v.string(), view: v.optional(v.string()) })),
query: v.optional(
Expand Down
10 changes: 10 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import type { StepTarget } from "../standards/steps.js"

export type Origin = "handwritten" | "generated"
export type CommandState = "beta" | "deprecated"
/**
* How long a command runs: `persistent` until it is stopped (a server, a watch), `interactive` until a person answers
* (a login). Unset, it is bounded by the default timeout.
*/
export type CommandLifetime = "persistent" | "interactive"

export interface CommandMeta {
origin?: Origin
Expand All @@ -28,6 +33,9 @@ export interface CommandMeta {
state?: CommandState
/** The command may answer with `meta.complete: false` and still exit 0; without it a partial answer fails. */
partial?: boolean
lifetime?: CommandLifetime
/** Stops at its own pace when the run is aborted from outside, keeping what it already did — a long fetch. */
cooperative?: boolean
examples?: readonly string[]
/** The entity the command answers with, or one of its views. */
output?: CommandOutput
Expand Down Expand Up @@ -80,6 +88,8 @@ export interface CommandInfo {
effects?: Effects
state?: CommandState
partial?: boolean
lifetime?: CommandLifetime
cooperative?: boolean
examples?: readonly string[]
output?: CommandOutput
query?: QueryInfo
Expand Down
Loading