@dolusoft/command-protocol
The JSON types of Command Console, its line tokenizer, and the pure functions over command input schemas, option lines and the envelope. No dependencies, no framework, no DOM.
pnpm add @dolusoft/command-protocol
Most consumers use it through @dolusoft/command-console (which re-exports its types) or
@dolusoft/command-console-core. Use it directly when a server or a test builds console output, or to split a line the way the console does.
Types
Every type here is plain JSON: finite numbers only, no undefined (an optional field without a value is absent), and
a value survives JSON.parse(JSON.stringify(x)) unchanged. JsonValue, JsonPrimitive and isJsonValue describe and
check it. Functions (a command's run and complete, callbacks) live in the core, never here.
-
Entries of the scrollback:
ConsoleEntryis anInputEntry(id,promptwhen the line ended,textornullwhen redacted,redacted,status: 'submitted' | 'cancelled', optionalactor:'user','assistant'or'external-agent', a label for the history and never a permission) or anOutputEntry(id,tone: 'default' | 'error' | 'notice',blocks, optionalinputIdof the line it answers, optionalquiet: truefor output a live region should not read out). -
Blocks of an output entry (
OutputBlock), never HTML:Block Fields texttextjsonvalue: JsonValuetablecolumns: { key, label }[],rows: Record<string, JsonPrimitive>[]listitems: string[]messagecode: MessageCode,params?(made by the library)customkind,data?: JsonValue(drawn by the consumer) -
Messages (
MessageCode,MessageParams): the library writes no text, only codes.command-not-found({ name }),command-failed(no params: the error's text is never shown),unterminated-quote({ index }),interrupted,invalid-args({ path, reason }: the first error of a command's options,reasonanInputErrorCode) andoperation-failed({ code }: anErrorCode, for an operation that ended without running its command). For a sensitive linename,indexandpatharenull. -
Commands:
CommandSpecis the JSON part of a command (name,aliases,summary,usage,hidden,sensitive: boolean | { fromArg },input(its input schema, below),danger: 'read' | 'change' | 'irreversible').CommandResponseis what a command may return (output,tone,quiet,effects,summary, a short result of at most 200 characters for the caller of an operation, anddata, a JSON result for that caller that is never drawn, at mostdataMaxBytes(32768) bytes as UTF-8 JSON). -
Outcomes (
CommandOutcome):{ status: 'ok', summary?, data? }or{ status: 'error', code: ErrorCode, message?, line? }, how an operation ended for its caller;linecomes only withnot-allowed, the line of achangecommand the user may type.ErrorCodeisinvalid-args,unknown-command,not-allowed,target-not-found,id-conflict,unsupported-version,too-large,busy,timeout,failed,connection-lostorcancelled. -
Effects (
ConsoleEffect):clear,close,push-context(frame),pop-context(count?, default 1),reset-context,clear-recall. They apply in order, before the response's output. -
Context frames (
ContextFrame):{ id, prompt, data? }. The top frame'spromptis the prompt of the next line.
Line syntax
Whitespace (tabs included) separates tokens. '…' groups literally. "…" groups, and inside it \" and \\ are the
only escapes. Outside quotes \ is an ordinary character, so C:\data stays C:\data. Adjacent parts join one token
(a"b c"d is ab cd); '' and "" give an empty token. There is no line continuation.
import { quoteArgument, tokenize } from '@dolusoft/command-protocol'
tokenize(`set title "Hello \\"world\\""`).tokens.map(t => t.value)
// ['set', 'title', 'Hello "world"']
const result = tokenize(`set title 'Hello`)
// result.ok === false, result.error: { code: 'unterminated-quote', index: 10 }
// result.tokens still holds the tolerant tokens; the last one has open: true
const args = ['set', 'path', 'C:\\Program Files']
const line = args.map(quoteArgument).join(' ')
// "set path 'C:\\Program Files'", which tokenizes back to the same three values
Each Token has its value (quotes removed), start and end (UTF-16 offsets into the line, end exclusive,
covering the raw text including its quotes), quoted and open. An unclosed quote gives ok: false with the index of
the opening quote, and the tokens are still returned: the last one is open and runs to the end of the line. The
console refuses such a line; completion works on the tolerant tokens. Nothing is ever dropped.
quoteArgument(value) returns the shortest of: the bare value, 'value', or "value" with escapes; it always
tokenizes back to value.
Input schemas
A command's input is a small subset of JSON Schema. The root is type: 'object'; a node is one of:
type | Keywords besides description and default |
|---|---|
string | enum, minLength, maxLength, pattern |
number, integer | enum, minimum, maximum |
boolean | none |
array | items (required), maxItems; no default |
object | properties, required |
checkInputSchema(schema) throws a TypeError naming the path for anything else: another keyword, a keyword on a
type it does not apply to, a property name that does not match ^[A-Za-z][A-Za-z0-9_-]*$, a pattern that does
not compile with the u flag, a required name that is not a property, a default the node rejects, or objects and
arrays nested deeper than the input limits allow. Nothing is ignored.
validateInput(schema, value) returns { ok: true, value } with a new value (an optional field missing from an
object takes its default, an absent optional array is []) or { ok: false, errors } with every error as
{ path, code }: path is ip, filter.ip, tags[2] or '' for the root, and the value is never in it. The codes
are type (an integer wants a whole number), required, additional (a field not in properties), limit and
the name of the failed keyword. Lengths count code points; a pattern is not anchored.
Every value is held to inputLimits whatever its schema allows: strings of at most 1000 code points
(maxTextLength), arrays of at most 50 items (maxArrayLength), objects and arrays at most 3 deep, the root object
being 1 (maxDepth). Beyond them the code is limit.
Option lines
A command with a schema is written name --field value …. parseOptions(args, schema) takes the token values after
the name and returns what validateInput returns:
import {
formatCommandLine,
type InputSchema,
parseOptions,
tokenize
} from '@dolusoft/command-protocol'
const schema: InputSchema = {
type: 'object',
properties: {
ip: { type: 'string' },
limit: { type: 'integer', default: 100 },
verbose: { type: 'boolean' },
tags: { type: 'array', items: { type: 'string' } }
},
required: ['ip']
}
const args = tokenize('search --ip 10.0.0.5 --verbose --tags a --tags b')
.tokens.slice(1)
.map(t => t.value)
parseOptions(args, schema)
// { ok: true, value: { ip: '10.0.0.5', limit: 100, verbose: true, tags: ['a', 'b'] } }
formatCommandLine('search', { ip: '10.0.0.5', tags: [] }, schema)
// 'search --ip 10.0.0.5 --limit 100'
A value converts to its field's type: a number as JSON writes it, an object (or an array item that is an object or
an array) as JSON text, a string as is even when it starts with --. A boolean field alone is true and also takes
true or false. An array field repeats its name; an absent one is []. An unknown name, or a token that is not a
name, is additional; a name without its value, or a second value of a field that is not an array, is type.
formatCommandLine(command, value, schema) writes the canonical line: the fields of the validated value in the order
of properties, every token through quoteArgument, true as the bare --name and false as --name false. Its
tokens after the name read back with parseOptions to the same value. A value the schema rejects throws.
formatCommandArgv(command, value, schema) gives the same tokens unquoted, as an array (['search', '--ip', '10.0.0.5', '--limit', '100']); tokenize(formatCommandLine(...)) gives them back.
Envelope
An Envelope carries one request between the tabs of a user; the consumer's transport moves it, the protocol only
describes and checks it:
{
"v": 1,
"id": "3f0c6c1e-6d1b-4a8e-9b7e-2a4c1f2d9e10",
"kind": "request",
"actor": "user",
"from": { "tab": "t-5b21" },
"target": { "tab": "t-5b21", "view": "v-91c0" },
"command": "dataview.search",
"args": { "ip": "10.0.0.5", "from": "2026-11-04T18:40:00Z" }
}
parseEnvelope(raw) reads what came from the transport and never changes it. It returns { ok: true, envelope }
with a new envelope that holds only the known fields, or { ok: false, code }: unsupported-version for a plain
object whose v is a number other than 1, too-large when its UTF-8 JSON is over envelopeMaxBytes (16384,
measured on the raw value), invalid for a missing or mistyped field, another kind, or args that is not a JSON
object within the input limits. A null target.view, which a typed transport may write back, is no view and is
left out. envelopeBytes(envelope) is the UTF-8 length of its JSON, for a sender to measure before it sends.
Remote envelopes
A desktop CLI sends a cli-request, a help-request or a cancel through the hub to the one tab it is bound to
(ADR 0014); the hub writes the stamp. A cli-request carries the command as argv (as the shell split it) or as one
line, never both:
{
"v": 1,
"kind": "cli-request",
"id": "r-1",
"argv": ["tasks.stop", "--id", "8812"],
"origin": { "kind": "cli", "grantId": "g-1", "bindingId": "b-1" },
"stampedAt": 1793957400000,
"expiresAt": 1793957430000
}
parseCliMessage(raw) checks it as parseEnvelope checks an envelope (an argv of at most remoteArgvMaxItems
items and remoteLineMax code points); for a cli-request it refuses, refusedCliRequest(raw) reads only the id
and the origin ids, so the tab can answer it. The tab answers with received, awaiting-approval, chunk,
finished, help-result, cancelled-before-start and cancel-requested (CliReply, isCliReply). Every error
carries its class (errorClassOf(code)), which the CLI turns into its exit code; suggest and next are argv the
CLI prints, never runs. A result's data travels as its JSON text in at most remoteChunksMax (3) chunks of at most
envelopeMaxBytes each: chunkData(id, text) cuts it, joinChunks(finished, chunks) joins it or gives undefined
when a part is missing. CommandSpec.confirm names the boolean field that turns a preview into the change; from
the CLI a change needs no such field and --dry-run asks for the preview. CommandSpec.effect says what a change
command changes (screen or server), and a change's finished says how it was let run (approval: card or
none).
What the tab runs for the CLI is not an envelope: capabilitiesParts(payload) cuts the JSON of a
CapabilitiesPayload (envelopes, features, minCli, revision, sensitive, commands) into one to three
parts of at most 16 KB for the hub, and readCapabilitiesParts(parts) reads them back. remoteFeatures and
remoteEnvelopes are what this version speaks. The normative field tables are in
docs/remote-contract.md;
fixtures/remote/ in the repository holds their examples.
License
MIT, see LICENSE.md.