From 1f0fe391aef31c138881a4609a97ed230c3db4be Mon Sep 17 00:00:00 2001 From: leemour Date: Fri, 16 Oct 2026 00:47:59 +0200 Subject: [PATCH] feat(commands): a command says how long it runs CommandMeta gains `lifetime` (persistent until stopped, interactive until a person answers; unset is bounded) and `cooperative` (stops at its own pace when aborted from outside), set with annotate and printed in commands --json. cli-core's run() decides these today by matching command names (watch/serve/mcp, setup, session start, messages sync); with the fields it can read metaOf(action) instead. Re-review step 6, PR 1. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 9 +++++++++ docs/REFERENCE.md | 5 +++++ src/commands/commands.test.ts | 16 ++++++++++++++++ src/commands/document.ts | 2 ++ src/commands/index.ts | 10 ++++++++++ 5 files changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5005c23..9ecc4d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 5f77637..f3ff4be 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -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. diff --git a/src/commands/commands.test.ts b/src/commands/commands.test.ts index 8ad950a..4c27f97 100644 --- a/src/commands/commands.test.ts +++ b/src/commands/commands.test.ts @@ -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"), { diff --git a/src/commands/document.ts b/src/commands/document.ts index 1db2fdc..e76f58f 100644 --- a/src/commands/document.ts +++ b/src/commands/document.ts @@ -49,6 +49,8 @@ const commandInfoSchema: v.GenericSchema = 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( diff --git a/src/commands/index.ts b/src/commands/index.ts index 89f4d26..9ee252f 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -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 @@ -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 @@ -80,6 +88,8 @@ export interface CommandInfo { effects?: Effects state?: CommandState partial?: boolean + lifetime?: CommandLifetime + cooperative?: boolean examples?: readonly string[] output?: CommandOutput query?: QueryInfo